Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -352,10 +352,9 @@ function App() {
onOpenConnectionTab={focusOrOpenConnectionTab}
/>
</div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-drag panel resize handle; resizing is a mouse affordance and the panels remain fully usable without it. */}
<div
className="panel-resize-handle panel-resize-handle-vertical"
onMouseDown={sidebar.handleResize}
{...sidebar.resizeProps}
data-tooltip={t('tooltip.resizeSidebar')}
/>
</>
Expand Down Expand Up @@ -438,8 +437,7 @@ function App() {
onRollbackTxn={rollbackTransaction}
/>
</ErrorBoundary>
{/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-drag results splitter; resizing is a mouse affordance and both panes remain fully usable without it. */}
<div className="resizer" onMouseDown={resultsSplit.onMouseDown} />
<div className="resizer" {...resultsSplit.resizeProps} />
<div className="results-pane" style={{ flex: `0 0 ${resultsSplit.percent}%`, minHeight: 0 }}>
<ErrorBoundary label={t('errorBoundary.results')} resetKey={activeTabId}>
<ResultsPane
Expand All @@ -459,10 +457,9 @@ function App() {

{jsonPanelVisible.value && (
<>
{/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-drag panel resize handle; resizing is a mouse affordance and the panels remain fully usable without it. */}
<div
className="panel-resize-handle panel-resize-handle-vertical"
onMouseDown={jsonPanel.handleResize}
{...jsonPanel.resizeProps}
data-tooltip={t('tooltip.resizeJsonPanel')}
/>
<div className="json-viewer-shell" style={{ width: jsonPanel.width }}>
Expand Down
18 changes: 3 additions & 15 deletions frontend/src/features/connections/ConnectionPickerMenu.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -13,20 +14,7 @@ export function ConnectionPickerMenu({ connections, anchorRef, onPick, onClose }
const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(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);
Expand Down
48 changes: 3 additions & 45 deletions frontend/src/features/connections/hooks/useFileDropZone.ts
Original file line number Diff line number Diff line change
@@ -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 <body> (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 } }));
// });
// }, []);
// }
11 changes: 4 additions & 7 deletions frontend/src/features/connections/hooks/useOpenSqliteEvents.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}, []);
}
6 changes: 2 additions & 4 deletions frontend/src/features/editor/SqlEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<Record<string, TableInfo[]>>(() => {
Expand Down Expand Up @@ -186,8 +186,6 @@ export const SqlEditor = memo(function SqlEditor({
languageRevision,
});

useEffect(() => subscribeShortcutsChanged(() => setShortcutRevision((n) => n + 1)), []);

useEffect(() => {
editorRef.current?.updateOptions(monacoFontOptions(fontSize));
}, [fontSize]);
Expand Down
21 changes: 6 additions & 15 deletions frontend/src/features/editor/hooks/useJumpToError.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<JumpToErrorDetail>).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);

Expand All @@ -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).
Expand Down
12 changes: 4 additions & 8 deletions frontend/src/features/editor/hooks/useSidebarInsert.ts
Original file line number Diff line number Diff line change
@@ -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<editor.IStandaloneCodeEditor | null>, 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<InsertSqlDetail>).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]);
}
6 changes: 3 additions & 3 deletions frontend/src/features/editor/hooks/useTabOpener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down
13 changes: 3 additions & 10 deletions frontend/src/features/layout/AppTitleBar.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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) =>
Expand Down
37 changes: 21 additions & 16 deletions frontend/src/features/layout/hooks/usePersistedPanelWidth.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 } };
}
Loading
Loading