Skip to content
Merged
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
47 changes: 42 additions & 5 deletions src/components/QueryEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import React, { useRef, useEffect, useState, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react';
import Editor, { useMonaco } from '@monaco-editor/react';
import type * as Monaco from 'monaco-editor';
import { Zap, Sparkles, Send, X, Loader2, AlignLeft, Trash2, Copy, Play } from 'lucide-react';
import { Zap, Sparkles, Send, X, Loader2, AlignLeft, Trash2, Copy, Play, Hash } from 'lucide-react';
import { cn } from '@/lib/utils';
import { motion, AnimatePresence } from 'framer-motion';
import { format } from 'sql-formatter';
Expand Down Expand Up @@ -48,11 +48,11 @@ interface ParsedTable {
}

// Static editor options - defined outside component to prevent re-creation on every render
const EDITOR_OPTIONS = {
const getEditorOptions = (showLineNumbers: boolean) => ({
minimap: { enabled: false },
fontSize: 13,
fontFamily: '"JetBrains Mono", "Fira Code", Menlo, Monaco, Consolas, monospace',
lineNumbers: 'on' as const,
lineNumbers: showLineNumbers ? ('on' as const) : ('off' as const),
roundedSelection: true,
scrollBeyondLastLine: false,
readOnly: false,
Expand Down Expand Up @@ -81,7 +81,7 @@ const EDITOR_OPTIONS = {
parameterHints: {
enabled: true
}
} as const;
});

export const QueryEditor = forwardRef<QueryEditorRef, QueryEditorProps>(({
value,
Expand All @@ -97,6 +97,15 @@ export const QueryEditor = forwardRef<QueryEditorRef, QueryEditorProps>(({
const monaco = useMonaco();
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
const [hasSelection, setHasSelection] = useState(false);

// Line numbers toggle state (persisted in localStorage)
const [showLineNumbers, setShowLineNumbers] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('editor-line-numbers');
return saved !== null ? saved === 'true' : true; // default: true
}
return true;
});

// Track last synced value to detect external changes
const lastSyncedValueRef = useRef<string>(value);
Expand All @@ -117,6 +126,20 @@ export const QueryEditor = forwardRef<QueryEditorRef, QueryEditorProps>(({
}
}, [value]);

// Update editor options when line numbers toggle changes
useEffect(() => {
if (editorRef.current) {
editorRef.current.updateOptions({ lineNumbers: showLineNumbers ? 'on' : 'off' });
}
}, [showLineNumbers]);

// Persist line numbers preference to localStorage
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('editor-line-numbers', String(showLineNumbers));
}
}, [showLineNumbers]);

const parsedSchema = useMemo((): ParsedTable[] => {
if (!schemaContext) return [];
try {
Expand Down Expand Up @@ -502,6 +525,20 @@ export const QueryEditor = forwardRef<QueryEditorRef, QueryEditorProps>(({

<div className="w-px h-4 bg-white/5 mx-1" />

<button
onClick={() => setShowLineNumbers(!showLineNumbers)}
title={showLineNumbers ? "Hide line numbers" : "Show line numbers"}
className={cn(
"px-2.5 py-1.5 rounded text-[10px] font-mono transition-all border active:scale-95 flex items-center gap-1.5",
showLineNumbers
? "bg-zinc-800 border-white/10 text-zinc-300"
: "bg-[#111] border-white/5 text-zinc-500 hover:text-zinc-300"
)}
>
<Hash className="w-3 h-3" />
LINES
</button>

<button
onClick={() => setShowAi(!showAi)}
className={cn(
Expand Down Expand Up @@ -699,7 +736,7 @@ export const QueryEditor = forwardRef<QueryEditorRef, QueryEditorProps>(({
run: () => handleFormat()
});
}}
options={EDITOR_OPTIONS}
options={getEditorOptions(showLineNumbers)}
/>

{/* Connection Type Badge */}
Expand Down
121 changes: 120 additions & 1 deletion tests/components/QueryEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ mock.module('@monaco-editor/react', () => ({
addCommand: (_keybinding: number, handler: () => void) => { capturedCommands.push({ keybinding: _keybinding, handler }); },
addAction: (action: { id: string; run: () => void }) => { capturedActions.push(action); },
focus: mock(() => {}),
updateOptions: mock(() => {}),
};

beforeMount?.(monacoMock);
Expand Down Expand Up @@ -246,6 +247,26 @@ describe('QueryEditor', () => {
void data;
return Promise.resolve();
});

// Mock localStorage for line numbers toggle (only if not already defined)
if (!globalThis.localStorage) {
const localStorageMock: Record<string, string> = {};
Object.defineProperty(globalThis, 'localStorage', {
value: {
getItem: (key: string) => localStorageMock[key] || null,
setItem: (key: string, value: string) => { localStorageMock[key] = value; },
removeItem: (key: string) => { delete localStorageMock[key]; },
clear: () => { Object.keys(localStorageMock).forEach(k => delete localStorageMock[k]); },
},
writable: true,
configurable: true,
});
} else {
// Clear existing localStorage
globalThis.localStorage.clear();
}


const nav = globalThis.navigator as Navigator & { clipboard?: Clipboard };
const clipboardWriteText: Clipboard['writeText'] = (data: string) =>
mockClipboardWriteText(data) as Promise<void>;
Expand Down Expand Up @@ -308,6 +329,12 @@ describe('QueryEditor', () => {
expect(queryByText('CLEAR')).not.toBeNull();
});

test('LINES button renders', () => {
const { queryByText } = render(React.createElement(QueryEditor, createDefaultProps()));
expect(queryByText('LINES')).not.toBeNull();
});


test('AI ASSISTANT toggle button renders', () => {
const { queryByText } = render(React.createElement(QueryEditor, createDefaultProps()));
expect(queryByText('AI ASSISTANT')).not.toBeNull();
Expand Down Expand Up @@ -387,6 +414,98 @@ describe('QueryEditor', () => {
expect(editor.value).toBe('');
});

// -----------------------------------------------------------------------
// LINES button (Line Numbers Toggle)
// -----------------------------------------------------------------------

test('LINES button toggles line numbers state', () => {
const { queryByText } = render(React.createElement(QueryEditor, createDefaultProps()));
const linesButton = queryByText('LINES');
expect(linesButton).not.toBeNull();

// Click to toggle
fireEvent.click(linesButton!);
// State should change (we can't directly test state, but button should still be there)
expect(queryByText('LINES')).not.toBeNull();
});

test('LINES button defaults to enabled (line numbers shown)', () => {
localStorage.clear();
const { queryByText } = render(React.createElement(QueryEditor, createDefaultProps()));
const linesButton = queryByText('LINES')!.closest('button');
// Default state should have line numbers enabled (bg-zinc-800 class)
expect(linesButton?.className).toContain('bg-zinc-800');
});

test('LINES button reads initial state from localStorage', () => {
localStorage.setItem('editor-line-numbers', 'false');
const { queryByText } = render(React.createElement(QueryEditor, createDefaultProps()));
const linesButton = queryByText('LINES')!.closest('button');
// Should read false from localStorage and show disabled state (bg-[#111])
expect(linesButton?.className).toContain('bg-[#111]');
});

test('LINES button saves state to localStorage when toggled', () => {
localStorage.clear();
const { queryByText } = render(React.createElement(QueryEditor, createDefaultProps()));
const linesButton = queryByText('LINES');

// Initial state should be 'true' (default)
expect(localStorage.getItem('editor-line-numbers')).toBe('true');

// Toggle to false
fireEvent.click(linesButton!);
expect(localStorage.getItem('editor-line-numbers')).toBe('false');

// Toggle back to true
fireEvent.click(linesButton!);
expect(localStorage.getItem('editor-line-numbers')).toBe('true');
});

test('LINES button updates editor options when toggled', () => {
mockUseMonacoReturn = { Range: class {} };
const { queryByText } = render(React.createElement(QueryEditor, createDefaultProps()));
const linesButton = queryByText('LINES');

// Toggle line numbers - should trigger editor.updateOptions
fireEvent.click(linesButton!);
// The editor mock doesn't track updateOptions calls, but we verify no crash occurs
expect(linesButton).not.toBeNull();
});

test('LINES button shows correct visual state when enabled', () => {
localStorage.setItem('editor-line-numbers', 'true');
const { queryByText } = render(React.createElement(QueryEditor, createDefaultProps()));
const linesButton = queryByText('LINES')!.closest('button');

// Enabled state should have specific classes
expect(linesButton?.className).toContain('bg-zinc-800');
expect(linesButton?.className).toContain('border-white/10');
expect(linesButton?.className).toContain('text-zinc-300');
});

test('LINES button shows correct visual state when disabled', () => {
localStorage.setItem('editor-line-numbers', 'false');
const { queryByText } = render(React.createElement(QueryEditor, createDefaultProps()));
const linesButton = queryByText('LINES')!.closest('button');

// Disabled state should have different classes
expect(linesButton?.className).toContain('bg-[#111]');
expect(linesButton?.className).toContain('border-white/5');
expect(linesButton?.className).toContain('text-zinc-500');
});

test('LINES button has correct tooltip', () => {
localStorage.setItem('editor-line-numbers', 'true');
const { queryByText } = render(React.createElement(QueryEditor, createDefaultProps()));
const linesButton = queryByText('LINES')!.closest('button');
expect(linesButton?.getAttribute('title')).toBe('Hide line numbers');

// Toggle and check tooltip changes
fireEvent.click(linesButton!);
expect(linesButton?.getAttribute('title')).toBe('Show line numbers');
});

// -----------------------------------------------------------------------
// COPY button
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -517,7 +636,7 @@ describe('QueryEditor', () => {
// Find the dismiss button (the X button that isn't the error X)
const buttons = container.querySelectorAll('button[type="button"]');
// The dismiss button is the one next to the Generate button
const dismissBtn = Array.from(buttons).find(btn => {
const dismissBtn = Array.from(buttons as NodeListOf<HTMLButtonElement>).find(btn => {
const svg = btn.querySelector('.lucide-x');
return svg !== null;
});
Expand Down