Start work on implementing edit-in-kitty in kitty-tool

This commit is contained in:
Kovid Goyal 2022-11-17 16:58:33 +05:30
parent d2dabc7d57
commit 0af48a4d05
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
6 changed files with 340 additions and 8 deletions

View File

@ -8,7 +8,7 @@ import subprocess
import sys import sys
from contextlib import contextmanager, suppress from contextlib import contextmanager, suppress
from functools import lru_cache from functools import lru_cache
from typing import Dict, Iterator, List, Set, Tuple, Union from typing import Dict, Iterator, List, Set, Tuple, Union, Sequence
import kitty.constants as kc import kitty.constants as kc
from kittens.tui.operations import Mode from kittens.tui.operations import Mode
@ -74,12 +74,19 @@ def generate_kittens_completion() -> None:
print(f'{kn}.HelpText = ""') print(f'{kn}.HelpText = ""')
def completion_for_launch_wrappers(*names: str) -> None: @lru_cache
def clone_safe_launch_opts() -> Sequence[GoOption]:
from kitty.launch import clone_safe_opts, options_spec from kitty.launch import clone_safe_opts, options_spec
opts = tuple(go_options_for_seq(parse_option_spec(options_spec())[0])) ans = []
allowed = clone_safe_opts() allowed = clone_safe_opts()
for o in opts: for o in go_options_for_seq(parse_option_spec(options_spec())[0]):
if o.obj_dict['name'] in allowed: if o.obj_dict['name'] in allowed:
ans.append(o)
return tuple(ans)
def completion_for_launch_wrappers(*names: str) -> None:
for o in clone_safe_launch_opts():
for name in names: for name in names:
print(o.as_option(name)) print(o.as_option(name))
@ -391,6 +398,14 @@ def update_completion() -> None:
with replace_if_needed('tools/cmd/completion/kitty_generated.go') as f: with replace_if_needed('tools/cmd/completion/kitty_generated.go') as f:
sys.stdout = f sys.stdout = f
generate_completions_for_kitty() generate_completions_for_kitty()
with replace_if_needed('tools/cmd/edit_in_kitty/launch_generated.go') as f:
sys.stdout = f
print('package edit_in_kitty')
print('import "kitty/tools/cli"')
print('func AddCloneSafeOpts(cmd *cli.Command) {')
completion_for_launch_wrappers('cmd')
print(''.join(CompletionSpec.from_string('type:file mime:text/* group:"Text files"').as_go_code('cmd.ArgCompleter', ' = ')))
print('}')
finally: finally:
sys.stdout = orig sys.stdout = orig

View File

@ -672,9 +672,10 @@ class EditCmd:
self.file_data = b'' self.file_data = b''
self.file_inode = -1, -1 self.file_inode = -1, -1
self.file_size = -1 self.file_size = -1
self.version = 0
self.source_window_id = self.editor_window_id = -1 self.source_window_id = self.editor_window_id = -1
self.abort_signaled = '' self.abort_signaled = ''
simple = 'file_inode', 'file_data', 'abort_signaled' simple = 'file_inode', 'file_data', 'abort_signaled', 'version'
for k, v in parse_message(msg, simple): for k, v in parse_message(msg, simple):
if k == 'file_inode': if k == 'file_inode':
q = map(int, v.split(':')) q = map(int, v.split(':'))
@ -685,10 +686,14 @@ class EditCmd:
elif k == 'file_data': elif k == 'file_data':
import base64 import base64
self.file_data = base64.standard_b64decode(v) self.file_data = base64.standard_b64decode(v)
elif k == 'version':
self.version = int(v)
else: else:
setattr(self, k, v) setattr(self, k, v)
if self.abort_signaled: if self.abort_signaled:
return return
if self.version > 0:
raise ValueError(f'Unsupported version received in edit protocol: {self.version}')
self.opts, extra_args = parse_opts_for_clone(['--type=overlay'] + self.args) self.opts, extra_args = parse_opts_for_clone(['--type=overlay'] + self.args)
self.file_spec = extra_args.pop() self.file_spec = extra_args.pop()
self.line_number = 0 self.line_number = 0

View File

@ -0,0 +1,253 @@
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
package edit_in_kitty
import (
"encoding/base64"
"fmt"
"io"
"io/fs"
"os"
"strconv"
"strings"
"golang.org/x/sys/unix"
"kitty/tools/cli"
"kitty/tools/tui"
"kitty/tools/tui/loop"
"kitty/tools/utils"
"kitty/tools/utils/humanize"
)
var _ = fmt.Print
func encode(x string) string {
return base64.StdEncoding.EncodeToString(utils.UnsafeStringToBytes(x))
}
type OnDataCallback = func(data_type string, data []byte) error
func edit_loop(data_to_send string, kill_if_signaled bool, on_data OnDataCallback) (err error) {
lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoMouseTracking)
if err != nil {
return
}
current_text := strings.Builder{}
data := strings.Builder{}
data.Grow(4096)
started := false
canceled := false
update_type := ""
handle_line := func(line string) error {
if canceled {
return nil
}
if started {
if update_type == "" {
update_type = line
} else {
if line == "KITTY_DATA_END" {
lp.QueueWriteString(update_type + "\r\n")
if update_type == "DONE" {
lp.Quit(0)
return nil
}
b, err := base64.StdEncoding.DecodeString(data.String())
data.Reset()
data.Grow(4096)
started = false
if err == nil {
err = on_data(update_type, b)
}
update_type = ""
if err != nil {
return err
}
} else {
data.WriteString(line)
}
}
} else {
if line == "KITTY_DATA_START" {
started = true
update_type = ""
}
}
return nil
}
check_for_line := func() error {
if canceled {
return nil
}
s := current_text.String()
for {
idx := strings.Index(s, "\n")
if idx < 0 {
break
}
err = handle_line(s[:idx])
if err != nil {
return err
}
s = s[idx+1:]
}
current_text.Reset()
current_text.Grow(4096)
if s != "" {
current_text.WriteString(s)
}
return nil
}
lp.OnInitialize = func() (string, error) {
pos, chunk_num := 0, 0
for {
limit := utils.Min(pos+2048, len(data_to_send))
if limit <= pos {
break
}
lp.QueueWriteString("\x1bP@kitty-edit|" + strconv.Itoa(chunk_num) + ":")
lp.QueueWriteString(data_to_send[pos:limit])
lp.QueueWriteString("\x1b\\")
chunk_num++
pos = limit
}
lp.QueueWriteString("\x1bP@kitty-edit|\x1b\\")
return "", nil
}
lp.OnText = func(text string, from_key_event bool, in_bracketed_paste bool) error {
if !from_key_event {
current_text.WriteString(text)
err = check_for_line()
if err != nil {
return err
}
}
return nil
}
const abort_msg = "\x1bP@kitty-edit|0:abort_signaled=interrupt\x1b\\\x1bP@kitty-edit|\x1b\\"
lp.OnKeyEvent = func(event *loop.KeyEvent) error {
if event.MatchesPressOrRepeat("ctrl+c") || event.MatchesPressOrRepeat("esc") {
event.Handled = true
canceled = true
lp.QueueWriteString(abort_msg)
if !started {
return tui.Canceled
}
}
return nil
}
err = lp.Run()
if err != nil {
return
}
if canceled {
return tui.Canceled
}
ds := lp.DeathSignalName()
if ds != "" {
fmt.Print(abort_msg)
if kill_if_signaled {
lp.KillIfSignalled()
return
}
return &tui.KilledBySignal{Msg: fmt.Sprint("Killed by signal: ", ds), SignalName: ds}
}
return
}
func edit_in_kitty(path string) (err error) {
read_file, err := os.Open(path)
if err != nil {
return fmt.Errorf("Failed to open %s for reading with error: %w", path, err)
}
defer read_file.Close()
var s unix.Stat_t
err = unix.Fstat(int(read_file.Fd()), &s)
if err != nil {
return fmt.Errorf("Failed to stat %s with error: %w", path, err)
}
if s.Size > 8*1024*1024 {
return fmt.Errorf("File size %s is too large for performant editing", humanize.Bytes(uint64(s.Size)))
}
file_data, err := io.ReadAll(read_file)
if err != nil {
return fmt.Errorf("Failed to read from %s with error: %w", path, err)
}
read_file.Close()
data := strings.Builder{}
data.Grow(len(file_data) * 4)
add := func(key, val string) {
if data.Len() > 0 {
data.WriteString(",")
}
data.WriteString(key)
data.WriteString("=")
data.WriteString(val)
}
add_encoded := func(key, val string) { add(key, encode(val)) }
if unix.Access(path, unix.R_OK|unix.W_OK) != nil {
return fmt.Errorf("%s is not readable and writeable", path)
}
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("Failed to get the current working directory with error: %w", err)
}
add_encoded("cwd", cwd)
for _, arg := range os.Args[2:] {
add_encoded("a", arg)
}
add("file_inode", fmt.Sprintf("%d:%d:%d", s.Dev, s.Ino, s.Mtim.Nano()))
add_encoded("file_data", utils.UnsafeBytesToString(file_data))
fmt.Println("Waiting for editing to be completed, press Esc to abort...")
write_data := func(data_type string, rdata []byte) (err error) {
err = utils.AtomicWriteFile(path, rdata, fs.FileMode(s.Mode).Perm())
if err != nil {
err = fmt.Errorf("Failed to write data to %s with error: %w", path, err)
}
return
}
err = edit_loop(data.String(), true, write_data)
if err != nil {
if err == tui.Canceled {
return err
}
return fmt.Errorf("Failed to receive edited file back from terminal with error: %w", err)
}
return
}
func EntryPoint(parent *cli.Command) *cli.Command {
sc := parent.AddSubCommand(&cli.Command{
Name: "edit-in-kitty",
Usage: "edit-in-kitty [options] file-to-edit",
ShortDescription: "Edit a file in a kitty overlay window",
HelpText: "Edit the specified file in a kitty overlay window. Works over SSH as well.\n\n" +
"For usage instructions see: https://sw.kovidgoyal.net/kitty/shell-integration/#edit-file",
Run: func(cmd *cli.Command, args []string) (ret int, err error) {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "Usage:", cmd.Usage)
return 1, fmt.Errorf("No file to edit specified.")
}
if len(args) != 1 {
fmt.Fprintln(os.Stderr, "Usage:", cmd.Usage)
return 1, fmt.Errorf("Only one file to edit must be specified")
}
err = edit_in_kitty(args[0])
return 0, err
},
})
AddCloneSafeOpts(sc)
return sc
}

View File

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"kitty/tools/cli" "kitty/tools/cli"
"kitty/tools/cmd/at" "kitty/tools/cmd/at"
"kitty/tools/cmd/edit_in_kitty"
"kitty/tools/cmd/update_self" "kitty/tools/cmd/update_self"
) )
@ -18,4 +19,6 @@ func KittyToolEntryPoints(root *cli.Command) {
at.EntryPoint(root) at.EntryPoint(root)
// update-self // update-self
update_self.EntryPoint(root) update_self.EntryPoint(root)
// edit-in-kitty
edit_in_kitty.EntryPoint(root)
} }

View File

@ -78,7 +78,7 @@ func update_self(version string) (err error) {
return unix.Exec(exe, []string{"kitty-tool", "--version"}, os.Environ()) return unix.Exec(exe, []string{"kitty-tool", "--version"}, os.Environ())
} }
func EntryPoint(root *cli.Command) { func EntryPoint(root *cli.Command) *cli.Command {
sc := root.AddSubCommand(&cli.Command{ sc := root.AddSubCommand(&cli.Command{
Name: "update-self", Name: "update-self",
Usage: "update-self [options ...]", Usage: "update-self [options ...]",
@ -101,4 +101,5 @@ func EntryPoint(root *cli.Command) {
Default: "latest", Default: "latest",
Help: "The version to fetch. The special words :code:`latest` and :code:`nightly` fetch the latest stable and nightly release respectively. Other values can be, for example: 0.27.1.", Help: "The version to fetch. The special words :code:`latest` and :code:`nightly` fetch the latest stable and nightly release respectively. Other values can be, for example: 0.27.1.",
}) })
return sc
} }

View File

@ -0,0 +1,55 @@
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
package utils
import (
"fmt"
"io/fs"
"os"
"path/filepath"
)
var _ = fmt.Print
func AtomicWriteFile(path string, data []byte, perm os.FileMode) (err error) {
path, err = filepath.EvalSymlinks(path)
if err == nil {
path, err = filepath.Abs(path)
if err == nil {
var f *os.File
f, err = os.CreateTemp(filepath.Dir(path), filepath.Base(path))
if err == nil {
removed := false
defer func() {
f.Close()
if !removed {
os.Remove(f.Name())
}
}()
_, err = f.Write(data)
if err == nil {
err = f.Chmod(perm)
if err == nil {
err = os.Rename(f.Name(), path)
if err == nil {
removed = true
}
}
}
}
}
}
return
}
func AtomicUpdateFile(path string, data []byte, perms ...fs.FileMode) (err error) {
perm := fs.FileMode(0o666)
if len(perms) > 0 {
perm = perms[0]
}
s, err := os.Stat(path)
if err == nil {
perm = s.Mode().Perm()
}
return AtomicWriteFile(path, data, perm)
}