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
124 changes: 98 additions & 26 deletions src/components/QueryEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,20 @@ export interface QueryEditorRef {
interface QueryEditorProps {
/** Initial value for the editor. Changes to this prop will update the editor content. */
value: string;
/**
* Which document `value` belongs to, so that switching documents is an event and not a
* string comparison. The tab id in both hosts.
*
* Without it the editor cannot see a switch to a tab whose text happens to equal the
* string it was already holding, and a new tab therefore opens showing the previous
* tab's text (#808): the parent mirrors typing one render behind, so at the moment a
* new empty tab arrives `value` can still be the empty string it started as, and a
* prop that never changed cannot announce anything.
*
* Optional: a host that renders a single document never switches, and omitting it
* leaves the text-only reconciliation below in charge.
*/
documentId?: string;
/** Optional callback for value changes. Only called on blur, execute, or explicit sync - NOT on every keystroke. */
onChange?: (val: string) => void;
/** Called when content changes in real-time. Use sparingly as it triggers on every keystroke. */
Expand Down Expand Up @@ -110,7 +124,17 @@ const getEditorOptions = (showLineNumbers: boolean) => ({

export const QueryEditor = forwardRef<QueryEditorRef, QueryEditorProps>(
(
{ value, onChange, onContentChange, onExplain, language = "sql", databaseType, schemaContext, capabilities },
{
value,
documentId,
onChange,
onContentChange,
onExplain,
language = "sql",
databaseType,
schemaContext,
capabilities,
},
ref,
) => {
const monaco = useMonacoInstance();
Expand Down Expand Up @@ -141,24 +165,62 @@ export const QueryEditor = forwardRef<QueryEditorRef, QueryEditorProps>(
// value at hydration, so there is no local default left that could overwrite it.
const showLineNumbers = useLineNumbersPreference();

// Track last synced value to detect external changes
const lastSyncedValueRef = useRef<string>(value);
const isInternalChangeRef = useRef<boolean>(false);

// Sync editor content when value prop changes externally (e.g., tab switch)
/*
Every text this editor has handed up through `onContentChange` and has not yet seen
come back down as `value`, oldest first, plus the document they belong to.

The parent mirrors the buffer: it writes each change into the tab's query and feeds
that straight back in as `value`, one render behind. An incoming `value` is
therefore one of two completely different things, and only one of them may touch
the model:

- OUR OWN text, arriving late. Writing it back rewrites the buffer with an older
string and moves the caret, which is the "typing scrambles, cursor jumps to
line 1" bug (#808).
- Somebody ELSE's text: another tab, a query loaded from history or the saved
list, a generated statement. That has to land.

An outstanding text is ours by construction, so this tells them apart by identity.
Asking instead whether the buffer has moved since the last sync can only infer it,
and an external write that arrives while the user is typing looks exactly like a
late echo under that reading.

Two equal strings need no tie-break: if an external write happens to carry text this
editor just sent up, applying it or skipping it leaves the same buffer.
*/
const echoesRef = useRef<{ documentId?: string; values: string[] }>({ documentId, values: [] });

// The ONE path that pushes an external value change into the model, now that
// <Editor> is uncontrolled (defaultValue) and the library's own controlled-value
// effect stays at its early return.
useEffect(() => {
if (editorRef.current && value !== lastSyncedValueRef.current) {
const currentEditorValue = editorRef.current.getValue();
// Only update if the new value is different from current editor content
// This prevents unnecessary updates when we're the source of the change
if (value !== currentEditorValue) {
isInternalChangeRef.current = true;
editorRef.current.setValue(value);
lastSyncedValueRef.current = value;
isInternalChangeRef.current = false;
}
const editor = editorRef.current;
if (!editor) return;

const echoes = echoesRef.current;

// A different document is in front of the user now: its text is authoritative
// whatever the buffer holds, and nothing the editor sent up belongs to it.
if (documentId !== echoes.documentId) {
echoesRef.current = { documentId, values: [] };
if (value !== editor.getValue()) editor.setValue(value);
return;
}

const echoIndex = echoes.values.indexOf(value);
if (echoIndex !== -1) {
// Ours, arriving late. Drop it and everything older with it: the parent moves
// through our texts in order, so a render carrying one of those cannot follow.
echoes.values.splice(0, echoIndex + 1);
return;
}
}, [value]);

// Nobody here wrote this, so it came from outside: a query loaded from history or
// the saved list, a generated statement. It replaces the buffer, which makes every
// outstanding echo a description of text that no longer exists.
echoes.values = [];
editor.setValue(value);
}, [value, documentId]);

// Update editor options when line numbers toggle changes
useEffect(() => {
Expand Down Expand Up @@ -243,7 +305,6 @@ export const QueryEditor = forwardRef<QueryEditorRef, QueryEditorProps>(
return;
}
editorRef.current.setValue(formatted);
lastSyncedValueRef.current = formatted;
onChange?.(formatted);
} catch (e) {
logger.warn("Statement formatting failed; the editor text is left as written", {
Expand Down Expand Up @@ -372,7 +433,6 @@ export const QueryEditor = forwardRef<QueryEditorRef, QueryEditorProps>(
setValue: (newValue: string) => {
if (editorRef.current) {
editorRef.current.setValue(newValue);
lastSyncedValueRef.current = newValue;
}
},
focus: () => editorRef.current?.focus(),
Expand All @@ -394,7 +454,6 @@ export const QueryEditor = forwardRef<QueryEditorRef, QueryEditorProps>(
const handleClear = () => {
if (editorRef.current) {
editorRef.current.setValue("");
lastSyncedValueRef.current = "";
onChange?.("");
}
};
Expand Down Expand Up @@ -452,18 +511,22 @@ export const QueryEditor = forwardRef<QueryEditorRef, QueryEditorProps>(
}
}, [monaco, language, schemaCompletionCache]);

// Every model change reaches here: a keystroke, and equally the writes Format, Clear
// and the imperative setValue make, since Monaco reports those through the same
// change event. All of them are this editor's own text, so all of them are recorded
// before they go up, and none of them may come back down into the buffer.
const handleEditorChange = (val: string | undefined) => {
const newValue = val || "";
// Only call onContentChange if provided (for real-time sync scenarios)
// This avoids the performance hit of updating parent state on every keystroke
onContentChange?.(newValue);
if (onContentChange) {
echoesRef.current.values.push(newValue);
onContentChange(newValue);
}
};

// Sync to parent on blur (when user leaves the editor)
const handleEditorBlur = () => {
if (editorRef.current) {
const currentValue = editorRef.current.getValue();
lastSyncedValueRef.current = currentValue;
onChange?.(currentValue);
}
};
Expand All @@ -472,7 +535,6 @@ export const QueryEditor = forwardRef<QueryEditorRef, QueryEditorProps>(
// Sync current content to parent before executing
if (editorRef.current) {
const currentValue = editorRef.current.getValue();
lastSyncedValueRef.current = currentValue;
onChange?.(currentValue);
}

Expand Down Expand Up @@ -572,7 +634,17 @@ export const QueryEditor = forwardRef<QueryEditorRef, QueryEditorProps>(
height="100%"
language={language}
theme={editorTheme}
value={value}
// `defaultValue`, not `value`: this editor owns its buffer, and the model is
// never driven by a prop. `@monaco-editor/react`'s controlled-`value` effect
// runs an `executeEdits` over the FULL model range whenever the prop differs
// from the buffer, and the prop is the parent's mirror of our own text, one
// render behind. A keystroke landing inside that window therefore made the
// library rewrite the whole buffer with older text and snap the caret to
// (1,1): the "typing scrambles / cursor jumps" bug (#808), easiest to hit
// where a render is slow. Passing `defaultValue` leaves that effect at its
// `t === void 0` early return, which makes the effect above the single place
// an outside change can reach the model.
defaultValue={value}
beforeMount={handleBeforeMount}
onChange={handleEditorChange}
loading={
Expand Down
1 change: 1 addition & 0 deletions src/components/Studio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,7 @@ export default function Studio() {
<QueryEditor
ref={queryEditorRef}
value={tabMgr.currentTab.query}
documentId={tabMgr.currentTab.id}
onContentChange={(val) => tabMgr.updateTabById(tabMgr.currentTab.id, { query: val })}
onExplain={
metadata?.capabilities.supportsExplain
Expand Down
1 change: 1 addition & 0 deletions src/workspace/StudioWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,7 @@ export function StudioWorkspace({
<QueryEditor
ref={queryEditorRef}
value={tabMgr.currentTab.query}
documentId={tabMgr.currentTab.id}
onContentChange={(val) => tabMgr.updateTabById(tabMgr.currentTab.id, { query: val })}
language={editorLanguageForTabType(tabMgr.currentTab.type)}
databaseType={conn.activeConnection?.type}
Expand Down
Loading
Loading