Skip to content

Commit 5c552cb

Browse files
authored
fix(settings): allow text selection in settings panel (#122)
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
1 parent 1c18ddf commit 5c552cb

3 files changed

Lines changed: 72 additions & 9 deletions

File tree

src/settings/SettingsWindow.test.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,33 @@ describe('SettingsWindow', () => {
273273
expect(__mockWindow.startDragging).not.toHaveBeenCalled();
274274
});
275275

276+
it('mousedown on a text-bearing element does NOT trigger drag (so users can highlight + copy)', async () => {
277+
const marker: CorruptMarker = { path: '/tmp/config.toml.corrupt-9', ts: 9 };
278+
invokeMock.mockImplementation(async (cmd: string) => {
279+
if (cmd === 'get_corrupt_marker') return marker;
280+
return defaultInvoke(cmd);
281+
});
282+
render(<SettingsWindow />);
283+
// Banner renders <code>config.toml</code> directly inside the
284+
// banner text — a text-bearing leaf. Mousedown on it must NOT drag.
285+
const banner = await screen.findByRole('alert');
286+
const codeEl = banner.querySelector('code')!;
287+
__mockWindow.startDragging.mockClear();
288+
fireEvent.mouseDown(codeEl, { target: codeEl, button: 0 });
289+
expect(__mockWindow.startDragging).not.toHaveBeenCalled();
290+
});
291+
292+
it('mousedown with a non-primary button is ignored (no drag, lets context menus through)', async () => {
293+
render(<SettingsWindow />);
294+
await waitFor(() => screen.getByRole('tab', { name: /AI/ }));
295+
__mockWindow.startDragging.mockClear();
296+
const root = screen
297+
.getByRole('tab', { name: /AI/ })
298+
.closest('[role="tablist"]')!.parentElement!;
299+
fireEvent.mouseDown(root, { target: root, button: 2 });
300+
expect(__mockWindow.startDragging).not.toHaveBeenCalled();
301+
});
302+
276303
it('basename helper handles paths without a slash by rendering them verbatim', async () => {
277304
const marker: CorruptMarker = { path: 'config.toml.corrupt-7', ts: 7 };
278305
invokeMock.mockImplementation(async (cmd: string) => {

src/settings/SettingsWindow.tsx

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -208,16 +208,28 @@ export function SettingsWindow() {
208208
}, []);
209209

210210
/**
211-
* Native window drag from any non-interactive surface — mirrors the
212-
* chat overlay's `handleDragStart` in App.tsx. Walks up the DOM from
213-
* the click target and bails if it hits a form control or button so
214-
* those keep working; otherwise calls `startDragging()`. We do this
215-
* via JS instead of `data-tauri-drag-region` because the attribute
216-
* only initiates drag from the element it's set on (and form
217-
* children inside the body block the attribute from working there).
211+
* Native window drag from non-interactive, non-text surfaces. Walks
212+
* up the DOM and bails on:
213+
* 1. Interactive tags (form controls, buttons, links, SVGs) so
214+
* clicks on them still register as clicks, not drags.
215+
* 2. Text-bearing leaves — any element that directly contains a
216+
* non-empty text node. This lets users click-drag to highlight
217+
* labels, values, and descriptions inside the body, then Cmd+C
218+
* to copy. Without this check the whole window would slide
219+
* under the cursor and the selection would never start.
220+
*
221+
* We do this via JS instead of `data-tauri-drag-region` because the
222+
* attribute only initiates drag from the element it's set on, and
223+
* form children inside the body block it from working at the root.
224+
*
225+
* Only the primary mouse button initiates a drag; secondary/middle
226+
* clicks pass through so context menus and middle-click behaviors
227+
* are unaffected.
218228
*/
219229
const handleDragStart = useCallback((e: React.MouseEvent) => {
220-
const el = e.target as HTMLElement | null;
230+
if (e.button !== 0) return;
231+
const el = e.target as HTMLElement;
232+
221233
const INTERACTIVE_TAGS = new Set([
222234
'TEXTAREA',
223235
'INPUT',
@@ -228,11 +240,24 @@ export function SettingsWindow() {
228240
'SVG',
229241
'LABEL',
230242
]);
231-
let current = el;
243+
let current: HTMLElement | null = el;
232244
while (current) {
233245
if (INTERACTIVE_TAGS.has(current.tagName.toUpperCase())) return;
234246
current = current.parentElement;
235247
}
248+
249+
// Bail if the click landed directly on a text node. Layout
250+
// wrappers (DIV/SECTION) without their own text still drag.
251+
for (const node of Array.from(el.childNodes)) {
252+
if (
253+
node.nodeType === Node.TEXT_NODE &&
254+
node.textContent &&
255+
node.textContent.trim().length > 0
256+
) {
257+
return;
258+
}
259+
}
260+
236261
e.preventDefault();
237262
void getCurrentWindow().startDragging();
238263
}, []);

src/styles/settings.module.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@
8686
font-size: 12.5px;
8787
color: var(--color-text-primary);
8888
flex-shrink: 0;
89+
/* Banner content (corrupt-config path, file name) must be selectable
90+
* so users can copy the path. The window-wide `user-select: none` on
91+
* `.window` is overridden here. */
92+
user-select: text;
8993
box-shadow:
9094
0 2px 8px -2px rgba(0, 0, 0, 0.3),
9195
inset 0 1px 0 rgba(255, 255, 255, 0.04);
@@ -181,6 +185,13 @@
181185
position: relative;
182186
padding: 18px 22px 24px;
183187
min-width: 0;
188+
/* Body content (labels, descriptions, values) must be selectable so
189+
* users can highlight + Cmd+C. Window chrome stays unselectable via
190+
* the `user-select: none` on `.window`. The drag handler in
191+
* `SettingsWindow.tsx` is the matching half: it bails out when the
192+
* mousedown target carries a direct text node, so dragging from text
193+
* runs the selection instead of moving the window. */
194+
user-select: text;
184195
}
185196
.bodyScrollable {
186197
overflow-y: auto;

0 commit comments

Comments
 (0)