From 1454af2d416f0eb738c2268ee3297cacb0215dd0 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 7 Jan 2022 18:43:07 +0530 Subject: [PATCH] macOS: Allow customizing the launch actions --- __main__.py | 22 ++++++++++++ kitty/boss.py | 67 +++++++++++------------------------ kitty/conf/utils.py | 3 +- kitty/launch.py | 24 +++++++++++++ kitty/open_actions.py | 69 +++++++++++++++++++++++++++++++++---- kitty_tests/open_actions.py | 5 +-- 6 files changed, 135 insertions(+), 55 deletions(-) diff --git a/__main__.py b/__main__.py index 235c3d6b1..a99c18c87 100644 --- a/__main__.py +++ b/__main__.py @@ -64,6 +64,27 @@ def launch(args: List[str]) -> None: runpy.run_path(exe, run_name='__main__') +def shebang(args: List[str]) -> None: + script_path = args[1] + cmd = args[2:] + if cmd == ['__ext__']: + cmd = [os.path.splitext(script_path)[1][1:].lower()] + try: + f = open(script_path) + except FileNotFoundError: + raise SystemExit(f'The file {script_path} does not exist') + with f: + if f.read(2) == '#!': + line = f.readline().strip() + _plat = sys.platform.lower() + is_macos: bool = 'darwin' in _plat + if is_macos: + cmd = line.split(' ') + else: + cmd = line.split(' ', maxsplit=1) + os.execvp(cmd[0], cmd + [script_path]) + + def run_kitten(args: List[str]) -> None: try: kitten = args[1] @@ -110,6 +131,7 @@ namespaced_entry_points['runpy'] = runpy namespaced_entry_points['launch'] = launch namespaced_entry_points['kitten'] = run_kitten namespaced_entry_points['edit-config'] = edit_config_file +namespaced_entry_points['shebang'] = shebang def setup_openssl_environment() -> None: diff --git a/kitty/boss.py b/kitty/boss.py index 2a45d2a34..5354a0817 100755 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -23,7 +23,7 @@ from .conf.utils import BadLine, KeyAction, to_cmdline from .config import common_opts_as_dict, prepare_config_file_for_editing from .constants import ( appname, config_dir, is_macos, is_wayland, kitty_exe, logo_png_file, - shell_path, supports_primary_selection, website_url + supports_primary_selection, website_url ) from .fast_data_types import ( CLOSE_BEING_CONFIRMED, GLFW_MOD_ALT, GLFW_MOD_CONTROL, GLFW_MOD_SHIFT, @@ -2200,56 +2200,31 @@ class Boss: if path == ":cocoa::application launched::": self.cocoa_application_launched = True return - is_executable = is_dir = False - with suppress(OSError): - is_executable = os.access(path, os.X_OK) - with suppress(OSError): - is_dir = os.path.isdir(path) - - def parse_macos_shebang(path: str) -> List[str]: - # The macos kernel splits the shebang line on spaces - try: - f = open(path) - except OSError: - return [] - with f: - if f.read(2) != '#!': - return [] - line = f.readline().strip() - return line.split(' ') - - needs_new_os_window = self.cocoa_application_launched or not self.os_window_map or self.active_tab is None - launch_cmd = [] - if needs_new_os_window: - launch_cmd += ['--type', 'os-window'] - if is_dir: - launch_cmd += ['--cwd', path] - elif is_executable: - launch_cmd += [path] - else: - from .guess_mime_type import guess_type - mt = guess_type(path) or '' - ext = os.path.splitext(path)[1].lower() - if ext in ('.sh', '.command', '.tool'): - launch_cmd += ['--hold'] + (parse_macos_shebang(path) or [shell_path]) + [path] - elif ext in ('.zsh', '.bash', '.fish'): - launch_cmd += ['--hold'] + (parse_macos_shebang(path) or [ext[1:]]) + [path] - elif mt.startswith('text/'): - launch_cmd += get_editor() + [path] - elif mt.startswith('image/'): - launch_cmd += [kitty_exe(), '+kitten', 'icat', '--hold', path] - else: - launch_cmd += [kitty_exe(), '+runpy', f'print("The file:", {path!r}, "is of unknown type, cannot open it.");' - 'from kitty.utils import hold_till_enter; hold_till_enter(); raise SystemExit(1)'] - + from .open_actions import actions_for_launch + from .launch import force_window_launch + actions = list(actions_for_launch(path)) tab = self.active_tab if tab is not None: w = tab.active_window else: w = None - self.launch(*launch_cmd) - if not needs_new_os_window and tab is not None and w is not None: - tab.remove_window(w) + needs_window_replaced = not self.cocoa_application_launched or not self.os_window_map and w is not None and w.id == 1 + + def clear_initial_window() -> None: + if needs_window_replaced and tab is not None and w is not None: + tab.remove_window(w) + + if not actions: + with force_window_launch(needs_window_replaced): + self.launch(kitty_exe(), '+runpy', f'print("The file:", {path!r}, "is of unknown type, cannot open it.");' + 'from kitty.utils import hold_till_enter; hold_till_enter(); raise SystemExit(1)') + clear_initial_window() + else: + with force_window_launch(needs_window_replaced): + self.dispatch_action(actions.pop(0)) + clear_initial_window() + if actions: + self.drain_actions(actions) @ac('debug', 'Show the effective configuration kitty is running with') def debug_config(self) -> None: diff --git a/kitty/conf/utils.py b/kitty/conf/utils.py index 4939cc860..716ae519e 100644 --- a/kitty/conf/utils.py +++ b/kitty/conf/utils.py @@ -72,9 +72,10 @@ class ToCmdline: def __exit__(self, *a: Any) -> None: self.override_env = None - def filter_env_vars(self, *a: str) -> 'ToCmdline': + def filter_env_vars(self, *a: str, **override: str) -> 'ToCmdline': remove = frozenset(a) self.override_env = {k: v for k, v in os.environ.items() if k not in remove} + self.override_env.update(override) return self def __call__(self, x: str) -> List[str]: diff --git a/kitty/launch.py b/kitty/launch.py index b9d0aa33b..33365025b 100644 --- a/kitty/launch.py +++ b/kitty/launch.py @@ -306,6 +306,28 @@ def apply_colors(window: Window, spec: Sequence[str]) -> None: patch_color_profiles(colors, profiles, True) +class ForceWindowLaunch: + + def __init__(self) -> None: + self.force = False + + def __bool__(self) -> bool: + return self.force + + def __call__(self, force: bool) -> 'ForceWindowLaunch': + self.force = force + return self + + def __enter__(self) -> None: + pass + + def __exit__(self, *a: object) -> None: + self.force = False + + +force_window_launch = ForceWindowLaunch() + + def launch( boss: Boss, opts: LaunchCLIOptions, @@ -389,6 +411,8 @@ def launch( if exe: final_cmd[0] = exe kw['cmd'] = final_cmd + if force_window_launch and opts.type not in ('background', 'clipboard', 'primary'): + opts.type = 'window' if opts.type == 'overlay' and active: kw['overlay_for'] = active.id if opts.type == 'background': diff --git a/kitty/open_actions.py b/kitty/open_actions.py index 9d7a48665..6f4b5f35c 100644 --- a/kitty/open_actions.py +++ b/kitty/open_actions.py @@ -4,11 +4,12 @@ import os import posixpath +import shlex from contextlib import suppress from typing import ( Any, Dict, Iterable, Iterator, List, NamedTuple, Optional, Tuple, cast ) -from urllib.parse import ParseResult, unquote, urlparse +from urllib.parse import ParseResult, quote, unquote, urlparse from .conf.utils import KeyAction, to_cmdline_implementation from .constants import config_dir @@ -16,7 +17,8 @@ from .guess_mime_type import guess_type from .options.utils import ActionAlias, resolve_aliases_and_parse_actions from .types import run_once from .typing import MatchType -from .utils import expandvars, log_error +from .utils import expandvars, get_editor, log_error, resolved_shell +from .fast_data_types import get_options class MatchCriteria(NamedTuple): @@ -68,12 +70,16 @@ def parse(lines: Iterable[str]) -> Iterator[OpenAction]: if match_criteria and raw_actions: entries.append((tuple(match_criteria), tuple(raw_actions))) - for (mc, action_defns) in entries: - actions: List[KeyAction] = [] - with to_cmdline_implementation.filter_env_vars('URL', 'FILE_PATH', 'FILE', 'FRAGMENT'): + with to_cmdline_implementation.filter_env_vars( + 'URL', 'FILE_PATH', 'FILE', 'FRAGMENT', + EDITOR=shlex.join(get_editor()), + SHELL=shlex.join(resolved_shell(get_options())) + ): + for (mc, action_defns) in entries: + actions: List[KeyAction] = [] for defn in action_defns: actions.extend(resolve_aliases_and_parse_actions(defn, alias_map, 'open_action')) - yield OpenAction(mc, tuple(actions)) + yield OpenAction(mc, tuple(actions)) def url_matches_criterion(purl: 'ParseResult', url: str, unquoted_path: str, mc: MatchCriteria) -> bool: @@ -195,9 +201,60 @@ def load_open_actions() -> Tuple[OpenAction, ...]: return tuple(parse(f)) +@run_once +def load_launch_actions() -> Tuple[OpenAction, ...]: + try: + f = open(os.path.join(config_dir, 'launch-actions.conf')) + except FileNotFoundError: + return () + with f: + return tuple(parse(f)) + + +@run_once +def default_launch_actions() -> Tuple[OpenAction, ...]: + SHELL = resolved_shell(get_options()) + return tuple(parse(f'''\ +# Open script files +protocol file +ext sh,command,tool +action launch --hold --type=os-window kitty +shebang $FILE_PATH {SHELL} + +# Open shell specific script files +protocol file +ext fish,bash,zsh +action launch --hold --type=os-window kitty +shebang $FILE_PATH __ext__ + +# Open directories +protocol file +mime inode/directory +action launch --type=os-window --cwd $FILE_PATH + +# Open text files without fragments in the editor +protocol file +mime text/* +action launch --type=os-window $EDITOR $FILE_PATH + +# Open image files with icat +protocol file +mime image/* +action launch --type=os-window kitty +kitten icat --hold $FILE_PATH +'''.splitlines())) + + def actions_for_url(url: str, actions_spec: Optional[str] = None) -> Iterator[KeyAction]: if actions_spec is None: actions = load_open_actions() else: actions = tuple(parse(actions_spec.splitlines())) yield from actions_for_url_from_list(url, actions) + + +def actions_for_launch(path: str) -> Iterator[KeyAction]: + url = f'file://{quote(path)}' + found = False + for action in actions_for_url_from_list(url, load_launch_actions()): + found = True + yield action + if not found: + yield from actions_for_url_from_list(url, default_launch_actions()) diff --git a/kitty_tests/open_actions.py b/kitty_tests/open_actions.py index 37c32421d..e8c5e1419 100644 --- a/kitty_tests/open_actions.py +++ b/kitty_tests/open_actions.py @@ -6,6 +6,7 @@ import os from contextlib import contextmanager from . import BaseTest +from kitty.utils import get_editor @contextmanager @@ -42,7 +43,7 @@ action two ''' def actions(url): - with patch_env(EDITOR='editor', FILE_PATH='notgood'): + with patch_env(FILE_PATH='notgood'): return tuple(actions_for_url(url, spec)) def single(url, func, *args): @@ -51,6 +52,6 @@ action two self.ae(acts[0].func, func) self.ae(acts[0].args, args) - single('file://hostname/tmp/moo.txt#23', 'launch', 'editor', '/tmp/moo.txt', '23') + single('file://hostname/tmp/moo.txt#23', 'launch', *get_editor(), '/tmp/moo.txt', '23') single('some thing.txt', 'ignored') self.ae(actions('x:///a.txt'), (KeyAction('one', ()), KeyAction('two', ())))