Skip to content

Commit fdff65d

Browse files
fix(schema): make double-click insert work in Firefox
Each schema row's single-click handler re-renders the tree (replaceChildren), swapping the row node between a double-click's two clicks. Firefox won't fire `dblclick` across that node swap (Chrome tolerates it), so double-clicking a table/db/column did nothing in Firefox. Detect the double-click ourselves instead of relying on the native event: track the last click on `app` (per instance) and treat a quick repeat on the same row as the double. Single click stays instant; works the same in both engines. Verified live in Firefox on antalya. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QGBS74oUsXarGkCRQKEFLu
1 parent 2ed1210 commit fdff65d

2 files changed

Lines changed: 68 additions & 9 deletions

File tree

src/ui/schema.js

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,23 @@ const treeRow = (icon, label, meta, { expanded, iconColor } = {}) => [
2323
h('span', { class: 'meta' }, meta),
2424
];
2525

26+
// Distinguish single- from double-click WITHOUT the native `dblclick` event.
27+
// Every row's single-click handler re-renders the tree (replaceChildren), which
28+
// swaps the row node between a double-click's two clicks — and Firefox refuses to
29+
// fire `dblclick` across that node swap (Chrome tolerates it), so the schema
30+
// double-clicks silently did nothing in Firefox. Instead we record the last
31+
// click on `app` (per instance → tests stay isolated) and treat a quick repeat
32+
// on the same row as the double. Single click stays instant; the double runs in
33+
// addition to that first click's expand.
34+
const DBLCLICK_MS = 300;
35+
function isDoubleClick(app, key) {
36+
const now = Date.now();
37+
const last = app._schemaClick;
38+
const dbl = !!last && last.key === key && now - last.at < DBLCLICK_MS;
39+
app._schemaClick = dbl ? null : { key, at: now };
40+
return dbl;
41+
}
42+
2643
export function renderSchema(app) {
2744
const list = app.dom.schemaList;
2845
if (!list) return;
@@ -52,10 +69,10 @@ export function renderSchema(app) {
5269
title: 'Click to expand · double-click to insert · shift-click for SHOW CREATE',
5370
onclick: (e) => {
5471
if (e.shiftKey) { app.actions.insertCreate('DATABASE ' + db.db); return; }
72+
if (isDoubleClick(app, 'db:' + db.db)) { app.actions.insertAtCursor(db.db); return; }
5573
db.expanded = !db.expanded;
5674
renderSchema(app);
5775
},
58-
ondblclick: (e) => { e.stopPropagation(); app.actions.insertAtCursor(db.db); },
5976
...dragProps(db.db),
6077
},
6178
...treeRow(Icon.database(), db.db, String(db.tables.length), { expanded: db.expanded }),
@@ -81,12 +98,12 @@ export function renderSchema(app) {
8198
...dragProps(key),
8299
onclick: (e) => {
83100
if (e.shiftKey) { app.actions.insertCreate(key); return; }
101+
if (isDoubleClick(app, 'tb:' + key)) { app.actions.replaceEditor('SELECT * FROM ' + key + ' LIMIT 100'); return; }
84102
if (state.expandedTables.has(key)) state.expandedTables.delete(key);
85103
else state.expandedTables.add(key);
86104
if (state.expandedTables.has(key) && tb.columns == null) app.actions.loadColumns(db.db, tb.name, tb);
87105
else renderSchema(app);
88106
},
89-
ondblclick: (e) => { e.stopPropagation(); app.actions.replaceEditor('SELECT * FROM ' + key + ' LIMIT 100'); },
90107
},
91108
...treeRow(Icon.table(), tb.name, formatRows(tb.total_rows), { expanded: isOpen, iconColor: 'var(--accent)' }),
92109
));
@@ -105,8 +122,11 @@ export function renderSchema(app) {
105122
style: { paddingLeft: '38px' },
106123
title: (c.comment && c.comment.trim())
107124
|| ('Double-click or drag to insert ' + c.name + ' · shift-click for ' + c.name + '::' + c.type),
108-
onclick: (e) => { e.stopPropagation(); if (e.shiftKey) app.actions.insertAtCursor(c.name + '::' + c.type); },
109-
ondblclick: (e) => { e.stopPropagation(); app.actions.insertAtCursor(c.name); },
125+
onclick: (e) => {
126+
e.stopPropagation();
127+
if (e.shiftKey) { app.actions.insertAtCursor(c.name + '::' + c.type); return; }
128+
if (isDoubleClick(app, 'col:' + key + '.' + c.name)) app.actions.insertAtCursor(c.name);
129+
},
110130
...dragProps(c.name),
111131
},
112132
...treeRow(Icon.col(), c.name, c.type, { expanded: null, iconColor: 'var(--fg-faint)' }),

tests/unit/schema.test.js

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
import { describe, it, expect } from 'vitest';
1+
import { describe, it, expect, vi } from 'vitest';
22
import { renderSchema } from '../../src/ui/schema.js';
33
import { IDENT_MIME } from '../../src/ui/editor.js';
44
import { makeApp } from '../helpers/fake-app.js';
55

66
const rows = (app) => [...app.dom.schemaList.querySelectorAll('.tree-row')];
77
const click = (el) => el.dispatchEvent(new Event('click', { bubbles: true }));
88
const shiftClick = (el) => el.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }));
9-
const dblclick = (el) => el.dispatchEvent(new Event('dblclick', { bubbles: true }));
9+
// A double-click is two quick clicks on the same row — the app detects it itself
10+
// rather than via the native `dblclick` event (which Firefox drops when the row
11+
// re-renders between clicks). Clicking the same captured node twice works even
12+
// though the first click detaches it: the listener + per-app state still fire.
13+
const dblclick = (el) => { click(el); click(el); };
1014
// Fire a dragstart with a stub dataTransfer and return what setData captured.
1115
const dragstart = (el) => {
1216
const e = new Event('dragstart', { bubbles: true });
@@ -129,7 +133,7 @@ describe('renderSchema tree', () => {
129133
renderSchema(app);
130134
expect(app.dom.schemaList.textContent).toContain('loading columns…');
131135
});
132-
it('columns: plain click inserts nothing; double-click inserts name; shift-click inserts ::type', () => {
136+
it('columns: a plain click inserts nothing, a quick repeat (double-click) inserts the name', () => {
133137
const app = withSchema();
134138
app.state.schema[0].tables[0].columns = [
135139
{ name: 'id', type: 'UInt64', comment: 'pk' }, // comment → title branch
@@ -140,12 +144,47 @@ describe('renderSchema tree', () => {
140144
const colRow = [...app.dom.schemaList.querySelectorAll('.tree-row.small')]
141145
.find((r) => r.querySelector('.label').textContent === 'id');
142146
click(colRow);
143-
expect(app.actions.insertAtCursor).not.toHaveBeenCalled(); // single click does nothing
144-
dblclick(colRow);
147+
expect(app.actions.insertAtCursor).not.toHaveBeenCalled(); // first click does nothing
148+
click(colRow); // quick repeat → double-click
145149
expect(app.actions.insertAtCursor).toHaveBeenCalledWith('id');
150+
});
151+
it('columns: shift-click inserts name::type', () => {
152+
const app = withSchema();
153+
app.state.schema[0].tables[0].columns = [{ name: 'id', type: 'UInt64', comment: 'pk' }];
154+
app.state.expandedTables.add('db1.orders');
155+
renderSchema(app);
156+
const colRow = [...app.dom.schemaList.querySelectorAll('.tree-row.small')]
157+
.find((r) => r.querySelector('.label').textContent === 'id');
146158
shiftClick(colRow);
147159
expect(app.actions.insertAtCursor).toHaveBeenCalledWith('id::UInt64');
148160
});
161+
it('two quick clicks on different rows are two single clicks, not a double', () => {
162+
const app = withSchema();
163+
renderSchema(app);
164+
const db1Row = rows(app).find((r) => r.querySelector('.label').textContent === 'db1');
165+
const db2Row = rows(app).find((r) => r.querySelector('.label').textContent === 'db2');
166+
click(db1Row); // single: collapses db1
167+
click(db2Row); // different row → single: expands db2 (not an insert)
168+
expect(app.actions.insertAtCursor).not.toHaveBeenCalled();
169+
expect(app.state.schema[1].expanded).toBe(true);
170+
});
171+
it('a slow second click on the same row is a single click, not a double (window expired)', () => {
172+
vi.useFakeTimers();
173+
try {
174+
const app = withSchema();
175+
renderSchema(app);
176+
let db2Row = rows(app).find((r) => r.querySelector('.label').textContent === 'db2');
177+
click(db2Row); // expand db2
178+
expect(app.state.schema[1].expanded).toBe(true);
179+
vi.advanceTimersByTime(400); // past DBLCLICK_MS (300ms)
180+
db2Row = rows(app).find((r) => r.querySelector('.label').textContent === 'db2');
181+
click(db2Row); // expired → single → collapses db2, not an insert
182+
expect(app.actions.insertAtCursor).not.toHaveBeenCalled();
183+
expect(app.state.schema[1].expanded).toBe(false);
184+
} finally {
185+
vi.useRealTimers();
186+
}
187+
});
149188
});
150189

151190
describe('renderSchema drag sources', () => {

0 commit comments

Comments
 (0)