diff --git a/docs/changelog.rst b/docs/changelog.rst index 95e8ff0c2..18904cbf2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -41,6 +41,8 @@ Detailed list of changes clone local shell and editor configuration on remote machines, and automatic re-use of existing connections to avoid connection setup latency. +- When pasting URLs at shell prompts automatically quote them. Also allow filtering pasted text and confirm pastes. See :opt:`paste_actions` for details. (:iss:`4873`) + - macOS: When using Apple's less as the pager for viewing scrollback strip out OSC codes as it cant parse them (:iss:`4788`) - diff kitten: Fix incorrect rendering in rare circumstances when scrolling after changing the context size (:iss:`4831`) diff --git a/docs/kittens/custom.rst b/docs/kittens/custom.rst index 12f74ba43..d7704831c 100644 --- a/docs/kittens/custom.rst +++ b/docs/kittens/custom.rst @@ -35,7 +35,7 @@ your machine). # get the kitty window into which to paste answer w = boss.window_id_map.get(target_window_id) if w is not None: - w.paste(answer) + w.paste_text(answer) Now in :file:`kitty.conf` add the lines:: diff --git a/kittens/hints/main.py b/kittens/hints/main.py index eb47a4516..002747c98 100644 --- a/kittens/hints/main.py +++ b/kittens/hints/main.py @@ -787,7 +787,7 @@ def handle_result(args: List[str], data: Dict[str, Any], target_window_id: int, if program == '-': w = boss.window_id_map.get(target_window_id) if w is not None: - w.paste(joined_text()) + w.paste_text(joined_text()) elif program == '@': set_clipboard_string(joined_text()) elif program == '*': diff --git a/kittens/unicode_input/main.py b/kittens/unicode_input/main.py index 0f0f40e5d..e4429f00b 100644 --- a/kittens/unicode_input/main.py +++ b/kittens/unicode_input/main.py @@ -591,7 +591,7 @@ def main(args: List[str]) -> Optional[str]: def handle_result(args: List[str], current_char: str, target_window_id: int, boss: BossType) -> None: w = boss.window_id_map.get(target_window_id) if w is not None: - w.paste(current_char) + w.paste_text(current_char) if __name__ == '__main__': diff --git a/kitty/boss.py b/kitty/boss.py index a47157ebb..c4d6104d0 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -1240,7 +1240,7 @@ class Boss: text = ' '.join(map(shlex.quote, urls)) else: text = '\n'.join(urls) - w.paste(text) + w.paste_text(text) @ac('win', 'Focus the nth OS window') def nth_os_window(self, num: int = 1) -> None: @@ -1590,7 +1590,7 @@ class Boss: if text: w = self.active_window if w is not None: - w.paste(text) + w.paste_with_actions(text) @ac('cp', 'Paste from the clipboard to the active window') def paste_from_clipboard(self) -> None: diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 1d924b3bb..8a02aea46 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -447,6 +447,21 @@ system clipboard. ''' ) +opt( + 'paste_actions', 'quote-urls-at-prompt', option_type='paste_actions', + long_text=''' +A comma separated list of actions to take when pasting text into the terminal. +Possibilities are: + +* quote-urls-at-prompt: If the text being pasted is a URL and the cursor is at a shell prompt, +automatically quote the URL (needs :ref:`shell_integration`). +* confirm: Confirm the paste if bracketed paste mode is not active or there is more +a large amount of text being pasted. +* filter: Run the filter_paste() function from the file :file:`paste-actions.py` in +the kitty config directory on the pasted text. The text returned by the function will be actually pasted. +''' +) + opt('strip_trailing_spaces', 'never', choices=('always', 'never', 'smart'), long_text=''' diff --git a/kitty/options/parse.py b/kitty/options/parse.py index 4ea7d98dd..a01517196 100644 --- a/kitty/options/parse.py +++ b/kitty/options/parse.py @@ -12,9 +12,9 @@ from kitty.options.utils import ( deprecated_hide_window_decorations_aliases, deprecated_macos_show_window_title_in_menubar_alias, deprecated_send_text, disable_ligatures, edge_width, env, font_features, hide_window_decorations, macos_option_as_alt, macos_titlebar_color, narrow_symbols, optional_edge_width, parse_map, - parse_mouse_map, resize_draw_strategy, scrollback_lines, scrollback_pager_history_size, - shell_integration, store_multiple, symbol_map, tab_activity_symbol, tab_bar_edge, - tab_bar_margin_height, tab_bar_min_tabs, tab_fade, tab_font_style, tab_separator, + parse_mouse_map, paste_actions, resize_draw_strategy, scrollback_lines, + scrollback_pager_history_size, shell_integration, store_multiple, symbol_map, tab_activity_symbol, + tab_bar_edge, tab_bar_margin_height, tab_bar_min_tabs, tab_fade, tab_font_style, tab_separator, tab_title_template, titlebar_color, to_cursor_shape, to_font_size, to_layout_names, to_modifiers, url_prefixes, url_style, visual_window_select_characters, window_border_width, window_size ) @@ -1086,6 +1086,9 @@ class Parser: def open_url_with(self, val: str, ans: typing.Dict[str, typing.Any]) -> None: ans['open_url_with'] = to_cmdline(val) + def paste_actions(self, val: str, ans: typing.Dict[str, typing.Any]) -> None: + ans['paste_actions'] = paste_actions(val) + def placement_strategy(self, val: str, ans: typing.Dict[str, typing.Any]) -> None: val = val.lower() if val not in self.choices_for_placement_strategy: diff --git a/kitty/options/types.py b/kitty/options/types.py index 707ce8329..72c064122 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -390,6 +390,7 @@ option_names = ( # {{{ 'mouse_map', 'narrow_symbols', 'open_url_with', + 'paste_actions', 'placement_strategy', 'pointer_shape_when_dragging', 'pointer_shape_when_grabbed', @@ -536,6 +537,7 @@ class Options: mark3_foreground: Color = Color(0, 0, 0) mouse_hide_wait: float = 0.0 if is_macos else 3.0 open_url_with: typing.List[str] = ['default'] + paste_actions: typing.FrozenSet[str] = frozenset({'quote-urls-at-prompt'}) placement_strategy: choices_for_placement_strategy = 'center' pointer_shape_when_dragging: choices_for_pointer_shape_when_dragging = 'beam' pointer_shape_when_grabbed: choices_for_pointer_shape_when_grabbed = 'arrow' diff --git a/kitty/options/utils.py b/kitty/options/utils.py index 62891c47c..47d7b8191 100644 --- a/kitty/options/utils.py +++ b/kitty/options/utils.py @@ -808,6 +808,15 @@ def shell_integration(x: str) -> FrozenSet[str]: return q +def paste_actions(x: str) -> FrozenSet[str]: + s = frozenset({'quote-urls-at-prompt', 'confirm', 'filter'}) + q = frozenset(x.lower().split(',')) + if not q.issubset(s): + log_error(f'Invalid paste actions: {q - s}, ignoring') + return q & s or frozenset({'invalid'}) + return q + + def action_alias(val: str) -> Iterable[Tuple[str, str]]: parts = val.split(maxsplit=1) if len(parts) > 1: diff --git a/kitty/window.py b/kitty/window.py index 971ba974c..18ea3f071 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -20,7 +20,7 @@ from typing import ( from .child import ProcessDesc from .cli_stub import CLIOptions from .config import build_ansi_color_table -from .constants import appname, is_macos, wakeup +from .constants import appname, is_macos, wakeup, config_dir from .fast_data_types import ( BGIMAGE_PROGRAM, BLIT_PROGRAM, CELL_BG_PROGRAM, CELL_FG_PROGRAM, CELL_PROGRAM, CELL_SPECIAL_PROGRAM, CURSOR_BEAM, CURSOR_BLOCK, @@ -42,7 +42,7 @@ from .notify import NotificationCommand, handle_notification_cmd from .options.types import Options from .rgb import to_color from .terminfo import get_capabilities -from .types import MouseEvent, WindowGeometry, ac +from .types import MouseEvent, WindowGeometry, ac, run_once from .typing import BossType, ChildType, EdgeLiteral, TabType, TypedDict from .utils import ( get_primary_selection, kitty_ansi_sanitizer_pat, load_shaders, log_error, @@ -339,6 +339,23 @@ def setup_colors(screen: Screen, opts: Options) -> None: ) +@run_once +def load_paste_filter() -> Callable[[str], str]: + import runpy + import traceback + try: + m = runpy.run_path(os.path.join(config_dir, 'paste-actions.py')) + func: Callable[[str], str] = m['filter_paste'] + except Exception as e: + if not isinstance(e, FileNotFoundError): + traceback.print_exc() + log_error(f'Failed to load custom draw_tab function with error: {e}') + + def func(text: str) -> str: + return text + return func + + def text_sanitizer(as_ansi: bool, add_wrap_markers: bool) -> Callable[[str], str]: pat = kitty_ansi_sanitizer_pat() ansi, wrap_markers = not as_ansi, not add_wrap_markers @@ -1131,13 +1148,13 @@ class Window: def paste_selection(self) -> None: txt = get_boss().current_primary_selection() if txt: - self.paste(txt) + self.paste_with_actions(txt) @ac('mouse', 'Paste the current primary selection or the clipboard if no selection is present') def paste_selection_or_clipboard(self) -> None: txt = get_boss().current_primary_selection_or_clipboard() if txt: - self.paste(txt) + self.paste_with_actions(txt) @ac('mouse', ''' Select clicked command output @@ -1236,6 +1253,58 @@ class Window: path = resolve_custom_file(path) if path else '' set_window_logo(self.os_window_id, self.tab_id, self.id, path, position or '', alpha) + def paste_with_actions(self, text: str) -> None: + if self.destroyed or not text: + return + opts = get_options() + if 'filter' in opts.paste_actions: + text = load_paste_filter()(text) + if not text: + return + if 'quote-urls-at-prompt' in opts.paste_actions and self.at_prompt: + prefixes = '|'.join(opts.url_prefixes) + if re.match(f'{prefixes}:', text) is not None: + import shlex + text = shlex.quote(text) + btext = text.encode('utf-8') + if 'confirm' in opts.paste_actions: + msg = '' + limit = 16 * 1024 + if not self.screen.in_bracketed_paste_mode: + msg = _('Pasting text into shells that do not support bracketed paste can be dangerous.') + elif len(btext) > limit: + msg = _('Pasting very large amounts of text ({} bytes) can be slow.').format(len(btext)) + if msg: + get_boss().confirm(msg + _(' Are you sure?'), partial(self.handle_paste_confirmation, btext), window=self) + return + self.paste_text(btext) + + def handle_paste_confirmation(self, btext: bytes, confirmed: bool) -> None: + if confirmed: + self.paste_text(btext) + + def paste_bytes(self, text: Union[str, bytes]) -> None: + # paste raw bytes without any processing + if isinstance(text, str): + text = text.encode('utf-8') + self.screen.paste_bytes(text) + + def paste_text(self, text: Union[str, bytes]) -> None: + if text and not self.destroyed: + if isinstance(text, str): + text = text.encode('utf-8') + if self.screen.in_bracketed_paste_mode: + while True: + new_text = text.replace(b'\033[201~', b'').replace(b'\x9b201~', b'') + if len(text) == len(new_text): + break + text = new_text + else: + # Workaround for broken editors like nano that cannot handle + # newlines in pasted text see https://github.com/kovidgoyal/kitty/issues/994 + text = text.replace(b'\r\n', b'\n').replace(b'\n', b'\r') + self.screen.paste(text) + # actions {{{ @ac('cp', 'Show scrollback in a pager like less') @@ -1275,28 +1344,9 @@ class Window: def show_last_visited_command_output(self) -> None: self.show_cmd_output(CommandOutput.last_visited, 'Last visited command output') - def paste_bytes(self, text: Union[str, bytes]) -> None: - # paste raw bytes without any processing - if isinstance(text, str): - text = text.encode('utf-8') - self.screen.paste_bytes(text) - @ac('cp', 'Paste the specified text into the current window') - def paste(self, text: Union[str, bytes]) -> None: - if text and not self.destroyed: - if isinstance(text, str): - text = text.encode('utf-8') - if self.screen.in_bracketed_paste_mode: - while True: - new_text = text.replace(b'\033[201~', b'').replace(b'\x9b201~', b'') - if len(text) == len(new_text): - break - text = new_text - else: - # Workaround for broken editors like nano that cannot handle - # newlines in pasted text see https://github.com/kovidgoyal/kitty/issues/994 - text = text.replace(b'\r\n', b'\n').replace(b'\n', b'\r') - self.screen.paste(text) + def paste(self, text: str) -> None: + self.paste_with_actions(text) @ac('cp', 'Copy the selected text from the active window to the clipboard') def copy_to_clipboard(self) -> None: