diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index 871ce4c31..894e72112 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -1206,7 +1206,7 @@ class ChildMonitor: def resize_pty(self, window_id: int, rows: int, cols: int, x_pixels: int, y_pixels: int) -> None: pass - def needs_write(self, child_id: int, data: Union[bytes, str]) -> bool: + def needs_write(self, child_id: int, data: bytes) -> bool: pass def set_iutf8_winid(self, win_id: int, on: bool) -> bool: diff --git a/kitty/launch.py b/kitty/launch.py index 97e935169..77c42df9a 100644 --- a/kitty/launch.py +++ b/kitty/launch.py @@ -3,7 +3,12 @@ import os -from typing import Any, Dict, Iterable, List, NamedTuple, Optional, Sequence +import shutil +from contextlib import suppress +from typing import ( + Any, Container, Dict, Iterable, Iterator, List, NamedTuple, Optional, + Sequence, Tuple +) from .boss import Boss from .child import Child @@ -11,13 +16,15 @@ from .cli import parse_args from .cli_stub import LaunchCLIOptions from .constants import kitty_exe, shell_path from .fast_data_types import ( - get_boss, get_options, get_os_window_title, patch_color_profiles, - set_clipboard_string + add_timer, get_boss, get_options, get_os_window_title, + patch_color_profiles, set_clipboard_string ) from .options.utils import env as parse_env from .tabs import Tab, TabManager from .types import run_once -from .utils import log_error, resolve_custom_file, set_primary_selection, which +from .utils import ( + get_editor, log_error, resolve_custom_file, set_primary_selection, which +) from .window import CwdRequest, CwdRequestType, Watchers, Window try: @@ -549,6 +556,113 @@ def parse_null_env(text: str) -> Dict[str, str]: return ans +def parse_message(msg: str, simple: Container[str]) -> Iterator[Tuple[str, str]]: + from base64 import standard_b64decode + for x in msg.split(','): + try: + k, v = x.split('=', 1) + except ValueError: + continue + if k not in simple: + v = standard_b64decode(v).decode('utf-8', 'replace') + yield k, v + + +class EditCmd: + + def __init__(self, msg: str) -> None: + self.args: List[str] = [] + self.cwd = self.file_name = self.file_localpath = '' + self.file_data = b'' + self.file_inode = -1, -1 + self.file_size = -1 + self.source_window_id = self.editor_window_id = -1 + self.abort_signaled = '' + simple = 'file_inode', 'file_data', 'abort_signaled' + for k, v in parse_message(msg, simple): + if k == 'file_inode': + q = map(int, v.split(':')) + self.file_inode = next(q), next(q) + self.file_size = next(q) + elif k == 'a': + self.args.append(v) + elif k == 'file_data': + import base64 + self.file_data = base64.standard_b64decode(v) + else: + setattr(self, k, v) + if self.abort_signaled: + return + self.file_spec = self.args.pop() + self.file_name = os.path.basename(self.file_spec) + self.file_localpath = os.path.normpath(os.path.join(self.cwd, self.file_spec)) + self.is_local_file = False + self.tdir = '' + with suppress(FileNotFoundError): + st = os.stat(self.file_localpath) + self.is_local_file = (st.st_dev, st.st_ino) == self.file_inode + if not self.is_local_file: + import tempfile + self.tdir = tempfile.mkdtemp() + self.file_localpath = os.path.join(self.tdir, self.file_name) + with open(self.file_localpath, 'wb') as f: + f.write(self.file_data) + self.file_obj = open(self.file_localpath, 'rb') + self.file_data = b'' + self.last_mod_time = self.file_mod_time + self.opts = parse_opts_for_clone(['--type=overlay'] + self.args) + if not self.opts.cwd: + self.opts.cwd = os.path.dirname(self.file_obj.name) + + def __del__(self) -> None: + if self.tdir: + with suppress(OSError): + shutil.rmtree(self.tdir) + self.tdir = '' + + def read_data(self) -> bytes: + self.file_obj.seek(0) + return self.file_obj.read() + + @property + def file_mod_time(self) -> int: + return os.stat(self.file_obj.fileno()).st_mtime_ns + + def schedule_check(self) -> None: + if not self.abort_signaled: + add_timer(self.check_status, 1.0, False) + + def check_status(self, timer_id: Optional[int] = None) -> None: + if self.abort_signaled: + return + boss = get_boss() + source_window = boss.window_id_map.get(self.source_window_id) + if source_window is not None and not self.is_local_file: + mtime = self.file_mod_time + if mtime != self.last_mod_time: + self.last_mod_time = mtime + data = self.read_data() + self.send_data(source_window, 'UPDATE', data) + editor_window = boss.window_id_map.get(self.editor_window_id) + if editor_window is None: + edits_in_flight.pop(self.source_window_id, None) + if source_window is not None: + self.send_data(source_window, 'DONE') + else: + self.schedule_check() + + def send_data(self, window: Window, data_type: str, data: bytes = b'') -> None: + window.write_to_child(f'KITTY_DATA_START\n{data_type}\n') + if data: + import base64 + mv = memoryview(base64.standard_b64encode(data)) + while mv: + window.write_to_child(bytes(mv[:512])) + window.write_to_child('\n') + mv = mv[512:] + window.write_to_child('KITTY_DATA_END\n') + + class CloneCmd: def __init__(self, msg: str) -> None: @@ -563,15 +677,14 @@ class CloneCmd: self.opts = parse_opts_for_clone(self.args) def parse_message(self, msg: str) -> None: - import base64 simple = 'pid', 'envfmt', 'shell' - for x in msg.split(','): - k, v = x.split('=', 1) + for k, v in parse_message(msg, simple): if k in simple: - setattr(self, k, int(v) if k == 'pid' else v) - continue - v = base64.standard_b64decode(v).decode('utf-8', 'replace') - if k == 'a': + if k == 'pid': + self.pid = int(v) + else: + setattr(self, k, v) + elif k == 'a': self.args.append(v) elif k == 'env': env = parse_bash_env(v) if self.envfmt == 'bash' else parse_null_env(v) @@ -594,6 +707,28 @@ class CloneCmd: self.history = v +edits_in_flight: Dict[int, EditCmd] = {} + + +def remote_edit(msg: str, window: Window) -> None: + c = EditCmd(msg) + if c.abort_signaled: + q = edits_in_flight.pop(window.id, None) + if q is not None: + q.abort_signaled = c.abort_signaled + return + cmdline = get_editor() + [c.file_obj.name] + w = launch(get_boss(), c.opts, cmdline, active=window) + if w is not None: + c.source_window_id = window.id + c.editor_window_id = w.id + q = edits_in_flight.pop(window.id, None) + if q is not None: + q.abort_signaled = 'replaced' + edits_in_flight[window.id] = c + c.schedule_check() + + def clone_and_launch(msg: str, window: Window) -> None: from .child import cmdline_of_process from .shell_integration import serialize_env diff --git a/kitty/parser.c b/kitty/parser.c index 5b4e40378..73a3427d8 100644 --- a/kitty/parser.c +++ b/kitty/parser.c @@ -1087,6 +1087,7 @@ dispatch_dcs(Screen *screen, PyObject DUMP_UNUSED *dump_callback) { } else IF_SIMPLE_PREFIX("ssh|", handle_remote_ssh) } else IF_SIMPLE_PREFIX("ask|", handle_remote_askpass) } else IF_SIMPLE_PREFIX("clone|", handle_remote_clone) + } else IF_SIMPLE_PREFIX("edit|", handle_remote_edit) #undef IF_SIMPLE_PREFIX } else { REPORT_ERROR("Unrecognized DCS @ code: 0x%x", screen->parser_buf[1]); diff --git a/kitty/window.py b/kitty/window.py index 50d3b3534..de178d624 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -751,6 +751,8 @@ class Window: def write_to_child(self, data: Union[str, bytes]) -> None: if data: + if isinstance(data, str): + data = data.encode('utf-8') if get_boss().child_monitor.needs_write(self.id, data) is not True: log_error(f'Failed to write to child {self.id} as it does not exist') @@ -1041,6 +1043,12 @@ class Window: self.current_remote_data.append(rest) return '' + def handle_remote_edit(self, msg: str) -> None: + cdata = self.append_remote_data(msg) + if cdata: + from .launch import remote_edit + remote_edit(cdata, self) + def handle_remote_clone(self, msg: str) -> None: cdata = self.append_remote_data(msg) if cdata: diff --git a/shell-integration/zsh/kitty-integration b/shell-integration/zsh/kitty-integration index 9f7df04ef..cd94da5de 100644 --- a/shell-integration/zsh/kitty-integration +++ b/shell-integration/zsh/kitty-integration @@ -386,6 +386,21 @@ _ksi_deferred_init() { builtin unfunction _ksi_deferred_init } +_ksi_transmit_data() { + data="${1//[[:space:]]}" + builtin local pos=0 + builtin local chunk_num=0 + while [ $pos -lt ${#data} ]; do + builtin local chunk="${data:$pos:2048}" + pos=$(($pos+2048)) + builtin print -nu "$_ksi_fd" -f '\eP@kitty-%s|%s:%s\e\\' "${2}" "${chunk_num}" "${chunk}" + chunk_num=$(($chunk_num+1)) + done + # save history so it is available in new shell + [ "$3" = "save_history" ] && builtin fc -AI + builtin print -nu "$_ksi_fd" -f '\eP@kitty-%s|\e\\' "${2}" +} + clone-in-kitty() { builtin local data="shell=zsh,pid=$$,cwd=$(builtin printf "%s" "$PWD" | builtin command base64)" while :; do @@ -408,17 +423,78 @@ clone-in-kitty() { env="${env}$(builtin printf "%s=%s\0" "$varname" "${(P)varname}")" done data="$data,env=$(builtin printf "%s" "$env" | builtin command base64)" - - data="${data//[[:space:]]}" - builtin local pos=0 - builtin local chunk_num=0 - while [ $pos -lt ${#data} ]; do - builtin local chunk="${data:$pos:2048}" - pos=$(($pos+2048)) - builtin print -nu "$_ksi_fd" -f '\eP@kitty-clone|%s:%s\e\\' "${chunk_num}" "${chunk}" - chunk_num=$(($chunk_num+1)) - done - # save history so it is available in new shell - builtin fc -AI - builtin print -nu "$_ksi_fd" '\eP@kitty-clone|\e\\' + _ksi_transmit_data "$data" "clone" "save_history" +} + + +edit-in-kitty() { + builtin local data="" + builtin local ed_filename="" + builtin local usage="Usage: edit-in-kitty [OPTIONS] FILE" + data="cwd=$(builtin printf "%s" "$PWD" | builtin command base64)" + while :; do + case "$1" in + "") break;; + -h|--help) + builtin printf "%s\n\n%s\n\n%s\n" "$usage" "Edit the specified file in a kitty overlay window. Works over SSH as well." "For usage instructions see: https://sw.kovidgoyal.net/kitty/shell-integration/#edit-file" + return + ;; + *) data="$data,a=$(builtin printf "%s" "$1" | builtin command base64)"; ed_filename="$1";; + esac + shift + done + [ -z "$ed_filename" ] && { + builtin echo "$usage" > /dev/stderr + return 1 + } + [ -r "$ed_filename" -a -w "$ed_filename" ] || { + builtin echo "$ed_filename is not readable and writable" > /dev/stderr + return 1 + } + [ ! -f "$ed_filename" ] && { + builtin echo "$ed_filename is not a file" > /dev/stderr + return 1 + } + builtin local stat_result="" + stat_result=$(builtin command stat -L --format '%d:%i:%s' "$ed_filename" 2> /dev/null) + [ $? != 0 ] && stat_result=$(builtin command stat -L -f '%d:%i:%z' "$ed_filename" 2> /dev/null) + [ -z "$stat_result" ] && { builtin echo "Failed to stat the file: $ed_filename" > /dev/stderr; return 1 } + data="$data,file_inode=$stat_result" + builtin local file_size=$(builtin echo "$stat_result" | builtin command cut -d: -f3) + [ "$file_size" -gt "2097152" ] && { builtin echo "File is too large for performant editing"; return 1; } + data="$data,file_data=$(builtin command cat "$ed_filename" | builtin command base64)" + _ksi_transmit_data "$data" "edit" + data="" + builtin echo "Waiting for editing to be completed..." + builtin local started="n" + builtin local line="" + builtin set -o localoptions -o localtraps + builtin local old_tty_settings=$(builtin command stty -g) + builtin command stty "-echo" + builtin trap "builtin command stty '$old_tty_settings'" EXIT + builtin trap "builtin command stty '$old_tty_settings'; _ksi_transmit_data 'abort_signaled=interrupt' 'edit'; return 1;" INT + while :; do + started="n" + while IFS= read -r line; do + if [ "$started" = "y" ]; then + [ "$line" = "UPDATE" ] && break; + [ "$line" = "DONE" ] && { started="done"; break; } + builtin printf "%s\n" "$line" > /dev/stderr; + return 1; + else + [ "$line" = "KITTY_DATA_START" ] && started="y" + fi + done + [ "$started" = "n" ] && continue; + data="" + while IFS= read -r line; do + [ "$line" = "KITTY_DATA_END" ] && break; + data="$data$line" + done + [ -n "$data" -a "$started" != "done" ] && { + builtin echo "Updating $ed_filename..." + builtin printf "%s" "$data" | builtin command base64 -d > "$ed_filename" + } + [ "$started" = "done" ] && break; + done }