main.go 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. // License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
  2. package edit_in_kitty
  3. import (
  4. "bytes"
  5. "encoding/base64"
  6. "fmt"
  7. "io"
  8. "io/fs"
  9. "os"
  10. "strconv"
  11. "strings"
  12. "golang.org/x/sys/unix"
  13. "kitty/tools/cli"
  14. "kitty/tools/tui"
  15. "kitty/tools/tui/loop"
  16. "kitty/tools/utils"
  17. "kitty/tools/utils/humanize"
  18. )
  19. var _ = fmt.Print
  20. func encode(x string) string {
  21. return base64.StdEncoding.EncodeToString(utils.UnsafeStringToBytes(x))
  22. }
  23. type OnDataCallback = func(data_type string, data []byte) error
  24. func edit_loop(data_to_send string, kill_if_signaled bool, on_data OnDataCallback) (err error) {
  25. lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoMouseTracking)
  26. if err != nil {
  27. return
  28. }
  29. current_text := strings.Builder{}
  30. data := strings.Builder{}
  31. data.Grow(4096)
  32. started := false
  33. canceled := false
  34. update_type := ""
  35. handle_line := func(line string) error {
  36. if canceled {
  37. return nil
  38. }
  39. if started {
  40. if update_type == "" {
  41. update_type = line
  42. } else {
  43. if line == "KITTY_DATA_END" {
  44. lp.QueueWriteString(update_type + "\r\n")
  45. if update_type == "DONE" {
  46. lp.Quit(0)
  47. return nil
  48. }
  49. b, err := base64.StdEncoding.DecodeString(data.String())
  50. data.Reset()
  51. data.Grow(4096)
  52. started = false
  53. if err == nil {
  54. err = on_data(update_type, b)
  55. }
  56. update_type = ""
  57. if err != nil {
  58. return err
  59. }
  60. } else {
  61. data.WriteString(line)
  62. }
  63. }
  64. } else {
  65. if line == "KITTY_DATA_START" {
  66. started = true
  67. update_type = ""
  68. }
  69. }
  70. return nil
  71. }
  72. check_for_line := func() error {
  73. if canceled {
  74. return nil
  75. }
  76. s := current_text.String()
  77. for {
  78. idx := strings.Index(s, "\n")
  79. if idx < 0 {
  80. break
  81. }
  82. err = handle_line(s[:idx])
  83. if err != nil {
  84. return err
  85. }
  86. s = s[idx+1:]
  87. }
  88. current_text.Reset()
  89. current_text.Grow(4096)
  90. if s != "" {
  91. current_text.WriteString(s)
  92. }
  93. return nil
  94. }
  95. lp.OnInitialize = func() (string, error) {
  96. pos, chunk_num := 0, 0
  97. for {
  98. limit := min(pos+2048, len(data_to_send))
  99. if limit <= pos {
  100. break
  101. }
  102. lp.QueueWriteString("\x1bP@kitty-edit|" + strconv.Itoa(chunk_num) + ":")
  103. lp.QueueWriteString(data_to_send[pos:limit])
  104. lp.QueueWriteString("\x1b\\")
  105. chunk_num++
  106. pos = limit
  107. }
  108. lp.QueueWriteString("\x1bP@kitty-edit|\x1b\\")
  109. return "", nil
  110. }
  111. lp.OnText = func(text string, from_key_event bool, in_bracketed_paste bool) error {
  112. if !from_key_event {
  113. current_text.WriteString(text)
  114. err = check_for_line()
  115. if err != nil {
  116. return err
  117. }
  118. }
  119. return nil
  120. }
  121. const abort_msg = "\x1bP@kitty-edit|0:abort_signaled=interrupt\x1b\\\x1bP@kitty-edit|\x1b\\"
  122. lp.OnKeyEvent = func(event *loop.KeyEvent) error {
  123. if event.MatchesPressOrRepeat("ctrl+c") || event.MatchesPressOrRepeat("esc") {
  124. event.Handled = true
  125. canceled = true
  126. lp.QueueWriteString(abort_msg)
  127. if !started {
  128. return tui.Canceled
  129. }
  130. }
  131. return nil
  132. }
  133. err = lp.Run()
  134. if err != nil {
  135. return
  136. }
  137. if canceled {
  138. return tui.Canceled
  139. }
  140. ds := lp.DeathSignalName()
  141. if ds != "" {
  142. fmt.Print(abort_msg)
  143. if kill_if_signaled {
  144. lp.KillIfSignalled()
  145. return
  146. }
  147. return &tui.KilledBySignal{Msg: fmt.Sprint("Killed by signal: ", ds), SignalName: ds}
  148. }
  149. return
  150. }
  151. func edit_in_kitty(path string, opts *Options) (err error) {
  152. read_file, err := os.Open(path)
  153. if err != nil {
  154. return fmt.Errorf("Failed to open %s for reading with error: %w", path, err)
  155. }
  156. defer read_file.Close()
  157. var s unix.Stat_t
  158. err = unix.Fstat(int(read_file.Fd()), &s)
  159. if err != nil {
  160. return fmt.Errorf("Failed to stat %s with error: %w", path, err)
  161. }
  162. if s.Size > int64(opts.MaxFileSize)*1024*1024 {
  163. return fmt.Errorf("File size %s is too large for performant editing", humanize.Bytes(uint64(s.Size)))
  164. }
  165. file_data, err := io.ReadAll(read_file)
  166. if err != nil {
  167. return fmt.Errorf("Failed to read from %s with error: %w", path, err)
  168. }
  169. read_file.Close()
  170. data := strings.Builder{}
  171. data.Grow(len(file_data) * 4)
  172. add := func(key, val string) {
  173. if data.Len() > 0 {
  174. data.WriteString(",")
  175. }
  176. data.WriteString(key)
  177. data.WriteString("=")
  178. data.WriteString(val)
  179. }
  180. add_encoded := func(key, val string) { add(key, encode(val)) }
  181. if unix.Access(path, unix.R_OK|unix.W_OK) != nil {
  182. return fmt.Errorf("%s is not readable and writeable", path)
  183. }
  184. cwd, err := os.Getwd()
  185. if err != nil {
  186. return fmt.Errorf("Failed to get the current working directory with error: %w", err)
  187. }
  188. add_encoded("cwd", cwd)
  189. for _, arg := range os.Args[2:] {
  190. add_encoded("a", arg)
  191. }
  192. add("file_inode", fmt.Sprintf("%d:%d:%d", s.Dev, s.Ino, s.Mtim.Nano()))
  193. add_encoded("file_data", utils.UnsafeBytesToString(file_data))
  194. fmt.Println("Waiting for editing to be completed, press Esc to abort...")
  195. write_data := func(data_type string, rdata []byte) (err error) {
  196. err = utils.AtomicWriteFile(path, bytes.NewReader(rdata), fs.FileMode(s.Mode).Perm())
  197. if err != nil {
  198. err = fmt.Errorf("Failed to write data to %s with error: %w", path, err)
  199. }
  200. return
  201. }
  202. err = edit_loop(data.String(), true, write_data)
  203. if err != nil {
  204. if err == tui.Canceled {
  205. return err
  206. }
  207. return fmt.Errorf("Failed to receive edited file back from terminal with error: %w", err)
  208. }
  209. return
  210. }
  211. type Options struct {
  212. MaxFileSize int
  213. }
  214. func EntryPoint(parent *cli.Command) *cli.Command {
  215. sc := parent.AddSubCommand(&cli.Command{
  216. Name: "edit-in-kitty",
  217. Usage: "[options] file-to-edit",
  218. ShortDescription: "Edit a file in a kitty overlay window",
  219. HelpText: "Edit the specified file in a kitty overlay window. Works over SSH as well.\n\n" +
  220. "For usage instructions see: https://sw.kovidgoyal.net/kitty/shell-integration/#edit-file",
  221. Run: func(cmd *cli.Command, args []string) (ret int, err error) {
  222. if len(args) == 0 {
  223. fmt.Fprintln(os.Stderr, "Usage:", cmd.Usage)
  224. return 1, fmt.Errorf("No file to edit specified.")
  225. }
  226. if len(args) != 1 {
  227. fmt.Fprintln(os.Stderr, "Usage:", cmd.Usage)
  228. return 1, fmt.Errorf("Only one file to edit must be specified")
  229. }
  230. var opts Options
  231. err = cmd.GetOptionValues(&opts)
  232. if err != nil {
  233. return 1, err
  234. }
  235. err = edit_in_kitty(args[0], &opts)
  236. return 0, err
  237. },
  238. })
  239. AddCloneSafeOpts(sc)
  240. sc.Add(cli.OptionSpec{
  241. Name: "--max-file-size",
  242. Default: "8",
  243. Type: "int",
  244. Help: "The maximum allowed size (in MB) of files to edit. Since the file data has to be base64 encoded and transmitted over the tty device, overly large files will not perform well.",
  245. })
  246. return sc
  247. }