main.go 6.4 KB

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