onStartResize(e, colPos)} {...colResizeProps} />
);
})}
diff --git a/frontend/src/shared/components/ShortcutsDialog.tsx b/frontend/src/shared/components/ShortcutsDialog.tsx
index 5737eae..d1fa272 100644
--- a/frontend/src/shared/components/ShortcutsDialog.tsx
+++ b/frontend/src/shared/components/ShortcutsDialog.tsx
@@ -1,6 +1,7 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal } from '@/shared/components/Modal';
+import { useShortcutsRevision } from '@/shared/hooks/useShortcutsRevision';
import {
APP_SHORTCUTS,
bindingFromKeyboardEvent,
@@ -15,30 +16,30 @@ import {
type ShortcutDef,
setCapturingBinding,
setShortcutBinding,
- subscribeShortcutsChanged,
} from '@/shared/lib/shortcuts';
+// Categories come from the static shortcut table, so the grouping never changes at runtime.
+const GROUPED_SHORTCUTS = (() => {
+ const map = new Map
();
+ for (const def of APP_SHORTCUTS) {
+ const list = map.get(def.category) ?? [];
+ list.push(def);
+ map.set(def.category, list);
+ }
+ return map;
+})();
+
interface Props {
onClose: () => void;
}
export function ShortcutsDialog({ onClose }: Props) {
const { t } = useTranslation();
- const [revision, setRevision] = useState(0);
const [recordingId, setRecordingId] = useState(null);
const [error, setError] = useState(null);
- useEffect(() => subscribeShortcutsChanged(() => setRevision((n) => n + 1)), []);
-
- const grouped = useMemo(() => {
- const map = new Map();
- for (const def of APP_SHORTCUTS) {
- const list = map.get(def.category) ?? [];
- list.push(def);
- map.set(def.category, list);
- }
- return map;
- }, [revision]);
+ // Bindings are read straight from storage during render, so a remap only needs a re-render.
+ useShortcutsRevision();
const applyBinding = useCallback(
(id: string, binding: KeyBinding) => {
@@ -92,7 +93,7 @@ export function ShortcutsDialog({ onClose }: Props) {
{error}
)}
- {[...grouped.entries()].map(([category, items]) => (
+ {[...GROUPED_SHORTCUTS.entries()].map(([category, items]) => (
{getShortcutCategory(category as ShortcutDef['category'])}
diff --git a/frontend/src/shared/hooks/useColumnResize.ts b/frontend/src/shared/hooks/useColumnResize.ts
index d584c55..3e5e048 100644
--- a/frontend/src/shared/hooks/useColumnResize.ts
+++ b/frontend/src/shared/hooks/useColumnResize.ts
@@ -1,31 +1,34 @@
import { useCallback, useRef } from 'react';
+import { type PointerDragProps, usePointerDrag } from '@/shared/hooks/usePointerDrag';
-export function useColumnResize(applyColumnWidth: (colPos: number, width: number) => void) {
+export function useColumnResize(applyColumnWidth: (colPos: number, width: number) => void): {
+ resizingRef: React.RefObject;
+ startColResize: (e: React.PointerEvent, colPos: number) => void;
+ colResizeProps: PointerDragProps;
+} {
const resizingRef = useRef(false);
+ const { startDrag, dragProps } = usePointerDrag();
const startColResize = useCallback(
- (e: React.MouseEvent, colPos: number) => {
- e.preventDefault();
+ (e: React.PointerEvent, colPos: number) => {
+ // Keeps the header's own click handler from treating the drag as a sort toggle.
e.stopPropagation();
resizingRef.current = true;
const startX = e.clientX;
const th = (e.target as HTMLElement).parentElement;
const startW = th?.getBoundingClientRect().width ?? 100;
- const onMove = (ev: MouseEvent) => {
- applyColumnWidth(colPos, Math.max(40, startW + (ev.clientX - startX)));
- };
- const onUp = () => {
- window.removeEventListener('mousemove', onMove);
- window.removeEventListener('mouseup', onUp);
- requestAnimationFrame(() => {
- resizingRef.current = false;
- });
- };
- window.addEventListener('mousemove', onMove);
- window.addEventListener('mouseup', onUp);
+ startDrag(e, {
+ onMove: (ev) => applyColumnWidth(colPos, Math.max(40, startW + (ev.clientX - startX))),
+ // Deferred so the click that follows pointerup still sees the flag and is ignored.
+ onEnd: () => {
+ requestAnimationFrame(() => {
+ resizingRef.current = false;
+ });
+ },
+ });
},
- [applyColumnWidth],
+ [applyColumnWidth, startDrag],
);
- return { resizingRef, startColResize };
+ return { resizingRef, startColResize, colResizeProps: dragProps };
}
diff --git a/frontend/src/shared/hooks/useDismissOnOutside.ts b/frontend/src/shared/hooks/useDismissOnOutside.ts
new file mode 100644
index 0000000..61a27b2
--- /dev/null
+++ b/frontend/src/shared/hooks/useDismissOnOutside.ts
@@ -0,0 +1,60 @@
+import { type RefObject, useEffect, useRef } from 'react';
+
+export interface DismissOnOutsideOptions {
+ /** Skip while the popover is closed; listeners attach only when true (default). */
+ enabled?: boolean;
+ /** Extra elements counted as "inside", e.g. the trigger button. */
+ alsoInside?: Array>;
+ /** Clicks with a matching ancestor are ignored, e.g. '.modal-overlay' for owned dialogs. */
+ ignoreSelector?: string;
+ /** Also dismiss on a right-click outside (context menus replace themselves). */
+ onContextMenu?: boolean;
+ /** Dismiss on Escape. Leave off when the caller already uses useModalEscape. */
+ onEscape?: boolean;
+}
+
+/**
+ * Dismisses a popover on mousedown outside it. Listens on window in the capture phase because the
+ * click is by definition outside the React subtree, so no synthetic handler ever sees it.
+ */
+export function useDismissOnOutside(
+ ref: RefObject,
+ onClose: () => void,
+ options: DismissOnOutsideOptions = {},
+): void {
+ const { enabled = true, alsoInside, ignoreSelector, onContextMenu = false, onEscape = false } = options;
+
+ // Ref'd so an unmemoized onClose doesn't re-subscribe on every parent re-render.
+ const onCloseRef = useRef(onClose);
+ onCloseRef.current = onClose;
+ const alsoInsideRef = useRef(alsoInside);
+ alsoInsideRef.current = alsoInside;
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ const onPointerDown = (e: MouseEvent) => {
+ const target = e.target as HTMLElement | null;
+ if (!target) return;
+ if (ignoreSelector && target.closest(ignoreSelector)) return;
+ if (ref.current?.contains(target)) return;
+ for (const extra of alsoInsideRef.current ?? []) {
+ if (extra.current?.contains(target)) return;
+ }
+ onCloseRef.current();
+ };
+
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onCloseRef.current();
+ };
+
+ window.addEventListener('mousedown', onPointerDown, true);
+ if (onContextMenu) window.addEventListener('contextmenu', onPointerDown, true);
+ if (onEscape) window.addEventListener('keydown', onKeyDown, true);
+ return () => {
+ window.removeEventListener('mousedown', onPointerDown, true);
+ if (onContextMenu) window.removeEventListener('contextmenu', onPointerDown, true);
+ if (onEscape) window.removeEventListener('keydown', onKeyDown, true);
+ };
+ }, [enabled, ref, ignoreSelector, onContextMenu, onEscape]);
+}
diff --git a/frontend/src/shared/hooks/useGridCore.ts b/frontend/src/shared/hooks/useGridCore.ts
index e072515..1c64e99 100644
--- a/frontend/src/shared/hooks/useGridCore.ts
+++ b/frontend/src/shared/hooks/useGridCore.ts
@@ -1,6 +1,7 @@
import { useCallback, useRef, useState } from 'react';
import { useColumnResize } from '@/shared/hooks/useColumnResize';
import { useGridGlobalKeys } from '@/shared/hooks/useGridGlobalKeys';
+import { usePointerDrag } from '@/shared/hooks/usePointerDrag';
import { queryElementInContainer } from '@/shared/lib/dom';
import { columnRangeSet, type FocusCol, rowRangeSet } from '@/shared/lib/grid';
import {
@@ -57,6 +58,8 @@ export function useGridCore({
const selectingRef = useRef(false);
const selectionAnchorRef = useRef(null);
const shiftMouseDownAppliedRef = useRef(false);
+ // True once a cell drag has left its origin cell, so the trailing click can be ignored.
+ const rangeDraggedRef = useRef(false);
const selectionRef = useRef<{ rows: Set; cols: Set }>({
rows: selectedRows,
cols: selectedColumns,
@@ -129,7 +132,8 @@ export function useGridCore({
setSelectedRows(new Set());
}, []);
- const { resizingRef, startColResize } = useColumnResize(applyColumnWidth);
+ const { resizingRef, startColResize, colResizeProps } = useColumnResize(applyColumnWidth);
+ const { startDrag, dragProps: cellDragProps } = usePointerDrag();
// Also mirrors into selectionRef so Ctrl+C sees the latest selection before React flushes state.
const applyCellRangeSelection = useCallback(
@@ -173,7 +177,7 @@ export function useGridCore({
return existingAnchor ?? (fDisplay >= 0 && fc >= 0 ? { row: fDisplay, col: fc } : fallback);
};
- const handleCellMouseDown = (displayIdx: number, colPos: number, e: React.MouseEvent) => {
+ const handleCellPointerDown = (displayIdx: number, colPos: number, e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
const shift = e.shiftKey || shiftHeldRef.current || e.nativeEvent.getModifierState?.('Shift');
@@ -205,18 +209,26 @@ export function useGridCore({
selectionAnchorRef.current = anchor;
applyCellRangeSelection(anchor, anchor);
- const onMove = (ev: MouseEvent) => {
- const hit = findDataCellAtPoint(tableWrapRef.current, ev.clientX, ev.clientY);
- if (hit) applyCellRangeSelection(anchor, hit);
- };
- const onUp = () => {
- selectingRef.current = false;
- setIsSelecting(false);
- window.removeEventListener('mousemove', onMove);
- window.removeEventListener('mouseup', onUp);
- };
- window.addEventListener('mousemove', onMove);
- window.addEventListener('mouseup', onUp);
+ startDrag(e, {
+ onMove: (ev) => {
+ const hit = findDataCellAtPoint(tableWrapRef.current, ev.clientX, ev.clientY);
+ if (!hit) return;
+ // Jitter inside the origin cell stays a plain click, not a range drag.
+ if (hit.row !== anchor.row || hit.col !== anchor.col) rangeDraggedRef.current = true;
+ applyCellRangeSelection(anchor, hit);
+ },
+ onEnd: () => {
+ selectingRef.current = false;
+ setIsSelecting(false);
+ // Deferred so the click that follows pointerup still sees the flag; cleared here too in
+ // case the release produced no click at all, which would swallow the next real one.
+ if (rangeDraggedRef.current) {
+ requestAnimationFrame(() => {
+ rangeDraggedRef.current = false;
+ });
+ }
+ },
+ });
};
const handleColumnHeaderClick = (col: string, colPos: number, e: React.MouseEvent) => {
@@ -320,6 +332,12 @@ export function useGridCore({
const handleCellClick = (displayIdx: number, globalIdx: number, colPos: number, e: React.MouseEvent) => {
e.stopPropagation();
+ // Pointer capture retargets the trailing click to the cell the drag started on, so without this
+ // the click would immediately collapse the range the drag just selected.
+ if (rangeDraggedRef.current) {
+ rangeDraggedRef.current = false;
+ return;
+ }
if (shiftMouseDownAppliedRef.current) {
shiftMouseDownAppliedRef.current = false;
return;
@@ -390,7 +408,9 @@ export function useGridCore({
focusRow,
focusElement,
startColResize,
- handleCellMouseDown,
+ colResizeProps,
+ handleCellPointerDown,
+ cellDragProps,
handleColumnHeaderClick,
handleRowGutterClick,
handleCellClick,
diff --git a/frontend/src/shared/hooks/usePointerDrag.ts b/frontend/src/shared/hooks/usePointerDrag.ts
new file mode 100644
index 0000000..c652c67
--- /dev/null
+++ b/frontend/src/shared/hooks/usePointerDrag.ts
@@ -0,0 +1,74 @@
+import { type PointerEvent as ReactPointerEvent, useCallback, useMemo, useRef } from 'react';
+
+export interface PointerDragHandlers {
+ onMove: (e: PointerEvent) => void;
+ /** Runs once when the drag ends, however it ends. */
+ onEnd?: () => void;
+ /** Forced on for the duration of the drag, then restored. */
+ cursor?: string;
+ /** Suppresses text selection while dragging. */
+ disableSelect?: boolean;
+}
+
+export interface PointerDragProps {
+ onPointerMove: (e: ReactPointerEvent) => void;
+ onPointerUp: () => void;
+ onPointerCancel: () => void;
+ onLostPointerCapture: () => void;
+}
+
+/**
+ * Drag tracking via pointer capture. setPointerCapture redirects every later pointer event to the
+ * element that started the drag - even once the cursor leaves it - so move/up stay ordinary React
+ * props instead of window listeners. Nothing is registered globally, so unmounting mid-drag drops
+ * the handlers with the element rather than leaking them for the life of the page.
+ *
+ * Spread `dragProps` on the same element whose handler calls `startDrag`.
+ */
+export function usePointerDrag(): {
+ startDrag: (e: ReactPointerEvent, handlers: PointerDragHandlers) => void;
+ dragProps: PointerDragProps;
+} {
+ const activeRef = useRef(null);
+ const restoreRef = useRef<{ cursor: string; userSelect: string } | null>(null);
+
+ const endDrag = useCallback(() => {
+ const active = activeRef.current;
+ if (!active) return;
+ activeRef.current = null;
+
+ const restore = restoreRef.current;
+ if (restore) {
+ document.body.style.cursor = restore.cursor;
+ document.body.style.userSelect = restore.userSelect;
+ restoreRef.current = null;
+ }
+ active.onEnd?.();
+ }, []);
+
+ const startDrag = useCallback((e: ReactPointerEvent, handlers: PointerDragHandlers) => {
+ // Keeps the drag from painting a text selection across the page.
+ e.preventDefault();
+ e.currentTarget.setPointerCapture(e.pointerId);
+ activeRef.current = handlers;
+
+ if (handlers.cursor || handlers.disableSelect) {
+ restoreRef.current = { cursor: document.body.style.cursor, userSelect: document.body.style.userSelect };
+ if (handlers.cursor) document.body.style.cursor = handlers.cursor;
+ if (handlers.disableSelect) document.body.style.userSelect = 'none';
+ }
+ }, []);
+
+ const dragProps = useMemo(
+ () => ({
+ onPointerMove: (e) => activeRef.current?.onMove(e.nativeEvent),
+ onPointerUp: endDrag,
+ onPointerCancel: endDrag,
+ // Capture is released early if the element is removed or the browser interrupts the gesture.
+ onLostPointerCapture: endDrag,
+ }),
+ [endDrag],
+ );
+
+ return { startDrag, dragProps };
+}
diff --git a/frontend/src/shared/hooks/useShortcutsRevision.ts b/frontend/src/shared/hooks/useShortcutsRevision.ts
new file mode 100644
index 0000000..eb2cc76
--- /dev/null
+++ b/frontend/src/shared/hooks/useShortcutsRevision.ts
@@ -0,0 +1,8 @@
+import { useSyncExternalStore } from 'react';
+import { getShortcutsRevision, subscribeShortcutsChanged } from '@/shared/lib/shortcuts';
+
+// Re-renders the caller whenever a binding is remapped or reset; the number itself only
+// matters as a cache key for consumers that memoize on it.
+export function useShortcutsRevision(): number {
+ return useSyncExternalStore(subscribeShortcutsChanged, getShortcutsRevision);
+}
diff --git a/frontend/src/shared/lib/emitter.ts b/frontend/src/shared/lib/emitter.ts
new file mode 100644
index 0000000..af2b46d
--- /dev/null
+++ b/frontend/src/shared/lib/emitter.ts
@@ -0,0 +1,25 @@
+type Listener = (payload: T) => void;
+
+export interface Emitter {
+ emit: (payload: T) => void;
+ subscribe: (listener: Listener) => () => void;
+}
+
+/**
+ * Typed one-shot command channel, for telling an imperative non-React object (a Monaco instance) to
+ * do something now. Deliberately not store state: "insert this text at the cursor" is an action, not
+ * a value worth persisting. Replaces window CustomEvents so the payload keeps its type instead of
+ * needing an unchecked cast out of event.detail, and so the name is not global.
+ */
+export function createEmitter(): Emitter {
+ const listeners = new Set>();
+ return {
+ emit(payload) {
+ for (const listener of listeners) listener(payload);
+ },
+ subscribe(listener) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ };
+}
diff --git a/frontend/src/shared/lib/insertSql.ts b/frontend/src/shared/lib/insertSql.ts
index a7854e6..2d20ad1 100644
--- a/frontend/src/shared/lib/insertSql.ts
+++ b/frontend/src/shared/lib/insertSql.ts
@@ -1,11 +1,11 @@
-// Sidebar dispatches, active SqlEditor listens - decoupled from Monaco via window event
-export const INSERT_SQL_EVENT = 'xensql:insert-sql';
+import { createEmitter } from '@/shared/lib/emitter';
-export interface InsertSqlDetail {
- text: string;
-}
+// Sidebar emits, the active SqlEditor subscribes - decoupled from Monaco without a window event.
+const insertSqlEmitter = createEmitter();
+
+export const subscribeInsertSql = insertSqlEmitter.subscribe;
export function insertSqlIntoEditor(text: string): void {
if (!text) return;
- window.dispatchEvent(new CustomEvent(INSERT_SQL_EVENT, { detail: { text } }));
+ insertSqlEmitter.emit(text);
}
diff --git a/frontend/src/shared/lib/jumpToError.ts b/frontend/src/shared/lib/jumpToError.ts
index bb11a29..365df64 100644
--- a/frontend/src/shared/lib/jumpToError.ts
+++ b/frontend/src/shared/lib/jumpToError.ts
@@ -1,22 +1,23 @@
import type { Monaco } from '@monaco-editor/react';
import type { editor } from 'monaco-editor';
-
-// Results pane dispatches, the active SqlEditor listens (mirrors INSERT_SQL_EVENT).
-export const JUMP_TO_ERROR_EVENT = 'xensql:jump-to-error';
+import { createEmitter } from '@/shared/lib/emitter';
export const QUERY_ERROR_MARKER_OWNER = 'xensql:query-error';
-export interface JumpToErrorDetail {
+export interface JumpToErrorRequest {
statement: string;
position: number; // 1-based char offset within the statement
message?: string;
}
+// Results pane emits, the active SqlEditor subscribes (mirrors insertSql).
+const jumpToErrorEmitter = createEmitter();
+
+export const subscribeJumpToError = jumpToErrorEmitter.subscribe;
+
export function jumpToQueryError(statement: string, position: number, message?: string): void {
if (!statement || position <= 0) return;
- window.dispatchEvent(
- new CustomEvent(JUMP_TO_ERROR_EVENT, { detail: { statement, position, message } }),
- );
+ jumpToErrorEmitter.emit({ statement, position, message });
}
export function clearQueryErrorMarkers(monaco: Monaco | null, model: editor.ITextModel | null): void {
diff --git a/frontend/src/shared/lib/shortcuts.ts b/frontend/src/shared/lib/shortcuts.ts
index 1ff590f..532e7a6 100644
--- a/frontend/src/shared/lib/shortcuts.ts
+++ b/frontend/src/shared/lib/shortcuts.ts
@@ -129,7 +129,13 @@ export const APP_SHORTCUTS: ShortcutDef[] = [
];
const STORAGE_KEY = STORAGE_KEYS.shortcuts;
-const SHORTCUTS_CHANGED_EVENT = 'xensql-shortcuts-changed';
+
+type ShortcutsListener = () => void;
+
+const listeners = new Set();
+
+// Bumped on every override write so useSyncExternalStore sees a new snapshot.
+let revision = 0;
// Prevents global dispatcher from firing actions while the Shortcuts dialog records a new binding
let capturingBinding = false;
@@ -157,7 +163,8 @@ function readOverrides(): OverrideMap {
function writeOverrides(overrides: OverrideMap) {
settings.setItem(STORAGE_KEY, JSON.stringify(overrides));
- window.dispatchEvent(new CustomEvent(SHORTCUTS_CHANGED_EVENT));
+ revision++;
+ for (const listener of listeners) listener();
}
function getShortcutDef(id: string): ShortcutDef | undefined {
@@ -303,8 +310,11 @@ export function toMonacoKeybinding(monaco: typeof import('monaco-editor'), bindi
return mod;
}
-export function subscribeShortcutsChanged(onChange: () => void): () => void {
- const handler = () => onChange();
- window.addEventListener(SHORTCUTS_CHANGED_EVENT, handler);
- return () => window.removeEventListener(SHORTCUTS_CHANGED_EVENT, handler);
+export function getShortcutsRevision(): number {
+ return revision;
+}
+
+export function subscribeShortcutsChanged(listener: ShortcutsListener): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
}
diff --git a/frontend/src/shared/lib/tableViewFilter.ts b/frontend/src/shared/lib/tableViewFilter.ts
deleted file mode 100644
index 28ba1a9..0000000
--- a/frontend/src/shared/lib/tableViewFilter.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-// useTabOpener dispatches; the addressed TableViewPane listens and refetches, since it owns the fetch.
-export const TABLE_VIEW_FILTER_EVENT = 'xensql:table-view-filter';
-
-export interface TableViewFilterDetail {
- tabId: string;
- filter: string;
-}
-
-export function requestTableViewFilter(tabId: string, filter: string): void {
- window.dispatchEvent(new CustomEvent(TABLE_VIEW_FILTER_EVENT, { detail: { tabId, filter } }));
-}
diff --git a/frontend/src/store/appStore.ts b/frontend/src/store/appStore.ts
index ced0b42..11b0208 100644
--- a/frontend/src/store/appStore.ts
+++ b/frontend/src/store/appStore.ts
@@ -6,6 +6,7 @@ import type {
ConnectionFolder,
EditorTab,
HistoryEntry,
+ PendingSqliteFile,
QueryError,
QueryResult,
ResultSet,
@@ -78,6 +79,11 @@ interface AppState {
savedQueries: SavedQuery[];
sidebarView: 'schema' | 'saved' | 'history';
selectedConnectionId: string | null;
+ /**
+ * A SQLite file handed to us by the OS (CLI arg, file drop, "Open with"). Held as state rather
+ * than emitted as an event so a file that arrives before the consumer mounts is not lost.
+ */
+ pendingSqliteFile: PendingSqliteFile | null;
setConnections: (c: ConnectionConfig[]) => void;
reorderConnections: (fromId: string, toId: string) => void;
@@ -134,6 +140,10 @@ interface AppState {
reorderTabs: (fromId: string, toId: string) => void;
setSidebarView: (v: AppState['sidebarView']) => void;
setSelectedConnection: (id: string | null) => void;
+ requestOpenSqliteFile: (file: PendingSqliteFile) => void;
+ clearPendingSqliteFile: () => void;
+ /** Asks the tab's table-view pane to refetch with this filter; the pane clears it once applied. */
+ requestTableViewFilter: (tabId: string, filter: string) => void;
}
export const useAppStore = create((set, get) => ({
@@ -151,6 +161,7 @@ export const useAppStore = create((set, get) => ({
savedQueries: [],
sidebarView: 'schema',
selectedConnectionId: null,
+ pendingSqliteFile: null,
setConnections: (connections) => set({ connections }),
reorderConnections: (fromId, toId) => {
@@ -420,4 +431,19 @@ export const useAppStore = create((set, get) => ({
},
setSidebarView: (sidebarView) => set({ sidebarView }),
setSelectedConnection: (selectedConnectionId) => set({ selectedConnectionId }),
+ requestOpenSqliteFile: (pendingSqliteFile) => set({ pendingSqliteFile }),
+ clearPendingSqliteFile: () => set({ pendingSqliteFile: null }),
+ requestTableViewFilter: (tabId, filter) =>
+ set((s) => {
+ const tab = s.tabs.find((t) => t.id === tabId);
+ if (!tab?.tableView) return s;
+ const session = s.tabSession[tabId] ?? emptyTabSession();
+ const base = session.tableViewState ?? tableViewStateFrom(tab.tableView);
+ return {
+ tabSession: {
+ ...s.tabSession,
+ [tabId]: { ...session, tableViewState: { ...base, pendingFilter: filter } },
+ },
+ };
+ }),
}));
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 7a40df8..d926f91 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -22,6 +22,12 @@ export interface ConnectionFolder {
name: string;
}
+/** A SQLite file the OS handed to the app (CLI arg, file drop, "Open with"). */
+export interface PendingSqliteFile {
+ filePath: string;
+ name: string;
+}
+
export interface ColumnInfo {
name: string;
dataType: string;
@@ -182,6 +188,12 @@ export interface TableViewSessionState {
primaryKeys: string[];
hasMore: boolean;
pending: TableViewPendingState;
+ /**
+ * A filter another part of the app (e.g. a foreign-key jump) wants applied. The pane owns the
+ * fetch, so it picks this up, refetches and clears it back to null. Held as state rather than
+ * emitted as an event so the request cannot be missed.
+ */
+ pendingFilter?: string | null;
}
export type TxnState = 'idle' | 'active' | 'error';