fix(terminal): restore terminal text copy behavior (#452)
* fix(terminal): restore terminal text copy behavior * fix(desktop): route Cmd+C through terminal-aware copy handler on macOS - Use a custom macOS Copy menu action to dispatch app copy events first, so terminal selections copy without native “no target” beep, while preserving DOM copy fallback for non-terminal contexts. --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
7bcf848035
commit
881e8bdf4f
@@ -98,6 +98,8 @@ const MENU_ITEM_OPEN_FILES_TAB_ID: &str = "menu_open_files_tab";
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
const MENU_ITEM_OPEN_TERMINAL_TAB_ID: &str = "menu_open_terminal_tab";
|
const MENU_ITEM_OPEN_TERMINAL_TAB_ID: &str = "menu_open_terminal_tab";
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
|
const MENU_ITEM_COPY_ID: &str = "menu_copy";
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
const MENU_ITEM_THEME_LIGHT_ID: &str = "menu_theme_light";
|
const MENU_ITEM_THEME_LIGHT_ID: &str = "menu_theme_light";
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
const MENU_ITEM_THEME_DARK_ID: &str = "menu_theme_dark";
|
const MENU_ITEM_THEME_DARK_ID: &str = "menu_theme_dark";
|
||||||
@@ -218,6 +220,7 @@ fn build_macos_menu<R: tauri::Runtime>(
|
|||||||
true,
|
true,
|
||||||
Some("Cmd+T"),
|
Some("Cmd+T"),
|
||||||
)?;
|
)?;
|
||||||
|
let copy = MenuItem::with_id(app, MENU_ITEM_COPY_ID, "Copy", true, Some("Cmd+C"))?;
|
||||||
|
|
||||||
let theme_light =
|
let theme_light =
|
||||||
MenuItem::with_id(app, MENU_ITEM_THEME_LIGHT_ID, "Light Theme", true, None::<&str>)?;
|
MenuItem::with_id(app, MENU_ITEM_THEME_LIGHT_ID, "Light Theme", true, None::<&str>)?;
|
||||||
@@ -353,7 +356,7 @@ fn build_macos_menu<R: tauri::Runtime>(
|
|||||||
&PredefinedMenuItem::redo(app, None)?,
|
&PredefinedMenuItem::redo(app, None)?,
|
||||||
&PredefinedMenuItem::separator(app)?,
|
&PredefinedMenuItem::separator(app)?,
|
||||||
&PredefinedMenuItem::cut(app, None)?,
|
&PredefinedMenuItem::cut(app, None)?,
|
||||||
&PredefinedMenuItem::copy(app, None)?,
|
©,
|
||||||
&PredefinedMenuItem::paste(app, None)?,
|
&PredefinedMenuItem::paste(app, None)?,
|
||||||
&PredefinedMenuItem::select_all(app, None)?,
|
&PredefinedMenuItem::select_all(app, None)?,
|
||||||
],
|
],
|
||||||
@@ -2462,6 +2465,10 @@ fn main() {
|
|||||||
dispatch_menu_action(app, "open-terminal-tab");
|
dispatch_menu_action(app, "open-terminal-tab");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if id == MENU_ITEM_COPY_ID {
|
||||||
|
dispatch_menu_action(app, "copy");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if id == MENU_ITEM_THEME_LIGHT_ID {
|
if id == MENU_ITEM_THEME_LIGHT_ID {
|
||||||
dispatch_menu_action(app, "theme-light");
|
dispatch_menu_action(app, "theme-light");
|
||||||
|
|||||||
@@ -339,29 +339,55 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
};
|
};
|
||||||
}, [useHiddenInputOverlay, focusHiddenInput]);
|
}, [useHiddenInputOverlay, focusHiddenInput]);
|
||||||
|
|
||||||
const copySelectionToClipboard = React.useCallback(async () => {
|
const getTerminalSelectionText = React.useCallback((): string => {
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
const terminal = terminalRef.current as unknown as {
|
||||||
return;
|
getSelection?: () => string;
|
||||||
|
} | null;
|
||||||
|
if (!terminal || typeof terminal.getSelection !== 'function') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const text = terminal.getSelection();
|
||||||
|
return typeof text === 'string' ? text : '';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getDomSelectionTextInViewport = React.useCallback((): string => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!selection) {
|
if (!selection) {
|
||||||
return;
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = selection.toString();
|
const text = selection.toString();
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
return;
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) {
|
if (!container) {
|
||||||
return;
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const anchorNode = selection.anchorNode;
|
const anchorNode = selection.anchorNode;
|
||||||
const focusNode = selection.focusNode;
|
const focusNode = selection.focusNode;
|
||||||
if (anchorNode && !container.contains(anchorNode)) {
|
if (anchorNode && !container.contains(anchorNode)) {
|
||||||
return;
|
return '';
|
||||||
}
|
}
|
||||||
if (focusNode && !container.contains(focusNode)) {
|
if (focusNode && !container.contains(focusNode)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const copySelectionToClipboard = React.useCallback(async () => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = getTerminalSelectionText() || getDomSelectionTextInViewport();
|
||||||
|
if (!text.trim()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,37 +414,34 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [getDomSelectionTextInViewport, getTerminalSelectionText]);
|
||||||
|
|
||||||
const hasCopyableSelectionInViewport = React.useCallback((): boolean => {
|
const hasCopyableSelectionInViewport = React.useCallback((): boolean => {
|
||||||
|
const terminalSelection = getTerminalSelectionText();
|
||||||
|
if (terminalSelection.trim()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Boolean(getDomSelectionTextInViewport().trim());
|
||||||
|
}, [getDomSelectionTextInViewport, getTerminalSelectionText]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return false;
|
return;
|
||||||
}
|
|
||||||
const selection = window.getSelection();
|
|
||||||
if (!selection) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const text = selection.toString();
|
|
||||||
if (!text.trim()) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = containerRef.current;
|
const handleMenuCopy = (event: Event) => {
|
||||||
if (!container) {
|
if (!hasCopyableSelectionInViewport()) {
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
void copySelectionToClipboard();
|
||||||
|
};
|
||||||
|
|
||||||
const anchorNode = selection.anchorNode;
|
window.addEventListener('openchamber:copy', handleMenuCopy);
|
||||||
const focusNode = selection.focusNode;
|
return () => {
|
||||||
if (anchorNode && !container.contains(anchorNode)) {
|
window.removeEventListener('openchamber:copy', handleMenuCopy);
|
||||||
return false;
|
};
|
||||||
}
|
}, [copySelectionToClipboard, hasCopyableSelectionInViewport]);
|
||||||
if (focusNode && !container.contains(focusNode)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetWriteState = React.useCallback(() => {
|
const resetWriteState = React.useCallback(() => {
|
||||||
pendingWriteRef.current = '';
|
pendingWriteRef.current = '';
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type MenuAction =
|
|||||||
| 'open-diff-tab'
|
| 'open-diff-tab'
|
||||||
| 'open-files-tab'
|
| 'open-files-tab'
|
||||||
| 'open-terminal-tab'
|
| 'open-terminal-tab'
|
||||||
|
| 'copy'
|
||||||
| 'theme-light'
|
| 'theme-light'
|
||||||
| 'theme-dark'
|
| 'theme-dark'
|
||||||
| 'theme-system'
|
| 'theme-system'
|
||||||
@@ -152,6 +153,15 @@ export const useMenuActions = (
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'copy': {
|
||||||
|
const copyEvent = new Event('openchamber:copy', { cancelable: true });
|
||||||
|
const wasHandled = !window.dispatchEvent(copyEvent);
|
||||||
|
if (!wasHandled) {
|
||||||
|
document.execCommand('copy');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'theme-light':
|
case 'theme-light':
|
||||||
setThemeMode('light');
|
setThemeMode('light');
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -888,11 +888,8 @@ html:not(.dark) .chat-scroll {
|
|||||||
/* Keep Ghostty's internal focus textarea fully non-painting on desktop. */
|
/* Keep Ghostty's internal focus textarea fully non-painting on desktop. */
|
||||||
.terminal-viewport-container textarea:not([data-terminal-hidden-input="true"]) {
|
.terminal-viewport-container textarea:not([data-terminal-hidden-input="true"]) {
|
||||||
opacity: 0 !important;
|
opacity: 0 !important;
|
||||||
width: 0 !important;
|
|
||||||
height: 0 !important;
|
|
||||||
font-size: 0 !important;
|
font-size: 0 !important;
|
||||||
line-height: 0 !important;
|
line-height: 0 !important;
|
||||||
clip-path: inset(100%) !important;
|
|
||||||
color: transparent !important;
|
color: transparent !important;
|
||||||
-webkit-text-fill-color: transparent !important;
|
-webkit-text-fill-color: transparent !important;
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
@@ -908,9 +905,6 @@ input[aria-label="Terminal input"] {
|
|||||||
color: transparent !important;
|
color: transparent !important;
|
||||||
-webkit-text-fill-color: transparent !important;
|
-webkit-text-fill-color: transparent !important;
|
||||||
opacity: 0 !important;
|
opacity: 0 !important;
|
||||||
width: 0 !important;
|
|
||||||
height: 0 !important;
|
|
||||||
clip-path: inset(100%) !important;
|
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user