diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3fc365d..c841676 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -352,10 +352,9 @@ function App() { onOpenConnectionTab={focusOrOpenConnectionTab} /> - {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-drag panel resize handle; resizing is a mouse affordance and the panels remain fully usable without it. */}
@@ -438,8 +437,7 @@ function App() { onRollbackTxn={rollbackTransaction} /> - {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-drag results splitter; resizing is a mouse affordance and both panes remain fully usable without it. */} -
+
- {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-drag panel resize handle; resizing is a mouse affordance and the panels remain fully usable without it. */}
diff --git a/frontend/src/features/connections/ConnectionPickerMenu.tsx b/frontend/src/features/connections/ConnectionPickerMenu.tsx index 0abcc98..729c1d3 100644 --- a/frontend/src/features/connections/ConnectionPickerMenu.tsx +++ b/frontend/src/features/connections/ConnectionPickerMenu.tsx @@ -1,5 +1,6 @@ -import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useLayoutEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useDismissOnOutside } from '@/shared/hooks/useDismissOnOutside'; import type { ConnectionConfig } from '@/types'; interface Props { @@ -13,20 +14,7 @@ export function ConnectionPickerMenu({ connections, anchorRef, onPick, onClose } const { t } = useTranslation(); const menuRef = useRef(null); - useEffect(() => { - const onMouseDown = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose(); - }; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - window.addEventListener('mousedown', onMouseDown, true); - window.addEventListener('keydown', onKey, true); - return () => { - window.removeEventListener('mousedown', onMouseDown, true); - window.removeEventListener('keydown', onKey, true); - }; - }, [onClose]); + useDismissOnOutside(menuRef, onClose, { onEscape: true }); // null until measured, so the menu stays hidden instead of flashing at (0,0) on the first frame. const [pos, setPos] = useState<{ top: number; left: number } | null>(null); diff --git a/frontend/src/features/connections/hooks/useFileDropZone.ts b/frontend/src/features/connections/hooks/useFileDropZone.ts index 4ce1369..90c2783 100644 --- a/frontend/src/features/connections/hooks/useFileDropZone.ts +++ b/frontend/src/features/connections/hooks/useFileDropZone.ts @@ -1,61 +1,19 @@ import { Events } from '@wailsio/runtime'; import { useEffect } from 'react'; +import { useAppStore } from '@/store/appStore'; const SQLITE_EXTENSIONS = /\.(sqlite3?|db|s3db|sl3)$/i; -const DROP_HOVER_CLASS = 'file-drop-target-active'; - -// Wails drop handler for SQLite files; dispatches xensql:open-sqlite CustomEvent so connections layer -// can subscribe without coupling to drop state. Drag-over feedback is CSS-only: the Wails runtime -// toggles `file-drop-target-active` on (the data-file-drop-target) while files hover. export function useFileDropZone(): void { useEffect(() => { - const clearDropHover = () => { - if (document.body.classList.contains(DROP_HOVER_CLASS)) { - document.body.classList.remove(DROP_HOVER_CLASS); - } - }; - - const onDragLeave = (e: DragEvent) => { - if (e.relatedTarget === null) clearDropHover(); - }; - - window.addEventListener('dragleave', onDragLeave); - window.addEventListener('mousemove', clearDropHover); - window.addEventListener('keydown', clearDropHover); - - const unsubDrop = Events.On('files-dropped', (e) => { + return Events.On('files-dropped', (e) => { const paths = (e.data as string[]) ?? []; const sqlitePaths = paths.filter((p) => SQLITE_EXTENSIONS.test(p)); if (sqlitePaths.length === 0) return; const filePath = sqlitePaths[0]; const fileName = filePath.split(/[/\\]/).pop() || ''; const name = fileName.replace(/\.[^.]+$/, ''); - window.dispatchEvent(new CustomEvent('xensql:open-sqlite', { detail: { filePath, name } })); + useAppStore.getState().requestOpenSqliteFile({ filePath, name }); }); - - return () => { - unsubDrop(); - window.removeEventListener('dragleave', onDragLeave); - window.removeEventListener('mousemove', clearDropHover); - window.removeEventListener('keydown', clearDropHover); - }; }, []); } - -// Waiting for this PR to be merged and released in Wails: https://github.com/wailsapp/wails/pull/5779/changes -// To Use the following code instead: - -// export function useFileDropZone(): void { -// useEffect(() => { -// return Events.On('files-dropped', (e) => { -// const paths = (e.data as string[]) ?? []; -// const sqlitePaths = paths.filter((p) => SQLITE_EXTENSIONS.test(p)); -// if (sqlitePaths.length === 0) return; -// const filePath = sqlitePaths[0]; -// const fileName = filePath.split(/[/\\]/).pop() || ''; -// const name = fileName.replace(/\.[^.]+$/, ''); -// window.dispatchEvent(new CustomEvent('xensql:open-sqlite', { detail: { filePath, name } })); -// }); -// }, []); -// } diff --git a/frontend/src/features/connections/hooks/useOpenSqliteEvents.ts b/frontend/src/features/connections/hooks/useOpenSqliteEvents.ts index 136305c..249a90e 100644 --- a/frontend/src/features/connections/hooks/useOpenSqliteEvents.ts +++ b/frontend/src/features/connections/hooks/useOpenSqliteEvents.ts @@ -1,23 +1,20 @@ import { Events } from '@wailsio/runtime'; import { useEffect } from 'react'; import { api } from '@/shared/lib/api'; +import { useAppStore } from '@/store/appStore'; -// Normalises CLI-arg pending file and Wails open-sqlite event into one xensql:open-sqlite CustomEvent. +// Normalises the CLI-arg pending file and the Wails open-sqlite event into one store request. export function useOpenSqliteEvents(): void { useEffect(() => { void api.getPendingFile().then((data) => { - if (data?.filePath) { - window.dispatchEvent(new CustomEvent('xensql:open-sqlite', { detail: data })); - } + if (data?.filePath) useAppStore.getState().requestOpenSqliteFile(data); }); }, []); useEffect(() => { return Events.On('open-sqlite', (e) => { const data = e.data as { filePath: string; name: string }; - if (data?.filePath) { - window.dispatchEvent(new CustomEvent('xensql:open-sqlite', { detail: data })); - } + if (data?.filePath) useAppStore.getState().requestOpenSqliteFile(data); }); }, []); } diff --git a/frontend/src/features/editor/SqlEditor.tsx b/frontend/src/features/editor/SqlEditor.tsx index 3f09a6b..8d7a97b 100644 --- a/frontend/src/features/editor/SqlEditor.tsx +++ b/frontend/src/features/editor/SqlEditor.tsx @@ -19,8 +19,8 @@ import { subscribeLanguageChanged } from '@/i18n'; import { ContextMenu } from '@/shared/components/ContextMenu'; import { useAppTheme } from '@/shared/hooks/useAppTheme'; import { useMeasuredHeight } from '@/shared/hooks/useMeasuredHeight'; +import { useShortcutsRevision } from '@/shared/hooks/useShortcutsRevision'; import { clearQueryErrorMarkers } from '@/shared/lib/jumpToError'; -import { subscribeShortcutsChanged } from '@/shared/lib/shortcuts'; import type { ColumnInfo, DriverType, EditorCursorState, SchemaInfo, TableInfo, TxnState } from '@/types'; const STATIC_EDITOR_OPTIONS = { @@ -107,7 +107,7 @@ export const SqlEditor = memo(function SqlEditor({ const contextMenuCleanupRef = useRef<(() => void) | null>(null); const isQueryRunningRef = useRef(isQueryRunning); const onRunRef = useRef(onRun); - const [shortcutRevision, setShortcutRevision] = useState(0); + const shortcutRevision = useShortcutsRevision(); const [languageRevision, setLanguageRevision] = useState(0); // Mirrors backend DefaultBrowseSchema: postgres/sqlite → 'public', mysql → '' (uses database, not schema). const tablesBySchema = useMemo>(() => { @@ -186,8 +186,6 @@ export const SqlEditor = memo(function SqlEditor({ languageRevision, }); - useEffect(() => subscribeShortcutsChanged(() => setShortcutRevision((n) => n + 1)), []); - useEffect(() => { editorRef.current?.updateOptions(monacoFontOptions(fontSize)); }, [fontSize]); diff --git a/frontend/src/features/editor/hooks/useJumpToError.ts b/frontend/src/features/editor/hooks/useJumpToError.ts index f9450dd..00fd1b1 100644 --- a/frontend/src/features/editor/hooks/useJumpToError.ts +++ b/frontend/src/features/editor/hooks/useJumpToError.ts @@ -1,12 +1,7 @@ import type { Monaco } from '@monaco-editor/react'; import type { editor } from 'monaco-editor'; import { type RefObject, useEffect } from 'react'; -import { - clearQueryErrorMarkers, - JUMP_TO_ERROR_EVENT, - type JumpToErrorDetail, - QUERY_ERROR_MARKER_OWNER, -} from '@/shared/lib/jumpToError'; +import { clearQueryErrorMarkers, QUERY_ERROR_MARKER_OWNER, subscribeJumpToError } from '@/shared/lib/jumpToError'; // Active editor only (mirrors useSidebarInsert). SplitStatements trims each statement, so it's a // verbatim substring of the buffer and indexOf maps the error position to an absolute offset. @@ -18,18 +13,16 @@ export function useJumpToError( ) { useEffect(() => { if (!isActive) return; - const handler = (e: Event) => { + return subscribeJumpToError((request) => { const ed = editorRef.current; const monaco = monacoRef.current; const model = ed?.getModel(); if (!ed || !monaco || !model) return; - const detail = (e as CustomEvent).detail; - if (!detail?.statement || detail.position <= 0) return; - const stmtStart = model.getValue().indexOf(detail.statement); + const stmtStart = model.getValue().indexOf(request.statement); ed.focus(); if (stmtStart < 0) return; // statement was edited away - const pos = model.getPositionAt(stmtStart + detail.position - 1); + const pos = model.getPositionAt(stmtStart + request.position - 1); ed.setPosition(pos); ed.revealPositionInCenter(pos); @@ -38,16 +31,14 @@ export function useJumpToError( monaco.editor.setModelMarkers(model, QUERY_ERROR_MARKER_OWNER, [ { severity: monaco.MarkerSeverity.Error, - message: detail.message ?? '', + message: request.message ?? '', startLineNumber: pos.lineNumber, startColumn: word ? word.startColumn : pos.column, endLineNumber: pos.lineNumber, endColumn: word ? word.endColumn : pos.column + 1, }, ]); - }; - window.addEventListener(JUMP_TO_ERROR_EVENT, handler); - return () => window.removeEventListener(JUMP_TO_ERROR_EVENT, handler); + }); }, [isActive, editorRef, monacoRef]); // Clear the squiggle when the user edits (sql is the controlled value). diff --git a/frontend/src/features/editor/hooks/useSidebarInsert.ts b/frontend/src/features/editor/hooks/useSidebarInsert.ts index 70bd4e2..613439e 100644 --- a/frontend/src/features/editor/hooks/useSidebarInsert.ts +++ b/frontend/src/features/editor/hooks/useSidebarInsert.ts @@ -1,23 +1,19 @@ import type { editor } from 'monaco-editor'; import { type RefObject, useEffect } from 'react'; -import { INSERT_SQL_EVENT, type InsertSqlDetail } from '@/shared/lib/insertSql'; +import { subscribeInsertSql } from '@/shared/lib/insertSql'; -// Only the active editor listens so sidebar inserts land in the visible tab. +// Only the active editor subscribes so sidebar inserts land in the visible tab. export function useSidebarInsert(editorRef: RefObject, isActive: boolean) { useEffect(() => { if (!isActive) return; - const handler = (e: Event) => { + return subscribeInsertSql((text) => { const ed = editorRef.current; if (!ed) return; - const text = (e as CustomEvent).detail?.text; - if (!text) return; const selection = ed.getSelection(); if (selection) { ed.executeEdits('insert-from-sidebar', [{ range: selection, text, forceMoveMarkers: true }]); } ed.focus(); - }; - window.addEventListener(INSERT_SQL_EVENT, handler); - return () => window.removeEventListener(INSERT_SQL_EVENT, handler); + }); }, [isActive, editorRef]); } diff --git a/frontend/src/features/editor/hooks/useTabOpener.ts b/frontend/src/features/editor/hooks/useTabOpener.ts index 1d10b29..4116fc0 100644 --- a/frontend/src/features/editor/hooks/useTabOpener.ts +++ b/frontend/src/features/editor/hooks/useTabOpener.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { api, newTabId } from '@/shared/lib/api'; import { appAlert, appError } from '@/shared/lib/appDialog'; -import { requestTableViewFilter } from '@/shared/lib/tableViewFilter'; +import { useAppStore } from '@/store/appStore'; import { useActiveTab, useConnectedIds, @@ -79,8 +79,8 @@ export function useTabOpener(setConnPickerOpen: (open: boolean) => void) { if (existing) { setSelectedConnection(connId); setActiveTab(existing.id); - // The pane owns the fetch; a state write alone wouldn't reload it. - if (options?.filter != null) requestTableViewFilter(existing.id, options.filter); + // The pane owns the fetch, so this records a request it picks up and clears. + if (options?.filter != null) useAppStore.getState().requestTableViewFilter(existing.id, options.filter); return; } diff --git a/frontend/src/features/layout/AppTitleBar.tsx b/frontend/src/features/layout/AppTitleBar.tsx index e5f2ab6..2da80d2 100644 --- a/frontend/src/features/layout/AppTitleBar.tsx +++ b/frontend/src/features/layout/AppTitleBar.tsx @@ -1,10 +1,11 @@ import { Application, Window } from '@wailsio/runtime'; import { Minus, Square, X } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import xensqlIcon from '@/assets/images/xensql-icon.png'; import { type EditAction, runEditAction } from '@/features/layout/lib/editActions'; import { ViewMenuContent } from '@/features/layout/ViewMenuContent'; +import { useDismissOnOutside } from '@/shared/hooks/useDismissOnOutside'; import { isDesktop, isMac } from '@/shared/lib/platform'; import { formatBinding, getEffectiveBinding, type KeyBinding } from '@/shared/lib/shortcuts'; @@ -72,15 +73,7 @@ export function AppTitleBar({ onAction, sidebarOpen, onToggleSidebar, jsonPanelO Window.ToggleMaximise(); }; - useEffect(() => { - if (!open) return; - const close = (e: MouseEvent) => { - if (barRef.current?.contains(e.target as Node)) return; - closeAll(); - }; - window.addEventListener('mousedown', close); - return () => window.removeEventListener('mousedown', close); - }, [open]); + useDismissOnOutside(barRef, closeAll, { enabled: open !== null }); const renderRows = (rows: MenuRow[]) => rows.map((row) => diff --git a/frontend/src/features/layout/hooks/usePersistedPanelWidth.ts b/frontend/src/features/layout/hooks/usePersistedPanelWidth.ts index cc2ba0a..dd78df5 100644 --- a/frontend/src/features/layout/hooks/usePersistedPanelWidth.ts +++ b/frontend/src/features/layout/hooks/usePersistedPanelWidth.ts @@ -1,5 +1,6 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { readStoredWidth, startPanelResize, storeWidth } from '@/features/layout/lib/panelResize'; +import { type PointerEvent as ReactPointerEvent, useCallback, useState } from 'react'; +import { readStoredWidth, storeWidth } from '@/features/layout/lib/panelResize'; +import { type PointerDragProps, usePointerDrag } from '@/shared/hooks/usePointerDrag'; import type { StorageKey } from '@/shared/lib/storageKeys'; export interface PersistedPanelWidthOptions { @@ -13,26 +14,30 @@ export interface PersistedPanelWidthOptions { export function usePersistedPanelWidth(opts: PersistedPanelWidthOptions): { width: number; - handleResize: (e: React.MouseEvent) => void; + /** Spread onto the resize handle; carries both the drag start and the captured pointer events. */ + resizeProps: PointerDragProps & { onPointerDown: (e: ReactPointerEvent) => void }; } { const [width, setWidth] = useState(() => readStoredWidth(opts.storageKey, opts.defaultWidth, opts.min, opts.max)); + const { startDrag, dragProps } = usePointerDrag(); - // End an in-progress drag if the component unmounts mid-resize. - const cleanupRef = useRef<(() => void) | null>(null); - useEffect(() => () => cleanupRef.current?.(), []); - - const handleResize = useCallback( - (e: React.MouseEvent) => { + const onPointerDown = useCallback( + (e: ReactPointerEvent) => { + const startX = e.clientX; const start = width; - cleanupRef.current = startPanelResize(e, 'x', (delta) => { - const signed = opts.edge === 'right' ? delta : -delta; - const next = Math.min(opts.max, Math.max(opts.min, start + signed)); - setWidth(next); - storeWidth(opts.storageKey, next); + startDrag(e, { + cursor: 'col-resize', + disableSelect: true, + onMove: (ev) => { + const delta = ev.clientX - startX; + const signed = opts.edge === 'right' ? delta : -delta; + const next = Math.min(opts.max, Math.max(opts.min, start + signed)); + setWidth(next); + storeWidth(opts.storageKey, next); + }, }); }, - [width, opts.storageKey, opts.min, opts.max, opts.edge], + [width, opts.storageKey, opts.min, opts.max, opts.edge, startDrag], ); - return { width, handleResize }; + return { width, resizeProps: { onPointerDown, ...dragProps } }; } diff --git a/frontend/src/features/layout/hooks/useVerticalSplitter.ts b/frontend/src/features/layout/hooks/useVerticalSplitter.ts index 579bb0e..240606e 100644 --- a/frontend/src/features/layout/hooks/useVerticalSplitter.ts +++ b/frontend/src/features/layout/hooks/useVerticalSplitter.ts @@ -1,55 +1,44 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { type PointerEvent as ReactPointerEvent, useCallback, useState } from 'react'; +import { type PointerDragProps, usePointerDrag } from '@/shared/hooks/usePointerDrag'; export interface VerticalSplitterOptions { initialPercent: number; minPercent: number; maxPercent: number; - /** CSS selector for the container whose height = 100%; queried once at mousedown. */ + /** CSS selector for the container whose height = 100%; queried once at pointerdown. */ containerSelector: string; } export function useVerticalSplitter(opts: VerticalSplitterOptions): { percent: number; - onMouseDown: (e: React.MouseEvent) => void; + /** Spread onto the splitter; carries both the drag start and the captured pointer events. */ + resizeProps: PointerDragProps & { onPointerDown: (e: ReactPointerEvent) => void }; } { const [percent, setPercent] = useState(opts.initialPercent); + const { startDrag, dragProps } = usePointerDrag(); - // End an in-progress drag if the component unmounts mid-resize. - const cleanupRef = useRef<(() => void) | null>(null); - useEffect(() => () => cleanupRef.current?.(), []); - - const onMouseDown = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); + const onPointerDown = useCallback( + (e: ReactPointerEvent) => { const startY = e.clientY; const startPct = percent; const container = document.querySelector(opts.containerSelector) as HTMLElement | null; const containerHeight = container?.clientHeight ?? 0; - const onMove = (ev: MouseEvent) => { - if (!containerHeight) return; - // Drag up grows the lower pane → (startY - clientY) sign. - const delta = startY - ev.clientY; - const nextPct = Math.min( - opts.maxPercent, - Math.max(opts.minPercent, startPct + (delta / containerHeight) * 100), - ); - setPercent(nextPct); - }; - const cleanup = () => { - window.removeEventListener('mousemove', onMove); - window.removeEventListener('mouseup', onUp); - cleanupRef.current = null; - }; - function onUp() { - cleanup(); - } - window.addEventListener('mousemove', onMove); - window.addEventListener('mouseup', onUp); - cleanupRef.current = cleanup; + startDrag(e, { + onMove: (ev) => { + if (!containerHeight) return; + // Drag up grows the lower pane → (startY - clientY) sign. + const delta = startY - ev.clientY; + const nextPct = Math.min( + opts.maxPercent, + Math.max(opts.minPercent, startPct + (delta / containerHeight) * 100), + ); + setPercent(nextPct); + }, + }); }, - [percent, opts.containerSelector, opts.minPercent, opts.maxPercent], + [percent, opts.containerSelector, opts.minPercent, opts.maxPercent, startDrag], ); - return { percent, onMouseDown }; + return { percent, resizeProps: { onPointerDown, ...dragProps } }; } diff --git a/frontend/src/features/layout/lib/panelResize.ts b/frontend/src/features/layout/lib/panelResize.ts index c4ed1a0..a557db6 100644 --- a/frontend/src/features/layout/lib/panelResize.ts +++ b/frontend/src/features/layout/lib/panelResize.ts @@ -1,36 +1,5 @@ import { settings } from '@/shared/lib/settingsStore'; -// Returns a teardown so the caller can end the drag on unmount. -export function startPanelResize( - e: React.MouseEvent, - axis: 'x' | 'y', - apply: (totalDelta: number) => void, -): () => void { - e.preventDefault(); - const start = axis === 'x' ? e.clientX : e.clientY; - - const onMove = (ev: MouseEvent) => { - const pos = axis === 'x' ? ev.clientX : ev.clientY; - apply(pos - start); - }; - - const cleanup = () => { - window.removeEventListener('mousemove', onMove); - window.removeEventListener('mouseup', onUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }; - function onUp() { - cleanup(); - } - - document.body.style.cursor = axis === 'x' ? 'col-resize' : 'row-resize'; - document.body.style.userSelect = 'none'; - window.addEventListener('mousemove', onMove); - window.addEventListener('mouseup', onUp); - return cleanup; -} - export function readStoredWidth(key: string, fallback: number, min?: number, max?: number): number { try { const v = settings.getItem(key); diff --git a/frontend/src/features/results/ResultsGrid.tsx b/frontend/src/features/results/ResultsGrid.tsx index e692fdc..0b46eb8 100644 --- a/frontend/src/features/results/ResultsGrid.tsx +++ b/frontend/src/features/results/ResultsGrid.tsx @@ -148,7 +148,9 @@ function ResultsGridImpl({ clearSelection, focusRow, startColResize, - handleCellMouseDown, + colResizeProps, + handleCellPointerDown, + cellDragProps, handleColumnHeaderClick, handleRowGutterClick, handleCellClick, @@ -462,6 +464,7 @@ function ResultsGridImpl({ }} onSortToggle={applySortToggle} onStartResize={startColResize} + colResizeProps={colResizeProps} /> } buildRowContext={(sortedIdx) => { @@ -515,7 +518,8 @@ function ResultsGridImpl({ .join(' ')} data-tooltip={t('tooltip.resultsCell')} onFocus={() => focusRow(ctx.globalIdx, colPos)} - onMouseDown={(e) => handleCellMouseDown(sortedIdx, colPos, e)} + onPointerDown={(e) => handleCellPointerDown(sortedIdx, colPos, e)} + {...cellDragProps} onClick={(e) => handleCellClick(sortedIdx, ctx.globalIdx, colPos, e)} onKeyDown={(e) => onGridKeyDown(e, sortedIdx, colPos)} onDoubleClick={openFocusedCellViewer} diff --git a/frontend/src/features/sidebar/ConnectionSwitcher.tsx b/frontend/src/features/sidebar/ConnectionSwitcher.tsx index 5949fc1..064118c 100644 --- a/frontend/src/features/sidebar/ConnectionSwitcher.tsx +++ b/frontend/src/features/sidebar/ConnectionSwitcher.tsx @@ -3,9 +3,11 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ConnectionDialog } from '@/features/connections/ConnectionDialog'; import { ConnectionsPanel } from '@/features/sidebar/ConnectionsPanel'; +import { useDismissOnOutside } from '@/shared/hooks/useDismissOnOutside'; import { api } from '@/shared/lib/api'; import { basename } from '@/shared/lib/connectionLabel'; import { cx } from '@/shared/lib/cx'; +import { useAppStore } from '@/store/appStore'; import { useConnectedIds, useConnections, useResolvedConnectionId, useStoreActions } from '@/store/selectors'; import type { ConnectionConfig } from '@/types'; import { DEFAULT_CONNECTION_COLOR } from '@/types'; @@ -46,48 +48,36 @@ export function ConnectionSwitcher({ onConnected, onOpenConnectionTab }: Props) : [current.driver, [current.host, current.database].filter(Boolean).join('/')].filter(Boolean).join(' · ') : ''; - // Always-mounted OS SQLite-drop listener; pre-fills the new-connection dialog regardless of active sidebar tab. + // Pre-fills the new-connection dialog for an OS-supplied SQLite file, whatever the active sidebar + // tab. Reading it as state means a file that lands before this mounts is still picked up. + const pendingSqliteFile = useAppStore((s) => s.pendingSqliteFile); + const clearPendingSqliteFile = useAppStore((s) => s.clearPendingSqliteFile); useEffect(() => { - const handler = (e: Event) => { - const { filePath, name } = (e as CustomEvent<{ filePath: string; name: string }>).detail; - setDialogConn({ - id: '', - name, - driver: 'sqlite', - color: DEFAULT_CONNECTION_COLOR, - filePath, - host: 'localhost', - port: 5432, - database: '', - username: '', - password: '', - sslMode: 'disable', - schema: '', - }); - }; - window.addEventListener('xensql:open-sqlite', handler); - return () => window.removeEventListener('xensql:open-sqlite', handler); - }, []); + if (!pendingSqliteFile) return; + setDialogConn({ + id: '', + name: pendingSqliteFile.name, + driver: 'sqlite', + color: DEFAULT_CONNECTION_COLOR, + filePath: pendingSqliteFile.filePath, + host: 'localhost', + port: 5432, + database: '', + username: '', + password: '', + sslMode: 'disable', + schema: '', + }); + clearPendingSqliteFile(); + }, [pendingSqliteFile, clearPendingSqliteFile]); - // Ignore clicks inside .modal-overlay so owned dialogs don't dismiss the popover. - useEffect(() => { - if (!open) return; - const onMouseDown = (e: MouseEvent) => { - const target = e.target as HTMLElement; - if (target.closest('.modal-overlay')) return; - if (menuRef.current?.contains(target) || anchorRef.current?.contains(target)) return; - setOpen(false); - }; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setOpen(false); - }; - window.addEventListener('mousedown', onMouseDown, true); - window.addEventListener('keydown', onKey, true); - return () => { - window.removeEventListener('mousedown', onMouseDown, true); - window.removeEventListener('keydown', onKey, true); - }; - }, [open]); + // ignoreSelector keeps clicks in owned dialogs (.modal-overlay) from dismissing the popover. + useDismissOnOutside(menuRef, () => setOpen(false), { + enabled: open, + alsoInside: [anchorRef], + ignoreSelector: '.modal-overlay', + onEscape: true, + }); const toggleMenu = () => { if (!hasConnections) { diff --git a/frontend/src/features/table-view/TableViewCell.tsx b/frontend/src/features/table-view/TableViewCell.tsx index db17ac8..da3142b 100644 --- a/frontend/src/features/table-view/TableViewCell.tsx +++ b/frontend/src/features/table-view/TableViewCell.tsx @@ -2,6 +2,7 @@ import { ExternalLink } from 'lucide-react'; import type { Dispatch, SetStateAction } from 'react'; import { TableViewCellEditor } from '@/features/table-view/TableViewCellEditor'; +import type { PointerDragProps } from '@/shared/hooks/usePointerDrag'; import type { FocusCol } from '@/shared/lib/grid'; import type { CellRange } from '@/shared/lib/gridCellRange'; import { gridSelectionHighlightClasses } from '@/shared/lib/gridCellRange'; @@ -30,7 +31,9 @@ interface Props { onOpenFk?: () => void; setEditing: Dispatch>; onCommitCell: (rowIdx: number, colIdx: number, colName: string, value: string | null) => void; - onMouseDown: (e: React.MouseEvent) => void; + onPointerDown: (e: React.PointerEvent) => void; + /** Pointer-capture drag props for the cell-range selection drag. */ + cellDragProps: PointerDragProps; onFocus: () => void; onClick: (e: React.MouseEvent) => void; onKeyDown: (e: React.KeyboardEvent) => void; @@ -61,7 +64,8 @@ export function TableViewCell({ onOpenFk, setEditing, onCommitCell, - onMouseDown, + onPointerDown, + cellDragProps, onFocus, onClick, onKeyDown, @@ -97,7 +101,8 @@ export function TableViewCell({ ] .filter(Boolean) .join(' ')} - onMouseDown={onMouseDown} + onPointerDown={onPointerDown} + {...cellDragProps} onFocus={onFocus} onClick={onClick} onKeyDown={onKeyDown} @@ -125,7 +130,7 @@ export function TableViewCell({ tabIndex={-1} aria-label={fkLabel} data-tooltip={fkLabel} - onMouseDown={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} onClick={(e) => { e.stopPropagation(); diff --git a/frontend/src/features/table-view/TableViewGrid.tsx b/frontend/src/features/table-view/TableViewGrid.tsx index 9e62f17..0d75c71 100644 --- a/frontend/src/features/table-view/TableViewGrid.tsx +++ b/frontend/src/features/table-view/TableViewGrid.tsx @@ -198,7 +198,9 @@ export const TableViewGrid = memo(function TableViewGrid({ focusRow, focusElement, startColResize, - handleCellMouseDown, + colResizeProps, + handleCellPointerDown, + cellDragProps, handleColumnHeaderClick, handleRowGutterClick, handleCellClick, @@ -498,6 +500,7 @@ export const TableViewGrid = memo(function TableViewGrid({ onSortChange(col); }} onStartResize={startColResize} + colResizeProps={colResizeProps} /> } buildRowContext={(rowIdx) => { @@ -572,10 +575,11 @@ export const TableViewGrid = memo(function TableViewGrid({ onOpenFk={fkLabel && onOpenForeignKey ? () => openForeignKey(rowIdx, ci, col) : undefined} setEditing={setEditing} onCommitCell={commitCell} - onMouseDown={(e) => { + onPointerDown={(e) => { if (editing != null) return; - handleCellMouseDown(rowIdx, colPos, e); + handleCellPointerDown(rowIdx, colPos, e); }} + cellDragProps={cellDragProps} onFocus={() => { if (focusedRowIdx !== rowIdx || focusedColPos !== colPos) { focusRow(rowIdx, colPos); diff --git a/frontend/src/features/table-view/TableViewPane.tsx b/frontend/src/features/table-view/TableViewPane.tsx index ed5f56f..512868b 100644 --- a/frontend/src/features/table-view/TableViewPane.tsx +++ b/frontend/src/features/table-view/TableViewPane.tsx @@ -12,7 +12,6 @@ import { api } from '@/shared/lib/api'; import { appError } from '@/shared/lib/appDialog'; import { appToast, toastError } from '@/shared/lib/appToast'; import { formatError } from '@/shared/lib/normalize'; -import { TABLE_VIEW_FILTER_EVENT, type TableViewFilterDetail } from '@/shared/lib/tableViewFilter'; import { useAppStore } from '@/store/appStore'; import type { DriverType, EditorTab, TableViewSessionState } from '@/types'; import { tableViewStateFrom } from '@/types'; @@ -47,6 +46,7 @@ export function TableViewPane({ // biome-ignore lint/style/noNonNullAssertion: TableViewPane is only rendered for table-view tabs, so tab.tableView is guaranteed present. const tv = tab.tableView!; const session = useAppStore((s) => s.tabSession[tab.id]?.tableViewState); + const pendingFilter = useAppStore((s) => s.tabSession[tab.id]?.tableViewState?.pendingFilter); const result = useAppStore((s) => s.tabSession[tab.id]?.result); const resultError = useAppStore((s) => s.tabSession[tab.id]?.resultError); const resultErrorInfo = useAppStore((s) => s.tabSession[tab.id]?.resultErrorInfo); @@ -167,19 +167,15 @@ export function TableViewPane({ fetchPageRef.current = fetchPage; useEffect(() => { - const handler = (e: Event) => { - const detail = (e as CustomEvent).detail; - if (detail?.tabId !== tab.id) return; - setFilterDraft(detail.filter); - // Claims the mount fetch: a never-activated tab would otherwise race a second, unfiltered stream. - initialFetchInFlightRef.current = true; - void fetchPageRef.current({ offset: 0, replace: true, filter: detail.filter }).finally(() => { - initialFetchInFlightRef.current = false; - }); - }; - window.addEventListener(TABLE_VIEW_FILTER_EVENT, handler); - return () => window.removeEventListener(TABLE_VIEW_FILTER_EVENT, handler); - }, [tab.id]); + if (pendingFilter == null) return; + setFilterDraft(pendingFilter); + // Claims the mount fetch: a never-activated tab would otherwise race a second, unfiltered stream. + initialFetchInFlightRef.current = true; + persistState({ pendingFilter: null }); + void fetchPageRef.current({ offset: 0, replace: true, filter: pendingFilter }).finally(() => { + initialFetchInFlightRef.current = false; + }); + }, [pendingFilter, persistState]); // The in-flight guard keeps StrictMode's double-invoked mount effect from starting two streams; // the registry cancels the older one, which can leave the pane empty until a manual refresh. diff --git a/frontend/src/shared/components/ContextMenu.tsx b/frontend/src/shared/components/ContextMenu.tsx index 69c8b1d..644acdb 100644 --- a/frontend/src/shared/components/ContextMenu.tsx +++ b/frontend/src/shared/components/ContextMenu.tsx @@ -1,4 +1,5 @@ -import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { type ReactNode, useLayoutEffect, useRef, useState } from 'react'; +import { useDismissOnOutside } from '@/shared/hooks/useDismissOnOutside'; import { useModalEscape } from '@/shared/hooks/useModalEscape'; import { cx } from '@/shared/lib/cx'; @@ -37,21 +38,8 @@ export function ContextMenu({ x, y, items, onClose }: Props) { }); }, [x, y]); - // Ref keeps listeners attached once per mount instead of re-subscribing on every parent re-render. - const onCloseRef = useRef(onClose); - onCloseRef.current = onClose; - useEffect(() => { - const onPointerDown = (e: MouseEvent) => { - if (menuRef.current?.contains(e.target as Node)) return; - onCloseRef.current(); - }; - window.addEventListener('mousedown', onPointerDown, true); - window.addEventListener('contextmenu', onPointerDown, true); - return () => { - window.removeEventListener('mousedown', onPointerDown, true); - window.removeEventListener('contextmenu', onPointerDown, true); - }; - }, []); + // Escape is handled by useModalEscape above, which stacks so only the topmost menu closes. + useDismissOnOutside(menuRef, onClose, { onContextMenu: true }); let separatorCount = 0; return ( diff --git a/frontend/src/shared/components/GridHeaderRow.tsx b/frontend/src/shared/components/GridHeaderRow.tsx index e180f9e..98bab23 100644 --- a/frontend/src/shared/components/GridHeaderRow.tsx +++ b/frontend/src/shared/components/GridHeaderRow.tsx @@ -1,6 +1,7 @@ import type { Virtualizer } from '@tanstack/react-virtual'; import { ChevronDown, ChevronsUpDown, ChevronUp } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import type { PointerDragProps } from '@/shared/hooks/usePointerDrag'; interface Props { displayColumns: string[]; @@ -11,7 +12,9 @@ interface Props { sortDirection: 'ASC' | 'DESC'; onHeaderClick: (col: string, colPos: number, e: React.MouseEvent) => void; onSortToggle: (col: string) => void; - onStartResize: (e: React.MouseEvent, colPos: number) => void; + onStartResize: (e: React.PointerEvent, colPos: number) => void; + /** Pointer-capture drag props for the resize handle; pairs with onStartResize. */ + colResizeProps: PointerDragProps; } export function GridHeaderRow({ @@ -24,6 +27,7 @@ export function GridHeaderRow({ onHeaderClick, onSortToggle, onStartResize, + colResizeProps, }: Props) { const { t } = useTranslation(); @@ -82,8 +86,7 @@ export function GridHeaderRow({ )} - {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-drag column resize handle; resizing is a mouse affordance and columns remain readable without it. */} -
onStartResize(e, colPos)} /> +
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';