fix(ui): harden Ctrl+L selection capture and menu delivery
Cover CodeMirror and DOM capture paths in tests, collapse text-control selections after capture, and deliver the desktop Edit-menu action over a single IPC channel so append cannot double-fire. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
co-authored by
Serhii Dziupin
parent
3ad3f21024
commit
682c42df8f
@@ -2250,6 +2250,13 @@ const dispatchMenuAction = (action) => {
|
||||
dispatchDomEventToWindow(target, 'openchamber:menu-action', action);
|
||||
};
|
||||
|
||||
// Append-style menu actions must reach the renderer exactly once. Dual IPC+DOM
|
||||
// delivery (dispatchMenuAction) would insert the selection twice.
|
||||
const dispatchAddSelectionToChat = () => {
|
||||
const target = getMenuTargetWindow();
|
||||
if (target) emitToWindow(target, 'openchamber:menu-action', 'add-selection-to-chat');
|
||||
};
|
||||
|
||||
// Mini-chat draft windows are not deduplicated, so this must reach the renderer
|
||||
// exactly once — emitToWindow alone (no DOM-event double dispatch). The renderer
|
||||
// resolves the active directory/project and opens the window.
|
||||
@@ -4559,7 +4566,7 @@ const buildMacMenu = () => {
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut' },
|
||||
{ label: 'Copy', accelerator: 'Cmd+C', click: () => handleCopyAction() },
|
||||
{ label: 'Add Selection to Chat', accelerator: 'Cmd+L', registerAccelerator: false, click: () => dispatchAction('add-selection-to-chat') },
|
||||
{ label: 'Add Selection to Chat', accelerator: 'Cmd+L', registerAccelerator: false, click: () => dispatchAddSelectionToChat() },
|
||||
{ role: 'paste' },
|
||||
{ role: 'selectAll' },
|
||||
],
|
||||
@@ -4657,7 +4664,7 @@ const buildAutoHiddenMenu = () => {
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut' },
|
||||
{ label: 'Copy', accelerator: 'Ctrl+C', click: () => handleCopyAction() },
|
||||
{ label: 'Add Selection to Chat', accelerator: 'Ctrl+L', registerAccelerator: false, click: () => dispatchAction('add-selection-to-chat') },
|
||||
{ label: 'Add Selection to Chat', accelerator: 'Ctrl+L', registerAccelerator: false, click: () => dispatchAddSelectionToChat() },
|
||||
{ role: 'paste' },
|
||||
{ role: 'selectAll' },
|
||||
],
|
||||
|
||||
@@ -4,6 +4,23 @@ const focusChatInputCalls: number[] = [];
|
||||
const pendingInputCalls: Array<{ text: string | null; mode?: string }> = [];
|
||||
const activeMainTabCalls: string[] = [];
|
||||
const sessionSwitcherCalls: boolean[] = [];
|
||||
const codeMirrorDispatches: Array<{ selection: { anchor: number } }> = [];
|
||||
|
||||
type MockCodeMirrorView = {
|
||||
state: {
|
||||
selection: { main: { from: number; to: number } };
|
||||
sliceDoc: (from: number, to: number) => string;
|
||||
};
|
||||
dispatch: (transaction: { selection: { anchor: number } }) => void;
|
||||
};
|
||||
|
||||
let codeMirrorView: MockCodeMirrorView | null = null;
|
||||
|
||||
mock.module('@codemirror/view', () => ({
|
||||
EditorView: {
|
||||
findFromDOM: () => codeMirrorView,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('@/components/chat/composer/editor/dom', () => ({
|
||||
focusChatInput: () => {
|
||||
@@ -39,13 +56,28 @@ const { addSelectionToChat, captureSelectionMarkdownForChat } = await import('./
|
||||
const originalDocument = globalThis.document;
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
const installEmptySelectionEnvironment = (activeElement: Element | null = null) => {
|
||||
const installSelectionEnvironment = (options: {
|
||||
activeElement?: Element | null;
|
||||
focusedCodeMirror?: Element | null;
|
||||
selection?: Selection | null;
|
||||
} = {}) => {
|
||||
const {
|
||||
activeElement = null,
|
||||
focusedCodeMirror = null,
|
||||
selection = null,
|
||||
} = options;
|
||||
|
||||
const documentLike = {
|
||||
activeElement,
|
||||
querySelector: () => null,
|
||||
querySelector: (selector: string) => {
|
||||
if (selector === '.cm-editor.cm-focused') {
|
||||
return focusedCodeMirror;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
const windowLike = {
|
||||
getSelection: () => null,
|
||||
getSelection: () => selection,
|
||||
};
|
||||
Object.defineProperty(globalThis, 'document', { value: documentLike, configurable: true });
|
||||
Object.defineProperty(globalThis, 'window', { value: windowLike, configurable: true });
|
||||
@@ -56,6 +88,8 @@ const clearCalls = () => {
|
||||
pendingInputCalls.length = 0;
|
||||
activeMainTabCalls.length = 0;
|
||||
sessionSwitcherCalls.length = 0;
|
||||
codeMirrorDispatches.length = 0;
|
||||
codeMirrorView = null;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
@@ -69,11 +103,11 @@ describe('captureSelectionMarkdownForChat', () => {
|
||||
});
|
||||
|
||||
test('returns null when nothing is selected', () => {
|
||||
installEmptySelectionEnvironment();
|
||||
installSelectionEnvironment();
|
||||
expect(captureSelectionMarkdownForChat()).toBeNull();
|
||||
});
|
||||
|
||||
test('captures a textarea selection outside the composer', () => {
|
||||
test('captures a textarea selection outside the composer and collapses it', () => {
|
||||
const textarea = {
|
||||
tagName: 'TEXTAREA',
|
||||
value: 'alpha beta gamma',
|
||||
@@ -82,8 +116,10 @@ describe('captureSelectionMarkdownForChat', () => {
|
||||
closest: () => null,
|
||||
} as unknown as HTMLTextAreaElement;
|
||||
|
||||
installEmptySelectionEnvironment(textarea);
|
||||
installSelectionEnvironment({ activeElement: textarea });
|
||||
expect(captureSelectionMarkdownForChat()).toBe('```md\nbeta\n```');
|
||||
expect(textarea.selectionStart).toBe(10);
|
||||
expect(textarea.selectionEnd).toBe(10);
|
||||
});
|
||||
|
||||
test('ignores selections inside the chat composer', () => {
|
||||
@@ -95,7 +131,117 @@ describe('captureSelectionMarkdownForChat', () => {
|
||||
closest: (selector: string) => (selector === '[data-chat-input="true"]' ? textarea : null),
|
||||
} as unknown as HTMLTextAreaElement;
|
||||
|
||||
installEmptySelectionEnvironment(textarea);
|
||||
installSelectionEnvironment({ activeElement: textarea });
|
||||
expect(captureSelectionMarkdownForChat()).toBeNull();
|
||||
});
|
||||
|
||||
test('captures a focused CodeMirror selection outside the composer and collapses it', () => {
|
||||
const focusedEditor = {
|
||||
closest: () => null,
|
||||
} as unknown as HTMLElement;
|
||||
|
||||
codeMirrorView = {
|
||||
state: {
|
||||
selection: { main: { from: 4, to: 11 } },
|
||||
sliceDoc: (from: number, to: number) => 'const x'.slice(0, to - from),
|
||||
},
|
||||
dispatch: (transaction) => {
|
||||
codeMirrorDispatches.push(transaction);
|
||||
codeMirrorView!.state.selection.main = {
|
||||
from: transaction.selection.anchor,
|
||||
to: transaction.selection.anchor,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// sliceDoc should return the selected slice; use explicit text instead of slice math.
|
||||
codeMirrorView.state.sliceDoc = () => 'const x';
|
||||
|
||||
installSelectionEnvironment({ focusedCodeMirror: focusedEditor });
|
||||
expect(captureSelectionMarkdownForChat()).toBe('```\nconst x\n```');
|
||||
expect(codeMirrorDispatches).toEqual([{ selection: { anchor: 11 } }]);
|
||||
expect(codeMirrorView.state.selection.main).toEqual({ from: 11, to: 11 });
|
||||
});
|
||||
|
||||
test('ignores a focused CodeMirror editor inside the chat composer', () => {
|
||||
const focusedEditor = {
|
||||
closest: (selector: string) => (selector === '[data-chat-input="true"]' ? focusedEditor : null),
|
||||
} as unknown as HTMLElement;
|
||||
|
||||
codeMirrorView = {
|
||||
state: {
|
||||
selection: { main: { from: 0, to: 5 } },
|
||||
sliceDoc: () => 'draft',
|
||||
},
|
||||
dispatch: (transaction) => {
|
||||
codeMirrorDispatches.push(transaction);
|
||||
},
|
||||
};
|
||||
|
||||
installSelectionEnvironment({ focusedCodeMirror: focusedEditor });
|
||||
expect(captureSelectionMarkdownForChat()).toBeNull();
|
||||
expect(codeMirrorDispatches).toEqual([]);
|
||||
});
|
||||
|
||||
test('captures a DOM selection from chat-message content and clears it', () => {
|
||||
const parent = {
|
||||
closest: (selector: string) => (selector === 'pre code' ? null : null),
|
||||
};
|
||||
const textNode = {
|
||||
nodeType: 3,
|
||||
parentElement: parent,
|
||||
};
|
||||
let rangeCount = 1;
|
||||
let collapsed = false;
|
||||
const selection = {
|
||||
get rangeCount() {
|
||||
return rangeCount;
|
||||
},
|
||||
get isCollapsed() {
|
||||
return collapsed;
|
||||
},
|
||||
toString: () => 'Hello world',
|
||||
getRangeAt: () => ({
|
||||
commonAncestorContainer: textNode,
|
||||
startContainer: textNode,
|
||||
endContainer: textNode,
|
||||
cloneContents: () => ({ childNodes: [] }),
|
||||
}),
|
||||
removeAllRanges: () => {
|
||||
rangeCount = 0;
|
||||
collapsed = true;
|
||||
},
|
||||
} as unknown as Selection;
|
||||
|
||||
installSelectionEnvironment({ selection });
|
||||
expect(captureSelectionMarkdownForChat()).toBe('```md\nHello world\n```');
|
||||
expect(selection.rangeCount).toBe(0);
|
||||
expect(selection.isCollapsed).toBe(true);
|
||||
});
|
||||
|
||||
test('ignores a DOM selection inside the chat composer', () => {
|
||||
const composerHost = {};
|
||||
const parent = {
|
||||
closest: (selector: string) => (selector === '[data-chat-input="true"]' ? composerHost : null),
|
||||
};
|
||||
const textNode = {
|
||||
nodeType: 3,
|
||||
parentElement: parent,
|
||||
};
|
||||
const selection = {
|
||||
rangeCount: 1,
|
||||
isCollapsed: false,
|
||||
toString: () => 'draft',
|
||||
getRangeAt: () => ({
|
||||
commonAncestorContainer: textNode,
|
||||
startContainer: textNode,
|
||||
endContainer: textNode,
|
||||
cloneContents: () => ({ childNodes: [] }),
|
||||
}),
|
||||
removeAllRanges: () => undefined,
|
||||
} as unknown as Selection;
|
||||
|
||||
installSelectionEnvironment({ selection });
|
||||
expect(captureSelectionMarkdownForChat()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -113,7 +259,7 @@ describe('addSelectionToChat', () => {
|
||||
selectionEnd: 8,
|
||||
closest: () => null,
|
||||
} as unknown as HTMLTextAreaElement;
|
||||
installEmptySelectionEnvironment(textarea);
|
||||
installSelectionEnvironment({ activeElement: textarea });
|
||||
|
||||
expect(addSelectionToChat()).toBe(true);
|
||||
expect(activeMainTabCalls).toEqual(['chat']);
|
||||
@@ -124,8 +270,23 @@ describe('addSelectionToChat', () => {
|
||||
expect(focusChatInputCalls.length).toBe(1);
|
||||
});
|
||||
|
||||
test('second capture after textarea collapse does not append again', () => {
|
||||
const textarea = {
|
||||
tagName: 'TEXTAREA',
|
||||
value: 'selected',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 8,
|
||||
closest: () => null,
|
||||
} as unknown as HTMLTextAreaElement;
|
||||
installSelectionEnvironment({ activeElement: textarea });
|
||||
|
||||
expect(addSelectionToChat()).toBe(true);
|
||||
expect(addSelectionToChat()).toBe(false);
|
||||
expect(pendingInputCalls).toEqual([{ text: '```md\nselected\n```', mode: 'append' }]);
|
||||
});
|
||||
|
||||
test('focuses chat input when nothing is selected', async () => {
|
||||
installEmptySelectionEnvironment();
|
||||
installSelectionEnvironment();
|
||||
|
||||
expect(addSelectionToChat()).toBe(false);
|
||||
expect(pendingInputCalls).toEqual([]);
|
||||
|
||||
@@ -30,9 +30,16 @@ const readTextControlSelection = (element: Element): string | null => {
|
||||
const tag = element.tagName?.toLowerCase();
|
||||
if (tag === 'textarea') {
|
||||
const control = element as HTMLTextAreaElement;
|
||||
return trimSelectionValue(
|
||||
control.value.slice(control.selectionStart ?? 0, control.selectionEnd ?? 0),
|
||||
) || null;
|
||||
const start = control.selectionStart ?? 0;
|
||||
const end = control.selectionEnd ?? 0;
|
||||
const text = trimSelectionValue(control.value.slice(start, end));
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
// Collapse so a duplicate menu delivery cannot append the same range twice.
|
||||
control.selectionStart = end;
|
||||
control.selectionEnd = end;
|
||||
return text;
|
||||
}
|
||||
|
||||
if (tag === 'input') {
|
||||
@@ -41,9 +48,15 @@ const readTextControlSelection = (element: Element): string | null => {
|
||||
if (!['text', 'search', 'url', 'tel', 'password'].includes(type)) {
|
||||
return null;
|
||||
}
|
||||
return trimSelectionValue(
|
||||
control.value.slice(control.selectionStart ?? 0, control.selectionEnd ?? 0),
|
||||
) || null;
|
||||
const start = control.selectionStart ?? 0;
|
||||
const end = control.selectionEnd ?? 0;
|
||||
const text = trimSelectionValue(control.value.slice(start, end));
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
control.selectionStart = end;
|
||||
control.selectionEnd = end;
|
||||
return text;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user