Move the kittens Go code into the kittens folder

This commit is contained in:
Kovid Goyal
2023-03-27 13:06:02 +05:30
parent 3f9579d61d
commit ff55121094
46 changed files with 17 additions and 21 deletions

View File

@@ -1,433 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package ask
import (
"fmt"
"io"
"kitty/tools/cli/markup"
"kitty/tools/tui/loop"
"kitty/tools/utils"
"kitty/tools/utils/style"
"kitty/tools/wcswidth"
"os"
"regexp"
"strings"
"unicode"
)
var _ = fmt.Print
type Choice struct {
text string
idx int
color, letter string
}
func (self Choice) prefix() string {
return string([]rune(self.text)[:self.idx])
}
func (self Choice) display_letter() string {
return string([]rune(self.text)[self.idx])
}
func (self Choice) suffix() string {
return string([]rune(self.text)[self.idx+1:])
}
type Range struct {
start, end, y int
}
func (self *Range) has_point(x, y int) bool {
return y == self.y && self.start <= x && x <= self.end
}
func truncate_at_space(text string, width int) (string, string) {
truncated, p := wcswidth.TruncateToVisualLengthWithWidth(text, width)
if len(truncated) == len(text) {
return text, ""
}
i := strings.LastIndexByte(truncated, ' ')
if i > 0 && p-i < 12 {
p = i + 1
}
return text[:p], text[p:]
}
func extra_for(width, screen_width int) int {
return utils.Max(0, screen_width-width)/2 + 1
}
func choices(o *Options) (response string, err error) {
response = ""
lp, err := loop.New()
if err != nil {
return "", err
}
lp.MouseTrackingMode(loop.BUTTONS_ONLY_MOUSE_TRACKING)
prefix_style_pat := regexp.MustCompile("^(?:\x1b\\[[^m]*?m)+")
choice_order := make([]Choice, 0, len(o.Choices))
clickable_ranges := make(map[string][]Range, 16)
allowed := utils.NewSet[string](utils.Max(2, len(o.Choices)))
response_on_accept := o.Default
switch o.Type {
case "yesno":
allowed.AddItems("y", "n")
if !allowed.Has(response_on_accept) {
response_on_accept = "y"
}
case "choices":
first_choice := ""
for i, x := range o.Choices {
letter, text, _ := strings.Cut(x, ":")
color := ""
if strings.Contains(letter, ";") {
letter, color, _ = strings.Cut(letter, ";")
}
letter = strings.ToLower(letter)
idx := strings.Index(strings.ToLower(text), letter)
idx = len([]rune(strings.ToLower(text)[:idx]))
allowed.Add(letter)
c := Choice{text: text, idx: idx, color: color, letter: letter}
choice_order = append(choice_order, c)
if i == 0 {
first_choice = letter
}
}
if !allowed.Has(response_on_accept) {
response_on_accept = first_choice
}
}
message := o.Message
hidden_text_start_pos := -1
hidden_text_end_pos := -1
hidden_text := ""
m := markup.New(true)
replacement_text := fmt.Sprintf("Press %s or click to show", m.Green(o.UnhideKey))
replacement_range := Range{-1, -1, -1}
if message != "" && o.HiddenTextPlaceholder != "" {
hidden_text_start_pos = strings.Index(message, o.HiddenTextPlaceholder)
if hidden_text_start_pos > -1 {
raw, err := io.ReadAll(os.Stdin)
if err != nil {
return "", fmt.Errorf("Failed to read hidden text from STDIN: %w", err)
}
hidden_text = strings.TrimRightFunc(utils.UnsafeBytesToString(raw), unicode.IsSpace)
hidden_text_end_pos = hidden_text_start_pos + len(replacement_text)
suffix := message[hidden_text_start_pos+len(o.HiddenTextPlaceholder):]
message = message[:hidden_text_start_pos] + replacement_text + suffix
}
}
draw_long_text := func(screen_width int, text string, msg_lines []string) []string {
if text == "" {
msg_lines = append(msg_lines, "")
} else {
width := screen_width - 2
prefix := prefix_style_pat.FindString(text)
for text != "" {
var t string
t, text = truncate_at_space(text, width)
t = strings.TrimSpace(t)
msg_lines = append(msg_lines, strings.Repeat(" ", extra_for(wcswidth.Stringwidth(t), width))+m.Bold(prefix+t))
}
}
return msg_lines
}
ctx := style.Context{AllowEscapeCodes: true}
draw_choice_boxes := func(y, screen_width, screen_height int, choices ...Choice) {
clickable_ranges = map[string][]Range{}
width := screen_width - 2
current_line_length := 0
type Item struct{ letter, text string }
type Line = []Item
var current_line Line
lines := make([]Line, 0, 32)
sep := " "
sep_sz := len(sep) + 2 // for the borders
for _, choice := range choices {
clickable_ranges[choice.letter] = make([]Range, 0, 4)
text := " " + choice.prefix()
color := choice.color
if choice.color == "" {
color = "green"
}
text += ctx.SprintFunc("fg=" + color)(choice.display_letter())
text += choice.suffix() + " "
sz := wcswidth.Stringwidth(text)
if sz+sep_sz+current_line_length > width {
lines = append(lines, current_line)
current_line = nil
current_line_length = 0
}
current_line = append(current_line, Item{choice.letter, text})
current_line_length += sz + sep_sz
}
if len(current_line) > 0 {
lines = append(lines, current_line)
}
highlight := func(text string) string {
return m.Yellow(text)
}
top := func(text string, highlight_frame bool) (ans string) {
ans = "╭" + strings.Repeat("─", wcswidth.Stringwidth(text)) + "╮"
if highlight_frame {
ans = highlight(ans)
}
return
}
middle := func(text string, highlight_frame bool) (ans string) {
f := "│"
if highlight_frame {
f = highlight(f)
}
return f + text + f
}
bottom := func(text string, highlight_frame bool) (ans string) {
ans = "╰" + strings.Repeat("─", wcswidth.Stringwidth(text)) + "╯"
if highlight_frame {
ans = highlight(ans)
}
return
}
print_line := func(add_borders func(string, bool) string, is_last bool, items ...Item) {
type Position struct {
letter string
x, size int
}
texts := make([]string, 0, 8)
positions := make([]Position, 0, 8)
x := 0
for _, item := range items {
text := item.text
positions = append(positions, Position{item.letter, x, wcswidth.Stringwidth(text) + 2})
text = add_borders(text, item.letter == response_on_accept)
text += sep
x += wcswidth.Stringwidth(text)
texts = append(texts, text)
}
line := strings.TrimRightFunc(strings.Join(texts, ""), unicode.IsSpace)
offset := extra_for(wcswidth.Stringwidth(line), width)
for _, pos := range positions {
x = pos.x
x += offset
clickable_ranges[pos.letter] = append(clickable_ranges[pos.letter], Range{x, x + pos.size - 1, y})
}
end := "\r\n"
if is_last {
end = ""
}
lp.QueueWriteString(strings.Repeat(" ", offset) + line + end)
y++
}
lp.AllowLineWrapping(false)
defer func() { lp.AllowLineWrapping(true) }()
for i, boxed_line := range lines {
print_line(top, false, boxed_line...)
print_line(middle, false, boxed_line...)
is_last := i == len(lines)-1
print_line(bottom, is_last, boxed_line...)
}
}
draw_yesno := func(y, screen_width, screen_height int) {
yes := m.Green("Y") + "es"
no := m.BrightRed("N") + "o"
if y+3 <= screen_height {
draw_choice_boxes(y, screen_width, screen_height, Choice{"Yes", 0, "green", "y"}, Choice{"No", 0, "red", "n"})
} else {
sep := strings.Repeat(" ", 3)
text := yes + sep + no
w := wcswidth.Stringwidth(text)
x := extra_for(w, screen_width-2)
nx := x + wcswidth.Stringwidth(yes) + len(sep)
clickable_ranges = map[string][]Range{
"y": {{x, x + wcswidth.Stringwidth(yes) - 1, y}},
"n": {{nx, nx + wcswidth.Stringwidth(no) - 1, y}},
}
lp.QueueWriteString(strings.Repeat(" ", x) + text)
}
}
draw_choice := func(y, screen_width, screen_height int) {
if y+3 <= screen_height {
draw_choice_boxes(y, screen_width, screen_height, choice_order...)
return
}
clickable_ranges = map[string][]Range{}
current_line := ""
current_ranges := map[string]int{}
width := screen_width - 2
commit_line := func(add_newline bool) {
x := extra_for(wcswidth.Stringwidth(current_line), width)
text := strings.Repeat(" ", x) + current_line
if add_newline {
lp.Println(text)
} else {
lp.QueueWriteString(text)
}
for letter, sz := range current_ranges {
clickable_ranges[letter] = []Range{{x, x + sz - 3, y}}
x += sz
}
current_ranges = map[string]int{}
y++
current_line = ""
}
for _, choice := range choice_order {
text := choice.prefix()
spec := ""
if choice.color != "" {
spec = "fg=" + choice.color
} else {
spec = "fg=green"
}
if choice.letter == response_on_accept {
spec += " u=straight"
}
text += ctx.SprintFunc(spec)(choice.display_letter())
text += choice.suffix()
text += " "
sz := wcswidth.Stringwidth(text)
if sz+wcswidth.Stringwidth(current_line) >= width {
commit_line(true)
}
current_line += text
current_ranges[choice.letter] = sz
}
if current_line != "" {
commit_line(false)
}
}
draw_screen := func() error {
lp.StartAtomicUpdate()
defer lp.EndAtomicUpdate()
lp.ClearScreen()
msg_lines := make([]string, 0, 8)
sz, err := lp.ScreenSize()
if err != nil {
return err
}
if message != "" {
scanner := utils.NewLineScanner(message)
for scanner.Scan() {
msg_lines = draw_long_text(int(sz.WidthCells), scanner.Text(), msg_lines)
}
}
y := int(sz.HeightCells) - len(msg_lines)
y = utils.Max(0, (y/2)-2)
lp.QueueWriteString(strings.Repeat("\r\n", y))
for _, line := range msg_lines {
if replacement_text != "" {
idx := strings.Index(line, replacement_text)
if idx > -1 {
x := wcswidth.Stringwidth(line[:idx])
replacement_range = Range{x, x + wcswidth.Stringwidth(replacement_text), y}
}
}
lp.Println(line)
y++
}
if sz.HeightCells > 2 {
lp.Println()
y++
}
switch o.Type {
case "yesno":
draw_yesno(y, int(sz.WidthCells), int(sz.HeightCells))
case "choices":
draw_choice(y, int(sz.WidthCells), int(sz.HeightCells))
}
return nil
}
unhide := func() {
if hidden_text != "" && message != "" {
message = message[:hidden_text_start_pos] + hidden_text + message[hidden_text_end_pos:]
hidden_text = ""
draw_screen()
}
}
lp.OnInitialize = func() (string, error) {
lp.SetCursorVisible(false)
return "", draw_screen()
}
lp.OnFinalize = func() string {
lp.SetCursorVisible(true)
return ""
}
lp.OnText = func(text string, from_key_event, in_bracketed_paste bool) error {
text = strings.ToLower(text)
if allowed.Has(text) {
response = text
lp.Quit(0)
} else if hidden_text != "" && text == o.UnhideKey {
unhide()
} else if o.Type == "yesno" {
lp.Quit(1)
}
return nil
}
lp.OnKeyEvent = func(ev *loop.KeyEvent) error {
if ev.MatchesPressOrRepeat("esc") || ev.MatchesPressOrRepeat("ctrl+c") {
ev.Handled = true
lp.Quit(1)
} else if ev.MatchesPressOrRepeat("enter") {
ev.Handled = true
response = response_on_accept
lp.Quit(0)
}
return nil
}
lp.OnMouseEvent = func(ev *loop.MouseEvent) error {
if ev.Event_type == loop.MOUSE_CLICK {
for letter, ranges := range clickable_ranges {
for _, r := range ranges {
if r.has_point(ev.Cell.X, ev.Cell.Y) {
response = letter
lp.Quit(0)
return nil
}
}
}
if hidden_text != "" && replacement_range.has_point(ev.Cell.X, ev.Cell.Y) {
unhide()
}
}
return nil
}
lp.OnResize = func(old, news loop.ScreenSize) error {
return draw_screen()
}
err = lp.Run()
if err != nil {
return "", err
}
ds := lp.DeathSignalName()
if ds != "" {
fmt.Println("Killed by signal: ", ds)
lp.KillIfSignalled()
return "", fmt.Errorf("Filled by signal: %s", ds)
}
return response, nil
}

View File

@@ -1,92 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package ask
import (
"fmt"
"io"
"os"
"path/filepath"
"time"
"kitty/tools/tui/loop"
"kitty/tools/tui/readline"
"kitty/tools/utils"
)
var _ = fmt.Print
func get_line(o *Options) (result string, err error) {
lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors)
if err != nil {
return
}
cwd, _ := os.Getwd()
ropts := readline.RlInit{Prompt: o.Prompt}
if o.Name != "" {
base := filepath.Join(utils.CacheDir(), "ask")
ropts.HistoryPath = filepath.Join(base, o.Name+".history.json")
os.MkdirAll(base, 0o755)
}
rl := readline.New(lp, ropts)
if o.Default != "" {
rl.SetText(o.Default)
}
lp.OnInitialize = func() (string, error) {
rl.Start()
return "", nil
}
lp.OnFinalize = func() string { rl.End(); return "" }
lp.OnResumeFromStop = func() error {
rl.Start()
return nil
}
lp.OnResize = rl.OnResize
lp.OnKeyEvent = func(event *loop.KeyEvent) error {
if event.MatchesPressOrRepeat("ctrl+c") {
return fmt.Errorf("Canceled by user")
}
err := rl.OnKeyEvent(event)
if err != nil {
if err == io.EOF {
lp.Quit(0)
return nil
}
if err == readline.ErrAcceptInput {
hi := readline.HistoryItem{Timestamp: time.Now(), Cmd: rl.AllText(), ExitCode: 0, Cwd: cwd}
rl.AddHistoryItem(hi)
result = rl.AllText()
lp.Quit(0)
return nil
}
return err
}
if event.Handled {
rl.Redraw()
return nil
}
return nil
}
lp.OnText = func(text string, from_key_event, in_bracketed_paste bool) error {
err := rl.OnText(text, from_key_event, in_bracketed_paste)
if err == nil {
rl.Redraw()
}
return err
}
err = lp.Run()
rl.Shutdown()
if err != nil {
return "", err
}
ds := lp.DeathSignalName()
if ds != "" {
return "", fmt.Errorf("Killed by signal: %s", ds)
}
return
}

View File

@@ -1,73 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package ask
import (
"errors"
"fmt"
"kitty/tools/cli"
"kitty/tools/cli/markup"
"kitty/tools/tui"
)
var _ = fmt.Print
type Response struct {
Items []string `json:"items"`
Response string `json:"response"`
}
func show_message(msg string) {
if msg != "" {
m := markup.New(true)
fmt.Println(m.Bold(msg))
}
}
func main(_ *cli.Command, o *Options, args []string) (rc int, err error) {
output := tui.KittenOutputSerializer()
result := &Response{Items: args}
if len(o.Prompt) > 2 && o.Prompt[0] == o.Prompt[len(o.Prompt)-1] && (o.Prompt[0] == '"' || o.Prompt[0] == '\'') {
o.Prompt = o.Prompt[1 : len(o.Prompt)-1]
}
switch o.Type {
case "yesno", "choices":
result.Response, err = choices(o)
if err != nil {
return 1, err
}
case "password":
show_message(o.Message)
pw, err := tui.ReadPassword(o.Prompt, false)
if err != nil {
if errors.Is(err, tui.Canceled) {
pw = ""
} else {
return 1, err
}
}
result.Response = pw
case "line":
show_message(o.Message)
result.Response, err = get_line(o)
if err != nil {
return 1, err
}
default:
return 1, fmt.Errorf("Unknown type: %s", o.Type)
}
s, err := output(result)
if err != nil {
return 1, err
}
_, err = fmt.Println(s)
if err != nil {
return 1, err
}
return
}
func EntryPoint(parent *cli.Command) {
create_cmd(parent, main)
}

View File

@@ -1,237 +0,0 @@
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
package clipboard
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"io"
"os"
"strings"
"kitty/tools/tty"
"kitty/tools/tui/loop"
"kitty/tools/utils"
)
var _ = fmt.Print
var _ = fmt.Print
func encode_read_from_clipboard(use_primary bool) string {
dest := "c"
if use_primary {
dest = "p"
}
return fmt.Sprintf("\x1b]52;%s;?\x1b\\", dest)
}
type base64_streaming_enc struct {
output func(string) loop.IdType
last_written_id loop.IdType
}
func (self *base64_streaming_enc) Write(p []byte) (int, error) {
if len(p) > 0 {
self.last_written_id = self.output(string(p))
}
return len(p), nil
}
var ErrTooMuchPipedData = errors.New("Too much piped data")
func read_all_with_max_size(r io.Reader, max_size int) ([]byte, error) {
b := make([]byte, 0, utils.Min(8192, max_size))
for {
if len(b) == cap(b) {
new_size := utils.Min(2*cap(b), max_size)
if new_size <= cap(b) {
return b, ErrTooMuchPipedData
}
b = append(make([]byte, 0, new_size), b...)
}
n, err := r.Read(b[len(b):cap(b)])
b = b[:len(b)+n]
if err != nil {
if err == io.EOF {
err = nil
}
return b, err
}
}
}
func preread_stdin() (data_src io.Reader, tempfile *os.File, err error) {
// we pre-read STDIN because otherwise if the output of a command is being piped in
// and that command itself transmits on the tty we will break. For example
// kitten @ ls | kitten clipboard
var stdin_data []byte
stdin_data, err = read_all_with_max_size(os.Stdin, 2*1024*1024)
if err == nil {
os.Stdin.Close()
} else if err != ErrTooMuchPipedData {
os.Stdin.Close()
err = fmt.Errorf("Failed to read from STDIN pipe with error: %w", err)
return
}
if err == ErrTooMuchPipedData {
tempfile, err = utils.CreateAnonymousTemp("")
if err != nil {
return nil, nil, fmt.Errorf("Failed to create a temporary from STDIN pipe with error: %w", err)
}
tempfile.Write(stdin_data)
_, err = io.Copy(tempfile, os.Stdin)
os.Stdin.Close()
if err != nil {
return nil, nil, fmt.Errorf("Failed to copy data from STDIN pipe to temp file with error: %w", err)
}
tempfile.Seek(0, os.SEEK_SET)
data_src = tempfile
} else if stdin_data != nil {
data_src = bytes.NewBuffer(stdin_data)
}
return
}
func run_plain_text_loop(opts *Options) (err error) {
stdin_is_tty := tty.IsTerminal(os.Stdin.Fd())
var data_src io.Reader
var tempfile *os.File
if !stdin_is_tty {
data_src, tempfile, err = preread_stdin()
if err != nil {
return err
}
if tempfile != nil {
defer tempfile.Close()
}
}
lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoMouseTracking)
if err != nil {
return
}
dest := "c"
if opts.UsePrimary {
dest = "p"
}
send_to_loop := func(data string) loop.IdType {
return lp.QueueWriteString(data)
}
enc_writer := base64_streaming_enc{output: send_to_loop}
enc := base64.NewEncoder(base64.StdEncoding, &enc_writer)
transmitting := true
after_read_from_stdin := func() {
transmitting = false
if opts.GetClipboard {
lp.QueueWriteString(encode_read_from_clipboard(opts.UsePrimary))
} else if opts.WaitForCompletion {
lp.QueueWriteString("\x1bP+q544e\x1b\\")
} else {
lp.Quit(0)
}
}
buf := make([]byte, 8192)
write_one_chunk := func() error {
n, err := data_src.Read(buf[:cap(buf)])
if err != nil && !errors.Is(err, io.EOF) {
send_to_loop("\x1b\\")
return err
}
if n > 0 {
enc.Write(buf[:n])
}
if errors.Is(err, io.EOF) {
enc.Close()
send_to_loop("\x1b\\")
after_read_from_stdin()
}
return nil
}
lp.OnInitialize = func() (string, error) {
if data_src != nil {
send_to_loop(fmt.Sprintf("\x1b]52;%s;", dest))
return "", write_one_chunk()
}
after_read_from_stdin()
return "", nil
}
lp.OnWriteComplete = func(id loop.IdType) error {
if id == enc_writer.last_written_id {
return write_one_chunk()
}
return nil
}
var clipboard_contents []byte
lp.OnEscapeCode = func(etype loop.EscapeCodeType, data []byte) (err error) {
switch etype {
case loop.DCS:
if strings.HasPrefix(utils.UnsafeBytesToString(data), "1+r") {
lp.Quit(0)
}
case loop.OSC:
q := utils.UnsafeBytesToString(data)
if strings.HasPrefix(q, "52;") {
parts := strings.SplitN(q, ";", 3)
if len(parts) < 3 {
lp.Quit(0)
return
}
data, err := base64.StdEncoding.DecodeString(parts[2])
if err != nil {
return fmt.Errorf("Invalid base64 encoded data from terminal with error: %w", err)
}
clipboard_contents = data
lp.Quit(0)
}
}
return
}
esc_count := 0
lp.OnKeyEvent = func(event *loop.KeyEvent) error {
if event.MatchesPressOrRepeat("ctrl+c") || event.MatchesPressOrRepeat("esc") {
if transmitting {
return nil
}
event.Handled = true
esc_count++
if esc_count < 2 {
key := "Esc"
if event.MatchesPressOrRepeat("ctrl+c") {
key = "Ctrl+C"
}
lp.QueueWriteString(fmt.Sprintf("Waiting for response from terminal, press %s again to abort. This could cause garbage to be spewed to the screen.\r\n", key))
} else {
return fmt.Errorf("Aborted by user!")
}
}
return nil
}
err = lp.Run()
if err != nil {
return
}
ds := lp.DeathSignalName()
if ds != "" {
fmt.Println("Killed by signal: ", ds)
lp.KillIfSignalled()
return
}
if len(clipboard_contents) > 0 {
_, err = os.Stdout.Write(clipboard_contents)
if err != nil {
err = fmt.Errorf("Failed to write to STDOUT with error: %w", err)
}
}
return
}

View File

@@ -1,32 +0,0 @@
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
package clipboard
import (
"os"
"kitty/tools/cli"
)
func run_mime_loop(opts *Options, args []string) (err error) {
cwd, err = os.Getwd()
if err != nil {
return err
}
if opts.GetClipboard {
return run_get_loop(opts, args)
}
return run_set_loop(opts, args)
}
func clipboard_main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) {
if len(args) > 0 {
return 0, run_mime_loop(opts, args)
}
return 0, run_plain_text_loop(opts)
}
func EntryPoint(parent *cli.Command) {
create_cmd(parent, clipboard_main)
}

View File

@@ -1,456 +0,0 @@
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
package clipboard
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"os"
"path/filepath"
"strings"
"sync"
"kitty/tools/tty"
"kitty/tools/tui/loop"
"kitty/tools/utils"
"kitty/tools/utils/images"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
)
var _ = fmt.Print
var cwd string
const OSC_NUMBER = "5522"
type Output struct {
arg string
ext string
arg_is_stream bool
mime_type string
remote_mime_type string
image_needs_conversion bool
is_stream bool
dest_is_tty bool
dest *os.File
err error
started bool
all_data_received bool
}
func (self *Output) cleanup() {
if self.dest != nil {
self.dest.Close()
if !self.is_stream {
os.Remove(self.dest.Name())
}
self.dest = nil
}
}
func (self *Output) add_data(data []byte) {
if self.err != nil {
return
}
if self.dest == nil {
if !self.image_needs_conversion && self.arg_is_stream {
self.is_stream = true
self.dest = os.Stdout
if self.arg == "/dev/stderr" {
self.dest = os.Stderr
}
self.dest_is_tty = tty.IsTerminal(self.dest.Fd())
} else {
d := cwd
if strings.ContainsRune(self.arg, os.PathSeparator) && !self.arg_is_stream {
d = filepath.Dir(self.arg)
}
f, err := os.CreateTemp(d, "."+filepath.Base(self.arg))
if err != nil {
self.err = err
return
}
self.dest = f
}
self.started = true
}
if self.dest_is_tty {
data = bytes.ReplaceAll(data, utils.UnsafeStringToBytes("\n"), utils.UnsafeStringToBytes("\r\n"))
}
_, self.err = self.dest.Write(data)
}
func (self *Output) write_image(img image.Image) (err error) {
var output *os.File
if self.arg_is_stream {
output = os.Stdout
if self.arg == "/dev/stderr" {
output = os.Stderr
}
} else {
output, err = os.Create(self.arg)
if err != nil {
return err
}
}
defer func() {
output.Close()
if err != nil && !self.arg_is_stream {
os.Remove(output.Name())
}
}()
return images.Encode(output, img, self.mime_type)
}
func (self *Output) commit() {
if self.err != nil {
return
}
if self.image_needs_conversion {
self.dest.Seek(0, os.SEEK_SET)
img, _, err := image.Decode(self.dest)
self.dest.Close()
os.Remove(self.dest.Name())
if err == nil {
err = self.write_image(img)
}
if err != nil {
self.err = fmt.Errorf("Failed to encode image data to %s with error: %w", self.mime_type, err)
}
} else {
self.dest.Close()
if !self.is_stream {
f, err := os.OpenFile(self.arg, os.O_CREATE|os.O_RDONLY, 0666)
if err == nil {
fi, err := f.Stat()
if err == nil {
self.dest.Chmod(fi.Mode().Perm())
}
f.Close()
os.Remove(f.Name())
}
self.err = os.Rename(self.dest.Name(), self.arg)
if self.err != nil {
os.Remove(self.dest.Name())
self.err = fmt.Errorf("Failed to rename temporary file used for downloading to destination: %s with error: %w", self.arg, self.err)
}
}
}
self.dest = nil
}
func (self *Output) assign_mime_type(available_mimes []string, aliases map[string][]string) (err error) {
if self.mime_type == "." {
self.remote_mime_type = "."
return
}
if slices.Contains(available_mimes, self.mime_type) {
self.remote_mime_type = self.mime_type
return
}
if len(aliases[self.mime_type]) > 0 {
for _, alias := range aliases[self.mime_type] {
if slices.Contains(available_mimes, alias) {
self.remote_mime_type = alias
return
}
}
}
for _, mt := range available_mimes {
if matched, _ := filepath.Match(self.mime_type, mt); matched {
self.remote_mime_type = mt
return
}
}
if images.EncodableImageTypes[self.mime_type] {
for _, mt := range available_mimes {
if images.DecodableImageTypes[mt] {
self.remote_mime_type = mt
self.image_needs_conversion = true
return
}
}
}
if is_textual_mime(self.mime_type) {
for _, mt := range available_mimes {
if mt == "text/plain" {
self.remote_mime_type = mt
return
}
}
}
return fmt.Errorf("The MIME type %s for %s not available on the clipboard", self.mime_type, self.arg)
}
func escape_metadata_value(k, x string) (ans string) {
if k == "mime" {
x = base64.StdEncoding.EncodeToString(utils.UnsafeStringToBytes(x))
}
return x
}
func unescape_metadata_value(k, x string) (ans string) {
if k == "mime" {
b, err := base64.StdEncoding.DecodeString(x)
if err == nil {
x = string(b)
}
}
return x
}
func encode_bytes(metadata map[string]string, payload []byte) string {
ans := strings.Builder{}
ans.Grow(2048)
ans.WriteString("\x1b]")
ans.WriteString(OSC_NUMBER)
ans.WriteString(";")
for k, v := range metadata {
if !strings.HasSuffix(ans.String(), ";") {
ans.WriteString(":")
}
ans.WriteString(k)
ans.WriteString("=")
ans.WriteString(escape_metadata_value(k, v))
}
if len(payload) > 0 {
ans.WriteString(";")
ans.WriteString(base64.StdEncoding.EncodeToString(payload))
}
ans.WriteString("\x1b\\")
return ans.String()
}
func encode(metadata map[string]string, payload string) string {
return encode_bytes(metadata, utils.UnsafeStringToBytes(payload))
}
func error_from_status(status string) error {
switch status {
case "ENOSYS":
return fmt.Errorf("no primary selection available on this system")
case "EPERM":
return fmt.Errorf("permission denied")
case "EBUSY":
return fmt.Errorf("a temporary error occurred, try again later.")
default:
return fmt.Errorf("%s", status)
}
}
func parse_escape_code(etype loop.EscapeCodeType, data []byte) (metadata map[string]string, payload []byte, err error) {
if etype != loop.OSC || !bytes.HasPrefix(data, utils.UnsafeStringToBytes(OSC_NUMBER+";")) {
return
}
parts := bytes.SplitN(data, utils.UnsafeStringToBytes(";"), 3)
metadata = make(map[string]string)
if len(parts) > 2 && len(parts[2]) > 0 {
payload, err = base64.StdEncoding.DecodeString(utils.UnsafeBytesToString(parts[2]))
if err != nil {
err = fmt.Errorf("Received OSC %s packet from terminal with invalid base64 encoded payload", OSC_NUMBER)
return
}
}
if len(parts) > 1 {
for _, record := range bytes.Split(parts[1], utils.UnsafeStringToBytes(":")) {
rp := bytes.SplitN(record, utils.UnsafeStringToBytes("="), 2)
v := ""
if len(rp) == 2 {
v = string(rp[1])
}
k := string(rp[0])
metadata[k] = unescape_metadata_value(k, v)
}
}
return
}
func parse_aliases(raw []string) (map[string][]string, error) {
ans := make(map[string][]string, len(raw))
for _, x := range raw {
k, v, found := strings.Cut(x, "=")
if !found {
return nil, fmt.Errorf("%s is not valid MIME alias specification", x)
}
ans[k] = append(ans[k], v)
ans[v] = append(ans[v], k)
}
return ans, nil
}
func run_get_loop(opts *Options, args []string) (err error) {
lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoMouseTracking)
if err != nil {
return err
}
var available_mimes []string
var wg sync.WaitGroup
var getting_data_for string
requested_mimes := make(map[string]*Output)
reading_available_mimes := true
outputs := make([]*Output, len(args))
aliases, merr := parse_aliases(opts.Alias)
if merr != nil {
return merr
}
for i, arg := range args {
outputs[i] = &Output{arg: arg, arg_is_stream: arg == "/dev/stdout" || arg == "/dev/stderr", ext: filepath.Ext(arg)}
if len(opts.Mime) > i {
outputs[i].mime_type = opts.Mime[i]
} else {
if outputs[i].arg_is_stream {
outputs[i].mime_type = "text/plain"
} else {
outputs[i].mime_type = utils.GuessMimeType(outputs[i].arg)
}
}
if outputs[i].mime_type == "" {
return fmt.Errorf("Could not detect the MIME type for: %s use --mime to specify it manually", arg)
}
}
defer func() {
for _, o := range outputs {
if o.dest != nil {
o.cleanup()
}
}
}()
basic_metadata := map[string]string{"type": "read"}
if opts.UsePrimary {
basic_metadata["loc"] = "primary"
}
lp.OnInitialize = func() (string, error) {
lp.QueueWriteString(encode(basic_metadata, "."))
return "", nil
}
lp.OnEscapeCode = func(etype loop.EscapeCodeType, data []byte) (err error) {
metadata, payload, err := parse_escape_code(etype, data)
if err != nil {
return err
}
if metadata == nil {
return nil
}
if reading_available_mimes {
switch metadata["status"] {
case "DATA":
available_mimes = strings.Split(utils.UnsafeBytesToString(payload), " ")
case "OK":
case "DONE":
reading_available_mimes = false
if len(available_mimes) == 0 {
return fmt.Errorf("The clipboard is empty")
}
for _, o := range outputs {
err = o.assign_mime_type(available_mimes, aliases)
if err != nil {
return err
}
if o.remote_mime_type == "." {
o.started = true
o.add_data(utils.UnsafeStringToBytes(strings.Join(available_mimes, "\n")))
o.all_data_received = true
} else {
requested_mimes[o.remote_mime_type] = o
}
}
if len(requested_mimes) > 0 {
lp.QueueWriteString(encode(basic_metadata, strings.Join(maps.Keys(requested_mimes), " ")))
} else {
lp.Quit(0)
}
default:
return fmt.Errorf("Failed to read list of available data types in the clipboard with error: %w", error_from_status(metadata["status"]))
}
} else {
switch metadata["status"] {
case "DATA":
current_mime := metadata["mime"]
o := requested_mimes[current_mime]
if o != nil {
if getting_data_for != current_mime {
if prev := requested_mimes[getting_data_for]; prev != nil && !prev.all_data_received {
prev.all_data_received = true
wg.Add(1)
go func() {
prev.commit()
wg.Done()
}()
}
getting_data_for = current_mime
}
if !o.all_data_received {
o.add_data(payload)
}
}
case "OK":
case "DONE":
if prev := requested_mimes[getting_data_for]; getting_data_for != "" && prev != nil && !prev.all_data_received {
prev.all_data_received = true
wg.Add(1)
go func() {
prev.commit()
wg.Done()
}()
getting_data_for = ""
}
lp.Quit(0)
default:
return fmt.Errorf("Failed to read data from the clipboard with error: %w", error_from_status(metadata["status"]))
}
}
return
}
esc_count := 0
lp.OnKeyEvent = func(event *loop.KeyEvent) error {
if event.MatchesPressOrRepeat("ctrl+c") || event.MatchesPressOrRepeat("esc") {
event.Handled = true
esc_count++
if esc_count < 2 {
key := "Esc"
if event.MatchesPressOrRepeat("ctrl+c") {
key = "Ctrl+C"
}
lp.QueueWriteString(fmt.Sprintf("Waiting for response from terminal, press %s again to abort. This could cause garbage to be spewed to the screen.\r\n", key))
} else {
return fmt.Errorf("Aborted by user!")
}
}
return nil
}
err = lp.Run()
wg.Wait()
if err != nil {
return
}
ds := lp.DeathSignalName()
if ds != "" {
fmt.Println("Killed by signal: ", ds)
lp.KillIfSignalled()
return
}
for _, o := range outputs {
if o.err != nil {
err = fmt.Errorf("Failed to get %s with error: %w", o.arg, o.err)
return
}
if !o.started {
err = fmt.Errorf("No data for %s with MIME type: %s", o.arg, o.mime_type)
return
}
}
return
}

View File

@@ -1,229 +0,0 @@
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
package clipboard
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"kitty/tools/tui/loop"
"kitty/tools/utils"
)
var _ = fmt.Print
type Input struct {
src io.Reader
arg string
ext string
is_stream bool
mime_type string
extra_mime_types []string
}
func is_textual_mime(x string) bool {
return strings.HasPrefix(x, "text/") || utils.KnownTextualMimes[x]
}
func is_text_plain_mime(x string) bool {
return x == "text/plain"
}
func (self *Input) has_mime_matching(predicate func(string) bool) bool {
if predicate(self.mime_type) {
return true
}
for _, i := range self.extra_mime_types {
if predicate(i) {
return true
}
}
return false
}
func write_loop(inputs []*Input, opts *Options) (err error) {
lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoMouseTracking)
if err != nil {
return err
}
var waiting_for_write loop.IdType
var buf [4096]byte
aliases, aerr := parse_aliases(opts.Alias)
if aerr != nil {
return aerr
}
num_text_mimes := 0
has_text_plain := false
for _, i := range inputs {
i.extra_mime_types = aliases[i.mime_type]
if i.has_mime_matching(is_textual_mime) {
num_text_mimes++
if !has_text_plain && i.has_mime_matching(is_text_plain_mime) {
has_text_plain = true
}
}
}
if num_text_mimes > 0 && !has_text_plain {
for _, i := range inputs {
if i.has_mime_matching(is_textual_mime) {
i.extra_mime_types = append(i.extra_mime_types, "text/plain")
break
}
}
}
make_metadata := func(ptype, mime string) map[string]string {
ans := map[string]string{"type": ptype}
if opts.UsePrimary {
ans["loc"] = "primary"
}
if mime != "" {
ans["mime"] = mime
}
return ans
}
lp.OnInitialize = func() (string, error) {
waiting_for_write = lp.QueueWriteString(encode(make_metadata("write", ""), ""))
return "", nil
}
write_chunk := func() error {
if len(inputs) == 0 {
return nil
}
i := inputs[0]
n, err := i.src.Read(buf[:])
if n > 0 {
waiting_for_write = lp.QueueWriteString(encode_bytes(make_metadata("wdata", i.mime_type), buf[:n]))
}
if err != nil {
if errors.Is(err, io.EOF) {
if len(i.extra_mime_types) > 0 {
lp.QueueWriteString(encode(make_metadata("walias", i.mime_type), strings.Join(i.extra_mime_types, " ")))
}
inputs = inputs[1:]
if len(inputs) == 0 {
lp.QueueWriteString(encode(make_metadata("wdata", ""), ""))
waiting_for_write = 0
}
return lp.OnWriteComplete(waiting_for_write)
}
return fmt.Errorf("Failed to read from %s with error: %w", i.arg, err)
}
return nil
}
lp.OnWriteComplete = func(msg_id loop.IdType) error {
if waiting_for_write == msg_id {
return write_chunk()
}
return nil
}
lp.OnEscapeCode = func(etype loop.EscapeCodeType, data []byte) (err error) {
metadata, _, err := parse_escape_code(etype, data)
if err != nil {
return err
}
if metadata != nil && metadata["type"] == "write" {
switch metadata["status"] {
case "DONE":
lp.Quit(0)
case "EIO":
return fmt.Errorf("Could not write to clipboard an I/O error occurred while the terminal was processing the data")
case "EINVAL":
return fmt.Errorf("Could not write to clipboard base64 encoding invalid")
case "ENOSYS":
return fmt.Errorf("Could not write to primary selection as the system does not support it")
case "EPERM":
return fmt.Errorf("Could not write to clipboard as permission was denied")
case "EBUSY":
return fmt.Errorf("Could not write to clipboard, a temporary error occurred, try again later.")
default:
return fmt.Errorf("Could not write to clipboard unknowns status returned from terminal: %#v", metadata["status"])
}
}
return
}
esc_count := 0
lp.OnKeyEvent = func(event *loop.KeyEvent) error {
if event.MatchesPressOrRepeat("ctrl+c") || event.MatchesPressOrRepeat("esc") {
event.Handled = true
esc_count++
if esc_count < 2 {
key := "Esc"
if event.MatchesPressOrRepeat("ctrl+c") {
key = "Ctrl+C"
}
lp.QueueWriteString(fmt.Sprintf("Waiting for response from terminal, press %s again to abort. This could cause garbage to be spewed to the screen.\r\n", key))
} else {
return fmt.Errorf("Aborted by user!")
}
}
return nil
}
err = lp.Run()
if err != nil {
return
}
ds := lp.DeathSignalName()
if ds != "" {
fmt.Println("Killed by signal: ", ds)
lp.KillIfSignalled()
return
}
return
}
func run_set_loop(opts *Options, args []string) (err error) {
inputs := make([]*Input, len(args))
to_process := make([]*Input, len(args))
defer func() {
for _, i := range inputs {
if i.src != nil {
rc, ok := i.src.(io.Closer)
if ok {
rc.Close()
}
}
}
}()
for i, arg := range args {
if arg == "/dev/stdin" {
f, _, err := preread_stdin()
if err != nil {
return err
}
inputs[i] = &Input{arg: arg, src: f, is_stream: true}
} else {
f, err := os.Open(arg)
if err != nil {
return fmt.Errorf("Failed to open %s with error: %w", arg, err)
}
inputs[i] = &Input{arg: arg, src: f, ext: filepath.Ext(arg)}
}
if i < len(opts.Mime) {
inputs[i].mime_type = opts.Mime[i]
} else if inputs[i].is_stream {
inputs[i].mime_type = "text/plain"
} else if inputs[i].ext != "" {
inputs[i].mime_type = utils.GuessMimeType(inputs[i].arg)
}
if inputs[i].mime_type == "" {
return fmt.Errorf("Could not guess MIME type for %s use the --mime option to specify a MIME type", arg)
}
to_process[i] = inputs[i]
if to_process[i].is_stream {
}
}
return write_loop(to_process, opts)
}

View File

@@ -1,386 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package diff
import (
"crypto/md5"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"unicode/utf8"
"kitty/tools/utils"
)
var _ = fmt.Print
var path_name_map, remote_dirs map[string]string
var mimetypes_cache, data_cache, hash_cache *utils.LRUCache[string, string]
var size_cache *utils.LRUCache[string, int64]
var lines_cache *utils.LRUCache[string, []string]
var highlighted_lines_cache *utils.LRUCache[string, []string]
var is_text_cache *utils.LRUCache[string, bool]
func init_caches() {
path_name_map = make(map[string]string, 32)
remote_dirs = make(map[string]string, 32)
const sz = 4096
size_cache = utils.NewLRUCache[string, int64](sz)
mimetypes_cache = utils.NewLRUCache[string, string](sz)
data_cache = utils.NewLRUCache[string, string](sz)
is_text_cache = utils.NewLRUCache[string, bool](sz)
lines_cache = utils.NewLRUCache[string, []string](sz)
highlighted_lines_cache = utils.NewLRUCache[string, []string](sz)
hash_cache = utils.NewLRUCache[string, string](sz)
}
func add_remote_dir(val string) {
x := filepath.Base(val)
idx := strings.LastIndex(x, "-")
if idx > -1 {
x = x[idx+1:]
} else {
x = ""
}
remote_dirs[val] = x
}
func mimetype_for_path(path string) string {
return mimetypes_cache.MustGetOrCreate(path, func(path string) string {
mt := utils.GuessMimeTypeWithFileSystemAccess(path)
if mt == "" {
mt = "application/octet-stream"
}
if utils.KnownTextualMimes[mt] {
if _, a, found := strings.Cut(mt, "/"); found {
mt = "text/" + a
}
}
return mt
})
}
func data_for_path(path string) (string, error) {
return data_cache.GetOrCreate(path, func(path string) (string, error) {
ans, err := os.ReadFile(path)
return utils.UnsafeBytesToString(ans), err
})
}
func size_for_path(path string) (int64, error) {
return size_cache.GetOrCreate(path, func(path string) (int64, error) {
s, err := os.Stat(path)
if err != nil {
return 0, err
}
return s.Size(), nil
})
}
func is_image(path string) bool {
return strings.HasPrefix(mimetype_for_path(path), "image/")
}
func is_path_text(path string) bool {
return is_text_cache.MustGetOrCreate(path, func(path string) bool {
if is_image(path) {
return false
}
s1, err := os.Stat(path)
if err == nil {
s2, err := os.Stat("/dev/null")
if err == nil && os.SameFile(s1, s2) {
return false
}
}
d, err := data_for_path(path)
if err != nil {
return false
}
return utf8.ValidString(d)
})
}
func hash_for_path(path string) (string, error) {
return hash_cache.GetOrCreate(path, func(path string) (string, error) {
ans, err := data_for_path(path)
if err != nil {
return "", err
}
hash := md5.Sum(utils.UnsafeStringToBytes(ans))
return utils.UnsafeBytesToString(hash[:]), err
})
}
// Remove all control codes except newlines
func sanitize_control_codes(x string) string {
pat := utils.MustCompile("[\x00-\x09\x0b-\x1f\x7f\u0080-\u009f]")
return pat.ReplaceAllLiteralString(x, "░")
}
func sanitize_tabs_and_carriage_returns(x string) string {
return strings.NewReplacer("\t", conf.Replace_tab_by, "\r", "⏎").Replace(x)
}
func sanitize(x string) string {
return sanitize_control_codes(sanitize_tabs_and_carriage_returns(x))
}
func text_to_lines(text string) []string {
lines := make([]string, 0, 512)
splitlines_like_git(text, false, func(line string) { lines = append(lines, line) })
return lines
}
func lines_for_path(path string) ([]string, error) {
return lines_cache.GetOrCreate(path, func(path string) ([]string, error) {
ans, err := data_for_path(path)
if err != nil {
return nil, err
}
return text_to_lines(sanitize(ans)), nil
})
}
func highlighted_lines_for_path(path string) ([]string, error) {
plain_lines, err := lines_for_path(path)
if err != nil {
return nil, err
}
if ans, found := highlighted_lines_cache.Get(path); found && len(ans) == len(plain_lines) {
return ans, nil
}
return plain_lines, nil
}
type Collection struct {
changes, renames, type_map map[string]string
adds, removes *utils.Set[string]
all_paths []string
paths_to_highlight *utils.Set[string]
added_count, removed_count int
}
func (self *Collection) add_change(left, right string) {
self.changes[left] = right
self.all_paths = append(self.all_paths, left)
self.paths_to_highlight.Add(left)
self.paths_to_highlight.Add(right)
self.type_map[left] = `diff`
}
func (self *Collection) add_rename(left, right string) {
self.renames[left] = right
self.all_paths = append(self.all_paths, left)
self.type_map[left] = `rename`
}
func (self *Collection) add_add(right string) {
self.adds.Add(right)
self.all_paths = append(self.all_paths, right)
self.paths_to_highlight.Add(right)
self.type_map[right] = `add`
if is_path_text(right) {
num, _ := lines_for_path(right)
self.added_count += len(num)
}
}
func (self *Collection) add_removal(left string) {
self.removes.Add(left)
self.all_paths = append(self.all_paths, left)
self.paths_to_highlight.Add(left)
self.type_map[left] = `removal`
if is_path_text(left) {
num, _ := lines_for_path(left)
self.removed_count += len(num)
}
}
func (self *Collection) finalize() {
utils.StableSortWithKey(self.all_paths, func(path string) string {
return path_name_map[path]
})
}
func (self *Collection) Len() int { return len(self.all_paths) }
func (self *Collection) Items() int { return len(self.all_paths) }
func (self *Collection) Apply(f func(path, typ, changed_path string) error) error {
for _, path := range self.all_paths {
typ := self.type_map[path]
changed_path := ""
switch typ {
case "diff":
changed_path = self.changes[path]
case "rename":
changed_path = self.renames[path]
}
if err := f(path, typ, changed_path); err != nil {
return err
}
}
return nil
}
func allowed(path string, patterns ...string) bool {
name := filepath.Base(path)
for _, pat := range patterns {
if matched, err := filepath.Match(pat, name); err == nil && matched {
return false
}
}
return true
}
func remote_hostname(path string) (string, string) {
for q, val := range remote_dirs {
if strings.HasPrefix(path, q) {
return q, val
}
}
return "", ""
}
func resolve_remote_name(path, defval string) string {
remote_dir, rh := remote_hostname(path)
if remote_dir != "" && rh != "" {
r, err := filepath.Rel(remote_dir, path)
if err == nil {
return rh + ":" + r
}
}
return defval
}
func walk(base string, patterns []string, names *utils.Set[string], pmap, path_name_map map[string]string) error {
return filepath.WalkDir(base, func(path string, d fs.DirEntry, err error) error {
is_allowed := allowed(path, patterns...)
if !is_allowed {
if d.IsDir() {
return fs.SkipDir
}
return nil
}
if d.IsDir() {
return nil
}
path, err = filepath.Abs(path)
if err != nil {
return err
}
name, err := filepath.Rel(base, path)
if err != nil {
return err
}
if name != "." {
path_name_map[path] = name
names.Add(name)
pmap[name] = path
}
return nil
})
}
func (self *Collection) collect_files(left, right string) error {
left_names, right_names := utils.NewSet[string](16), utils.NewSet[string](16)
left_path_map, right_path_map := make(map[string]string, 16), make(map[string]string, 16)
err := walk(left, conf.Ignore_name, left_names, left_path_map, path_name_map)
if err != nil {
return err
}
err = walk(right, conf.Ignore_name, right_names, right_path_map, path_name_map)
common_names := left_names.Intersect(right_names)
changed_names := utils.NewSet[string](common_names.Len())
for n := range common_names.Iterable() {
ld, err := data_for_path(left_path_map[n])
var rd string
if err == nil {
rd, err = data_for_path(right_path_map[n])
}
if err != nil {
return err
}
if ld != rd {
changed_names.Add(n)
self.add_change(left_path_map[n], right_path_map[n])
}
}
removed := left_names.Subtract(common_names)
added := right_names.Subtract(common_names)
ahash, rhash := make(map[string]string, added.Len()), make(map[string]string, removed.Len())
for a := range added.Iterable() {
ahash[a], err = hash_for_path(right_path_map[a])
if err != nil {
return err
}
}
for r := range removed.Iterable() {
rhash[r], err = hash_for_path(left_path_map[r])
if err != nil {
return err
}
}
for name, rh := range rhash {
found := false
for n, ah := range ahash {
if ah == rh {
ld, _ := data_for_path(left_path_map[name])
rd, _ := data_for_path(right_path_map[n])
if ld == rd {
self.add_rename(left_path_map[name], right_path_map[n])
added.Discard(n)
found = true
break
}
}
}
if !found {
self.add_removal(left_path_map[name])
}
}
for name := range added.Iterable() {
self.add_add(right_path_map[name])
}
return nil
}
func create_collection(left, right string) (ans *Collection, err error) {
ans = &Collection{
changes: make(map[string]string),
renames: make(map[string]string),
type_map: make(map[string]string),
adds: utils.NewSet[string](32),
removes: utils.NewSet[string](32),
paths_to_highlight: utils.NewSet[string](32),
all_paths: make([]string, 0, 32),
}
left_stat, err := os.Stat(left)
if err != nil {
return nil, err
}
if left_stat.IsDir() {
err = ans.collect_files(left, right)
if err != nil {
return nil, err
}
} else {
pl, err := filepath.Abs(left)
if err != nil {
return nil, err
}
pr, err := filepath.Abs(right)
if err != nil {
return nil, err
}
path_name_map[pl] = resolve_remote_name(pl, left)
path_name_map[pr] = resolve_remote_name(pr, right)
ans.add_change(pl, pr)
}
ans.finalize()
return ans, err
}

View File

@@ -1,53 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package diff
import (
"fmt"
"os"
"path/filepath"
"testing"
"kitty/tools/utils"
"github.com/google/go-cmp/cmp"
)
var _ = fmt.Print
func TestDiffCollectWalk(t *testing.T) {
tdir := t.TempDir()
j := func(x ...string) string { return filepath.Join(append([]string{tdir}, x...)...) }
os.MkdirAll(j("a", "b"), 0o700)
os.WriteFile(j("a/b/c"), nil, 0o600)
os.WriteFile(j("b"), nil, 0o600)
os.WriteFile(j("d"), nil, 0o600)
os.WriteFile(j("e"), nil, 0o600)
os.WriteFile(j("#d#"), nil, 0o600)
os.WriteFile(j("e~"), nil, 0o600)
os.MkdirAll(j("f"), 0o700)
os.WriteFile(j("f/g"), nil, 0o600)
os.WriteFile(j("h space"), nil, 0o600)
expected_names := utils.NewSetWithItems("d", "e", "f/g", "h space")
expected_pmap := map[string]string{
"d": j("d"),
"e": j("e"),
"f/g": j("f/g"),
"h space": j("h space"),
}
names := utils.NewSet[string](16)
pmap := make(map[string]string, 16)
if err := walk(tdir, []string{"*~", "#*#", "b"}, names, pmap, map[string]string{}); err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(
utils.Sort(expected_names.AsSlice(), func(a, b string) bool { return a < b }),
utils.Sort(names.AsSlice(), func(a, b string) bool { return a < b }),
); diff != "" {
t.Fatal(diff)
}
if diff := cmp.Diff(expected_pmap, pmap); diff != "" {
t.Fatal(diff)
}
}

View File

@@ -1,264 +0,0 @@
// Copied from the Go stdlib, with modifications.
//https://github.com/golang/go/raw/master/src/internal/diff/diff.go
// Copyright 2022 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package diff
import (
"bytes"
"fmt"
"sort"
"strings"
)
// A pair is a pair of values tracked for both the x and y side of a diff.
// It is typically a pair of line indexes.
type pair struct{ x, y int }
// Diff returns an anchored diff of the two texts old and new
// in the “unified diff” format. If old and new are identical,
// Diff returns a nil slice (no output).
//
// Unix diff implementations typically look for a diff with
// the smallest number of lines inserted and removed,
// which can in the worst case take time quadratic in the
// number of lines in the texts. As a result, many implementations
// either can be made to run for a long time or cut off the search
// after a predetermined amount of work.
//
// In contrast, this implementation looks for a diff with the
// smallest number of “unique” lines inserted and removed,
// where unique means a line that appears just once in both old and new.
// We call this an “anchored diff” because the unique lines anchor
// the chosen matching regions. An anchored diff is usually clearer
// than a standard diff, because the algorithm does not try to
// reuse unrelated blank lines or closing braces.
// The algorithm also guarantees to run in O(n log n) time
// instead of the standard O(n²) time.
//
// Some systems call this approach a “patience diff,” named for
// the “patience sorting” algorithm, itself named for a solitaire card game.
// We avoid that name for two reasons. First, the name has been used
// for a few different variants of the algorithm, so it is imprecise.
// Second, the name is frequently interpreted as meaning that you have
// to wait longer (to be patient) for the diff, meaning that it is a slower algorithm,
// when in fact the algorithm is faster than the standard one.
func Diff(oldName, old, newName, new string, num_of_context_lines int) []byte {
if old == new {
return nil
}
x := lines(old)
y := lines(new)
// Print diff header.
var out bytes.Buffer
fmt.Fprintf(&out, "diff %s %s\n", oldName, newName)
fmt.Fprintf(&out, "--- %s\n", oldName)
fmt.Fprintf(&out, "+++ %s\n", newName)
// Loop over matches to consider,
// expanding each match to include surrounding lines,
// and then printing diff chunks.
// To avoid setup/teardown cases outside the loop,
// tgs returns a leading {0,0} and trailing {len(x), len(y)} pair
// in the sequence of matches.
var (
done pair // printed up to x[:done.x] and y[:done.y]
chunk pair // start lines of current chunk
count pair // number of lines from each side in current chunk
ctext []string // lines for current chunk
)
for _, m := range tgs(x, y) {
if m.x < done.x {
// Already handled scanning forward from earlier match.
continue
}
// Expand matching lines as far possible,
// establishing that x[start.x:end.x] == y[start.y:end.y].
// Note that on the first (or last) iteration we may (or definitey do)
// have an empty match: start.x==end.x and start.y==end.y.
start := m
for start.x > done.x && start.y > done.y && x[start.x-1] == y[start.y-1] {
start.x--
start.y--
}
end := m
for end.x < len(x) && end.y < len(y) && x[end.x] == y[end.y] {
end.x++
end.y++
}
// Emit the mismatched lines before start into this chunk.
// (No effect on first sentinel iteration, when start = {0,0}.)
for _, s := range x[done.x:start.x] {
ctext = append(ctext, "-"+s)
count.x++
}
for _, s := range y[done.y:start.y] {
ctext = append(ctext, "+"+s)
count.y++
}
// If we're not at EOF and have too few common lines,
// the chunk includes all the common lines and continues.
C := num_of_context_lines // number of context lines
if (end.x < len(x) || end.y < len(y)) &&
(end.x-start.x < C || (len(ctext) > 0 && end.x-start.x < 2*C)) {
for _, s := range x[start.x:end.x] {
ctext = append(ctext, " "+s)
count.x++
count.y++
}
done = end
continue
}
// End chunk with common lines for context.
if len(ctext) > 0 {
n := end.x - start.x
if n > C {
n = C
}
for _, s := range x[start.x : start.x+n] {
ctext = append(ctext, " "+s)
count.x++
count.y++
}
done = pair{start.x + n, start.y + n}
// Format and emit chunk.
// Convert line numbers to 1-indexed.
// Special case: empty file shows up as 0,0 not 1,0.
if count.x > 0 {
chunk.x++
}
if count.y > 0 {
chunk.y++
}
fmt.Fprintf(&out, "@@ -%d,%d +%d,%d @@\n", chunk.x, count.x, chunk.y, count.y)
for _, s := range ctext {
out.WriteString(s)
}
count.x = 0
count.y = 0
ctext = ctext[:0]
}
// If we reached EOF, we're done.
if end.x >= len(x) && end.y >= len(y) {
break
}
// Otherwise start a new chunk.
chunk = pair{end.x - C, end.y - C}
for _, s := range x[chunk.x:end.x] {
ctext = append(ctext, " "+s)
count.x++
count.y++
}
done = end
}
return out.Bytes()
}
// lines returns the lines in the file x, including newlines.
// If the file does not end in a newline, one is supplied
// along with a warning about the missing newline.
func lines(x string) []string {
l := strings.SplitAfter(x, "\n")
if l[len(l)-1] == "" {
l = l[:len(l)-1]
} else {
// Treat last line as having a message about the missing newline attached,
// using the same text as BSD/GNU diff (including the leading backslash).
l[len(l)-1] += "\n\\ No newline at end of file\n"
}
return l
}
// tgs returns the pairs of indexes of the longest common subsequence
// of unique lines in x and y, where a unique line is one that appears
// once in x and once in y.
//
// The longest common subsequence algorithm is as described in
// Thomas G. Szymanski, “A Special Case of the Maximal Common
// Subsequence Problem,” Princeton TR #170 (January 1975),
// available at https://research.swtch.com/tgs170.pdf.
func tgs(x, y []string) []pair {
// Count the number of times each string appears in a and b.
// We only care about 0, 1, many, counted as 0, -1, -2
// for the x side and 0, -4, -8 for the y side.
// Using negative numbers now lets us distinguish positive line numbers later.
m := make(map[string]int)
for _, s := range x {
if c := m[s]; c > -2 {
m[s] = c - 1
}
}
for _, s := range y {
if c := m[s]; c > -8 {
m[s] = c - 4
}
}
// Now unique strings can be identified by m[s] = -1+-4.
//
// Gather the indexes of those strings in x and y, building:
// xi[i] = increasing indexes of unique strings in x.
// yi[i] = increasing indexes of unique strings in y.
// inv[i] = index j such that x[xi[i]] = y[yi[j]].
var xi, yi, inv []int
for i, s := range y {
if m[s] == -1+-4 {
m[s] = len(yi)
yi = append(yi, i)
}
}
for i, s := range x {
if j, ok := m[s]; ok && j >= 0 {
xi = append(xi, i)
inv = append(inv, j)
}
}
// Apply Algorithm A from Szymanski's paper.
// In those terms, A = J = inv and B = [0, n).
// We add sentinel pairs {0,0}, and {len(x),len(y)}
// to the returned sequence, to help the processing loop.
J := inv
n := len(xi)
T := make([]int, n)
L := make([]int, n)
for i := range T {
T[i] = n + 1
}
for i := 0; i < n; i++ {
k := sort.Search(n, func(k int) bool {
return T[k] >= J[i]
})
T[k] = J[i]
L[i] = k + 1
}
k := 0
for _, v := range L {
if k < v {
k = v
}
}
seq := make([]pair, 2+k)
seq[1+k] = pair{len(x), len(y)} // sentinel at end
lastj := n
for i := n - 1; i >= 0; i-- {
if L[i] == k && J[i] < lastj {
seq[k] = pair{xi[i], yi[J[i]]}
k--
}
}
seq[0] = pair{0, 0} // sentinel at start
return seq
}

View File

@@ -1,207 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package diff
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"kitty/tools/utils"
"kitty/tools/utils/images"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
)
var _ = fmt.Print
var _ = os.WriteFile
var ErrNoLexer = errors.New("No lexer available for this format")
var DefaultStyle = (&utils.Once[*chroma.Style]{Run: func() *chroma.Style {
// Default style generated by python style.py default pygments.styles.default.DefaultStyle
// with https://raw.githubusercontent.com/alecthomas/chroma/master/_tools/style.py
return styles.Register(chroma.MustNewStyle("default", chroma.StyleEntries{
chroma.TextWhitespace: "#bbbbbb",
chroma.Comment: "italic #3D7B7B",
chroma.CommentPreproc: "noitalic #9C6500",
chroma.Keyword: "bold #008000",
chroma.KeywordPseudo: "nobold",
chroma.KeywordType: "nobold #B00040",
chroma.Operator: "#666666",
chroma.OperatorWord: "bold #AA22FF",
chroma.NameBuiltin: "#008000",
chroma.NameFunction: "#0000FF",
chroma.NameClass: "bold #0000FF",
chroma.NameNamespace: "bold #0000FF",
chroma.NameException: "bold #CB3F38",
chroma.NameVariable: "#19177C",
chroma.NameConstant: "#880000",
chroma.NameLabel: "#767600",
chroma.NameEntity: "bold #717171",
chroma.NameAttribute: "#687822",
chroma.NameTag: "bold #008000",
chroma.NameDecorator: "#AA22FF",
chroma.LiteralString: "#BA2121",
chroma.LiteralStringDoc: "italic",
chroma.LiteralStringInterpol: "bold #A45A77",
chroma.LiteralStringEscape: "bold #AA5D1F",
chroma.LiteralStringRegex: "#A45A77",
chroma.LiteralStringSymbol: "#19177C",
chroma.LiteralStringOther: "#008000",
chroma.LiteralNumber: "#666666",
chroma.GenericHeading: "bold #000080",
chroma.GenericSubheading: "bold #800080",
chroma.GenericDeleted: "#A00000",
chroma.GenericInserted: "#008400",
chroma.GenericError: "#E40000",
chroma.GenericEmph: "italic",
chroma.GenericStrong: "bold",
chroma.GenericPrompt: "bold #000080",
chroma.GenericOutput: "#717171",
chroma.GenericTraceback: "#04D",
chroma.Error: "border:#FF0000",
chroma.Background: " bg:#f8f8f8",
}))
}}).Get
// Clear the background colour.
func clear_background(style *chroma.Style) *chroma.Style {
builder := style.Builder()
bg := builder.Get(chroma.Background)
bg.Background = 0
bg.NoInherit = true
builder.AddEntry(chroma.Background, bg)
style, _ = builder.Build()
return style
}
func ansi_formatter(w io.Writer, style *chroma.Style, it chroma.Iterator) error {
const SGR_PREFIX = "\033["
const SGR_SUFFIX = "m"
style = clear_background(style)
before, after := make([]byte, 0, 64), make([]byte, 0, 64)
nl := []byte{'\n'}
write_sgr := func(which []byte) {
if len(which) > 1 {
w.Write(utils.UnsafeStringToBytes(SGR_PREFIX))
w.Write(which[:len(which)-1])
w.Write(utils.UnsafeStringToBytes(SGR_SUFFIX))
}
}
write := func(text string) {
write_sgr(before)
w.Write(utils.UnsafeStringToBytes(text))
write_sgr(after)
}
for token := it(); token != chroma.EOF; token = it() {
entry := style.Get(token.Type)
before, after = before[:0], after[:0]
if !entry.IsZero() {
if entry.Bold == chroma.Yes {
before = append(before, '1', ';')
after = append(after, '2', '2', '1', ';')
}
if entry.Underline == chroma.Yes {
before = append(before, '4', ';')
after = append(after, '2', '4', ';')
}
if entry.Italic == chroma.Yes {
before = append(before, '3', ';')
after = append(after, '2', '3', ';')
}
if entry.Colour.IsSet() {
before = append(before, fmt.Sprintf("38:2:%d:%d:%d;", entry.Colour.Red(), entry.Colour.Green(), entry.Colour.Blue())...)
after = append(after, '3', '9', ';')
}
}
// independently format each line in a multiline token, needed for the diff kitten highlighting to work, also
// pagers like less reset SGR formatting at line boundaries
text := sanitize(token.Value)
for text != "" {
idx := strings.IndexByte(text, '\n')
if idx < 0 {
write(text)
break
}
write(text[:idx])
w.Write(nl)
text = text[idx+1:]
}
}
return nil
}
func highlight_file(path string) (highlighted string, err error) {
filename_for_detection := filepath.Base(path)
ext := filepath.Ext(filename_for_detection)
if ext != "" {
ext = strings.ToLower(ext[1:])
r := conf.Syntax_aliases[ext]
if r != "" {
filename_for_detection = "file." + r
}
}
text, err := data_for_path(path)
if err != nil {
return "", err
}
lexer := lexers.Match(filename_for_detection)
if lexer == nil {
if err == nil {
lexer = lexers.Analyse(text)
}
}
if lexer == nil {
return "", fmt.Errorf("Cannot highlight %#v: %w", path, ErrNoLexer)
}
lexer = chroma.Coalesce(lexer)
name := conf.Pygments_style
var style *chroma.Style
if name == "default" {
style = DefaultStyle()
} else {
style = styles.Get(name)
}
if style == nil {
if conf.Background.IsDark() && !conf.Foreground.IsDark() {
style = styles.Get("monokai")
if style == nil {
style = styles.Get("github-dark")
}
} else {
style = DefaultStyle()
}
if style == nil {
style = styles.Fallback
}
}
iterator, err := lexer.Tokenise(nil, text)
if err != nil {
return "", err
}
formatter := chroma.FormatterFunc(ansi_formatter)
w := strings.Builder{}
w.Grow(len(text) * 2)
err = formatter.Format(&w, style, iterator)
// os.WriteFile(filepath.Base(path+".highlighted"), []byte(w.String()), 0o600)
return w.String(), err
}
func highlight_all(paths []string) {
ctx := images.Context{}
ctx.Parallel(0, len(paths), func(nums <-chan int) {
for i := range nums {
path := paths[i]
raw, err := highlight_file(path)
if err == nil {
highlighted_lines_cache.Set(path, text_to_lines(raw))
}
}
})
}

View File

@@ -1,174 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package diff
import (
"archive/tar"
"bytes"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
"kitty/tools/cli"
"kitty/tools/cmd/ssh"
"kitty/tools/config"
"kitty/tools/tui/loop"
"kitty/tools/utils"
)
var _ = fmt.Print
func load_config(opts *Options) (ans *Config, err error) {
ans = NewConfig()
p := config.ConfigParser{LineHandler: ans.Parse}
err = p.LoadConfig("diff.conf", opts.Config, opts.Override)
if err != nil {
return nil, err
}
return ans, nil
}
var conf *Config
var opts *Options
var lp *loop.Loop
func isdir(path string) bool {
if s, err := os.Stat(path); err == nil {
return s.IsDir()
}
return false
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func get_ssh_file(hostname, rpath string) (string, error) {
tdir, err := os.MkdirTemp("", "*-"+hostname)
if err != nil {
return "", err
}
add_remote_dir(tdir)
is_abs := strings.HasPrefix(rpath, "/")
for strings.HasPrefix(rpath, "/") {
rpath = rpath[1:]
}
cmd := []string{ssh.SSHExe(), hostname, "tar", "-c", "-f", "-"}
if is_abs {
cmd = append(cmd, "-C", "/")
}
cmd = append(cmd, rpath)
c := exec.Command(cmd[0], cmd[1:]...)
c.Stdin, c.Stderr = os.Stdin, os.Stderr
stdout, err := c.Output()
if err != nil {
return "", fmt.Errorf("Failed to ssh into remote host %s to get file %s with error: %w", hostname, rpath, err)
}
tf := tar.NewReader(bytes.NewReader(stdout))
count, err := utils.ExtractAllFromTar(tf, tdir)
if err != nil {
return "", fmt.Errorf("Failed to untar data from remote host %s to get file %s with error: %w", hostname, rpath, err)
}
ans := filepath.Join(tdir, rpath)
if count == 1 {
filepath.WalkDir(tdir, func(path string, d fs.DirEntry, err error) error {
if !d.IsDir() {
ans = path
return fs.SkipAll
}
return nil
})
}
return ans, nil
}
func get_remote_file(path string) (string, error) {
if strings.HasPrefix(path, "ssh:") {
parts := strings.SplitN(path, ":", 3)
if len(parts) == 3 {
return get_ssh_file(parts[1], parts[2])
}
}
return path, nil
}
func main(_ *cli.Command, opts_ *Options, args []string) (rc int, err error) {
opts = opts_
conf, err = load_config(opts)
if err != nil {
return 1, err
}
if len(args) != 2 {
return 1, fmt.Errorf("You must specify exactly two files/directories to compare")
}
if err = set_diff_command(conf.Diff_cmd); err != nil {
return 1, err
}
init_caches()
create_formatters()
defer func() {
for tdir := range remote_dirs {
os.RemoveAll(tdir)
}
}()
left, err := get_remote_file(args[0])
if err != nil {
return 1, err
}
right, err := get_remote_file(args[1])
if err != nil {
return 1, err
}
if isdir(left) != isdir(right) {
return 1, fmt.Errorf("The items to be diffed should both be either directories or files. Comparing a directory to a file is not valid.'")
}
if !exists(left) {
return 1, fmt.Errorf("%s does not exist", left)
}
if !exists(right) {
return 1, fmt.Errorf("%s does not exist", right)
}
lp, err = loop.New()
if err != nil {
return 1, err
}
h := Handler{left: left, right: right, lp: lp}
lp.OnInitialize = func() (string, error) {
lp.SetCursorVisible(false)
lp.SetCursorShape(loop.BAR_CURSOR, true)
lp.AllowLineWrapping(false)
lp.SetWindowTitle(fmt.Sprintf("%s vs. %s", left, right))
h.initialize()
return "", nil
}
lp.OnWakeup = h.on_wakeup
lp.OnFinalize = func() string {
lp.SetCursorVisible(true)
lp.SetCursorShape(loop.BLOCK_CURSOR, true)
h.finalize()
return ""
}
lp.OnResize = h.on_resize
lp.OnKeyEvent = h.on_key_event
lp.OnText = h.on_text
err = lp.Run()
if err != nil {
return 1, err
}
ds := lp.DeathSignalName()
if ds != "" {
fmt.Println("Killed by signal: ", ds)
lp.KillIfSignalled()
return 1, nil
}
return
}
func EntryPoint(parent *cli.Command) {
create_cmd(parent, main)
}

View File

@@ -1,376 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package diff
import (
"bytes"
"errors"
"fmt"
"kitty/tools/utils"
"kitty/tools/utils/images"
"kitty/tools/utils/shlex"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
var _ = fmt.Print
const GIT_DIFF = `git diff --no-color --no-ext-diff --exit-code -U_CONTEXT_ --no-index --`
const DIFF_DIFF = `diff -p -U _CONTEXT_ --`
var diff_cmd []string
var GitExe = (&utils.Once[string]{Run: func() string {
return utils.FindExe("git")
}}).Get
var DiffExe = (&utils.Once[string]{Run: func() string {
return utils.FindExe("diff")
}}).Get
func find_differ() {
if GitExe() != "git" && exec.Command(GitExe(), "--help").Run() == nil {
diff_cmd, _ = shlex.Split(GIT_DIFF)
} else if DiffExe() != "diff" && exec.Command(DiffExe(), "--help").Run() == nil {
diff_cmd, _ = shlex.Split(DIFF_DIFF)
} else {
diff_cmd = []string{}
}
}
func set_diff_command(q string) error {
switch q {
case "auto":
find_differ()
case "builtin", "":
diff_cmd = []string{}
case "diff":
diff_cmd, _ = shlex.Split(DIFF_DIFF)
case "git":
diff_cmd, _ = shlex.Split(GIT_DIFF)
default:
c, err := shlex.Split(q)
if err != nil {
return err
}
diff_cmd = c
}
return nil
}
type Center struct{ offset, left_size, right_size int }
type Chunk struct {
is_context bool
left_start, right_start int
left_count, right_count int
centers []Center
}
func (self *Chunk) add_line() {
self.right_count++
}
func (self *Chunk) remove_line() {
self.left_count++
}
func (self *Chunk) context_line() {
self.left_count++
self.right_count++
}
func changed_center(left, right string) (ans Center) {
if len(left) > 0 && len(right) > 0 {
ll, rl := len(left), len(right)
ml := utils.Min(ll, rl)
for ; ans.offset < ml && left[ans.offset] == right[ans.offset]; ans.offset++ {
}
suffix_count := 0
for ; suffix_count < ml && left[ll-1-suffix_count] == right[rl-1-suffix_count]; suffix_count++ {
}
ans.left_size = ll - suffix_count - ans.offset
ans.right_size = rl - suffix_count - ans.offset
}
return
}
func (self *Chunk) finalize(left_lines, right_lines []string) {
if !self.is_context && self.left_count == self.right_count {
for i := 0; i < self.left_count; i++ {
self.centers = append(self.centers, changed_center(left_lines[self.left_start+i], right_lines[self.right_start+i]))
}
}
}
type Hunk struct {
left_start, left_count int
right_start, right_count int
title string
added_count, removed_count int
chunks []*Chunk
current_chunk *Chunk
largest_line_number int
}
func (self *Hunk) new_chunk(is_context bool) *Chunk {
left_start, right_start := self.left_start, self.right_start
if len(self.chunks) > 0 {
c := self.chunks[len(self.chunks)-1]
left_start = c.left_start + c.left_count
right_start = c.right_start + c.right_count
}
return &Chunk{is_context: is_context, left_start: left_start, right_start: right_start}
}
func (self *Hunk) ensure_diff_chunk() {
if self.current_chunk == nil || self.current_chunk.is_context {
if self.current_chunk != nil {
self.chunks = append(self.chunks, self.current_chunk)
}
self.current_chunk = self.new_chunk(false)
}
}
func (self *Hunk) ensure_context_chunk() {
if self.current_chunk == nil || !self.current_chunk.is_context {
if self.current_chunk != nil {
self.chunks = append(self.chunks, self.current_chunk)
}
self.current_chunk = self.new_chunk(true)
}
}
func (self *Hunk) add_line() {
self.ensure_diff_chunk()
self.current_chunk.add_line()
self.added_count++
}
func (self *Hunk) remove_line() {
self.ensure_diff_chunk()
self.current_chunk.remove_line()
self.removed_count++
}
func (self *Hunk) context_line() {
self.ensure_context_chunk()
self.current_chunk.context_line()
}
func (self *Hunk) finalize(left_lines, right_lines []string) error {
if self.current_chunk != nil {
self.chunks = append(self.chunks, self.current_chunk)
}
// Sanity check
c := self.chunks[len(self.chunks)-1]
if c.left_start+c.left_count != self.left_start+self.left_count {
return fmt.Errorf("Left side line mismatch %d != %d", c.left_start+c.left_count, self.left_start+self.left_count)
}
if c.right_start+c.right_count != self.right_start+self.right_count {
return fmt.Errorf("Right side line mismatch %d != %d", c.right_start+c.right_count, self.right_start+self.right_count)
}
for _, c := range self.chunks {
c.finalize(left_lines, right_lines)
}
return nil
}
type Patch struct {
all_hunks []*Hunk
largest_line_number, added_count, removed_count int
}
func (self *Patch) Len() int { return len(self.all_hunks) }
func splitlines_like_git(raw string, strip_trailing_lines bool, process_line func(string)) {
sz := len(raw)
if strip_trailing_lines {
for sz > 0 && (raw[sz-1] == '\n' || raw[sz-1] == '\r') {
sz--
}
}
start := 0
for i := 0; i < sz; i++ {
switch raw[i] {
case '\n':
process_line(raw[start:i])
start = i + 1
case '\r':
process_line(raw[start:i])
start = i + 1
if start < sz && raw[start] == '\n' {
i++
start++
}
}
}
if start < sz {
process_line(raw[start:sz])
}
}
func parse_range(x string) (start, count int) {
s, c, found := strings.Cut(x, ",")
start, _ = strconv.Atoi(s)
if start < 0 {
start = -start
}
count = 1
if found {
count, _ = strconv.Atoi(c)
}
return
}
func parse_hunk_header(line string) *Hunk {
parts := strings.SplitN(line, "@@", 3)
linespec := strings.TrimSpace(parts[1])
title := ""
if len(parts) == 3 {
title = strings.TrimSpace(parts[2])
}
left, right, _ := strings.Cut(linespec, " ")
ls, lc := parse_range(left)
rs, rc := parse_range(right)
return &Hunk{
title: title, left_start: ls - 1, left_count: lc, right_start: rs - 1, right_count: rc,
largest_line_number: utils.Max(ls-1+lc, rs-1+rc),
}
}
func parse_patch(raw string, left_lines, right_lines []string) (ans *Patch, err error) {
ans = &Patch{all_hunks: make([]*Hunk, 0, 32)}
var current_hunk *Hunk
splitlines_like_git(raw, true, func(line string) {
if strings.HasPrefix(line, "@@ ") {
current_hunk = parse_hunk_header(line)
ans.all_hunks = append(ans.all_hunks, current_hunk)
} else if current_hunk != nil {
var ch byte
if len(line) > 0 {
ch = line[0]
}
switch ch {
case '+':
current_hunk.add_line()
case '-':
current_hunk.remove_line()
case '\\':
default:
current_hunk.context_line()
}
}
})
for _, h := range ans.all_hunks {
err = h.finalize(left_lines, right_lines)
if err != nil {
return
}
ans.added_count += h.added_count
ans.removed_count += h.removed_count
}
if len(ans.all_hunks) > 0 {
ans.largest_line_number = ans.all_hunks[len(ans.all_hunks)-1].largest_line_number
}
return
}
func run_diff(file1, file2 string, num_of_context_lines int) (ok, is_different bool, patch string, err error) {
// we resolve symlinks because git diff does not follow symlinks, while diff
// does. We want consistent behavior, also for integration with git difftool
// we always want symlinks to be followed.
path1, err := filepath.EvalSymlinks(file1)
if err != nil {
return
}
path2, err := filepath.EvalSymlinks(file2)
if err != nil {
return
}
if len(diff_cmd) == 0 {
data1, err := data_for_path(path1)
if err != nil {
return false, false, "", err
}
data2, err := data_for_path(path2)
if err != nil {
return false, false, "", err
}
patchb := Diff(path1, data1, path2, data2, num_of_context_lines)
if patchb == nil {
return true, false, "", nil
}
return true, len(patchb) > 0, utils.UnsafeBytesToString(patchb), nil
} else {
context := strconv.Itoa(num_of_context_lines)
cmd := utils.Map(func(x string) string {
return strings.ReplaceAll(x, "_CONTEXT_", context)
}, diff_cmd)
cmd = append(cmd, path1, path2)
c := exec.Command(cmd[0], cmd[1:]...)
stdout, stderr := bytes.Buffer{}, bytes.Buffer{}
c.Stdout, c.Stderr = &stdout, &stderr
err = c.Run()
if err != nil {
var e *exec.ExitError
if errors.As(err, &e) && e.ExitCode() == 1 {
return true, true, stdout.String(), nil
}
return false, false, stderr.String(), err
}
return true, false, stdout.String(), nil
}
}
func do_diff(file1, file2 string, context_count int) (ans *Patch, err error) {
ok, _, raw, err := run_diff(file1, file2, context_count)
if !ok {
return nil, fmt.Errorf("Failed to diff %s vs. %s with errors:\n%s", file1, file2, raw)
}
if err != nil {
return
}
left_lines, err := lines_for_path(file1)
if err != nil {
return
}
right_lines, err := lines_for_path(file2)
if err != nil {
return
}
ans, err = parse_patch(raw, left_lines, right_lines)
return
}
type diff_job struct{ file1, file2 string }
func diff(jobs []diff_job, context_count int) (ans map[string]*Patch, err error) {
ans = make(map[string]*Patch)
ctx := images.Context{}
type result struct {
file1, file2 string
err error
patch *Patch
}
results := make(chan result, len(jobs))
ctx.Parallel(0, len(jobs), func(nums <-chan int) {
for i := range nums {
job := jobs[i]
r := result{file1: job.file1, file2: job.file2}
r.patch, r.err = do_diff(job.file1, job.file2, context_count)
results <- r
}
})
close(results)
for r := range results {
if r.err != nil {
return nil, r.err
}
ans[r.file1] = r.patch
}
return ans, nil
}

View File

@@ -1,652 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package diff
import (
"errors"
"fmt"
"math"
"strconv"
"strings"
"kitty/tools/tui/graphics"
"kitty/tools/tui/sgr"
"kitty/tools/utils"
"kitty/tools/utils/style"
"kitty/tools/wcswidth"
)
var _ = fmt.Print
type LineType int
const (
TITLE_LINE LineType = iota
FULL_TITLE_LINE
CHANGE_LINE
HUNK_TITLE_LINE
IMAGE_LINE
EMPTY_LINE
)
type Reference struct {
path string
linenum int
}
type LogicalLine struct {
src Reference
line_type LineType
screen_lines []string
is_change_start bool
left_image, right_image struct {
key string
count int
}
image_lines_offset int
}
func (self *LogicalLine) IncrementScrollPosBy(pos *ScrollPos, amt int) (delta int) {
if len(self.screen_lines) > 0 {
npos := utils.Max(0, utils.Min(pos.screen_line+amt, len(self.screen_lines)-1))
delta = npos - pos.screen_line
pos.screen_line = npos
}
return
}
func join_half_lines(left, right string) string {
return left + "\x1b[m" + right + "\x1b[m"
}
func fit_in(text string, count int) string {
truncated := wcswidth.TruncateToVisualLength(text, count)
if len(truncated) >= len(text) {
return text
}
if count > 1 {
truncated = wcswidth.TruncateToVisualLength(text, count-1)
}
return truncated + ``
}
func fill_in(text string, sz int) string {
w := wcswidth.Stringwidth(text)
if w < sz {
text += strings.Repeat(` `, (sz - w))
}
return text
}
func place_in(text string, sz int) string {
return fill_in(fit_in(text, sz), sz)
}
var title_format, text_format, margin_format, added_format, removed_format, added_margin_format, removed_margin_format, filler_format, margin_filler_format, hunk_margin_format, hunk_format, statusline_format, added_count_format, removed_count_format, message_format func(...any) string
func create_formatters() {
ctx := style.Context{AllowEscapeCodes: true}
text_format = ctx.SprintFunc(fmt.Sprintf("bg=%s", conf.Background.AsRGBSharp()))
filler_format = ctx.SprintFunc(fmt.Sprintf("bg=%s", conf.Filler_bg.AsRGBSharp()))
if conf.Margin_filler_bg.IsSet {
margin_filler_format = ctx.SprintFunc(fmt.Sprintf("bg=%s", conf.Margin_filler_bg.Color.AsRGBSharp()))
} else {
margin_filler_format = ctx.SprintFunc(fmt.Sprintf("bg=%s", conf.Filler_bg.AsRGBSharp()))
}
added_format = ctx.SprintFunc(fmt.Sprintf("bg=%s", conf.Added_bg.AsRGBSharp()))
added_margin_format = ctx.SprintFunc(fmt.Sprintf("fg=%s bg=%s", conf.Margin_fg.AsRGBSharp(), conf.Added_margin_bg.AsRGBSharp()))
removed_format = ctx.SprintFunc(fmt.Sprintf("bg=%s", conf.Removed_bg.AsRGBSharp()))
removed_margin_format = ctx.SprintFunc(fmt.Sprintf("fg=%s bg=%s", conf.Margin_fg.AsRGBSharp(), conf.Removed_margin_bg.AsRGBSharp()))
title_format = ctx.SprintFunc(fmt.Sprintf("fg=%s bg=%s bold", conf.Title_fg.AsRGBSharp(), conf.Title_bg.AsRGBSharp()))
margin_format = ctx.SprintFunc(fmt.Sprintf("fg=%s bg=%s", conf.Margin_fg.AsRGBSharp(), conf.Margin_bg.AsRGBSharp()))
statusline_format = ctx.SprintFunc(fmt.Sprintf("fg=%s", conf.Margin_fg.AsRGBSharp()))
added_count_format = ctx.SprintFunc(fmt.Sprintf("fg=%s", conf.Highlight_added_bg.AsRGBSharp()))
removed_count_format = ctx.SprintFunc(fmt.Sprintf("fg=%s", conf.Highlight_removed_bg.AsRGBSharp()))
hunk_format = ctx.SprintFunc(fmt.Sprintf("fg=%s bg=%s", conf.Margin_fg.AsRGBSharp(), conf.Hunk_bg.AsRGBSharp()))
hunk_margin_format = ctx.SprintFunc(fmt.Sprintf("fg=%s bg=%s", conf.Margin_fg.AsRGBSharp(), conf.Hunk_margin_bg.AsRGBSharp()))
message_format = ctx.SprintFunc("bold")
}
func center_span(ltype string, offset, size int) *sgr.Span {
ans := sgr.NewSpan(offset, size)
switch ltype {
case "add":
ans.SetBackground(conf.Highlight_added_bg).SetClosingBackground(conf.Added_bg)
case "remove":
ans.SetBackground(conf.Highlight_removed_bg).SetClosingBackground(conf.Removed_bg)
}
return ans
}
func title_lines(left_path, right_path string, columns, margin_size int, ans []*LogicalLine) []*LogicalLine {
left_name, right_name := path_name_map[left_path], path_name_map[right_path]
name := ""
m := strings.Repeat(` `, margin_size)
ll := LogicalLine{line_type: TITLE_LINE, src: Reference{path: left_path, linenum: 0}}
if right_name != "" && right_name != left_name {
n1 := fit_in(m+sanitize(left_name), columns/2-margin_size)
n1 = place_in(n1, columns/2)
n2 := fit_in(m+sanitize(right_name), columns/2-margin_size)
n2 = place_in(n2, columns/2)
name = n1 + n2
} else {
name = place_in(m+sanitize(left_name), columns)
ll.line_type = FULL_TITLE_LINE
}
l1 := ll
l1.screen_lines = []string{title_format(name)}
l2 := ll
l2.line_type = EMPTY_LINE
l2.screen_lines = []string{title_format(strings.Repeat("━", columns))}
return append(ans, &l1, &l2)
}
type LogicalLines struct {
lines []*LogicalLine
margin_size, columns int
}
func (self *LogicalLines) At(i int) *LogicalLine { return self.lines[i] }
func (self *LogicalLines) ScreenLineAt(pos ScrollPos) string {
if pos.logical_line < len(self.lines) && pos.logical_line >= 0 {
line := self.lines[pos.logical_line]
if pos.screen_line < len(line.screen_lines) && pos.screen_line >= 0 {
return self.lines[pos.logical_line].screen_lines[pos.screen_line]
}
}
return ""
}
func (self *LogicalLines) Len() int { return len(self.lines) }
func (self *LogicalLines) NumScreenLinesTo(a ScrollPos) (ans int) {
return self.Minus(a, ScrollPos{})
}
// a - b in terms of number of screen lines between the positions
func (self *LogicalLines) Minus(a, b ScrollPos) (delta int) {
if a.logical_line == b.logical_line {
return a.screen_line - b.screen_line
}
amt := 1
if a.Less(b) {
amt = -1
} else {
a, b = b, a
}
for i := a.logical_line; i < utils.Min(len(self.lines), b.logical_line+1); i++ {
line := self.lines[i]
switch i {
case a.logical_line:
delta += utils.Max(0, len(line.screen_lines)-a.screen_line)
case b.logical_line:
delta += b.screen_line
default:
delta += len(line.screen_lines)
}
}
return delta * amt
}
func (self *LogicalLines) IncrementScrollPosBy(pos *ScrollPos, amt int) (delta int) {
if pos.logical_line < 0 || pos.logical_line >= len(self.lines) || amt == 0 {
return
}
one := 1
if amt < 0 {
one = -1
}
for amt != 0 {
line := self.lines[pos.logical_line]
d := line.IncrementScrollPosBy(pos, amt)
if d == 0 {
nlp := pos.logical_line + one
if nlp < 0 || nlp >= len(self.lines) {
break
}
pos.logical_line = nlp
if one > 0 {
pos.screen_line = 0
} else {
pos.screen_line = len(self.lines[nlp].screen_lines) - 1
}
delta += one
amt -= one
} else {
amt -= d
delta += d
}
}
return
}
func human_readable(size int64) string {
divisor, suffix := 1, "B"
for i, candidate := range []string{"B", "KB", "MB", "GB", "TB", "PB", "EB"} {
if size < (1 << ((i + 1) * 10)) {
divisor, suffix = (1 << (i * 10)), candidate
break
}
}
fs := float64(size) / float64(divisor)
s := strconv.FormatFloat(fs, 'f', 2, 64)
if idx := strings.Index(s, "."); idx > -1 {
s = s[:idx+2]
}
if strings.HasSuffix(s, ".0") || strings.HasSuffix(s, ".00") {
idx := strings.IndexByte(s, '.')
s = s[:idx]
}
return s + " " + suffix
}
func render_diff_line(number, text, ltype string, margin_size int, available_cols int) string {
m, c := margin_format, text_format
switch ltype {
case `filler`:
m = margin_filler_format
c = filler_format
case `remove`:
m = removed_margin_format
c = removed_format
case `add`:
m = added_margin_format
c = added_format
}
margin := m(place_in(number, margin_size))
content := c(fill_in(text, available_cols))
return margin + content
}
func image_lines(left_path, right_path string, screen_size screen_size, margin_size int, image_size graphics.Size, ans []*LogicalLine) ([]*LogicalLine, error) {
columns := screen_size.columns
available_cols := columns/2 - margin_size
ll, err := first_binary_line(left_path, right_path, columns, margin_size, func(path string, formatter, margin_formatter formatter) (string, error) {
sz, err := size_for_path(path)
if err != nil {
return "", err
}
text := fmt.Sprintf("Size: %s", human_readable(sz))
res := image_collection.ResolutionOf(path)
if res.Width > -1 {
text = fmt.Sprintf("Dimensions: %dx%d %s", res.Width, res.Height, text)
}
text = place_in(text, available_cols)
return margin_formatter(strings.Repeat(` `, margin_size)) + formatter(text), err
})
if err != nil {
return nil, err
}
ll.image_lines_offset = len(ll.screen_lines)
do_side := func(path string, filler string) []string {
if path == "" {
return nil
}
sz, err := image_collection.GetSizeIfAvailable(path, image_size)
if err == nil {
count := int(math.Ceil(float64(sz.Height) / float64(screen_size.cell_height)))
return utils.Repeat(filler, count)
}
if errors.Is(err, graphics.ErrNotFound) {
return style.WrapTextAsLines("Loading image...", "", available_cols)
}
return style.WrapTextAsLines(fmt.Sprintf("Failed to load image: %s", err), "", available_cols)
}
left_lines := do_side(left_path, removed_format(strings.Repeat(` `, available_cols)))
if ll.left_image.count = len(left_lines); ll.left_image.count > 0 {
ll.left_image.key = left_path
}
right_lines := do_side(right_path, added_format(strings.Repeat(` `, available_cols)))
if ll.right_image.count = len(right_lines); ll.right_image.count > 0 {
ll.right_image.key = right_path
}
filler := filler_format(strings.Repeat(` `, available_cols))
m := strings.Repeat(` `, margin_size)
get_line := func(i int, which []string, margin_fmt func(...any) string) string {
if i < len(which) {
return margin_fmt(m) + which[i]
}
return margin_filler_format(m) + filler
}
for i := 0; i < utils.Max(len(left_lines), len(right_lines)); i++ {
left, right := get_line(i, left_lines, removed_margin_format), get_line(i, right_lines, added_margin_format)
ll.screen_lines = append(ll.screen_lines, left+right)
}
ll.line_type = IMAGE_LINE
return append(ans, ll), nil
}
type formatter = func(...any) string
func first_binary_line(left_path, right_path string, columns, margin_size int, renderer func(path string, formatter, margin_formatter formatter) (string, error)) (*LogicalLine, error) {
available_cols := columns/2 - margin_size
line := ""
if left_path == "" {
filler := render_diff_line(``, ``, `filler`, margin_size, available_cols)
r, err := renderer(right_path, added_format, added_margin_format)
if err != nil {
return nil, err
}
line = filler + r
} else if right_path == "" {
filler := render_diff_line(``, ``, `filler`, margin_size, available_cols)
l, err := renderer(left_path, removed_format, removed_margin_format)
if err != nil {
return nil, err
}
line = l + filler
} else {
l, err := renderer(left_path, removed_format, removed_margin_format)
if err != nil {
return nil, err
}
r, err := renderer(right_path, added_format, added_margin_format)
if err != nil {
return nil, err
}
line = l + r
}
ref := left_path
if ref == "" {
ref = right_path
}
ll := LogicalLine{is_change_start: true, line_type: CHANGE_LINE, src: Reference{path: ref, linenum: 0}, screen_lines: []string{line}}
if left_path == "" {
ll.src.path = right_path
}
return &ll, nil
}
func binary_lines(left_path, right_path string, columns, margin_size int, ans []*LogicalLine) (ans2 []*LogicalLine, err error) {
available_cols := columns/2 - margin_size
ll, err := first_binary_line(left_path, right_path, columns, margin_size, func(path string, formatter, margin_formatter formatter) (string, error) {
sz, err := size_for_path(path)
if err != nil {
return "", err
}
text := fmt.Sprintf("Binary file: %s", human_readable(sz))
text = place_in(text, available_cols)
return margin_formatter(strings.Repeat(` `, margin_size)) + formatter(text), err
})
if err != nil {
return nil, err
}
return append(ans, ll), nil
}
type DiffData struct {
left_path, right_path string
available_cols, margin_size int
left_lines, right_lines []string
filler_line, left_filler_line, right_filler_line string
}
func hunk_title(hunk_num int, hunk *Hunk, margin_size, available_cols int) string {
m := hunk_margin_format(strings.Repeat(" ", margin_size))
t := fmt.Sprintf("@@ -%d,%d +%d,%d @@ %s", hunk.left_start+1, hunk.left_count, hunk.right_start+1, hunk.right_count, hunk.title)
return m + hunk_format(place_in(t, available_cols))
}
func lines_for_context_chunk(data *DiffData, hunk_num int, chunk *Chunk, chunk_num int, ans []*LogicalLine) []*LogicalLine {
for i := 0; i < chunk.left_count; i++ {
left_line_number := chunk.left_start + i
right_line_number := chunk.right_start + i
ll := LogicalLine{line_type: CHANGE_LINE, src: Reference{path: data.left_path, linenum: left_line_number}}
left_line_number_s := strconv.Itoa(left_line_number + 1)
right_line_number_s := strconv.Itoa(right_line_number + 1)
for _, text := range splitlines(data.left_lines[left_line_number], data.available_cols) {
line := render_diff_line(left_line_number_s, text, `context`, data.margin_size, data.available_cols)
if right_line_number_s == left_line_number_s {
line += line
} else {
line += render_diff_line(right_line_number_s, text, `context`, data.margin_size, data.available_cols)
}
ll.screen_lines = append(ll.screen_lines, line)
left_line_number_s, right_line_number_s = "", ""
}
ans = append(ans, &ll)
}
return ans
}
func splitlines(text string, width int) []string {
return style.WrapTextAsLines(text, "", width)
}
func render_half_line(line_number int, line, ltype string, margin_size, available_cols int, center Center, ans []string) []string {
size := center.left_size
if ltype != "remove" {
size = center.right_size
}
if size > 0 {
span := center_span(ltype, center.offset, size)
line = sgr.InsertFormatting(line, span)
}
lnum := strconv.Itoa(line_number + 1)
for _, sc := range splitlines(line, available_cols) {
ans = append(ans, render_diff_line(lnum, sc, ltype, margin_size, available_cols))
lnum = ""
}
return ans
}
func lines_for_diff_chunk(data *DiffData, hunk_num int, chunk *Chunk, chunk_num int, ans []*LogicalLine) []*LogicalLine {
common := utils.Min(chunk.left_count, chunk.right_count)
ll, rl := make([]string, 0, 32), make([]string, 0, 32)
for i := 0; i < utils.Max(chunk.left_count, chunk.right_count); i++ {
ll, rl = ll[:0], rl[:0]
ref_ln, ref_path := 0, ""
var center Center
if i < len(chunk.centers) {
center = chunk.centers[i]
}
if i < chunk.left_count {
ref_path = data.left_path
ref_ln = chunk.left_start + i
ll = render_half_line(ref_ln, data.left_lines[ref_ln], "remove", data.margin_size, data.available_cols, center, ll)
}
if i < chunk.right_count {
ref_path = data.right_path
ref_ln = chunk.right_start + i
rl = render_half_line(ref_ln, data.right_lines[ref_ln], "add", data.margin_size, data.available_cols, center, rl)
}
if i < common {
extra := len(ll) - len(rl)
if extra < 0 {
ll = append(ll, utils.Repeat(data.left_filler_line, -extra)...)
} else if extra > 0 {
rl = append(rl, utils.Repeat(data.right_filler_line, extra)...)
}
} else {
if len(ll) > 0 {
rl = append(rl, utils.Repeat(data.filler_line, len(ll))...)
} else if len(rl) > 0 {
ll = append(ll, utils.Repeat(data.filler_line, len(rl))...)
}
}
logline := LogicalLine{line_type: CHANGE_LINE, src: Reference{path: ref_path, linenum: ref_ln}, is_change_start: i == 0}
for l := 0; l < len(ll); l++ {
logline.screen_lines = append(logline.screen_lines, join_half_lines(ll[l], rl[l]))
}
ans = append(ans, &logline)
}
return ans
}
func lines_for_diff(left_path string, right_path string, patch *Patch, columns, margin_size int, ans []*LogicalLine) (result []*LogicalLine, err error) {
ht := LogicalLine{line_type: HUNK_TITLE_LINE, src: Reference{path: left_path}}
if patch.Len() == 0 {
ht.screen_lines = []string{"The files are identical"}
ht.line_type = EMPTY_LINE
ans = append(ans, &ht)
return ans, nil
}
available_cols := columns/2 - margin_size
data := DiffData{left_path: left_path, right_path: right_path, available_cols: available_cols, margin_size: margin_size}
if left_path != "" {
data.left_lines, err = highlighted_lines_for_path(left_path)
if err != nil {
return
}
}
if right_path != "" {
data.right_lines, err = highlighted_lines_for_path(right_path)
if err != nil {
return
}
}
data.filler_line = render_diff_line("", "", "filler", margin_size, available_cols)
data.left_filler_line = render_diff_line("", "", "remove", margin_size, available_cols)
data.right_filler_line = render_diff_line("", "", "add", margin_size, available_cols)
for hunk_num, hunk := range patch.all_hunks {
htl := ht
htl.src.linenum = hunk.left_start
htl.screen_lines = []string{hunk_title(hunk_num, hunk, margin_size, columns-margin_size)}
ans = append(ans, &htl)
for cnum, chunk := range hunk.chunks {
if chunk.is_context {
ans = lines_for_context_chunk(&data, hunk_num, chunk, cnum, ans)
} else {
ans = lines_for_diff_chunk(&data, hunk_num, chunk, cnum, ans)
}
}
}
return ans, nil
}
func all_lines(path string, columns, margin_size int, is_add bool, ans []*LogicalLine) ([]*LogicalLine, error) {
available_cols := columns/2 - margin_size
ltype := `add`
if !is_add {
ltype = `remove`
}
lines, err := highlighted_lines_for_path(path)
if err != nil {
return nil, err
}
filler := render_diff_line(``, ``, `filler`, margin_size, available_cols)
msg_written := false
ll := LogicalLine{src: Reference{path: path}, line_type: CHANGE_LINE}
for line_number, line := range lines {
hlines := make([]string, 0, 8)
hlines = render_half_line(line_number, line, ltype, margin_size, available_cols, Center{}, hlines)
l := ll
l.src.linenum = line_number
l.is_change_start = line_number == 0
for _, hl := range hlines {
empty := filler
if !msg_written {
msg_written = true
msg := `This file was added`
if !is_add {
msg = `This file was removed`
}
empty = render_diff_line(``, msg, `filler`, margin_size, available_cols)
}
var text string
if is_add {
text = join_half_lines(empty, hl)
} else {
text = join_half_lines(hl, empty)
}
l.screen_lines = append(l.screen_lines, text)
}
ans = append(ans, &l)
}
return ans, nil
}
func rename_lines(path, other_path string, columns, margin_size int, ans []*LogicalLine) ([]*LogicalLine, error) {
m := strings.Repeat(" ", margin_size)
ll := LogicalLine{src: Reference{path: path, linenum: 0}, line_type: CHANGE_LINE, is_change_start: true}
for _, line := range splitlines(fmt.Sprintf(`The file %s was renamed to %s`, sanitize(path_name_map[path]), sanitize(path_name_map[other_path])), columns-margin_size) {
ll.screen_lines = append(ll.screen_lines, m+line)
}
return append(ans, &ll), nil
}
func render(collection *Collection, diff_map map[string]*Patch, screen_size screen_size, largest_line_number int, image_size graphics.Size) (result *LogicalLines, err error) {
margin_size := utils.Max(3, len(strconv.Itoa(largest_line_number))+1)
ans := make([]*LogicalLine, 0, 1024)
empty_line := LogicalLine{line_type: EMPTY_LINE}
columns := screen_size.columns
err = collection.Apply(func(path, item_type, changed_path string) error {
ans = title_lines(path, changed_path, columns, margin_size, ans)
defer func() {
el := empty_line
ans = append(ans, &el)
}()
is_binary := !is_path_text(path)
if !is_binary && item_type == `diff` && !is_path_text(changed_path) {
is_binary = true
}
is_img := is_binary && is_image(path) || (item_type == `diff` && is_image(changed_path))
_ = is_img
switch item_type {
case "diff":
if is_binary {
if is_img {
ans, err = image_lines(path, changed_path, screen_size, margin_size, image_size, ans)
} else {
ans, err = binary_lines(path, changed_path, columns, margin_size, ans)
}
} else {
ans, err = lines_for_diff(path, changed_path, diff_map[path], columns, margin_size, ans)
}
if err != nil {
return err
}
case "add":
if is_binary {
if is_img {
ans, err = image_lines("", path, screen_size, margin_size, image_size, ans)
} else {
ans, err = binary_lines("", path, columns, margin_size, ans)
}
} else {
ans, err = all_lines(path, columns, margin_size, true, ans)
}
if err != nil {
return err
}
case "removal":
if is_binary {
if is_img {
ans, err = image_lines(path, "", screen_size, margin_size, image_size, ans)
} else {
ans, err = binary_lines(path, "", columns, margin_size, ans)
}
} else {
ans, err = all_lines(path, columns, margin_size, false, ans)
}
if err != nil {
return err
}
case "rename":
ans, err = rename_lines(path, changed_path, columns, margin_size, ans)
if err != nil {
return err
}
default:
return fmt.Errorf("Unknown change type: %#v", item_type)
}
return nil
})
return &LogicalLines{lines: ans[:len(ans)-1], margin_size: margin_size, columns: columns}, err
}
func (self *LogicalLines) num_of_screen_lines() (ans int) {
for _, l := range self.lines {
ans += len(l.screen_lines)
}
return
}

View File

@@ -1,145 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package diff
import (
"fmt"
"regexp"
"strings"
"sync"
"kitty/tools/tui/sgr"
"kitty/tools/utils"
"kitty/tools/utils/images"
"kitty/tools/wcswidth"
"golang.org/x/exp/slices"
)
var _ = fmt.Print
type Search struct {
pat *regexp.Regexp
matches map[ScrollPos][]*sgr.Span
}
func (self *Search) Len() int { return len(self.matches) }
func (self *Search) find_matches_in_lines(clean_lines []string, origin int, send_result func(screen_line, offset, size int)) {
lengths := utils.Map(func(x string) int { return len(x) }, clean_lines)
offsets := make([]int, len(clean_lines))
for i := range clean_lines {
if i > 0 {
offsets[i] = offsets[i-1] + lengths[i-1]
}
}
matches := self.pat.FindAllStringIndex(strings.Join(clean_lines, ""), -1)
pos := 0
find_pos := func(start int) int {
for i := pos; i < len(clean_lines); i++ {
if start < offsets[i]+lengths[i] {
pos = i
return pos
}
}
return -1
}
for _, m := range matches {
start, end := m[0], m[1]
total_size := end - start
if total_size < 1 {
continue
}
start_line := find_pos(start)
if start_line > -1 {
end_line := find_pos(end)
if end_line > -1 {
for i := start_line; i <= end_line; i++ {
offset := 0
if i == start_line {
offset = start - offsets[i]
}
size := len(clean_lines[i]) - offset
if i == end_line {
size = (end - offsets[i]) - offset
}
send_result(i, origin+offset, size)
}
}
}
}
}
func (self *Search) find_matches_in_line(line *LogicalLine, margin_size, cols int, send_result func(screen_line, offset, size int)) {
half_width := cols / 2
right_offset := half_width + margin_size
left_clean_lines, right_clean_lines := make([]string, len(line.screen_lines)), make([]string, len(line.screen_lines))
lt := line.line_type
for i, line := range line.screen_lines {
line = wcswidth.StripEscapeCodes(line)
if lt == HUNK_TITLE_LINE || lt == FULL_TITLE_LINE {
if len(line) > margin_size {
left_clean_lines[i] = line[margin_size:]
}
} else {
if len(line) >= half_width+1 {
left_clean_lines[i] = line[margin_size:half_width]
}
if len(line) > right_offset {
right_clean_lines[i] = line[right_offset:]
}
}
}
self.find_matches_in_lines(left_clean_lines, margin_size, send_result)
self.find_matches_in_lines(right_clean_lines, right_offset, send_result)
}
func (self *Search) Has(pos ScrollPos) bool {
return len(self.matches[pos]) > 0
}
func (self *Search) search(logical_lines *LogicalLines) {
margin_size := logical_lines.margin_size
cols := logical_lines.columns
self.matches = make(map[ScrollPos][]*sgr.Span)
ctx := images.Context{}
mutex := sync.Mutex{}
s := sgr.NewSpan(0, 0)
s.SetForeground(conf.Search_fg).SetBackground(conf.Search_bg)
ctx.Parallel(0, logical_lines.Len(), func(nums <-chan int) {
for i := range nums {
line := logical_lines.At(i)
if line.line_type == EMPTY_LINE || line.line_type == IMAGE_LINE {
continue
}
self.find_matches_in_line(line, margin_size, cols, func(screen_line, offset, size int) {
mutex.Lock()
defer mutex.Unlock()
sn := *s
sn.Offset, sn.Size = offset, size
pos := ScrollPos{i, screen_line}
self.matches[pos] = append(self.matches[pos], &sn)
})
}
})
for _, spans := range self.matches {
slices.SortFunc(spans, func(a, b *sgr.Span) bool { return a.Offset < b.Offset })
}
}
func (self *Search) markup_line(line string, pos ScrollPos) string {
spans := self.matches[pos]
if spans == nil {
return line
}
return sgr.InsertFormatting(line, spans...)
}
func do_search(pat *regexp.Regexp, logical_lines *LogicalLines) *Search {
ans := &Search{pat: pat, matches: make(map[ScrollPos][]*sgr.Span)}
ans.search(logical_lines)
return ans
}

View File

@@ -1,629 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package diff
import (
"fmt"
"regexp"
"strconv"
"strings"
"kitty/tools/config"
"kitty/tools/tty"
"kitty/tools/tui/graphics"
"kitty/tools/tui/loop"
"kitty/tools/tui/readline"
"kitty/tools/utils"
"kitty/tools/wcswidth"
)
var _ = fmt.Print
type ResultType int
const (
COLLECTION ResultType = iota
DIFF
HIGHLIGHT
IMAGE_LOAD
IMAGE_RESIZE
)
type ScrollPos struct {
logical_line, screen_line int
}
func (self ScrollPos) Less(other ScrollPos) bool {
return self.logical_line < other.logical_line || (self.logical_line == other.logical_line && self.screen_line < other.screen_line)
}
func (self ScrollPos) Add(other ScrollPos) ScrollPos {
return ScrollPos{self.logical_line + other.logical_line, self.screen_line + other.screen_line}
}
type AsyncResult struct {
err error
rtype ResultType
collection *Collection
diff_map map[string]*Patch
page_size graphics.Size
}
var image_collection *graphics.ImageCollection
type screen_size struct{ rows, columns, num_lines, cell_width, cell_height int }
type Handler struct {
async_results chan AsyncResult
shortcut_tracker config.ShortcutTracker
left, right string
collection *Collection
diff_map map[string]*Patch
logical_lines *LogicalLines
lp *loop.Loop
current_context_count, original_context_count int
added_count, removed_count int
screen_size screen_size
scroll_pos, max_scroll_pos ScrollPos
restore_position *ScrollPos
inputting_command bool
statusline_message string
rl *readline.Readline
current_search *Search
current_search_is_regex, current_search_is_backward bool
largest_line_number int
images_resized_to graphics.Size
}
func (self *Handler) calculate_statistics() {
self.added_count, self.removed_count = self.collection.added_count, self.collection.removed_count
self.largest_line_number = 0
for _, patch := range self.diff_map {
self.added_count += patch.added_count
self.removed_count += patch.removed_count
self.largest_line_number = utils.Max(patch.largest_line_number, self.largest_line_number)
}
}
var DebugPrintln = tty.DebugPrintln
func (self *Handler) update_screen_size(sz loop.ScreenSize) {
self.screen_size.rows = int(sz.HeightCells)
self.screen_size.columns = int(sz.WidthCells)
self.screen_size.num_lines = self.screen_size.rows - 1
self.screen_size.cell_height = int(sz.CellHeight)
self.screen_size.cell_width = int(sz.CellWidth)
}
func (self *Handler) on_escape_code(etype loop.EscapeCodeType, payload []byte) error {
switch etype {
case loop.APC:
gc := graphics.GraphicsCommandFromAPC(payload)
if gc != nil {
if !image_collection.HandleGraphicsCommand(gc) {
self.draw_screen()
}
}
}
return nil
}
func (self *Handler) finalize() {
image_collection.Finalize(self.lp)
}
func (self *Handler) initialize() {
self.rl = readline.New(self.lp, readline.RlInit{DontMarkPrompts: true, Prompt: "/"})
self.lp.OnEscapeCode = self.on_escape_code
image_collection = graphics.NewImageCollection()
image_collection.Initialize(self.lp)
self.current_context_count = opts.Context
if self.current_context_count < 0 {
self.current_context_count = int(conf.Num_context_lines)
}
sz, _ := self.lp.ScreenSize()
self.update_screen_size(sz)
self.original_context_count = self.current_context_count
self.lp.SetDefaultColor(loop.FOREGROUND, conf.Foreground)
self.lp.SetDefaultColor(loop.CURSOR, conf.Foreground)
self.lp.SetDefaultColor(loop.BACKGROUND, conf.Background)
self.lp.SetDefaultColor(loop.SELECTION_BG, conf.Select_bg)
if conf.Select_fg.IsSet {
self.lp.SetDefaultColor(loop.SELECTION_FG, conf.Select_fg.Color)
}
self.async_results = make(chan AsyncResult, 32)
go func() {
r := AsyncResult{}
r.collection, r.err = create_collection(self.left, self.right)
self.async_results <- r
self.lp.WakeupMainThread()
}()
self.draw_screen()
}
func (self *Handler) generate_diff() {
self.diff_map = nil
jobs := make([]diff_job, 0, 32)
self.collection.Apply(func(path, typ, changed_path string) error {
if typ == "diff" {
if is_path_text(path) && is_path_text(changed_path) {
jobs = append(jobs, diff_job{path, changed_path})
}
}
return nil
})
go func() {
r := AsyncResult{rtype: DIFF}
r.diff_map, r.err = diff(jobs, self.current_context_count)
self.async_results <- r
self.lp.WakeupMainThread()
}()
}
func (self *Handler) on_wakeup() error {
var r AsyncResult
for {
select {
case r = <-self.async_results:
if r.err != nil {
return r.err
}
r.err = self.handle_async_result(r)
if r.err != nil {
return r.err
}
default:
return nil
}
}
}
func (self *Handler) highlight_all() {
text_files := utils.Filter(self.collection.paths_to_highlight.AsSlice(), is_path_text)
go func() {
r := AsyncResult{rtype: HIGHLIGHT}
highlight_all(text_files)
self.async_results <- r
self.lp.WakeupMainThread()
}()
}
func (self *Handler) load_all_images() {
self.collection.Apply(func(path, item_type, changed_path string) error {
if path != "" && is_image(path) {
image_collection.AddPaths(path)
}
if changed_path != "" && is_image(changed_path) {
image_collection.AddPaths(changed_path)
}
return nil
})
go func() {
r := AsyncResult{rtype: IMAGE_LOAD}
image_collection.LoadAll()
self.async_results <- r
self.lp.WakeupMainThread()
}()
}
func (self *Handler) resize_all_images_if_needed() {
if self.logical_lines == nil {
return
}
margin_size := self.logical_lines.margin_size
columns := self.logical_lines.columns
available_cols := columns/2 - margin_size
sz := graphics.Size{
Width: available_cols * self.screen_size.cell_width,
Height: self.screen_size.num_lines * 2 * self.screen_size.cell_height,
}
if sz != self.images_resized_to {
go func() {
image_collection.ResizeForPageSize(sz.Width, sz.Height)
r := AsyncResult{rtype: IMAGE_RESIZE, page_size: sz}
self.async_results <- r
self.lp.WakeupMainThread()
}()
}
}
func (self *Handler) rerender_diff() error {
if self.diff_map != nil && self.collection != nil {
err := self.render_diff()
if err != nil {
return err
}
self.draw_screen()
}
return nil
}
func (self *Handler) handle_async_result(r AsyncResult) error {
switch r.rtype {
case COLLECTION:
self.collection = r.collection
self.generate_diff()
self.highlight_all()
self.load_all_images()
case DIFF:
self.diff_map = r.diff_map
self.calculate_statistics()
err := self.render_diff()
if err != nil {
return err
}
self.scroll_pos = ScrollPos{}
if self.restore_position != nil {
self.scroll_pos = *self.restore_position
if self.max_scroll_pos.Less(self.scroll_pos) {
self.scroll_pos = self.max_scroll_pos
}
self.restore_position = nil
}
self.draw_screen()
case IMAGE_RESIZE:
self.images_resized_to = r.page_size
return self.rerender_diff()
case IMAGE_LOAD, HIGHLIGHT:
return self.rerender_diff()
}
return nil
}
func (self *Handler) on_resize(old_size, new_size loop.ScreenSize) error {
self.update_screen_size(new_size)
if self.diff_map != nil && self.collection != nil {
err := self.render_diff()
if err != nil {
return err
}
if self.max_scroll_pos.Less(self.scroll_pos) {
self.scroll_pos = self.max_scroll_pos
}
}
self.draw_screen()
return nil
}
func (self *Handler) render_diff() (err error) {
if self.screen_size.columns < 8 {
return fmt.Errorf("Screen too narrow, need at least 8 columns")
}
if self.screen_size.rows < 2 {
return fmt.Errorf("Screen too short, need at least 2 rows")
}
self.logical_lines, err = render(self.collection, self.diff_map, self.screen_size, self.largest_line_number, self.images_resized_to)
if err != nil {
return err
}
last := self.logical_lines.Len() - 1
self.max_scroll_pos.logical_line = last
if last > -1 {
self.max_scroll_pos.screen_line = len(self.logical_lines.At(last).screen_lines) - 1
} else {
self.max_scroll_pos.screen_line = 0
}
self.logical_lines.IncrementScrollPosBy(&self.max_scroll_pos, -self.screen_size.num_lines+1)
if self.current_search != nil {
self.current_search.search(self.logical_lines)
}
return nil
}
func (self *Handler) draw_image(key string, num_rows, starting_row int) {
image_collection.PlaceImageSubRect(self.lp, key, self.images_resized_to, 0, self.screen_size.cell_height*starting_row, -1, -1)
}
func (self *Handler) draw_image_pair(ll *LogicalLine, starting_row int) {
if ll.left_image.key != "" {
self.lp.MoveCursorHorizontally(self.logical_lines.margin_size)
self.draw_image(ll.left_image.key, ll.left_image.count, starting_row)
self.lp.QueueWriteString("\r")
}
if ll.right_image.key != "" {
self.lp.MoveCursorHorizontally(self.logical_lines.margin_size + self.logical_lines.columns/2)
self.draw_image(ll.right_image.key, ll.right_image.count, starting_row)
self.lp.QueueWriteString("\r")
}
}
func (self *Handler) draw_screen() {
self.lp.StartAtomicUpdate()
defer self.lp.EndAtomicUpdate()
self.resize_all_images_if_needed()
image_collection.DeleteAllVisiblePlacements(self.lp)
lp.MoveCursorTo(1, 1)
lp.ClearToEndOfScreen()
if self.logical_lines == nil || self.diff_map == nil || self.collection == nil {
lp.Println(`Calculating diff, please wait...`)
return
}
pos := self.scroll_pos
seen_images := utils.NewSet[int]()
for num_written := 0; num_written < self.screen_size.num_lines; num_written++ {
ll := self.logical_lines.At(pos.logical_line)
is_image := ll != nil && ll.line_type == IMAGE_LINE
sl := self.logical_lines.ScreenLineAt(pos)
if is_image && !seen_images.Has(pos.logical_line) && pos.screen_line >= ll.image_lines_offset {
seen_images.Add(pos.logical_line)
self.draw_image_pair(ll, pos.screen_line-ll.image_lines_offset)
}
if self.current_search != nil {
sl = self.current_search.markup_line(sl, pos)
}
lp.QueueWriteString(sl)
lp.MoveCursorVertically(1)
lp.QueueWriteString("\r")
if self.logical_lines.IncrementScrollPosBy(&pos, 1) == 0 {
break
}
}
self.draw_status_line()
}
func (self *Handler) draw_status_line() {
if self.logical_lines == nil || self.diff_map == nil {
return
}
self.lp.MoveCursorTo(1, self.screen_size.rows)
self.lp.ClearToEndOfLine()
self.lp.SetCursorVisible(self.inputting_command)
if self.inputting_command {
self.rl.RedrawNonAtomic()
} else if self.statusline_message != "" {
self.lp.QueueWriteString(message_format(wcswidth.TruncateToVisualLength(sanitize(self.statusline_message), self.screen_size.columns)))
} else {
num := self.logical_lines.NumScreenLinesTo(self.scroll_pos)
den := self.logical_lines.NumScreenLinesTo(self.max_scroll_pos)
var frac int
if den > 0 {
frac = int((float64(num) * 100.0) / float64(den))
}
sp := statusline_format(fmt.Sprintf("%d%%", frac))
var counts string
if self.current_search == nil {
counts = added_count_format(strconv.Itoa(self.added_count)) + statusline_format(`,`) + removed_count_format(strconv.Itoa(self.removed_count))
} else {
counts = statusline_format(fmt.Sprintf("%d matches", self.current_search.Len()))
}
suffix := counts + " " + sp
prefix := statusline_format(":")
filler := strings.Repeat(" ", utils.Max(0, self.screen_size.columns-wcswidth.Stringwidth(prefix)-wcswidth.Stringwidth(suffix)))
self.lp.QueueWriteString(prefix + filler + suffix)
}
}
func (self *Handler) on_text(text string, a, b bool) error {
if self.inputting_command {
defer self.draw_status_line()
return self.rl.OnText(text, a, b)
}
if self.statusline_message != "" {
self.statusline_message = ""
self.draw_status_line()
return nil
}
return nil
}
func (self *Handler) do_search(query string) {
self.current_search = nil
if len(query) < 2 {
return
}
if !self.current_search_is_regex {
query = regexp.QuoteMeta(query)
}
pat, err := regexp.Compile(`(?i)` + query)
if err != nil {
self.statusline_message = fmt.Sprintf("Bad regex: %s", err)
self.lp.Beep()
return
}
self.current_search = do_search(pat, self.logical_lines)
if self.current_search.Len() == 0 {
self.current_search = nil
self.statusline_message = fmt.Sprintf("No matches for: %#v", query)
self.lp.Beep()
} else {
if self.scroll_to_next_match(false, true) {
self.draw_screen()
} else {
self.lp.Beep()
}
}
}
func (self *Handler) on_key_event(ev *loop.KeyEvent) error {
if self.inputting_command {
defer self.draw_status_line()
if ev.MatchesPressOrRepeat("esc") {
self.inputting_command = false
ev.Handled = true
return nil
}
if ev.MatchesPressOrRepeat("enter") {
self.inputting_command = false
ev.Handled = true
self.do_search(self.rl.AllText())
self.draw_screen()
return nil
}
return self.rl.OnKeyEvent(ev)
}
if self.statusline_message != "" {
if ev.Type != loop.RELEASE {
ev.Handled = true
self.statusline_message = ""
self.draw_status_line()
}
return nil
}
if self.current_search != nil && ev.MatchesPressOrRepeat("esc") {
self.current_search = nil
self.draw_screen()
return nil
}
ac := self.shortcut_tracker.Match(ev, conf.KeyboardShortcuts)
if ac != nil {
return self.dispatch_action(ac.Name, ac.Args)
}
return nil
}
func (self *Handler) scroll_lines(amt int) (delta int) {
before := self.scroll_pos
delta = self.logical_lines.IncrementScrollPosBy(&self.scroll_pos, amt)
if delta > 0 && self.max_scroll_pos.Less(self.scroll_pos) {
self.scroll_pos = self.max_scroll_pos
delta = self.logical_lines.Minus(self.scroll_pos, before)
}
return
}
func (self *Handler) scroll_to_next_change(backwards bool) bool {
if backwards {
for i := self.scroll_pos.logical_line - 1; i >= 0; i-- {
line := self.logical_lines.At(i)
if line.is_change_start {
self.scroll_pos = ScrollPos{i, 0}
return true
}
}
} else {
for i := self.scroll_pos.logical_line + 1; i < self.logical_lines.Len(); i++ {
line := self.logical_lines.At(i)
if line.is_change_start {
self.scroll_pos = ScrollPos{i, 0}
return true
}
}
}
return false
}
func (self *Handler) scroll_to_next_match(backwards, include_current_match bool) bool {
if self.current_search == nil {
return false
}
if self.current_search_is_backward {
backwards = !backwards
}
offset, delta := 1, 1
if include_current_match {
offset = 0
}
if backwards {
offset *= -1
delta *= -1
}
pos := self.scroll_pos
if offset != 0 && self.logical_lines.IncrementScrollPosBy(&pos, offset) == 0 {
return false
}
for {
if self.current_search.Has(pos) {
self.scroll_pos = pos
self.draw_screen()
return true
}
if self.logical_lines.IncrementScrollPosBy(&pos, delta) == 0 || self.max_scroll_pos.Less(pos) {
break
}
}
return false
}
func (self *Handler) change_context_count(val int) bool {
val = utils.Max(0, val)
if val == self.current_context_count {
return false
}
self.current_context_count = val
p := self.scroll_pos
self.restore_position = &p
self.generate_diff()
self.draw_screen()
return true
}
func (self *Handler) start_search(is_regex, is_backward bool) {
if self.inputting_command {
self.lp.Beep()
return
}
self.inputting_command = true
self.current_search_is_regex = is_regex
self.current_search_is_backward = is_backward
self.rl.SetText(``)
self.draw_status_line()
}
func (self *Handler) dispatch_action(name, args string) error {
switch name {
case `quit`:
self.lp.Quit(0)
case `scroll_by`:
if args == "" {
args = "1"
}
amt, err := strconv.Atoi(args)
if err == nil {
if self.scroll_lines(amt) == 0 {
self.lp.Beep()
} else {
self.draw_screen()
}
} else {
self.lp.Beep()
}
case `scroll_to`:
done := false
switch {
case strings.Contains(args, `change`):
done = self.scroll_to_next_change(strings.Contains(args, `prev`))
case strings.Contains(args, `match`):
done = self.scroll_to_next_match(strings.Contains(args, `prev`), false)
case strings.Contains(args, `page`):
amt := self.screen_size.num_lines
if strings.Contains(args, `prev`) {
amt *= -1
}
done = self.scroll_lines(amt) != 0
default:
npos := self.scroll_pos
if strings.Contains(args, `end`) {
npos = self.max_scroll_pos
} else {
npos = ScrollPos{}
}
done = npos != self.scroll_pos
self.scroll_pos = npos
}
if done {
self.draw_screen()
} else {
self.lp.Beep()
}
case `change_context`:
new_ctx := self.current_context_count
switch args {
case `all`:
new_ctx = 100000
case `default`:
new_ctx = self.original_context_count
default:
delta, _ := strconv.Atoi(args)
new_ctx += delta
}
if !self.change_context_count(new_ctx) {
self.lp.Beep()
}
case `start_search`:
if self.diff_map != nil && self.logical_lines != nil {
a, b, _ := strings.Cut(args, " ")
self.start_search(config.StringToBool(a), config.StringToBool(b))
}
}
return nil
}

View File

@@ -1,327 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package hints
import (
"fmt"
"io"
"os"
"strconv"
"strings"
"unicode"
"kitty/tools/cli"
"kitty/tools/tty"
"kitty/tools/tui"
"kitty/tools/tui/loop"
"kitty/tools/utils"
"kitty/tools/utils/style"
"kitty/tools/wcswidth"
)
var _ = fmt.Print
func convert_text(text string, cols int) string {
lines := make([]string, 0, 64)
empty_line := strings.Repeat("\x00", cols) + "\n"
s1 := utils.NewLineScanner(text)
for s1.Scan() {
full_line := s1.Text()
if full_line == "" {
continue
}
if strings.TrimRight(full_line, "\r") == "" {
for i := 0; i < len(full_line); i++ {
lines = append(lines, empty_line)
}
continue
}
appended := false
s2 := utils.NewSeparatorScanner(full_line, "\r")
for s2.Scan() {
line := s2.Text()
if line != "" {
line_sz := wcswidth.Stringwidth(line)
extra := cols - line_sz
if extra > 0 {
line += strings.Repeat("\x00", extra)
}
lines = append(lines, line)
lines = append(lines, "\r")
appended = true
}
}
if appended {
lines[len(lines)-1] = "\n"
}
}
ans := strings.Join(lines, "")
return strings.TrimRight(ans, "\r\n")
}
func parse_input(text string) string {
cols, err := strconv.Atoi(os.Getenv("OVERLAID_WINDOW_COLS"))
if err == nil {
return convert_text(text, cols)
}
term, err := tty.OpenControllingTerm()
if err == nil {
sz, err := term.GetSize()
term.Close()
if err == nil {
return convert_text(text, int(sz.Col))
}
}
return convert_text(text, 80)
}
type Result struct {
Match []string `json:"match"`
Programs []string `json:"programs"`
Multiple_joiner string `json:"multiple_joiner"`
Customize_processing string `json:"customize_processing"`
Type string `json:"type"`
Groupdicts []map[string]any `json:"groupdicts"`
Extra_cli_args []string `json:"extra_cli_args"`
Linenum_action string `json:"linenum_action"`
Cwd string `json:"cwd"`
}
func encode_hint(num int, alphabet string) (res string) {
runes := []rune(alphabet)
d := len(runes)
for res == "" || num > 0 {
res = string(runes[num%d]) + res
num /= d
}
return
}
func decode_hint(x string, alphabet string) (ans int) {
base := len(alphabet)
index_map := make(map[rune]int, len(alphabet))
for i, c := range alphabet {
index_map[c] = i
}
for _, char := range x {
ans = ans*base + index_map[char]
}
return
}
func main(_ *cli.Command, o *Options, args []string) (rc int, err error) {
output := tui.KittenOutputSerializer()
if tty.IsTerminal(os.Stdin.Fd()) {
tui.ReportError(fmt.Errorf("You must pass the text to be hinted on STDIN"))
return 1, nil
}
stdin, err := io.ReadAll(os.Stdin)
if err != nil {
tui.ReportError(fmt.Errorf("Failed to read from STDIN with error: %w", err))
return 1, nil
}
if len(args) > 0 && o.CustomizeProcessing == "" && o.Type != "linenum" {
tui.ReportError(fmt.Errorf("Extra command line arguments present: %s", strings.Join(args, " ")))
return 1, nil
}
input_text := parse_input(utils.UnsafeBytesToString(stdin))
text, all_marks, index_map, err := find_marks(input_text, o, os.Args[2:]...)
if err != nil {
tui.ReportError(err)
return 1, nil
}
result := Result{
Programs: o.Program, Multiple_joiner: o.MultipleJoiner, Customize_processing: o.CustomizeProcessing, Type: o.Type,
Extra_cli_args: args, Linenum_action: o.LinenumAction,
}
result.Cwd, _ = os.Getwd()
alphabet := o.Alphabet
if alphabet == "" {
alphabet = DEFAULT_HINT_ALPHABET
}
ignore_mark_indices := utils.NewSet[int](8)
window_title := o.WindowTitle
if window_title == "" {
switch o.Type {
case "url":
window_title = "Choose URL"
default:
window_title = "Choose text"
}
}
current_text := ""
current_input := ""
match_suffix := ""
switch o.AddTrailingSpace {
case "always":
match_suffix = " "
case "never":
default:
if o.Multiple {
match_suffix = " "
}
}
chosen := []*Mark{}
lp, err := loop.New(loop.NoAlternateScreen) // no alternate screen reduces flicker on exit
if err != nil {
return
}
fctx := style.Context{AllowEscapeCodes: true}
faint := fctx.SprintFunc("dim")
hint_style := fctx.SprintFunc(fmt.Sprintf("fg=%s bg=%s bold", o.HintsForegroundColor, o.HintsBackgroundColor))
text_style := fctx.SprintFunc(fmt.Sprintf("fg=bright-%s bold", o.HintsTextColor))
highlight_mark := func(m *Mark, mark_text string) string {
hint := encode_hint(m.Index, alphabet)
if current_input != "" && !strings.HasPrefix(hint, current_input) {
return faint(mark_text)
}
hint = hint[len(current_input):]
if hint == "" {
hint = " "
}
mark_text = mark_text[len(hint):]
return hint_style(hint) + text_style(mark_text)
}
render := func() string {
ans := text
for i := len(all_marks) - 1; i >= 0; i-- {
mark := &all_marks[i]
if ignore_mark_indices.Has(mark.Index) {
continue
}
mtext := highlight_mark(mark, ans[mark.Start:mark.End])
ans = ans[:mark.Start] + mtext + ans[mark.End:]
}
ans = strings.ReplaceAll(ans, "\x00", "")
return strings.TrimRightFunc(strings.NewReplacer("\r", "\r\n", "\n", "\r\n").Replace(ans), unicode.IsSpace)
}
draw_screen := func() {
lp.StartAtomicUpdate()
defer lp.EndAtomicUpdate()
if current_text == "" {
current_text = render()
}
lp.ClearScreen()
lp.QueueWriteString(current_text)
}
reset := func() {
current_input = ""
current_text = ""
}
lp.OnInitialize = func() (string, error) {
lp.SendOverlayReady()
lp.SetCursorVisible(false)
lp.SetWindowTitle(window_title)
lp.AllowLineWrapping(false)
draw_screen()
return "", nil
}
lp.OnFinalize = func() string {
lp.SetCursorVisible(true)
return ""
}
lp.OnResize = func(old_size, new_size loop.ScreenSize) error {
draw_screen()
return nil
}
lp.OnText = func(text string, _, _ bool) error {
changed := false
for _, ch := range text {
if strings.ContainsRune(alphabet, ch) {
current_input += string(ch)
changed = true
}
}
if changed {
matches := []*Mark{}
for idx, m := range index_map {
if eh := encode_hint(idx, alphabet); strings.HasPrefix(eh, current_input) {
matches = append(matches, m)
}
}
if len(matches) == 1 {
chosen = append(chosen, matches[0])
if o.Multiple {
ignore_mark_indices.Add(matches[0].Index)
reset()
} else {
lp.Quit(0)
return nil
}
}
current_text = ""
draw_screen()
}
return nil
}
lp.OnKeyEvent = func(ev *loop.KeyEvent) error {
if ev.MatchesPressOrRepeat("backspace") {
ev.Handled = true
r := []rune(current_input)
if len(r) > 0 {
r = r[:len(r)-1]
current_input = string(r)
current_text = ""
}
draw_screen()
} else if ev.MatchesPressOrRepeat("enter") || ev.MatchesPressOrRepeat("space") {
ev.Handled = true
if current_input != "" {
idx := decode_hint(current_input, alphabet)
if m := index_map[idx]; m != nil {
chosen = append(chosen, m)
ignore_mark_indices.Add(idx)
if o.Multiple {
reset()
draw_screen()
} else {
lp.Quit(0)
}
} else {
current_input = ""
current_text = ""
draw_screen()
}
}
} else if ev.MatchesPressOrRepeat("esc") {
if o.Multiple {
lp.Quit(0)
} else {
lp.Quit(1)
}
}
return nil
}
err = lp.Run()
if err != nil {
return 1, err
}
ds := lp.DeathSignalName()
if ds != "" {
fmt.Println("Killed by signal: ", ds)
lp.KillIfSignalled()
return 1, nil
}
if lp.ExitCode() != 0 {
return lp.ExitCode(), nil
}
result.Match = make([]string, len(chosen))
result.Groupdicts = make([]map[string]any, len(chosen))
for i, m := range chosen {
result.Match[i] = m.Text + match_suffix
result.Groupdicts[i] = m.Groupdict
}
fmt.Println(output(result))
return
}
func EntryPoint(parent *cli.Command) {
create_cmd(parent, main)
}

View File

@@ -1,419 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package hints
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"kitty"
"kitty/tools/config"
"kitty/tools/utils"
"os/exec"
"path/filepath"
"regexp"
"strings"
"unicode/utf8"
"github.com/seancfoley/ipaddress-go/ipaddr"
"golang.org/x/exp/slices"
)
var _ = fmt.Print
const (
DEFAULT_HINT_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"
FILE_EXTENSION = `\.(?:[a-zA-Z0-9]{2,7}|[ahcmo])(?:\b|[^.])`
)
func path_regex() string {
return fmt.Sprintf(`(?:\S*?/[\r\S]+)|(?:\S[\r\S]*%s)\b`, FILE_EXTENSION)
}
func default_linenum_regex() string {
return fmt.Sprintf(`(?P<path>%s):(?P<line>\d+)`, path_regex())
}
type Mark struct {
Index int `json:"index"`
Start int `json:"start"`
End int `json:"end"`
Text string `json:"text"`
Group_id string `json:"group_id"`
Is_hyperlink bool `json:"is_hyperlink"`
Groupdict map[string]any `json:"groupdict"`
}
func process_escape_codes(text string) (ans string, hyperlinks []Mark) {
removed_size, idx := 0, 0
active_hyperlink_url := ""
active_hyperlink_id := ""
active_hyperlink_start_offset := 0
add_hyperlink := func(end int) {
hyperlinks = append(hyperlinks, Mark{
Index: idx, Start: active_hyperlink_start_offset, End: end, Text: active_hyperlink_url, Is_hyperlink: true, Group_id: active_hyperlink_id})
active_hyperlink_url, active_hyperlink_id = "", ""
active_hyperlink_start_offset = 0
idx++
}
ans = utils.ReplaceAll(utils.MustCompile("\x1b(?:\\[[0-9;:]*?m|\\].*?\x1b\\\\)"), text, func(raw string, groupdict map[string]utils.SubMatch) string {
if !strings.HasPrefix(raw, "\x1b]8") {
removed_size += len(raw)
return ""
}
start := groupdict[""].Start - removed_size
removed_size += len(raw)
if active_hyperlink_url != "" {
add_hyperlink(start)
}
raw = raw[4 : len(raw)-2]
if metadata, url, found := strings.Cut(raw, ";"); found && url != "" {
active_hyperlink_url = url
active_hyperlink_start_offset = start
if metadata != "" {
for _, entry := range strings.Split(metadata, ":") {
if strings.HasPrefix(entry, "id=") && len(entry) > 3 {
active_hyperlink_id = entry[3:]
}
}
}
}
return ""
})
if active_hyperlink_url != "" {
add_hyperlink(len(ans))
}
return
}
type PostProcessorFunc = func(string, int, int) (int, int)
type GroupProcessorFunc = func(map[string]string)
func is_punctuation(b string) bool {
switch b {
case ",", ".", "?", "!":
return true
}
return false
}
func closing_bracket_for(ch string) string {
switch ch {
case "(":
return ")"
case "[":
return "]"
case "{":
return "}"
case "<":
return ">"
case "*":
return "*"
case `"`:
return `"`
case "'":
return "'"
case "“":
return "”"
case "":
return ""
}
return ""
}
func char_at(s string, i int) string {
ans, _ := utf8.DecodeRuneInString(s[i:])
if ans == utf8.RuneError {
return ""
}
return string(ans)
}
func matching_remover(openers ...string) PostProcessorFunc {
return func(text string, s, e int) (int, int) {
if s < e && e <= len(text) {
before := char_at(text, s)
if slices.Index(openers, before) > -1 {
q := closing_bracket_for(before)
if e > 0 && char_at(text, e-1) == q {
s++
e--
} else if char_at(text, e) == q {
s++
}
}
}
return s, e
}
}
func linenum_group_processor(gd map[string]string) {
pat := utils.MustCompile(`:\d+$`)
gd[`path`] = pat.ReplaceAllStringFunc(gd["path"], func(m string) string {
gd["line"] = m[1:]
return ``
})
gd[`path`] = utils.Expanduser(gd[`path`])
}
var PostProcessorMap = (&utils.Once[map[string]PostProcessorFunc]{Run: func() map[string]PostProcessorFunc {
return map[string]PostProcessorFunc{
"url": func(text string, s, e int) (int, int) {
if s > 4 && text[s-5:s] == "link:" { // asciidoc URLs
url := text[s:e]
idx := strings.LastIndex(url, "[")
if idx > -1 {
e -= len(url) - idx
}
}
for e > 1 && is_punctuation(char_at(text, e)) { // remove trailing punctuation
e--
}
// truncate url at closing bracket/quote
if s > 0 && e <= len(text) && closing_bracket_for(char_at(text, s-1)) != "" {
q := closing_bracket_for(char_at(text, s-1))
idx := strings.Index(text[s:], q)
if idx > 0 {
e = s + idx
}
}
// reStructuredText URLs
if e > 3 && text[e-2:e] == "`_" {
e -= 2
}
return s, e
},
"brackets": matching_remover("(", "{", "[", "<"),
"quotes": matching_remover("'", `"`, "“", ""),
"ip": func(text string, s, e int) (int, int) {
addr := ipaddr.NewHostName(text[s:e])
if !addr.IsAddress() {
return -1, -1
}
return s, e
},
}
}}).Get
type KittyOpts struct {
Url_prefixes *utils.Set[string]
Select_by_word_characters string
}
func read_relevant_kitty_opts(path string) KittyOpts {
ans := KittyOpts{Select_by_word_characters: kitty.KittyConfigDefaults.Select_by_word_characters}
handle_line := func(key, val string) error {
switch key {
case "url_prefixes":
ans.Url_prefixes = utils.NewSetWithItems(strings.Split(val, " ")...)
case "select_by_word_characters":
ans.Select_by_word_characters = strings.TrimSpace(val)
}
return nil
}
cp := config.ConfigParser{LineHandler: handle_line}
cp.ParseFiles(path)
if ans.Url_prefixes == nil {
ans.Url_prefixes = utils.NewSetWithItems(kitty.KittyConfigDefaults.Url_prefixes...)
}
return ans
}
var RelevantKittyOpts = (&utils.Once[KittyOpts]{Run: func() KittyOpts {
return read_relevant_kitty_opts(filepath.Join(utils.ConfigDir(), "kitty.conf"))
}}).Get
func functions_for(opts *Options) (pattern string, post_processors []PostProcessorFunc, group_processors []GroupProcessorFunc) {
switch opts.Type {
case "url":
var url_prefixes *utils.Set[string]
if opts.UrlPrefixes == "default" {
url_prefixes = RelevantKittyOpts().Url_prefixes
} else {
url_prefixes = utils.NewSetWithItems(strings.Split(opts.UrlPrefixes, ",")...)
}
pattern = fmt.Sprintf(`(?:%s)://[^%s]{3,}`, strings.Join(url_prefixes.AsSlice(), "|"), URL_DELIMITERS)
post_processors = append(post_processors, PostProcessorMap()["url"])
case "path":
pattern = path_regex()
post_processors = append(post_processors, PostProcessorMap()["brackets"], PostProcessorMap()["quotes"])
case "line":
pattern = "(?m)^\\s*(.+)[\\s\x00]*$"
case "hash":
pattern = "[0-9a-f][0-9a-f\r]{6,127}"
case "ip":
pattern = (
// IPv4 with no validation
`((?:\d{1,3}\.){3}\d{1,3}` + "|" +
// IPv6 with no validation
`(?:[a-fA-F0-9]{0,4}:){2,7}[a-fA-F0-9]{1,4})`)
post_processors = append(post_processors, PostProcessorMap()["ip"])
case "word":
chars := opts.WordCharacters
if chars == "" {
chars = RelevantKittyOpts().Select_by_word_characters
}
chars = regexp.QuoteMeta(chars)
chars = strings.ReplaceAll(chars, "-", "\\-")
pattern = fmt.Sprintf(`[%s\pL\pN]{%d,}`, chars, opts.MinimumMatchLength)
post_processors = append(post_processors, PostProcessorMap()["brackets"], PostProcessorMap()["quotes"])
default:
pattern = opts.Regex
if opts.Type == "linenum" {
if pattern == kitty.HintsDefaultRegex {
pattern = default_linenum_regex()
}
post_processors = append(post_processors, PostProcessorMap()["brackets"], PostProcessorMap()["quotes"])
group_processors = append(group_processors, linenum_group_processor)
}
}
return
}
func mark(r *regexp.Regexp, post_processors []PostProcessorFunc, group_processors []GroupProcessorFunc, text string, opts *Options) (ans []Mark) {
sanitize_pat := regexp.MustCompile("[\r\n\x00]")
names := r.SubexpNames()
for i, v := range r.FindAllStringSubmatchIndex(text, -1) {
match_start, match_end := v[0], v[1]
for match_end > match_start+1 && text[match_end-1] == 0 {
match_end--
}
full_match := text[match_start:match_end]
if len([]rune(full_match)) < opts.MinimumMatchLength {
continue
}
for _, f := range post_processors {
match_start, match_end = f(text, match_start, match_end)
if match_start < 0 {
break
}
}
if match_start < 0 {
continue
}
full_match = sanitize_pat.ReplaceAllLiteralString(text[match_start:match_end], "")
gd := make(map[string]string, len(names))
for x, name := range names {
if name != "" {
idx := 2 * x
if s, e := v[idx], v[idx+1]; s > -1 && e > -1 {
s = utils.Max(s, match_start)
e = utils.Min(e, match_end)
gd[name] = sanitize_pat.ReplaceAllLiteralString(text[s:e], "")
}
}
}
for _, f := range group_processors {
f(gd)
}
gd2 := make(map[string]any, len(gd))
for k, v := range gd {
gd2[k] = v
}
ans = append(ans, Mark{
Index: i, Start: match_start, End: match_end, Text: full_match, Groupdict: gd2,
})
}
return
}
type ErrNoMatches struct{ Type string }
func adjust_python_offsets(text string, marks []Mark) error {
// python returns rune based offsets (unicode chars not utf-8 bytes)
adjust := utils.RuneOffsetsToByteOffsets(text)
for i := range marks {
mark := &marks[i]
if mark.End < mark.Start {
return fmt.Errorf("The end of a mark must not be before its start")
}
s, e := adjust(mark.Start), adjust(mark.End)
if s < 0 || e < 0 {
return fmt.Errorf("Overlapping marks are not supported")
}
mark.Start, mark.End = s, e
}
return nil
}
func (self *ErrNoMatches) Error() string {
none_of := "matches"
switch self.Type {
case "urls":
none_of = "URLs"
case "hyperlinks":
none_of = "hyperlinks"
}
return fmt.Sprintf("No %s found", none_of)
}
func find_marks(text string, opts *Options, cli_args ...string) (sanitized_text string, ans []Mark, index_map map[int]*Mark, err error) {
sanitized_text, hyperlinks := process_escape_codes(text)
run_basic_matching := func() error {
pattern, post_processors, group_processors := functions_for(opts)
r, err := regexp.Compile(pattern)
if err != nil {
return fmt.Errorf("Failed to compile the regex pattern: %#v with error: %w", pattern, err)
}
ans = mark(r, post_processors, group_processors, sanitized_text, opts)
return nil
}
if opts.CustomizeProcessing != "" {
cmd := exec.Command(utils.KittyExe(), append([]string{"+runpy", "from kittens.hints.main import custom_marking; custom_marking()"}, cli_args...)...)
cmd.Stdin = strings.NewReader(sanitized_text)
stdout, stderr := bytes.Buffer{}, bytes.Buffer{}
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err = cmd.Run()
if err != nil {
var e *exec.ExitError
if errors.As(err, &e) && e.ExitCode() == 2 {
err = run_basic_matching()
if err != nil {
return
}
goto process_answer
} else {
return "", nil, nil, fmt.Errorf("Failed to run custom processor %#v with error: %w\n%s", opts.CustomizeProcessing, err, stderr.String())
}
}
ans = make([]Mark, 0, 32)
err = json.Unmarshal(stdout.Bytes(), &ans)
if err != nil {
return "", nil, nil, fmt.Errorf("Failed to load output from custom processor %#v with error: %w", opts.CustomizeProcessing, err)
}
err = adjust_python_offsets(sanitized_text, ans)
if err != nil {
return "", nil, nil, fmt.Errorf("Custom processor %#v produced invalid mark output with error: %w", opts.CustomizeProcessing, err)
}
} else if opts.Type == "hyperlink" {
ans = hyperlinks
} else {
err = run_basic_matching()
if err != nil {
return
}
}
process_answer:
if len(ans) == 0 {
return "", nil, nil, &ErrNoMatches{Type: opts.Type}
}
largest_index := ans[len(ans)-1].Index
offset := utils.Max(0, opts.HintsOffset)
index_map = make(map[int]*Mark, len(ans))
for i := range ans {
m := &ans[i]
if opts.Ascending {
m.Index += offset
} else {
m.Index = largest_index - m.Index + offset
}
index_map[m.Index] = m
}
return
}

View File

@@ -1,131 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package hints
import (
"errors"
"fmt"
"kitty"
"kitty/tools/utils"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
)
var _ = fmt.Print
func TestHintMarking(t *testing.T) {
var opts *Options
cols := 20
cli_args := []string{}
reset := func() {
opts = &Options{Type: "url", UrlPrefixes: "default", Regex: kitty.HintsDefaultRegex}
cols = 20
cli_args = []string{}
}
r := func(text string, url ...string) (marks []Mark) {
ptext := convert_text(text, cols)
ptext, marks, _, err := find_marks(ptext, opts, cli_args...)
if err != nil {
var e *ErrNoMatches
if len(url) != 0 || !errors.As(err, &e) {
t.Fatalf("%#v failed with error: %s", text, err)
}
return
}
actual := utils.Map(func(m Mark) string { return m.Text }, marks)
if diff := cmp.Diff(url, actual); diff != "" {
t.Fatalf("%#v failed:\n%s", text, diff)
}
for _, m := range marks {
q := strings.NewReplacer("\n", "", "\r", "", "\x00", "").Replace(ptext[m.Start:m.End])
if diff := cmp.Diff(m.Text, q); diff != "" {
t.Fatalf("Mark start and end dont point to correct offset in text for %#v\n%s", text, diff)
}
}
return
}
reset()
u := `http://test.me/`
r(u, u)
r(`"`+u+`"`, u)
r("("+u+")", u)
cols = len(u)
r(u+"\nxxx", u+"xxx")
cols = 20
r("link:"+u+"[xxx]", u)
r("`xyz <"+u+">`_.", u)
r(`<a href="`+u+`">moo`, u)
r("\x1b[mhttp://test.me/1234\n\x1b[mx", "http://test.me/1234")
r("\x1b[mhttp://test.me/12345\r\x1b[m6\n\x1b[mx", "http://test.me/123456")
opts.Type = "linenum"
m := func(text, path string, line int) {
ptext := convert_text(text, cols)
_, marks, _, err := find_marks(ptext, opts, cli_args...)
if err != nil {
t.Fatalf("%#v failed with error: %s", text, err)
}
gd := map[string]any{"path": path, "line": strconv.Itoa(line)}
if diff := cmp.Diff(marks[0].Groupdict, gd); diff != "" {
t.Fatalf("%#v failed:\n%s", text, diff)
}
}
m("file.c:23", "file.c", 23)
m("file.c:23:32", "file.c", 23)
m("file.cpp:23:1", "file.cpp", 23)
m("a/file.c:23", "a/file.c", 23)
m("a/file.c:23:32", "a/file.c", 23)
m("~/file.c:23:32", utils.Expanduser("~/file.c"), 23)
reset()
opts.Type = "path"
r("file.c", "file.c")
r("file.c.", "file.c")
r("file.epub.", "file.epub")
r("(file.epub)", "file.epub")
r("some/path", "some/path")
reset()
cols = 60
opts.Type = "ip"
r(`100.64.0.0`, `100.64.0.0`)
r(`2001:0db8:0000:0000:0000:ff00:0042:8329`, `2001:0db8:0000:0000:0000:ff00:0042:8329`)
r(`2001:db8:0:0:0:ff00:42:8329`, `2001:db8:0:0:0:ff00:42:8329`)
r(`2001:db8::ff00:42:8329`, `2001:db8::ff00:42:8329`)
r(`2001:DB8::FF00:42:8329`, `2001:DB8::FF00:42:8329`)
r(`0000:0000:0000:0000:0000:0000:0000:0001`, `0000:0000:0000:0000:0000:0000:0000:0001`)
r(`::1`, `::1`)
r(`255.255.255.256`)
r(`:1`)
reset()
tdir := t.TempDir()
simple := filepath.Join(tdir, "simple.py")
cli_args = []string{"--customize-processing", simple, "extra1"}
os.WriteFile(simple, []byte(`
def mark(text, args, Mark, extra_cli_args, *a):
import re
for idx, m in enumerate(re.finditer(r'\w+', text)):
start, end = m.span()
mark_text = text[start:end].replace('\n', '').replace('\0', '')
yield Mark(idx, start, end, mark_text, {"idx": idx, "args": extra_cli_args})
`), 0o600)
opts.Type = "regex"
opts.CustomizeProcessing = simple
marks := r("漢字 b", `漢字`, `b`)
if diff := cmp.Diff(marks[0].Groupdict, map[string]any{"idx": float64(0), "args": []any{"extra1"}}); diff != "" {
t.Fatalf("Did not get expected groupdict from custom processor:\n%s", diff)
}
opts.Regex = "b"
os.WriteFile(simple, []byte(""), 0o600)
r("a b", `b`)
}

View File

@@ -1,5 +0,0 @@
// generated by gen-wcwidth.py, do not edit
package hints
const URL_DELIMITERS = `\x00-\x09\x0b-\x0c\x0e-\x20\x7f-\xa0\xad\x{600}-\x{605}\x{61c}\x{6dd}\x{70f}\x{890}-\x{891}\x{8e2}\x{1680}\x{180e}\x{2000}-\x{200f}\x{2028}-\x{202f}\x{205f}-\x{2064}\x{2066}-\x{206f}\x{3000}\x{d800}-\x{f8ff}\x{feff}\x{fff9}-\x{fffb}\x{110bd}\x{110cd}\x{13430}-\x{1343f}\x{1bca0}-\x{1bca3}\x{1d173}-\x{1d17a}\x{e0001}\x{e0020}-\x{e007f}\x{f0000}-\x{ffffd}\x{100000}-\x{10fffd}`

View File

@@ -1,422 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package hyperlinked_grep
import (
"bytes"
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"unicode"
"kitty/tools/cli"
"kitty/tools/utils"
"golang.org/x/sys/unix"
)
var _ = fmt.Print
var RgExe = (&utils.Once[string]{Run: func() string {
return utils.FindExe("rg")
}}).Get
func get_options_for_rg() (expecting_args map[string]bool, alias_map map[string]string, err error) {
var raw []byte
raw, err = exec.Command(RgExe(), "--help").Output()
if err != nil {
err = fmt.Errorf("Failed to execute rg: %w", err)
return
}
scanner := utils.NewLineScanner(utils.UnsafeBytesToString(raw))
options_started := false
expecting_args = make(map[string]bool, 64)
alias_map = make(map[string]string, 52)
for scanner.Scan() {
line := scanner.Text()
if options_started {
s := strings.TrimLeft(line, " ")
indent := len(line) - len(s)
if indent < 12 && indent > 0 {
s, _, expecting_arg := strings.Cut(s, "<")
single_letter_aliases := make([]string, 0, 1)
long_option_names := make([]string, 0, 1)
for _, x := range strings.Split(s, ",") {
x = strings.TrimSpace(x)
if strings.HasPrefix(x, "--") {
long_option_names = append(long_option_names, x[2:])
} else if strings.HasPrefix(x, "-") {
single_letter_aliases = append(single_letter_aliases, x[1:])
}
}
if len(long_option_names) == 0 {
err = fmt.Errorf("Failed to parse rg help output line: %s", line)
return
}
for _, x := range single_letter_aliases {
alias_map[x] = long_option_names[0]
}
for _, x := range long_option_names[1:] {
alias_map[x] = long_option_names[0]
}
expecting_args[long_option_names[0]] = expecting_arg
}
} else {
if strings.HasPrefix(line, "OPTIONS:") {
options_started = true
}
}
}
return
}
type kitten_options struct {
matching_lines, context_lines, file_headers bool
with_filename, heading, line_number bool
stats, count, count_matches bool
files, files_with_matches, files_without_match bool
vimgrep bool
}
func default_kitten_opts() *kitten_options {
return &kitten_options{
matching_lines: true, context_lines: true, file_headers: true,
with_filename: true, heading: true, line_number: true,
}
}
func parse_args(args ...string) (delegate_to_rg bool, sanitized_args []string, kitten_opts *kitten_options, err error) {
options_that_expect_args, alias_map, err := get_options_for_rg()
if err != nil {
return
}
options_that_expect_args["kitten"] = true
kitten_opts = default_kitten_opts()
sanitized_args = make([]string, 0, len(args))
expecting_option_arg := ""
context_separator := "--"
field_context_separator := "-"
field_match_separator := "-"
handle_option_arg := func(key, val string, with_equals bool) error {
if key != "kitten" {
if with_equals {
sanitized_args = append(sanitized_args, "--"+key+"="+val)
} else {
sanitized_args = append(sanitized_args, "--"+key, val)
}
}
switch key {
case "path-separator":
if val != string(os.PathSeparator) {
delegate_to_rg = true
}
case "context-separator":
context_separator = val
case "field-context-separator":
field_context_separator = val
case "field-match-separator":
field_match_separator = val
case "kitten":
k, v, found := strings.Cut(val, "=")
if !found || k != "hyperlink" {
return fmt.Errorf("Unknown --kitten option: %s", val)
}
for _, x := range strings.Split(v, ",") {
switch x {
case "none":
kitten_opts.context_lines = false
kitten_opts.file_headers = false
kitten_opts.matching_lines = false
case "all":
kitten_opts.context_lines = true
kitten_opts.file_headers = true
kitten_opts.matching_lines = true
case "matching_lines":
kitten_opts.matching_lines = true
case "file_headers":
kitten_opts.file_headers = true
case "context_lines":
kitten_opts.context_lines = true
default:
return fmt.Errorf("hyperlink option invalid: %s", x)
}
}
}
return nil
}
handle_bool_option := func(key string) {
switch key {
case "no-context-separator":
context_separator = ""
case "no-filename":
kitten_opts.with_filename = false
case "with-filename":
kitten_opts.with_filename = true
case "heading":
kitten_opts.heading = true
case "no-heading":
kitten_opts.heading = false
case "line-number":
kitten_opts.line_number = true
case "no-line-number":
kitten_opts.line_number = false
case "pretty":
kitten_opts.line_number = true
kitten_opts.heading = true
case "stats":
kitten_opts.stats = true
case "count":
kitten_opts.count = true
case "count-matches":
kitten_opts.count_matches = true
case "files":
kitten_opts.files = true
case "files-with-matches":
kitten_opts.files_with_matches = true
case "files-without-match":
kitten_opts.files_without_match = true
case "vimgrep":
kitten_opts.vimgrep = true
case "null", "null-data", "type-list", "version", "help":
delegate_to_rg = true
}
}
for i, x := range args {
if expecting_option_arg != "" {
if err = handle_option_arg(expecting_option_arg, x, false); err != nil {
return
}
expecting_option_arg = ""
} else {
if x == "--" {
sanitized_args = append(sanitized_args, args[i:]...)
break
}
if strings.HasPrefix(x, "--") {
a, b, found := strings.Cut(x, "=")
a = a[2:]
q := alias_map[a]
if q != "" {
a = q
}
if found {
if _, is_known_option := options_that_expect_args[a]; is_known_option {
if err = handle_option_arg(a, b, true); err != nil {
return
}
} else {
sanitized_args = append(sanitized_args, x)
}
} else {
if options_that_expect_args[a] {
expecting_option_arg = a
} else {
handle_bool_option(a)
sanitized_args = append(sanitized_args, x)
}
}
} else if strings.HasPrefix(x, "-") {
ok := true
chars := make([]string, len(x)-1)
for i, ch := range x[1:] {
chars[i] = string(ch)
_, ok = alias_map[string(ch)]
if !ok {
sanitized_args = append(sanitized_args, x)
break
}
}
if ok {
for _, ch := range chars {
target := alias_map[ch]
if options_that_expect_args[target] {
expecting_option_arg = target
} else {
handle_bool_option(target)
sanitized_args = append(sanitized_args, "-"+ch)
}
}
}
} else {
sanitized_args = append(sanitized_args, x)
}
}
}
if !kitten_opts.with_filename || context_separator != "--" || field_context_separator != "-" || field_match_separator != "-" {
delegate_to_rg = true
}
return
}
type stdout_filter struct {
prefix []byte
process_line func(string)
}
func (self *stdout_filter) Write(p []byte) (n int, err error) {
n = len(p)
for len(p) > 0 {
idx := bytes.IndexByte(p, '\n')
if idx < 0 {
self.prefix = append(self.prefix, p...)
break
}
line := p[:idx]
if len(self.prefix) > 0 {
self.prefix = append(self.prefix, line...)
line = self.prefix
}
p = p[idx+1:]
self.process_line(utils.UnsafeBytesToString(line))
self.prefix = self.prefix[:0]
}
return
}
func main(_ *cli.Command, _ *Options, args []string) (rc int, err error) {
delegate_to_rg, sanitized_args, kitten_opts, err := parse_args(args...)
if delegate_to_rg {
sanitized_args = append([]string{"rg"}, sanitized_args...)
err = unix.Exec(RgExe(), sanitized_args, os.Environ())
if err != nil {
err = fmt.Errorf("Failed to execute rg: %w", err)
rc = 1
}
return
}
cmdline := append([]string{"--pretty", "--with-filename"}, sanitized_args...)
cmd := exec.Command(RgExe(), cmdline...)
cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr
buf := stdout_filter{prefix: make([]byte, 0, 8*1024)}
cmd.Stdout = &buf
sgr_pat := regexp.MustCompile("\x1b\\[.*?m")
osc_pat := regexp.MustCompile("\x1b\\].*?\x1b\\\\")
num_pat := regexp.MustCompile(`^(\d+)([:-])`)
path_with_count_pat := regexp.MustCompile(`^(.*?)(:\d+)`)
path_with_linenum_pat := regexp.MustCompile(`^(.*?):(\d+):`)
stats_pat := regexp.MustCompile(`^\d+ matches$`)
vimgrep_pat := regexp.MustCompile(`^(.*?):(\d+):(\d+):`)
in_stats := false
in_result := ""
hostname := utils.Hostname()
get_quoted_url := func(file_path string) string {
q, err := filepath.Abs(file_path)
if err == nil {
file_path = q
}
file_path = filepath.ToSlash(file_path)
file_path = strings.Join(utils.Map(url.PathEscape, strings.Split(file_path, "/")), "/")
return "file://" + hostname + file_path
}
write := func(items ...string) {
for _, x := range items {
os.Stdout.WriteString(x)
}
}
write_hyperlink := func(url, line, frag string) {
write("\033]8;;", url)
if frag != "" {
write("#", frag)
}
write("\033\\", line, "\n\033]8;;\033\\")
}
buf.process_line = func(line string) {
line = osc_pat.ReplaceAllLiteralString(line, "") // remove existing hyperlinks
clean_line := strings.TrimRightFunc(line, unicode.IsSpace)
clean_line = sgr_pat.ReplaceAllLiteralString(clean_line, "") // remove SGR formatting
if clean_line == "" {
in_result = ""
write("\n")
} else if in_stats {
write(line, "\n")
} else if in_result != "" {
if kitten_opts.line_number {
m := num_pat.FindStringSubmatch(clean_line)
if len(m) > 0 {
is_match_line := len(m) > 1 && m[2] == ":"
if (is_match_line && kitten_opts.matching_lines) || (!is_match_line && kitten_opts.context_lines) {
write_hyperlink(in_result, line, m[1])
return
}
}
}
write(line, "\n")
} else {
if strings.TrimSpace(line) != "" {
// The option priority should be consistent with ripgrep here.
if kitten_opts.stats && !in_stats && stats_pat.MatchString(clean_line) {
in_stats = true
} else if kitten_opts.count || kitten_opts.count_matches {
if m := path_with_count_pat.FindStringSubmatch(clean_line); len(m) > 0 && kitten_opts.file_headers {
write_hyperlink(get_quoted_url(m[1]), line, "")
return
}
} else if kitten_opts.files || kitten_opts.files_with_matches || kitten_opts.files_without_match {
if kitten_opts.file_headers {
write_hyperlink(get_quoted_url(clean_line), line, "")
return
}
} else if kitten_opts.vimgrep || !kitten_opts.heading {
var m []string
// When the vimgrep option is present, it will take precedence.
if kitten_opts.vimgrep {
m = vimgrep_pat.FindStringSubmatch(clean_line)
} else {
m = path_with_linenum_pat.FindStringSubmatch(clean_line)
}
if len(m) > 0 && (kitten_opts.file_headers || kitten_opts.matching_lines) {
write_hyperlink(get_quoted_url(m[1]), line, m[2])
return
}
} else {
in_result = get_quoted_url(clean_line)
if kitten_opts.file_headers {
write_hyperlink(in_result, line, "")
return
}
}
}
write(line, "\n")
}
}
err = cmd.Run()
var ee *exec.ExitError
if err != nil {
if errors.As(err, &ee) {
return ee.ExitCode(), nil
}
return 1, fmt.Errorf("Failed to execute rg: %w", err)
}
return
}
func specialize_command(hg *cli.Command) {
hg.Usage = "arguments for the rg command"
hg.ShortDescription = "Add hyperlinks to the output of ripgrep"
hg.HelpText = "The hyperlinked_grep kitten is a thin wrapper around the rg command. It automatically adds hyperlinks to the output of rg allowing the user to click on search results to have them open directly in their editor. For details on its usage, see :doc:`/kittens/hyperlinked_grep`."
hg.IgnoreAllArgs = true
hg.OnlyArgsAllowed = true
hg.ArgCompleter = cli.CompletionForWrapper("rg")
}
func EntryPoint(parent *cli.Command) {
create_cmd(parent, main)
}

View File

@@ -1,93 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package hyperlinked_grep
import (
"fmt"
"kitty/tools/utils/shlex"
"testing"
"github.com/google/go-cmp/cmp"
)
var _ = fmt.Print
func TestRgArgParsing(t *testing.T) {
if RgExe() == "rg" {
t.Skip("Skipping as rg not found in PATH")
}
check_failure := func(args ...string) {
_, _, _, err := parse_args(args...)
if err == nil {
t.Fatalf("No error when parsing: %#v", args)
}
}
check_failure("--kitten", "xyz")
check_failure("--kitten", "xyz=1")
check_kitten_opts := func(matching, context, headers bool, args ...string) {
_, _, kitten_opts, err := parse_args(args...)
if err != nil {
t.Fatalf("error when parsing: %#v: %s", args, err)
}
if matching != kitten_opts.matching_lines {
t.Fatalf("Matching lines not correct for: %#v", args)
}
if context != kitten_opts.context_lines {
t.Fatalf("Context lines not correct for: %#v", args)
}
if headers != kitten_opts.file_headers {
t.Fatalf("File headers not correct for: %#v", args)
}
}
check_kitten_opts(true, true, true)
check_kitten_opts(false, false, false, "--kitten", "hyperlink=none")
check_kitten_opts(false, false, true, "--kitten", "hyperlink=none", "--count", "--kitten=hyperlink=file_headers")
check_kitten_opts(false, false, true, "--kitten", "hyperlink=none,file_headers")
check_kitten_opts = func(with_filename, heading, line_number bool, args ...string) {
_, _, kitten_opts, err := parse_args(args...)
if err != nil {
t.Fatalf("error when parsing: %#v: %s", args, err)
}
if with_filename != kitten_opts.with_filename {
t.Fatalf("with_filename not correct for: %#v", args)
}
if heading != kitten_opts.heading {
t.Fatalf("heading not correct for: %#v", args)
}
if line_number != kitten_opts.line_number {
t.Fatalf("line_number not correct for: %#v", args)
}
}
check_kitten_opts(true, true, true)
check_kitten_opts(true, false, true, "--no-heading")
check_kitten_opts(true, true, true, "--no-heading", "--pretty")
check_kitten_opts(true, true, true, "--no-heading", "--heading")
check_args := func(args, expected string) {
a, err := shlex.Split(args)
if err != nil {
t.Fatal(err)
}
_, actual, _, err := parse_args(a...)
if err != nil {
t.Fatalf("error when parsing: %#v: %s", args, err)
}
ex, err := shlex.Split(expected)
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(ex, actual); diff != "" {
t.Fatalf("args not correct for %s\n%s", args, diff)
}
}
check_args("--count --max-depth 10 --XxX yyy abcd", "--count --max-depth 10 --XxX yyy abcd")
check_args("--max-depth=10 --kitten hyperlink=none abcd", "--max-depth=10 abcd")
check_args("-m 10 abcd", "--max-count 10 abcd")
check_args("-nm 10 abcd", "-n --max-count 10 abcd")
check_args("-mn 10 abcd", "-n --max-count 10 abcd")
}

View File

@@ -1,137 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package icat
import (
"errors"
"fmt"
"os"
"time"
"kitty/tools/tui/graphics"
"kitty/tools/tui/loop"
"kitty/tools/utils"
"kitty/tools/utils/images"
"kitty/tools/utils/shm"
)
var _ = fmt.Print
func DetectSupport(timeout time.Duration) (memory, files, direct bool, err error) {
temp_files_to_delete := make([]string, 0, 8)
shm_files_to_delete := make([]shm.MMap, 0, 8)
var direct_query_id, file_query_id, memory_query_id uint32
lp, e := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoMouseTracking)
if e != nil {
err = e
return
}
print_error := func(format string, args ...any) {
lp.Println(fmt.Sprintf(format, args...))
}
defer func() {
if len(temp_files_to_delete) > 0 && transfer_by_file != supported {
for _, name := range temp_files_to_delete {
os.Remove(name)
}
}
if len(shm_files_to_delete) > 0 && transfer_by_memory != supported {
for _, name := range shm_files_to_delete {
name.Unlink()
}
}
}()
lp.OnInitialize = func() (string, error) {
var iid uint32
lp.AddTimer(timeout, false, func(loop.IdType) error {
return fmt.Errorf("Timed out waiting for a response form the terminal: %w", os.ErrDeadlineExceeded)
})
g := func(t graphics.GRT_t, payload string) uint32 {
iid += 1
g1 := &graphics.GraphicsCommand{}
g1.SetTransmission(t).SetAction(graphics.GRT_action_query).SetImageId(iid).SetDataWidth(1).SetDataHeight(1).SetFormat(
graphics.GRT_format_rgb).SetDataSize(uint64(len(payload)))
g1.WriteWithPayloadToLoop(lp, utils.UnsafeStringToBytes(payload))
return iid
}
direct_query_id = g(graphics.GRT_transmission_direct, "123")
tf, err := images.CreateTempInRAM()
if err == nil {
file_query_id = g(graphics.GRT_transmission_tempfile, tf.Name())
temp_files_to_delete = append(temp_files_to_delete, tf.Name())
tf.Write([]byte{1, 2, 3})
tf.Close()
} else {
print_error("Failed to create temporary file for data transfer, file based transfer is disabled. Error: %v", err)
}
sf, err := shm.CreateTemp("icat-", 3)
if err == nil {
memory_query_id = g(graphics.GRT_transmission_sharedmem, sf.Name())
shm_files_to_delete = append(shm_files_to_delete, sf)
copy(sf.Slice(), []byte{1, 2, 3})
sf.Close()
} else {
var ens *shm.ErrNotSupported
if !errors.As(err, &ens) {
print_error("Failed to create SHM for data transfer, memory based transfer is disabled. Error: %v", err)
}
}
lp.QueueWriteString("\x1b[c")
return "", nil
}
lp.OnEscapeCode = func(etype loop.EscapeCodeType, payload []byte) (err error) {
switch etype {
case loop.CSI:
if len(payload) > 3 && payload[0] == '?' && payload[len(payload)-1] == 'c' {
lp.Quit(0)
return nil
}
case loop.APC:
g := graphics.GraphicsCommandFromAPC(payload)
if g != nil {
if g.ResponseMessage() == "OK" {
switch g.ImageId() {
case direct_query_id:
direct = true
case file_query_id:
files = true
case memory_query_id:
memory = true
}
}
return
}
}
return
}
lp.OnKeyEvent = func(event *loop.KeyEvent) error {
if event.MatchesPressOrRepeat("ctrl+c") {
event.Handled = true
print_error("Waiting for response from terminal, aborting now could lead to corruption")
}
if event.MatchesPressOrRepeat("ctrl+z") {
event.Handled = true
}
return nil
}
err = lp.Run()
if err != nil {
return
}
ds := lp.DeathSignalName()
if ds != "" {
fmt.Println("Killed by signal: ", ds)
lp.KillIfSignalled()
return
}
return
}

View File

@@ -1,58 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package icat
import (
"fmt"
"kitty/tools/tui/graphics"
"kitty/tools/utils/images"
)
var _ = fmt.Print
func Render(path string, ro *images.RenderOptions, frames []images.IdentifyRecord) (ans []*image_frame, err error) {
ro.TempfilenameTemplate = shm_template
image_frames, filenames, err := images.RenderWithMagick(path, ro, frames)
if err == nil {
ans = make([]*image_frame, len(image_frames))
for i, x := range image_frames {
ans[i] = &image_frame{
filename: filenames[x.Number], filename_is_temporary: true,
number: x.Number, width: x.Width, height: x.Height, left: x.Left, top: x.Top,
transmission_format: graphics.GRT_format_rgba, delay_ms: int(x.Delay_ms), compose_onto: x.Compose_onto,
}
if x.Is_opaque {
ans[i].transmission_format = graphics.GRT_format_rgb
}
}
}
return ans, err
}
func render_image_with_magick(imgd *image_data, src *opened_input) (err error) {
err = src.PutOnFilesystem()
if err != nil {
return err
}
frames, err := images.IdentifyWithMagick(src.FileSystemName())
if err != nil {
return err
}
imgd.format_uppercase = frames[0].Fmt_uppercase
imgd.canvas_width, imgd.canvas_height = frames[0].Canvas.Width, frames[0].Canvas.Height
set_basic_metadata(imgd)
if !imgd.needs_conversion {
make_output_from_input(imgd, src)
return nil
}
ro := images.RenderOptions{RemoveAlpha: remove_alpha, Flip: flip, Flop: flop}
if scale_image(imgd) {
ro.ResizeTo.X, ro.ResizeTo.Y = imgd.canvas_width, imgd.canvas_height
}
imgd.frames, err = Render(src.FileSystemName(), &ro, frames)
if err != nil {
return err
}
return nil
}

View File

@@ -1,278 +0,0 @@
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
package icat
import (
"fmt"
"os"
"runtime"
"strconv"
"strings"
"sync/atomic"
"time"
"kitty/tools/cli"
"kitty/tools/tty"
"kitty/tools/tui"
"kitty/tools/tui/graphics"
"kitty/tools/utils"
"kitty/tools/utils/images"
"kitty/tools/utils/style"
"golang.org/x/sys/unix"
)
var _ = fmt.Print
type Place struct {
width, height, left, top int
}
var opts *Options
var place *Place
var z_index int32
var remove_alpha *images.NRGBColor
var flip, flop bool
type transfer_mode int
const (
unknown transfer_mode = iota
unsupported
supported
)
var transfer_by_file, transfer_by_memory, transfer_by_stream transfer_mode
var files_channel chan input_arg
var output_channel chan *image_data
var num_of_items int
var keep_going *atomic.Bool
var screen_size *unix.Winsize
func send_output(imgd *image_data) {
output_channel <- imgd
}
func parse_mirror() (err error) {
flip = opts.Mirror == "both" || opts.Mirror == "vertical"
flop = opts.Mirror == "both" || opts.Mirror == "horizontal"
return
}
func parse_background() (err error) {
if opts.Background == "" || opts.Background == "none" {
return nil
}
col, err := style.ParseColor(opts.Background)
if err != nil {
return fmt.Errorf("Invalid value for --background: %w", err)
}
remove_alpha = &images.NRGBColor{R: col.Red, G: col.Green, B: col.Blue}
return
}
func parse_z_index() (err error) {
val := opts.ZIndex
var origin int32
if strings.HasPrefix(val, "--") {
origin = -1073741824
val = val[1:]
}
i, err := strconv.ParseInt(val, 10, 32)
if err != nil {
return fmt.Errorf("Invalid value for --z-index with error: %w", err)
}
z_index = int32(i) + origin
return
}
func parse_place() (err error) {
if opts.Place == "" {
return nil
}
area, pos, found := strings.Cut(opts.Place, "@")
if !found {
return fmt.Errorf("Invalid --place specification: %s", opts.Place)
}
w, h, found := strings.Cut(area, "x")
if !found {
return fmt.Errorf("Invalid --place specification: %s", opts.Place)
}
l, t, found := strings.Cut(pos, "x")
if !found {
return fmt.Errorf("Invalid --place specification: %s", opts.Place)
}
place = &Place{}
place.width, err = strconv.Atoi(w)
if err != nil {
return err
}
place.height, err = strconv.Atoi(h)
if err != nil {
return err
}
place.left, err = strconv.Atoi(l)
if err != nil {
return err
}
place.top, err = strconv.Atoi(t)
if err != nil {
return err
}
return nil
}
func print_error(format string, args ...any) {
fmt.Fprintf(os.Stderr, format, args...)
fmt.Fprintln(os.Stderr)
}
func main(cmd *cli.Command, o *Options, args []string) (rc int, err error) {
opts = o
err = parse_place()
if err != nil {
return 1, err
}
err = parse_z_index()
if err != nil {
return 1, err
}
err = parse_background()
if err != nil {
return 1, err
}
err = parse_mirror()
if err != nil {
return 1, err
}
t, err := tty.OpenControllingTerm()
if err != nil {
return 1, fmt.Errorf("Failed to open controlling terminal with error: %w", err)
}
screen_size, err = t.GetSize()
if err != nil {
return 1, fmt.Errorf("Failed to query terminal using TIOCGWINSZ with error: %w", err)
}
if opts.PrintWindowSize {
fmt.Printf("%dx%d", screen_size.Xpixel, screen_size.Ypixel)
return 0, nil
}
if opts.Clear {
cc := &graphics.GraphicsCommand{}
cc.SetAction(graphics.GRT_action_delete).SetDelete(graphics.GRT_free_visible)
cc.WriteWithPayloadTo(os.Stdout, nil)
}
if screen_size.Xpixel == 0 || screen_size.Ypixel == 0 {
return 1, fmt.Errorf("Terminal does not support reporting screen sizes in pixels, use a terminal such as kitty, WezTerm, Konsole, etc. that does.")
}
items, err := process_dirs(args...)
if err != nil {
return 1, err
}
if opts.Place != "" && len(items) > 1 {
return 1, fmt.Errorf("The --place option can only be used with a single image, not %d", len(items))
}
files_channel = make(chan input_arg, len(items))
for _, ia := range items {
files_channel <- ia
}
num_of_items = len(items)
output_channel = make(chan *image_data, 1)
keep_going = &atomic.Bool{}
keep_going.Store(true)
if !opts.DetectSupport && num_of_items > 0 {
num_workers := utils.Max(1, utils.Min(num_of_items, runtime.NumCPU()))
for i := 0; i < num_workers; i++ {
go run_worker()
}
}
passthrough_mode := no_passthrough
switch opts.Passthrough {
case "tmux":
passthrough_mode = tmux_passthrough
case "detect":
if tui.TmuxSocketAddress() != "" {
passthrough_mode = tmux_passthrough
}
}
if passthrough_mode == no_passthrough && (opts.TransferMode == "detect" || opts.DetectSupport) {
memory, files, direct, err := DetectSupport(time.Duration(opts.DetectionTimeout * float64(time.Second)))
if err != nil {
return 1, err
}
if !direct {
keep_going.Store(false)
return 1, fmt.Errorf("This terminal does not support the graphics protocol use a terminal such as kitty, WezTerm or Konsole that does. If you are running inside a terminal multiplexer such as tmux or screen that might be interfering as well.")
}
if memory {
transfer_by_memory = supported
} else {
transfer_by_memory = unsupported
}
if files {
transfer_by_file = supported
} else {
transfer_by_file = unsupported
}
}
if passthrough_mode != no_passthrough {
// tmux doesnt allow responses from the terminal so we cant detect if memory or file based transferring is supported
transfer_by_memory = unsupported
transfer_by_file = unsupported
transfer_by_stream = supported
}
if opts.DetectSupport {
if transfer_by_memory == supported {
print_error("memory")
} else if transfer_by_file == supported {
print_error("files")
} else {
print_error("stream")
}
return 0, nil
}
use_unicode_placeholder := opts.UnicodePlaceholder
if passthrough_mode != no_passthrough {
use_unicode_placeholder = true
}
base_id := uint32(opts.ImageId)
for num_of_items > 0 {
imgd := <-output_channel
if base_id != 0 {
imgd.image_id = base_id
base_id++
if base_id == 0 {
base_id++
}
}
imgd.use_unicode_placeholder = use_unicode_placeholder
imgd.passthrough_mode = passthrough_mode
num_of_items--
if imgd.err != nil {
print_error("Failed to process \x1b[31m%s\x1b[39m: %s\r\n", imgd.source_name, imgd.err)
} else {
transmit_image(imgd)
if imgd.err != nil {
print_error("Failed to transmit \x1b[31m%s\x1b[39m: %s\r\n", imgd.source_name, imgd.err)
}
}
}
keep_going.Store(false)
if opts.Hold {
fmt.Print("\r")
if opts.Place != "" {
fmt.Println()
}
tui.HoldTillEnter(false)
}
return 0, nil
}
func EntryPoint(parent *cli.Command) {
create_cmd(parent, main)
}

View File

@@ -1,183 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package icat
import (
"fmt"
"image"
"image/gif"
"kitty/tools/tui/graphics"
"kitty/tools/utils"
"kitty/tools/utils/images"
"kitty/tools/utils/shm"
"github.com/disintegration/imaging"
)
var _ = fmt.Print
func resize_frame(imgd *image_data, img image.Image) (image.Image, image.Rectangle) {
b := img.Bounds()
left, top, width, height := b.Min.X, b.Min.Y, b.Dx(), b.Dy()
new_width := int(imgd.scaled_frac.x * float64(width))
new_height := int(imgd.scaled_frac.y * float64(height))
img = imaging.Resize(img, new_width, new_height, imaging.Lanczos)
newleft := int(imgd.scaled_frac.x * float64(left))
newtop := int(imgd.scaled_frac.y * float64(top))
return img, image.Rect(newleft, newtop, newleft+new_width, newtop+new_height)
}
const shm_template = "kitty-icat-*"
func add_frame(ctx *images.Context, imgd *image_data, img image.Image) *image_frame {
is_opaque := false
if imgd.format_uppercase == "JPEG" {
// special cased because EXIF orientation could have already changed this image to an NRGBA making IsOpaque() very slow
is_opaque = true
} else {
is_opaque = images.IsOpaque(img)
}
b := img.Bounds()
if imgd.scaled_frac.x != 0 {
img, b = resize_frame(imgd, img)
}
f := image_frame{width: b.Dx(), height: b.Dy(), number: len(imgd.frames) + 1, left: b.Min.X, top: b.Min.Y}
dest_rect := image.Rect(0, 0, f.width, f.height)
var final_img image.Image
bytes_per_pixel := 4
if is_opaque || remove_alpha != nil {
var rgb *images.NRGB
bytes_per_pixel = 3
m, err := shm.CreateTemp(shm_template, uint64(f.width*f.height*bytes_per_pixel))
if err != nil {
rgb = images.NewNRGB(dest_rect)
} else {
rgb = &images.NRGB{Pix: m.Slice(), Stride: bytes_per_pixel * f.width, Rect: dest_rect}
f.shm = m
}
f.transmission_format = graphics.GRT_format_rgb
f.in_memory_bytes = rgb.Pix
final_img = rgb
} else {
var rgba *image.NRGBA
m, err := shm.CreateTemp(shm_template, uint64(f.width*f.height*bytes_per_pixel))
if err != nil {
rgba = image.NewNRGBA(dest_rect)
} else {
rgba = &image.NRGBA{Pix: m.Slice(), Stride: bytes_per_pixel * f.width, Rect: dest_rect}
f.shm = m
}
f.transmission_format = graphics.GRT_format_rgba
f.in_memory_bytes = rgba.Pix
final_img = rgba
}
ctx.PasteCenter(final_img, img, remove_alpha)
imgd.frames = append(imgd.frames, &f)
if flip {
ctx.FlipPixelsV(bytes_per_pixel, f.width, f.height, f.in_memory_bytes)
if f.height < imgd.canvas_height {
f.top = (2*imgd.canvas_height - f.height - f.top) % imgd.canvas_height
}
}
if flop {
ctx.FlipPixelsH(bytes_per_pixel, f.width, f.height, f.in_memory_bytes)
if f.width < imgd.canvas_width {
f.left = (2*imgd.canvas_width - f.width - f.left) % imgd.canvas_width
}
}
return &f
}
func scale_image(imgd *image_data) bool {
if imgd.needs_scaling {
width, height := imgd.canvas_width, imgd.canvas_height
if imgd.canvas_width < imgd.available_width && opts.ScaleUp && place != nil {
r := float64(imgd.available_width) / float64(imgd.canvas_width)
imgd.canvas_width, imgd.canvas_height = imgd.available_width, int(r*float64(imgd.canvas_height))
}
neww, newh := images.FitImage(imgd.canvas_width, imgd.canvas_height, imgd.available_width, imgd.available_height)
imgd.needs_scaling = false
imgd.scaled_frac.x = float64(neww) / float64(width)
imgd.scaled_frac.y = float64(newh) / float64(height)
imgd.canvas_width = int(imgd.scaled_frac.x * float64(width))
imgd.canvas_height = int(imgd.scaled_frac.y * float64(height))
return true
}
return false
}
func load_one_frame_image(ctx *images.Context, imgd *image_data, src *opened_input) (img image.Image, err error) {
img, err = imaging.Decode(src.file, imaging.AutoOrientation(true))
src.Rewind()
if err != nil {
return
}
// reset the sizes as we read EXIF tags here which could have rotated the image
imgd.canvas_width = img.Bounds().Dx()
imgd.canvas_height = img.Bounds().Dy()
set_basic_metadata(imgd)
scale_image(imgd)
return
}
func calc_min_gap(gaps []int) int {
// Some broken GIF images have all zero gaps, browsers with their usual
// idiot ideas render these with a default 100ms gap https://bugzilla.mozilla.org/show_bug.cgi?id=125137
// Browsers actually force a 100ms gap at any zero gap frame, but that
// just means it is impossible to deliberately use zero gap frames for
// sophisticated blending, so we dont do that.
max_gap := utils.Max(0, gaps...)
min_gap := 0
if max_gap <= 0 {
min_gap = 10
}
return min_gap
}
func (frame *image_frame) set_disposal(anchor_frame int, disposal byte) int {
anchor_frame, frame.compose_onto = images.SetGIFFrameDisposal(frame.number, anchor_frame, disposal)
return anchor_frame
}
func (frame *image_frame) set_delay(gap, min_gap int) {
frame.delay_ms = utils.Max(min_gap, gap) * 10
if frame.delay_ms == 0 {
frame.delay_ms = -1
}
}
func add_gif_frames(ctx *images.Context, imgd *image_data, gf *gif.GIF) error {
min_gap := images.CalcMinimumGIFGap(gf.Delay)
scale_image(imgd)
anchor_frame := 1
for i, paletted_img := range gf.Image {
frame := add_frame(ctx, imgd, paletted_img)
frame.set_delay(gf.Delay[i], min_gap)
anchor_frame = frame.set_disposal(anchor_frame, gf.Disposal[i])
}
return nil
}
func render_image_with_go(imgd *image_data, src *opened_input) (err error) {
ctx := images.Context{}
switch {
case imgd.format_uppercase == "GIF" && opts.Loop != 0:
gif_frames, err := gif.DecodeAll(src.file)
src.Rewind()
if err != nil {
return fmt.Errorf("Failed to decode GIF file with error: %w", err)
}
err = add_gif_frames(&ctx, imgd, gif_frames)
if err != nil {
return err
}
default:
img, err := load_one_frame_image(&ctx, imgd, src)
if err != nil {
return err
}
add_frame(&ctx, imgd, img)
}
return nil
}

View File

@@ -1,329 +0,0 @@
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
package icat
import (
"bytes"
"fmt"
"image"
"image/color"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"kitty/tools/tty"
"kitty/tools/tui/graphics"
"kitty/tools/utils"
"kitty/tools/utils/images"
"kitty/tools/utils/shm"
)
var _ = fmt.Print
type BytesBuf struct {
data []byte
pos int64
}
func (self *BytesBuf) Seek(offset int64, whence int) (int64, error) {
switch whence {
case io.SeekStart:
self.pos = offset
case io.SeekCurrent:
self.pos += offset
case io.SeekEnd:
self.pos = int64(len(self.data)) + offset
default:
return self.pos, fmt.Errorf("Unknown value for whence: %#v", whence)
}
self.pos = utils.Max(0, utils.Min(self.pos, int64(len(self.data))))
return self.pos, nil
}
func (self *BytesBuf) Read(p []byte) (n int, err error) {
nb := utils.Min(int64(len(p)), int64(len(self.data))-self.pos)
if nb == 0 {
err = io.EOF
} else {
n = copy(p, self.data[self.pos:self.pos+nb])
self.pos += nb
}
return
}
func (self *BytesBuf) Close() error {
self.data = nil
self.pos = 0
return nil
}
type input_arg struct {
arg string
value string
is_http_url bool
}
func is_http_url(arg string) bool {
return strings.HasPrefix(arg, "https://") || strings.HasPrefix(arg, "http://")
}
func process_dirs(args ...string) (results []input_arg, err error) {
results = make([]input_arg, 0, 64)
if opts.Stdin != "no" && (opts.Stdin == "yes" || !tty.IsTerminal(os.Stdin.Fd())) {
results = append(results, input_arg{arg: "/dev/stdin"})
}
for _, arg := range args {
if arg != "" {
if is_http_url(arg) {
results = append(results, input_arg{arg: arg, value: arg, is_http_url: true})
} else {
if strings.HasPrefix(arg, "file://") {
u, err := url.Parse(arg)
if err != nil {
return nil, &fs.PathError{Op: "Parse", Path: arg, Err: err}
}
arg = u.Path
}
s, err := os.Stat(arg)
if err != nil {
return nil, &fs.PathError{Op: "Stat", Path: arg, Err: err}
}
if s.IsDir() {
filepath.WalkDir(arg, func(path string, d fs.DirEntry, walk_err error) error {
if walk_err != nil {
if d == nil {
err = &fs.PathError{Op: "Stat", Path: arg, Err: walk_err}
}
return walk_err
}
if !d.IsDir() {
mt := utils.GuessMimeType(path)
if strings.HasPrefix(mt, "image/") {
results = append(results, input_arg{arg: arg, value: path})
}
}
return nil
})
} else {
results = append(results, input_arg{arg: arg, value: arg})
}
}
}
}
return results, nil
}
type opened_input struct {
file io.ReadSeekCloser
name_to_unlink string
}
func (self *opened_input) Rewind() {
if self.file != nil {
self.file.Seek(0, io.SeekStart)
}
}
func (self *opened_input) Release() {
if self.file != nil {
self.file.Close()
self.file = nil
}
if self.name_to_unlink != "" {
os.Remove(self.name_to_unlink)
self.name_to_unlink = ""
}
}
func (self *opened_input) PutOnFilesystem() (err error) {
if self.name_to_unlink != "" {
return
}
f, err := images.CreateTempInRAM()
if err != nil {
return fmt.Errorf("Failed to create a temporary file to store input data with error: %w", err)
}
self.Rewind()
_, err = io.Copy(f, self.file)
if err != nil {
f.Close()
return fmt.Errorf("Failed to copy input data to temporary file with error: %w", err)
}
self.Release()
self.file = f
self.name_to_unlink = f.Name()
return
}
func (self *opened_input) FileSystemName() string { return self.name_to_unlink }
type image_frame struct {
filename string
shm shm.MMap
in_memory_bytes []byte
filename_is_temporary bool
width, height, left, top int
transmission_format graphics.GRT_f
compose_onto int
number int
disposal_background color.NRGBA
delay_ms int
}
type image_data struct {
canvas_width, canvas_height int
format_uppercase string
available_width, available_height int
needs_scaling, needs_conversion bool
scaled_frac struct{ x, y float64 }
frames []*image_frame
image_number uint32
image_id uint32
cell_x_offset int
move_x_by int
move_to struct{ x, y int }
width_cells, height_cells int
use_unicode_placeholder bool
passthrough_mode passthrough_type
// for error reporting
err error
source_name string
}
func set_basic_metadata(imgd *image_data) {
if imgd.frames == nil {
imgd.frames = make([]*image_frame, 0, 32)
}
imgd.available_width = int(screen_size.Xpixel)
imgd.available_height = 10 * imgd.canvas_height
if place != nil {
imgd.available_width = place.width * int(screen_size.Xpixel) / int(screen_size.Col)
imgd.available_height = place.height * int(screen_size.Ypixel) / int(screen_size.Row)
}
imgd.needs_scaling = imgd.canvas_width > imgd.available_width || imgd.canvas_height > imgd.available_height || opts.ScaleUp
imgd.needs_conversion = imgd.needs_scaling || remove_alpha != nil || flip || flop || imgd.format_uppercase != "PNG"
}
func report_error(source_name, msg string, err error) {
imgd := image_data{source_name: source_name, err: fmt.Errorf("%s: %w", msg, err)}
send_output(&imgd)
}
func make_output_from_input(imgd *image_data, f *opened_input) {
bb, ok := f.file.(*BytesBuf)
frame := image_frame{}
imgd.frames = append(imgd.frames, &frame)
frame.width = imgd.canvas_width
frame.height = imgd.canvas_height
if imgd.format_uppercase != "PNG" {
panic(fmt.Sprintf("Unknown transmission format: %s", imgd.format_uppercase))
}
frame.transmission_format = graphics.GRT_format_png
if ok {
frame.in_memory_bytes = bb.data
} else {
frame.filename = f.file.(*os.File).Name()
if f.name_to_unlink != "" {
frame.filename_is_temporary = true
f.name_to_unlink = ""
}
}
}
func process_arg(arg input_arg) {
var f opened_input
if arg.is_http_url {
resp, err := http.Get(arg.value)
if err != nil {
report_error(arg.value, "Could not get", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
report_error(arg.value, "Could not get", fmt.Errorf("bad status: %v", resp.Status))
return
}
dest := bytes.Buffer{}
dest.Grow(64 * 1024)
_, err = io.Copy(&dest, resp.Body)
if err != nil {
report_error(arg.value, "Could not download", err)
return
}
f.file = &BytesBuf{data: dest.Bytes()}
} else if arg.value == "" {
stdin, err := io.ReadAll(os.Stdin)
if err != nil {
report_error("<stdin>", "Could not read from", err)
return
}
f.file = &BytesBuf{data: stdin}
} else {
q, err := os.Open(arg.value)
if err != nil {
report_error(arg.value, "Could not open", err)
return
}
f.file = q
}
defer f.Release()
can_use_go := false
var c image.Config
var format string
var err error
imgd := image_data{source_name: arg.value}
if opts.Engine == "auto" || opts.Engine == "native" {
c, format, err = image.DecodeConfig(f.file)
f.Rewind()
can_use_go = err == nil
}
if !keep_going.Load() {
return
}
if can_use_go {
imgd.canvas_width = c.Width
imgd.canvas_height = c.Height
imgd.format_uppercase = strings.ToUpper(format)
set_basic_metadata(&imgd)
if !imgd.needs_conversion {
make_output_from_input(&imgd, &f)
send_output(&imgd)
return
}
err = render_image_with_go(&imgd, &f)
if err != nil {
report_error(arg.value, "Could not render image to RGB", err)
return
}
} else {
err = render_image_with_magick(&imgd, &f)
if err != nil {
report_error(arg.value, "ImageMagick failed", err)
return
}
}
if !keep_going.Load() {
return
}
send_output(&imgd)
}
func run_worker() {
for {
select {
case arg := <-files_channel:
if !keep_going.Load() {
return
}
process_arg(arg)
default:
return
}
}
}

View File

@@ -1,408 +0,0 @@
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
package icat
import (
"bytes"
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"io"
"kitty"
"math"
not_rand "math/rand"
"os"
"path/filepath"
"strings"
"kitty/tools/tui"
"kitty/tools/tui/graphics"
"kitty/tools/tui/loop"
"kitty/tools/utils"
"kitty/tools/utils/images"
"kitty/tools/utils/shm"
)
var _ = fmt.Print
type passthrough_type int
const (
no_passthrough passthrough_type = iota
tmux_passthrough
)
func new_graphics_command(imgd *image_data) *graphics.GraphicsCommand {
gc := graphics.GraphicsCommand{}
switch imgd.passthrough_mode {
case tmux_passthrough:
gc.WrapPrefix = "\033Ptmux;"
gc.WrapSuffix = "\033\\"
gc.EncodeSerializedDataFunc = func(x string) string { return strings.ReplaceAll(x, "\033", "\033\033") }
}
return &gc
}
func gc_for_image(imgd *image_data, frame_num int, frame *image_frame) *graphics.GraphicsCommand {
gc := new_graphics_command(imgd)
gc.SetDataWidth(uint64(frame.width)).SetDataHeight(uint64(frame.height))
gc.SetQuiet(graphics.GRT_quiet_silent)
gc.SetFormat(frame.transmission_format)
if imgd.image_number != 0 {
gc.SetImageNumber(imgd.image_number)
}
if imgd.image_id != 0 {
gc.SetImageId(imgd.image_id)
}
if frame_num == 0 {
gc.SetAction(graphics.GRT_action_transmit_and_display)
if imgd.use_unicode_placeholder {
gc.SetUnicodePlaceholder(graphics.GRT_create_unicode_placeholder)
gc.SetColumns(uint64(imgd.width_cells))
gc.SetRows(uint64(imgd.height_cells))
}
if imgd.cell_x_offset > 0 {
gc.SetXOffset(uint64(imgd.cell_x_offset))
}
if z_index != 0 {
gc.SetZIndex(z_index)
}
if place != nil {
gc.SetCursorMovement(graphics.GRT_cursor_static)
}
} else {
gc.SetAction(graphics.GRT_action_frame)
gc.SetGap(int32(frame.delay_ms))
if frame.compose_onto > 0 {
gc.SetOverlaidFrame(uint64(frame.compose_onto))
} else {
bg := (uint32(frame.disposal_background.R) << 24) | (uint32(frame.disposal_background.G) << 16) | (uint32(frame.disposal_background.B) << 8) | uint32(frame.disposal_background.A)
gc.SetBackgroundColor(bg)
}
gc.SetLeftEdge(uint64(frame.left)).SetTopEdge(uint64(frame.top))
}
return gc
}
func transmit_shm(imgd *image_data, frame_num int, frame *image_frame) (err error) {
var mmap shm.MMap
var data_size int64
if frame.in_memory_bytes == nil {
f, err := os.Open(frame.filename)
if err != nil {
return fmt.Errorf("Failed to open image data output file: %s with error: %w", frame.filename, err)
}
defer f.Close()
data_size, _ = f.Seek(0, io.SeekEnd)
f.Seek(0, io.SeekStart)
mmap, err = shm.CreateTemp("icat-*", uint64(data_size))
if err != nil {
return fmt.Errorf("Failed to create a SHM file for transmission: %w", err)
}
dest := mmap.Slice()
for len(dest) > 0 {
n, err := f.Read(dest)
dest = dest[n:]
if err != nil {
if errors.Is(err, io.EOF) {
break
}
mmap.Unlink()
return fmt.Errorf("Failed to read data from image output data file: %w", err)
}
}
} else {
if frame.shm == nil {
data_size = int64(len(frame.in_memory_bytes))
mmap, err = shm.CreateTemp("icat-*", uint64(data_size))
if err != nil {
return fmt.Errorf("Failed to create a SHM file for transmission: %w", err)
}
copy(mmap.Slice(), frame.in_memory_bytes)
} else {
mmap = frame.shm
frame.shm = nil
}
}
gc := gc_for_image(imgd, frame_num, frame)
gc.SetTransmission(graphics.GRT_transmission_sharedmem)
gc.SetDataSize(uint64(data_size))
gc.WriteWithPayloadTo(os.Stdout, utils.UnsafeStringToBytes(mmap.Name()))
mmap.Close()
return nil
}
func transmit_file(imgd *image_data, frame_num int, frame *image_frame) (err error) {
is_temp := false
fname := ""
var data_size int
if frame.in_memory_bytes == nil {
is_temp = frame.filename_is_temporary
fname, err = filepath.Abs(frame.filename)
if err != nil {
return fmt.Errorf("Failed to convert image data output file: %s to absolute path with error: %w", frame.filename, err)
}
frame.filename = "" // so it isnt deleted in cleanup
} else {
is_temp = true
if frame.shm != nil && frame.shm.FileSystemName() != "" {
fname = frame.shm.FileSystemName()
frame.shm.Close()
frame.shm = nil
} else {
f, err := images.CreateTempInRAM()
if err != nil {
return fmt.Errorf("Failed to create a temp file for image data transmission: %w", err)
}
data_size = len(frame.in_memory_bytes)
_, err = bytes.NewBuffer(frame.in_memory_bytes).WriteTo(f)
f.Close()
if err != nil {
return fmt.Errorf("Failed to write image data to temp file for transmission: %w", err)
}
fname = f.Name()
}
}
gc := gc_for_image(imgd, frame_num, frame)
if is_temp {
gc.SetTransmission(graphics.GRT_transmission_tempfile)
} else {
gc.SetTransmission(graphics.GRT_transmission_file)
}
if data_size > 0 {
gc.SetDataSize(uint64(data_size))
}
gc.WriteWithPayloadTo(os.Stdout, utils.UnsafeStringToBytes(fname))
return nil
}
func transmit_stream(imgd *image_data, frame_num int, frame *image_frame) (err error) {
data := frame.in_memory_bytes
if data == nil {
f, err := os.Open(frame.filename)
if err != nil {
return fmt.Errorf("Failed to open image data output file: %s with error: %w", frame.filename, err)
}
data, err = io.ReadAll(f)
f.Close()
if err != nil {
return fmt.Errorf("Failed to read data from image output data file: %w", err)
}
}
gc := gc_for_image(imgd, frame_num, frame)
gc.WriteWithPayloadTo(os.Stdout, data)
return nil
}
func calculate_in_cell_x_offset(width, cell_width int) int {
extra_pixels := width % cell_width
if extra_pixels == 0 {
return 0
}
switch opts.Align {
case "left":
return 0
case "right":
return cell_width - extra_pixels
default:
return (cell_width - extra_pixels) / 2
}
}
func place_cursor(imgd *image_data) {
cw := int(screen_size.Xpixel) / int(screen_size.Col)
ch := int(screen_size.Ypixel) / int(screen_size.Row)
imgd.cell_x_offset = calculate_in_cell_x_offset(imgd.canvas_width, cw)
imgd.width_cells = int(math.Ceil(float64(imgd.canvas_width) / float64(cw)))
imgd.height_cells = int(math.Ceil(float64(imgd.canvas_height) / float64(ch)))
if place == nil {
switch opts.Align {
case "center":
imgd.move_x_by = (int(screen_size.Col) - imgd.width_cells) / 2
case "right":
imgd.move_x_by = (int(screen_size.Col) - imgd.width_cells)
}
} else {
imgd.move_to.x = place.left + 1
imgd.move_to.y = place.top + 1
switch opts.Align {
case "center":
imgd.move_to.x += (place.width - imgd.width_cells) / 2
case "right":
imgd.move_to.x += (place.width - imgd.width_cells)
}
}
}
func next_random() (ans uint32) {
for ans == 0 {
b := make([]byte, 4)
_, err := rand.Read(b)
if err == nil {
ans = binary.LittleEndian.Uint32(b[:])
} else {
ans = not_rand.Uint32()
}
}
return ans
}
func write_unicode_placeholder(imgd *image_data) {
prefix := ""
foreground := fmt.Sprintf("\033[38:2:%d:%d:%dm", (imgd.image_id>>16)&255, (imgd.image_id>>8)&255, imgd.image_id&255)
os.Stdout.WriteString(foreground)
restore := "\033[39m"
if imgd.move_to.y > 0 {
os.Stdout.WriteString(loop.SAVE_CURSOR)
restore += loop.RESTORE_CURSOR
} else if imgd.move_x_by > 0 {
prefix = strings.Repeat(" ", imgd.move_x_by)
}
defer func() { os.Stdout.WriteString(restore) }()
if imgd.move_to.y > 0 {
fmt.Printf(loop.MoveCursorToTemplate, imgd.move_to.y, 0)
}
id_char := string(images.NumberToDiacritic[(imgd.image_id>>24)&255])
for r := 0; r < imgd.height_cells; r++ {
if imgd.move_to.x > 0 {
fmt.Printf("\x1b[%dC", imgd.move_to.x)
} else {
os.Stdout.WriteString(prefix)
}
for c := 0; c < imgd.width_cells; c++ {
os.Stdout.WriteString(string(kitty.ImagePlaceholderChar) + string(images.NumberToDiacritic[r]) + string(images.NumberToDiacritic[c]) + id_char)
}
os.Stdout.WriteString("\n\r")
}
}
var seen_image_ids *utils.Set[uint32]
func transmit_image(imgd *image_data) {
if seen_image_ids == nil {
seen_image_ids = utils.NewSet[uint32](32)
}
defer func() {
for _, frame := range imgd.frames {
if frame.filename_is_temporary && frame.filename != "" {
os.Remove(frame.filename)
frame.filename = ""
}
if frame.shm != nil {
frame.shm.Unlink()
frame.shm.Close()
frame.shm = nil
}
frame.in_memory_bytes = nil
}
}()
var f func(*image_data, int, *image_frame) error
if opts.TransferMode != "detect" {
switch opts.TransferMode {
case "file":
f = transmit_file
case "memory":
f = transmit_shm
case "stream":
f = transmit_stream
}
}
if f == nil && transfer_by_memory == supported && imgd.frames[0].in_memory_bytes != nil {
f = transmit_shm
}
if f == nil && transfer_by_file == supported {
f = transmit_file
}
if f == nil {
f = transmit_stream
}
if imgd.image_id == 0 {
if imgd.use_unicode_placeholder {
for imgd.image_id&0xFF000000 == 0 || imgd.image_id&0x00FFFF00 == 0 || seen_image_ids.Has(imgd.image_id) {
// Generate a 32-bit image id using rejection sampling such that the most
// significant byte and the two bytes in the middle are non-zero to avoid
// collisions with applications that cannot represent non-zero most
// significant bytes (which is represented by the third combining character)
// or two non-zero bytes in the middle (which requires 24-bit color mode).
imgd.image_id = next_random()
}
seen_image_ids.Add(imgd.image_id)
} else {
if len(imgd.frames) > 1 {
for imgd.image_number == 0 {
imgd.image_number = next_random()
}
}
}
}
place_cursor(imgd)
if imgd.use_unicode_placeholder && utils.Max(imgd.width_cells, imgd.height_cells) >= len(images.NumberToDiacritic) {
imgd.err = fmt.Errorf("Image too large to be displayed using Unicode placeholders. Maximum size is %dx%d cells", len(images.NumberToDiacritic), len(images.NumberToDiacritic))
return
}
switch imgd.passthrough_mode {
case tmux_passthrough:
imgd.err = tui.TmuxAllowPassthrough()
if imgd.err != nil {
return
}
}
fmt.Print("\r")
if !imgd.use_unicode_placeholder {
if imgd.move_x_by > 0 {
fmt.Printf("\x1b[%dC", imgd.move_x_by)
}
if imgd.move_to.x > 0 {
fmt.Printf(loop.MoveCursorToTemplate, imgd.move_to.y, imgd.move_to.x)
}
}
frame_control_cmd := new_graphics_command(imgd)
frame_control_cmd.SetAction(graphics.GRT_action_animate)
if imgd.image_id != 0 {
frame_control_cmd.SetImageId(imgd.image_id)
} else {
frame_control_cmd.SetImageNumber(imgd.image_number)
}
is_animated := len(imgd.frames) > 1
for frame_num, frame := range imgd.frames {
err := f(imgd, frame_num, frame)
if err != nil {
imgd.err = err
return
}
if is_animated {
switch frame_num {
case 0:
// set gap for the first frame and number of loops for the animation
c := frame_control_cmd
c.SetTargetFrame(uint64(frame.number))
c.SetGap(int32(frame.delay_ms))
switch {
case opts.Loop < 0:
c.SetNumberOfLoops(1)
case opts.Loop > 0:
c.SetNumberOfLoops(uint64(opts.Loop) + 1)
}
c.WriteWithPayloadTo(os.Stdout, nil)
case 1:
c := frame_control_cmd
c.SetAnimationControl(2) // set animation to loading mode
c.WriteWithPayloadTo(os.Stdout, nil)
}
}
}
if imgd.use_unicode_placeholder {
write_unicode_placeholder(imgd)
}
if is_animated {
c := frame_control_cmd
c.SetAnimationControl(3) // set animation to normal mode
c.WriteWithPayloadTo(os.Stdout, nil)
}
if imgd.move_to.x == 0 {
fmt.Println() // ensure cursor is on new line
}
}

View File

@@ -5,9 +5,9 @@ package main
import (
"os"
"kitty/kittens/ssh"
"kitty/tools/cli"
"kitty/tools/cmd/completion"
"kitty/tools/cmd/ssh"
"kitty/tools/cmd/tool"
)

View File

@@ -5,8 +5,8 @@ package pytest
import (
"fmt"
"kitty/kittens/ssh"
"kitty/tools/cli"
"kitty/tools/cmd/ssh"
"kitty/tools/utils/shm"
)

View File

@@ -1,107 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package ssh
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"kitty/tools/cli"
"kitty/tools/tty"
"kitty/tools/utils/shm"
)
var _ = fmt.Print
func fatal(err error) {
cli.ShowError(err)
os.Exit(1)
}
func trigger_ask(name string) {
term, err := tty.OpenControllingTerm()
if err != nil {
fatal(err)
}
defer term.Close()
_, err = term.WriteString("\x1bP@kitty-ask|" + name + "\x1b\\")
if err != nil {
fatal(err)
}
}
func RunSSHAskpass() {
msg := os.Args[len(os.Args)-1]
prompt := os.Getenv("SSH_ASKPASS_PROMPT")
is_confirm := prompt == "confirm"
q_type := "get_line"
if is_confirm {
q_type = "confirm"
}
is_fingerprint_check := strings.Contains(msg, "(yes/no/[fingerprint])")
q := map[string]any{
"message": msg,
"type": q_type,
"is_password": !is_fingerprint_check,
}
data, err := json.Marshal(q)
if err != nil {
fatal(err)
}
data_shm, err := shm.CreateTemp("askpass-*", uint64(len(data)+32))
if err != nil {
fatal(fmt.Errorf("Failed to create SHM file with error: %w", err))
}
defer data_shm.Close()
defer data_shm.Unlink()
data_shm.Slice()[0] = 0
shm.WriteWithSize(data_shm, data, 1)
err = data_shm.Flush()
if err != nil {
fatal(fmt.Errorf("Failed to flush SHM file with error: %w", err))
}
trigger_ask(data_shm.Name())
for {
time.Sleep(50 * time.Millisecond)
if data_shm.Slice()[0] == 1 {
break
}
}
data, err = shm.ReadWithSize(data_shm, 1)
if err != nil {
fatal(fmt.Errorf("Failed to read from SHM file with error: %w", err))
}
response := ""
if is_confirm {
var ok bool
err = json.Unmarshal(data, &ok)
if err != nil {
fatal(fmt.Errorf("Failed to parse response data: %#v with error: %w", string(data), err))
}
response = "no"
if ok {
response = "yes"
}
} else {
err = json.Unmarshal(data, &response)
if err != nil {
fatal(fmt.Errorf("Failed to parse response data: %#v with error: %w", string(data), err))
}
if is_fingerprint_check {
response = strings.ToLower(response)
if response == "y" {
response = "yes"
} else if response == "n" {
response = "no"
}
}
}
if response != "" {
fmt.Println(response)
}
}

View File

@@ -1,399 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package ssh
import (
"archive/tar"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"time"
"kitty/tools/config"
"kitty/tools/utils"
"kitty/tools/utils/paths"
"kitty/tools/utils/shlex"
"github.com/bmatcuk/doublestar"
"golang.org/x/sys/unix"
)
var _ = fmt.Print
type EnvInstruction struct {
key, val string
delete_on_remote, copy_from_local, literal_quote bool
}
func quote_for_sh(val string, literal_quote bool) string {
if literal_quote {
return utils.QuoteStringForSH(val)
}
// See https://www.gnu.org/software/bash/manual/html_node/Double-Quotes.html
b := strings.Builder{}
b.Grow(len(val) + 16)
b.WriteRune('"')
runes := []rune(val)
for i, ch := range runes {
if ch == '\\' || ch == '`' || ch == '"' || (ch == '$' && i+1 < len(runes) && runes[i+1] == '(') {
// special chars are escaped
// $( is escaped to prevent execution
b.WriteRune('\\')
}
b.WriteRune(ch)
}
b.WriteRune('"')
return b.String()
}
func (self *EnvInstruction) Serialize(for_python bool, get_local_env func(string) (string, bool)) string {
var unset func() string
var export func(string) string
if for_python {
dumps := func(x ...any) string {
ans, _ := json.Marshal(x)
return utils.UnsafeBytesToString(ans)
}
export = func(val string) string {
if val == "" {
return fmt.Sprintf("export %s", dumps(self.key))
}
return fmt.Sprintf("export %s", dumps(self.key, val, self.literal_quote))
}
unset = func() string {
return fmt.Sprintf("unset %s", dumps(self.key))
}
} else {
kq := utils.QuoteStringForSH(self.key)
unset = func() string {
return fmt.Sprintf("unset %s", kq)
}
export = func(val string) string {
return fmt.Sprintf("export %s=%s", kq, quote_for_sh(val, self.literal_quote))
}
}
if self.delete_on_remote {
return unset()
}
if self.copy_from_local {
val, found := get_local_env(self.key)
if !found {
return ""
}
return export(val)
}
return export(self.val)
}
func final_env_instructions(for_python bool, get_local_env func(string) (string, bool), env ...*EnvInstruction) string {
seen := make(map[string]int, len(env))
ans := make([]string, 0, len(env))
for _, ei := range env {
q := ei.Serialize(for_python, get_local_env)
if q != "" {
if pos, found := seen[ei.key]; found {
ans[pos] = q
} else {
seen[ei.key] = len(ans)
ans = append(ans, q)
}
}
}
return strings.Join(ans, "\n")
}
type CopyInstruction struct {
local_path, arcname string
exclude_patterns []string
}
func ParseEnvInstruction(spec string) (ans []*EnvInstruction, err error) {
const COPY_FROM_LOCAL string = "_kitty_copy_env_var_"
ei := &EnvInstruction{}
found := false
ei.key, ei.val, found = strings.Cut(spec, "=")
ei.key = strings.TrimSpace(ei.key)
if found {
ei.val = strings.TrimSpace(ei.val)
if ei.val == COPY_FROM_LOCAL {
ei.val = ""
ei.copy_from_local = true
}
} else {
ei.delete_on_remote = true
}
if ei.key == "" {
err = fmt.Errorf("The env directive must not be empty")
}
ans = []*EnvInstruction{ei}
return
}
var paths_ctx *paths.Ctx
func resolve_file_spec(spec string, is_glob bool) ([]string, error) {
if paths_ctx == nil {
paths_ctx = &paths.Ctx{}
}
ans := os.ExpandEnv(paths_ctx.ExpandHome(spec))
if !filepath.IsAbs(ans) {
ans = paths_ctx.AbspathFromHome(ans)
}
if is_glob {
files, err := doublestar.Glob(ans)
if err != nil {
return nil, fmt.Errorf("%s is not a valid glob pattern with error: %w", spec, err)
}
if len(files) == 0 {
return nil, fmt.Errorf("%s matches no files", spec)
}
return files, nil
}
err := unix.Access(ans, unix.R_OK)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("%s does not exist", spec)
}
return nil, fmt.Errorf("Cannot read from: %s with error: %w", spec, err)
}
return []string{ans}, nil
}
func get_arcname(loc, dest, home string) (arcname string) {
if dest != "" {
arcname = dest
} else {
arcname = filepath.Clean(loc)
if filepath.HasPrefix(arcname, home) {
ra, err := filepath.Rel(home, arcname)
if err == nil {
arcname = ra
}
}
}
prefix := "home/"
if strings.HasPrefix(arcname, "/") {
prefix = "root"
}
return prefix + arcname
}
func ParseCopyInstruction(spec string) (ans []*CopyInstruction, err error) {
args, err := shlex.Split("copy " + spec)
if err != nil {
return nil, err
}
opts, args, err := parse_copy_args(args)
if err != nil {
return nil, err
}
locations := make([]string, 0, len(args))
for _, arg := range args {
locs, err := resolve_file_spec(arg, opts.Glob)
if err != nil {
return nil, err
}
locations = append(locations, locs...)
}
if len(locations) == 0 {
return nil, fmt.Errorf("No files to copy specified")
}
if len(locations) > 1 && opts.Dest != "" {
return nil, fmt.Errorf("Specifying a remote location with more than one file is not supported")
}
home := paths_ctx.HomePath()
ans = make([]*CopyInstruction, 0, len(locations))
for _, loc := range locations {
ci := CopyInstruction{local_path: loc, exclude_patterns: opts.Exclude}
if opts.SymlinkStrategy != "preserve" {
ci.local_path, err = filepath.EvalSymlinks(loc)
if err != nil {
return nil, fmt.Errorf("Failed to resolve symlinks in %#v with error: %w", loc, err)
}
}
if opts.SymlinkStrategy == "resolve" {
ci.arcname = get_arcname(ci.local_path, opts.Dest, home)
} else {
ci.arcname = get_arcname(loc, opts.Dest, home)
}
ans = append(ans, &ci)
}
return
}
type file_unique_id struct {
dev, inode uint64
}
func excluded(pattern, path string) bool {
if !strings.ContainsRune(pattern, '/') {
path = filepath.Base(path)
}
if matched, err := doublestar.PathMatch(pattern, path); matched && err == nil {
return true
}
return false
}
func get_file_data(callback func(h *tar.Header, data []byte) error, seen map[file_unique_id]string, local_path, arcname string, exclude_patterns []string) error {
s, err := os.Lstat(local_path)
if err != nil {
return err
}
u, ok := s.Sys().(unix.Stat_t)
cb := func(h *tar.Header, data []byte, arcname string) error {
h.Name = arcname
if h.Typeflag == tar.TypeDir {
h.Name = strings.TrimRight(h.Name, "/") + "/"
}
h.Size = int64(len(data))
h.Mode = int64(s.Mode().Perm())
h.ModTime = s.ModTime()
h.Format = tar.FormatPAX
if ok {
h.AccessTime = time.Unix(0, u.Atim.Nano())
h.ChangeTime = time.Unix(0, u.Ctim.Nano())
}
return callback(h, data)
}
// we only copy regular files, directories and symlinks
switch s.Mode().Type() {
case fs.ModeSymlink:
target, err := os.Readlink(local_path)
if err != nil {
return err
}
err = cb(&tar.Header{
Typeflag: tar.TypeSymlink,
Linkname: target,
}, nil, arcname)
if err != nil {
return err
}
case fs.ModeDir:
local_path = filepath.Clean(local_path)
type entry struct {
path, arcname string
}
stack := []entry{{local_path, arcname}}
for len(stack) > 0 {
x := stack[0]
stack = stack[1:]
entries, err := os.ReadDir(x.path)
if err != nil {
if x.path == local_path {
return err
}
continue
}
err = cb(&tar.Header{Typeflag: tar.TypeDir}, nil, x.arcname)
if err != nil {
return err
}
for _, e := range entries {
entry_path := filepath.Join(x.path, e.Name())
aname := path.Join(x.arcname, e.Name())
ok := true
for _, pat := range exclude_patterns {
if excluded(pat, entry_path) {
ok = false
break
}
}
if !ok {
continue
}
if e.IsDir() {
stack = append(stack, entry{entry_path, aname})
} else {
err = get_file_data(callback, seen, entry_path, aname, exclude_patterns)
if err != nil {
return err
}
}
}
}
case 0: // Regular file
fid := file_unique_id{dev: uint64(u.Dev), inode: uint64(u.Ino)}
if prev, ok := seen[fid]; ok { // Hard link
err = cb(&tar.Header{Typeflag: tar.TypeLink, Linkname: prev}, nil, arcname)
if err != nil {
return err
}
}
seen[fid] = arcname
data, err := os.ReadFile(local_path)
if err != nil {
return err
}
err = cb(&tar.Header{Typeflag: tar.TypeReg}, data, arcname)
if err != nil {
return err
}
}
return nil
}
func (ci *CopyInstruction) get_file_data(callback func(h *tar.Header, data []byte) error, seen map[file_unique_id]string) (err error) {
ep := ci.exclude_patterns
for _, folder_name := range []string{"__pycache__", ".DS_Store"} {
ep = append(ep, "**/"+folder_name, "**/"+folder_name+"/**")
}
return get_file_data(callback, seen, ci.local_path, ci.arcname, ep)
}
type ConfigSet struct {
all_configs []*Config
}
func config_for_hostname(hostname_to_match, username_to_match string, cs *ConfigSet) *Config {
matcher := func(q *Config) bool {
for _, pat := range strings.Split(q.Hostname, " ") {
upat := "*"
if strings.Contains(pat, "@") {
upat, pat, _ = strings.Cut(pat, "@")
}
var host_matched, user_matched bool
if matched, err := filepath.Match(pat, hostname_to_match); matched && err == nil {
host_matched = true
}
if matched, err := filepath.Match(upat, username_to_match); matched && err == nil {
user_matched = true
}
if host_matched && user_matched {
return true
}
}
return false
}
for _, c := range utils.Reversed(cs.all_configs) {
if matcher(c) {
return c
}
}
return cs.all_configs[0]
}
func (self *ConfigSet) line_handler(key, val string) error {
c := self.all_configs[len(self.all_configs)-1]
if key == "hostname" {
c = NewConfig()
self.all_configs = append(self.all_configs, c)
}
return c.Parse(key, val)
}
func load_config(hostname_to_match string, username_to_match string, overrides []string, paths ...string) (*Config, []config.ConfigLine, error) {
ans := &ConfigSet{all_configs: []*Config{NewConfig()}}
p := config.ConfigParser{LineHandler: ans.line_handler}
err := p.LoadConfig("ssh.conf", paths, overrides)
if err != nil {
return nil, nil, err
}
return config_for_hostname(hostname_to_match, username_to_match, ans), p.BadLines(), nil
}

View File

@@ -1,110 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package ssh
import (
"fmt"
"kitty/tools/utils"
"os"
"path/filepath"
"testing"
"github.com/google/go-cmp/cmp"
)
var _ = fmt.Print
func TestSSHConfigParsing(t *testing.T) {
tdir := t.TempDir()
hostname := "unmatched"
username := ""
conf := ""
for_python := false
cf := filepath.Join(tdir, "ssh.conf")
rt := func(expected_env ...string) {
os.WriteFile(cf, []byte(conf), 0o600)
c, bad_lines, err := load_config(hostname, username, nil, cf)
if err != nil {
t.Fatal(err)
}
if len(bad_lines) != 0 {
t.Fatalf("Bad config line: %s with error: %s", bad_lines[0].Line, bad_lines[0].Err)
}
actual := final_env_instructions(for_python, func(key string) (string, bool) {
if key == "LOCAL_ENV" {
return "LOCAL_VAL", true
}
return "", false
}, c.Env...)
if expected_env == nil {
expected_env = []string{}
}
diff := cmp.Diff(expected_env, utils.Splitlines(actual))
if diff != "" {
t.Fatalf("Unexpected env for\nhostname: %#v\nusername: %#v\nconf: %s\n%s", hostname, username, conf, diff)
}
}
rt()
conf = "env a=b"
rt(`export 'a'="b"`)
conf = "env a=b\nhostname 2\nenv a=c\nenv b=b"
rt(`export 'a'="b"`)
hostname = "2"
rt(`export 'a'="c"`, `export 'b'="b"`)
conf = "env a="
rt(`export 'a'=""`)
conf = "env a"
rt(`unset 'a'`)
conf = "env a=b\nhostname test@2\nenv a=c\nenv b=b"
hostname = "unmatched"
rt(`export 'a'="b"`)
hostname = "2"
rt(`export 'a'="b"`)
username = "test"
rt(`export 'a'="c"`, `export 'b'="b"`)
conf = "env a=b\nhostname 1 2\nenv a=c\nenv b=b"
username = ""
hostname = "unmatched"
rt(`export 'a'="b"`)
hostname = "1"
rt(`export 'a'="c"`, `export 'b'="b"`)
hostname = "2"
rt(`export 'a'="c"`, `export 'b'="b"`)
for_python = true
rt(`export ["a","c",false]`, `export ["b","b",false]`)
conf = "env a="
rt(`export ["a"]`)
conf = "env a"
rt(`unset ["a"]`)
conf = "env LOCAL_ENV=_kitty_copy_env_var_"
rt(`export ["LOCAL_ENV","LOCAL_VAL",false]`)
ci, err := ParseCopyInstruction("--exclude moose --dest=target " + cf)
if err != nil {
t.Fatal(err)
}
diff := cmp.Diff("home/target", ci[0].arcname)
if diff != "" {
t.Fatalf("Incorrect arcname:\n%s", diff)
}
diff = cmp.Diff(cf, ci[0].local_path)
if diff != "" {
t.Fatalf("Incorrect local_path:\n%s", diff)
}
diff = cmp.Diff([]string{"moose"}, ci[0].exclude_patterns)
if diff != "" {
t.Fatalf("Incorrect excludes:\n%s", diff)
}
ci, err = ParseCopyInstruction("--glob " + filepath.Join(filepath.Dir(cf), "*.conf"))
if err != nil {
t.Fatal(err)
}
diff = cmp.Diff(cf, ci[0].local_path)
if diff != "" {
t.Fatalf("Incorrect local_path:\n%s", diff)
}
if len(ci) != 1 {
t.Fatal(ci)
}
}

View File

@@ -1,69 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package ssh
import (
"archive/tar"
_ "embed"
"errors"
"fmt"
"io"
"kitty/tools/utils"
"regexp"
"strings"
)
var _ = fmt.Print
//go:embed data_generated.bin
var embedded_data string
type Entry struct {
metadata *tar.Header
data []byte
}
type Container map[string]Entry
var Data = (&utils.Once[Container]{Run: func() Container {
tr := tar.NewReader(utils.ReaderForCompressedEmbeddedData(embedded_data))
ans := make(Container, 64)
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
panic(err)
}
data, err := utils.ReadAll(tr, int(hdr.Size))
if err != nil {
panic(err)
}
ans[hdr.Name] = Entry{hdr, data}
}
return ans
}}).Get
func (self Container) files_matching(prefix string, exclude_patterns ...string) []string {
ans := make([]string, 0, len(self))
patterns := make([]*regexp.Regexp, len(exclude_patterns))
for i, exp := range exclude_patterns {
patterns[i] = regexp.MustCompile(exp)
}
for name := range self {
if strings.HasPrefix(name, prefix) {
excluded := false
for _, pat := range patterns {
if matched := pat.FindString(name); matched != "" {
excluded = true
break
}
}
if !excluded {
ans = append(ans, name)
}
}
}
return ans
}

View File

@@ -1,800 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package ssh
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"kitty"
"net/url"
"os"
"os/exec"
"os/user"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"kitty/tools/cli"
"kitty/tools/themes"
"kitty/tools/tty"
"kitty/tools/tui"
"kitty/tools/tui/loop"
"kitty/tools/utils"
"kitty/tools/utils/secrets"
"kitty/tools/utils/shm"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
"golang.org/x/sys/unix"
)
var _ = fmt.Print
func get_destination(hostname string) (username, hostname_for_match string) {
u, err := user.Current()
if err == nil {
username = u.Username
}
hostname_for_match = hostname
if strings.HasPrefix(hostname, "ssh://") {
p, err := url.Parse(hostname)
if err == nil {
hostname_for_match = p.Hostname()
if p.User.Username() != "" {
username = p.User.Username()
}
}
} else if strings.Contains(hostname, "@") && hostname[0] != '@' {
username, hostname_for_match, _ = strings.Cut(hostname, "@")
}
if strings.Contains(hostname, "@") && hostname[0] != '@' {
_, hostname_for_match, _ = strings.Cut(hostname_for_match, "@")
}
hostname_for_match, _, _ = strings.Cut(hostname_for_match, ":")
return
}
func read_data_from_shared_memory(shm_name string) ([]byte, error) {
data, err := shm.ReadWithSizeAndUnlink(shm_name, func(s fs.FileInfo) error {
if stat, ok := s.Sys().(unix.Stat_t); ok {
if os.Getuid() != int(stat.Uid) || os.Getgid() != int(stat.Gid) {
return fmt.Errorf("Incorrect owner on SHM file")
}
}
if s.Mode().Perm() != 0o600 {
return fmt.Errorf("Incorrect permissions on SHM file")
}
return nil
})
return data, err
}
func add_cloned_env(val string) (ans map[string]string, err error) {
data, err := read_data_from_shared_memory(val)
if err != nil {
return nil, err
}
err = json.Unmarshal(data, &ans)
return ans, err
}
func parse_kitten_args(found_extra_args []string, username, hostname_for_match string) (overrides []string, literal_env map[string]string, ferr error) {
literal_env = make(map[string]string)
overrides = make([]string, 0, 4)
for i, a := range found_extra_args {
if i%2 == 0 {
continue
}
if key, val, found := strings.Cut(a, "="); found {
if key == "clone_env" {
le, err := add_cloned_env(val)
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return nil, nil, ferr
}
} else if le != nil {
literal_env = le
}
} else if key != "hostname" {
overrides = append(overrides, key+"="+val)
}
}
}
if len(overrides) > 0 {
overrides = append([]string{"hostname " + username + "@" + hostname_for_match}, overrides...)
}
return
}
func connection_sharing_args(kitty_pid int) ([]string, error) {
rd := utils.RuntimeDir()
// Bloody OpenSSH generates a 40 char hash and in creating the socket
// appends a 27 char temp suffix to it. Socket max path length is approx
// ~104 chars. And on idiotic Apple the path length to the runtime dir
// (technically the cache dir since Apple has no runtime dir and thinks it's
// a great idea to delete files in /tmp) is ~48 chars.
if len(rd) > 35 {
idiotic_design := fmt.Sprintf("/tmp/kssh-rdir-%d", os.Geteuid())
if err := utils.AtomicCreateSymlink(rd, idiotic_design); err != nil {
return nil, err
}
rd = idiotic_design
}
cp := strings.Replace(kitty.SSHControlMasterTemplate, "{kitty_pid}", strconv.Itoa(kitty_pid), 1)
cp = strings.Replace(cp, "{ssh_placeholder}", "%C", 1)
return []string{
"-o", "ControlMaster=auto",
"-o", "ControlPath=" + filepath.Join(rd, cp),
"-o", "ControlPersist=yes",
"-o", "ServerAliveInterval=60",
"-o", "ServerAliveCountMax=5",
"-o", "TCPKeepAlive=no",
}, nil
}
func set_askpass() (need_to_request_data bool) {
need_to_request_data = true
sentinel := filepath.Join(utils.CacheDir(), "openssh-is-new-enough-for-askpass")
_, err := os.Stat(sentinel)
sentinel_exists := err == nil
if sentinel_exists || GetSSHVersion().SupportsAskpassRequire() {
if !sentinel_exists {
os.WriteFile(sentinel, []byte{0}, 0o644)
}
need_to_request_data = false
}
exe, err := os.Executable()
if err == nil {
os.Setenv("SSH_ASKPASS", exe)
os.Setenv("KITTY_KITTEN_RUN_MODULE", "ssh_askpass")
if !need_to_request_data {
os.Setenv("SSH_ASKPASS_REQUIRE", "force")
}
} else {
need_to_request_data = true
}
return
}
type connection_data struct {
remote_args []string
host_opts *Config
hostname_for_match string
username string
echo_on bool
request_data bool
literal_env map[string]string
test_script string
dont_create_shm bool
shm_name string
script_type string
rcmd []string
replacements map[string]string
request_id string
bootstrap_script string
}
func get_effective_ksi_env_var(x string) string {
parts := strings.Split(strings.TrimSpace(strings.ToLower(x)), " ")
current := utils.NewSetWithItems(parts...)
if current.Has("disabled") {
return ""
}
allowed := utils.NewSetWithItems(kitty.AllowedShellIntegrationValues...)
if !current.IsSubsetOf(allowed) {
return RelevantKittyOpts().Shell_integration
}
return x
}
func serialize_env(cd *connection_data, get_local_env func(string) (string, bool)) (string, string) {
ksi := ""
if cd.host_opts.Shell_integration == "inherited" {
ksi = get_effective_ksi_env_var(RelevantKittyOpts().Shell_integration)
} else {
ksi = get_effective_ksi_env_var(cd.host_opts.Shell_integration)
}
env := make([]*EnvInstruction, 0, 8)
add_env := func(key, val string, fallback ...string) *EnvInstruction {
if val == "" && len(fallback) > 0 {
val = fallback[0]
}
if val != "" {
env = append(env, &EnvInstruction{key: key, val: val, literal_quote: true})
return env[len(env)-1]
}
return nil
}
add_non_literal_env := func(key, val string, fallback ...string) *EnvInstruction {
ans := add_env(key, val, fallback...)
if ans != nil {
ans.literal_quote = false
}
return ans
}
for k, v := range cd.literal_env {
add_env(k, v)
}
add_env("TERM", os.Getenv("TERM"), RelevantKittyOpts().Term)
add_env("COLORTERM", "truecolor")
env = append(env, cd.host_opts.Env...)
add_env("KITTY_WINDOW_ID", os.Getenv("KITTY_WINDOW_ID"))
add_env("WINDOWID", os.Getenv("WINDOWID"))
if ksi != "" {
add_env("KITTY_SHELL_INTEGRATION", ksi)
} else {
env = append(env, &EnvInstruction{key: "KITTY_SHELL_INTEGRATION", delete_on_remote: true})
}
add_non_literal_env("KITTY_SSH_KITTEN_DATA_DIR", cd.host_opts.Remote_dir)
add_non_literal_env("KITTY_LOGIN_SHELL", cd.host_opts.Login_shell)
add_non_literal_env("KITTY_LOGIN_CWD", cd.host_opts.Cwd)
if cd.host_opts.Remote_kitty != Remote_kitty_no {
add_env("KITTY_REMOTE", cd.host_opts.Remote_kitty.String())
}
add_env("KITTY_PUBLIC_KEY", os.Getenv("KITTY_PUBLIC_KEY"))
return final_env_instructions(cd.script_type == "py", get_local_env, env...), ksi
}
func make_tarfile(cd *connection_data, get_local_env func(string) (string, bool)) ([]byte, error) {
env_script, ksi := serialize_env(cd, get_local_env)
w := bytes.Buffer{}
w.Grow(64 * 1024)
gw, err := gzip.NewWriterLevel(&w, gzip.BestCompression)
if err != nil {
return nil, err
}
tw := tar.NewWriter(gw)
rd := strings.TrimRight(cd.host_opts.Remote_dir, "/")
seen := make(map[file_unique_id]string, 32)
add := func(h *tar.Header, data []byte) (err error) {
// some distro's like nix mess with installed file permissions so ensure
// files are at least readable and writable by owning user
h.Mode |= 0o600
err = tw.WriteHeader(h)
if err != nil {
return
}
if data != nil {
_, err := tw.Write(data)
if err != nil {
return err
}
}
return
}
for _, ci := range cd.host_opts.Copy {
err = ci.get_file_data(add, seen)
if err != nil {
return nil, err
}
}
type fe struct {
arcname string
data []byte
}
now := time.Now()
add_data := func(items ...fe) error {
for _, item := range items {
err := add(
&tar.Header{
Typeflag: tar.TypeReg, Name: item.arcname, Format: tar.FormatPAX, Size: int64(len(item.data)),
Mode: 0o644, ModTime: now, ChangeTime: now, AccessTime: now,
}, item.data)
if err != nil {
return err
}
}
return nil
}
add_entries := func(prefix string, items ...Entry) error {
for _, item := range items {
err := add(
&tar.Header{
Typeflag: item.metadata.Typeflag, Name: path.Join(prefix, path.Base(item.metadata.Name)), Format: tar.FormatPAX,
Size: int64(len(item.data)), Mode: item.metadata.Mode, ModTime: item.metadata.ModTime,
AccessTime: item.metadata.AccessTime, ChangeTime: item.metadata.ChangeTime,
}, item.data)
if err != nil {
return err
}
}
return nil
}
add_data(fe{"data.sh", utils.UnsafeStringToBytes(env_script)})
if cd.script_type == "sh" {
add_data(fe{"bootstrap-utils.sh", Data()[path.Join("shell-integration/ssh/bootstrap-utils.sh")].data})
}
if ksi != "" {
for _, fname := range Data().files_matching(
"shell-integration/",
"shell-integration/ssh/.+", // bootstrap files are sent as command line args
"shell-integration/zsh/kitty.zsh", // backward compat file not needed by ssh kitten
) {
arcname := path.Join("home/", rd, "/", path.Dir(fname))
err = add_entries(arcname, Data()[fname])
if err != nil {
return nil, err
}
}
}
if cd.host_opts.Remote_kitty != Remote_kitty_no {
arcname := path.Join("home/", rd, "/kitty")
err = add_data(fe{arcname + "/version", utils.UnsafeStringToBytes(kitty.VersionString)})
if err != nil {
return nil, err
}
for _, x := range []string{"kitty", "kitten"} {
err = add_entries(path.Join(arcname, "bin"), Data()[path.Join("shell-integration", "ssh", x)])
if err != nil {
return nil, err
}
}
}
err = add_entries(path.Join("home", ".terminfo"), Data()["terminfo/kitty.terminfo"])
if err == nil {
err = add_entries(path.Join("home", ".terminfo", "x"), Data()["terminfo/x/xterm-kitty"])
}
if err == nil {
err = tw.Close()
if err == nil {
err = gw.Close()
}
}
return w.Bytes(), err
}
func prepare_home_command(cd *connection_data) string {
is_python := cd.script_type == "py"
homevar := ""
for _, ei := range cd.host_opts.Env {
if ei.key == "HOME" && !ei.delete_on_remote {
if ei.copy_from_local {
homevar = os.Getenv("HOME")
} else {
homevar = ei.val
}
}
}
export_home_cmd := ""
if homevar != "" {
if is_python {
export_home_cmd = base64.StdEncoding.EncodeToString(utils.UnsafeStringToBytes(homevar))
} else {
export_home_cmd = fmt.Sprintf("export HOME=%s; cd \"$HOME\"", utils.QuoteStringForSH(homevar))
}
}
return export_home_cmd
}
func prepare_exec_cmd(cd *connection_data) string {
// ssh simply concatenates multiple commands using a space see
// line 1129 of ssh.c and on the remote side sshd.c runs the
// concatenated command as shell -c cmd
if cd.script_type == "py" {
return base64.RawStdEncoding.EncodeToString(utils.UnsafeStringToBytes(strings.Join(cd.remote_args, " ")))
}
args := make([]string, len(cd.remote_args))
for i, arg := range cd.remote_args {
args[i] = strings.ReplaceAll(arg, "'", "'\"'\"'")
}
return "unset KITTY_SHELL_INTEGRATION; exec \"$login_shell\" -c '" + strings.Join(args, " ") + "'"
}
var data_shm shm.MMap
func prepare_script(script string, replacements map[string]string) string {
if _, found := replacements["EXEC_CMD"]; !found {
replacements["EXEC_CMD"] = ""
}
if _, found := replacements["EXPORT_HOME_CMD"]; !found {
replacements["EXPORT_HOME_CMD"] = ""
}
keys := maps.Keys(replacements)
for i, key := range keys {
keys[i] = "\\b" + key + "\\b"
}
pat := regexp.MustCompile(strings.Join(keys, "|"))
return pat.ReplaceAllStringFunc(script, func(key string) string { return replacements[key] })
}
func bootstrap_script(cd *connection_data) (err error) {
if cd.request_id == "" {
cd.request_id = os.Getenv("KITTY_PID") + "-" + os.Getenv("KITTY_WINDOW_ID")
}
export_home_cmd := prepare_home_command(cd)
exec_cmd := ""
if len(cd.remote_args) > 0 {
exec_cmd = prepare_exec_cmd(cd)
}
pw, err := secrets.TokenHex()
if err != nil {
return err
}
tfd, err := make_tarfile(cd, os.LookupEnv)
if err != nil {
return err
}
data := map[string]string{
"tarfile": base64.StdEncoding.EncodeToString(tfd),
"pw": pw,
"hostname": cd.hostname_for_match, "username": cd.username,
}
encoded_data, err := json.Marshal(data)
if err == nil && !cd.dont_create_shm {
data_shm, err = shm.CreateTemp(fmt.Sprintf("kssh-%d-", os.Getpid()), uint64(len(encoded_data)+8))
if err == nil {
err = shm.WriteWithSize(data_shm, encoded_data, 0)
if err == nil {
err = data_shm.Flush()
}
}
}
if err != nil {
return err
}
if !cd.dont_create_shm {
cd.shm_name = data_shm.Name()
}
sensitive_data := map[string]string{"REQUEST_ID": cd.request_id, "DATA_PASSWORD": pw, "PASSWORD_FILENAME": cd.shm_name}
replacements := map[string]string{
"EXPORT_HOME_CMD": export_home_cmd,
"EXEC_CMD": exec_cmd,
"TEST_SCRIPT": cd.test_script,
}
add_bool := func(ok bool, key string) {
if ok {
replacements[key] = "1"
} else {
replacements[key] = "0"
}
}
add_bool(cd.request_data, "REQUEST_DATA")
add_bool(cd.echo_on, "ECHO_ON")
sd := maps.Clone(replacements)
if cd.request_data {
maps.Copy(sd, sensitive_data)
}
maps.Copy(replacements, sensitive_data)
cd.replacements = replacements
cd.bootstrap_script = utils.UnsafeBytesToString(Data()["shell-integration/ssh/bootstrap."+cd.script_type].data)
cd.bootstrap_script = prepare_script(cd.bootstrap_script, sd)
return err
}
func wrap_bootstrap_script(cd *connection_data) {
// sshd will execute the command we pass it by join all command line
// arguments with a space and passing it as a single argument to the users
// login shell with -c. If the user has a non POSIX login shell it might
// have different escaping semantics and syntax, so the command it should
// execute has to be as simple as possible, basically of the form
// interpreter -c unwrap_script escaped_bootstrap_script
// The unwrap_script is responsible for unescaping the bootstrap script and
// executing it.
encoded_script := ""
unwrap_script := ""
if cd.script_type == "py" {
encoded_script = base64.StdEncoding.EncodeToString(utils.UnsafeStringToBytes(cd.bootstrap_script))
unwrap_script = `"import base64, sys; eval(compile(base64.standard_b64decode(sys.argv[-1]), 'bootstrap.py', 'exec'))"`
} else {
// We cant rely on base64 being available on the remote system, so instead
// we quote the bootstrap script by replacing ' and \ with \v and \f
// also replacing \n and ! with \r and \b for tcsh
// finally surrounding with '
encoded_script = "'" + strings.NewReplacer("'", "\v", "\\", "\f", "\n", "\r", "!", "\b").Replace(cd.bootstrap_script) + "'"
unwrap_script = `'eval "$(echo "$0" | tr \\\v\\\f\\\r\\\b \\\047\\\134\\\n\\\041)"' `
}
cd.rcmd = []string{"exec", cd.host_opts.Interpreter, "-c", unwrap_script, encoded_script}
}
func get_remote_command(cd *connection_data) error {
interpreter := cd.host_opts.Interpreter
q := strings.ToLower(path.Base(interpreter))
is_python := strings.Contains(q, "python")
cd.script_type = "sh"
if is_python {
cd.script_type = "py"
}
err := bootstrap_script(cd)
if err != nil {
return err
}
wrap_bootstrap_script(cd)
return nil
}
func drain_potential_tty_garbage(term *tty.Term) {
err := term.ApplyOperations(tty.TCSANOW, tty.SetNoEcho)
if err != nil {
return
}
canary, err := secrets.TokenBase64()
if err != nil {
return
}
dcs, err := tui.DCSToKitty("echo", canary+"\n\r")
if err != nil {
return
}
err = term.WriteAllString(dcs)
if err != nil {
return
}
q := utils.UnsafeStringToBytes(canary)
data := make([]byte, 0)
give_up_at := time.Now().Add(2 * time.Second)
buf := make([]byte, 0, 8192)
for !bytes.Contains(data, q) {
buf = buf[:cap(buf)]
timeout := give_up_at.Sub(time.Now())
if timeout < 0 {
break
}
n, err := term.ReadWithTimeout(buf, timeout)
if err != nil {
return
}
data = append(data, buf[:n]...)
}
}
func change_colors(color_scheme string) (ans string, err error) {
if color_scheme == "" {
return
}
var theme *themes.Theme
if !strings.HasSuffix(color_scheme, ".conf") {
cs := os.ExpandEnv(color_scheme)
tc, closer, err := themes.LoadThemes(-1)
if err != nil && errors.Is(err, themes.ErrNoCacheFound) {
tc, closer, err = themes.LoadThemes(time.Hour * 24)
}
if err != nil {
return "", err
}
defer closer.Close()
theme = tc.ThemeByName(cs)
if theme == nil {
return "", fmt.Errorf("No theme named %#v found", cs)
}
} else {
theme, err = themes.ThemeFromFile(utils.ResolveConfPath(color_scheme))
if err != nil {
return "", err
}
}
ans, err = theme.AsEscapeCodes()
if err == nil {
ans = "\033[#P" + ans
}
return
}
func run_ssh(ssh_args, server_args, found_extra_args []string) (rc int, err error) {
go Data()
go RelevantKittyOpts()
defer func() {
if data_shm != nil {
data_shm.Close()
data_shm.Unlink()
}
}()
cmd := append([]string{SSHExe()}, ssh_args...)
cd := connection_data{remote_args: server_args[1:]}
hostname := server_args[0]
if len(cd.remote_args) == 0 {
cmd = append(cmd, "-t")
}
insertion_point := len(cmd)
cmd = append(cmd, "--", hostname)
uname, hostname_for_match := get_destination(hostname)
overrides, literal_env, err := parse_kitten_args(found_extra_args, uname, hostname_for_match)
if err != nil {
return 1, err
}
host_opts, bad_lines, err := load_config(hostname_for_match, uname, overrides)
if err != nil {
return 1, err
}
if len(bad_lines) > 0 {
for _, x := range bad_lines {
fmt.Fprintf(os.Stderr, "Ignoring bad config line: %s:%d with error: %s", filepath.Base(x.Src_file), x.Line_number, x.Err)
}
}
if host_opts.Share_connections {
kpid, err := strconv.Atoi(os.Getenv("KITTY_PID"))
if err != nil {
return 1, fmt.Errorf("Invalid KITTY_PID env var not an integer: %#v", os.Getenv("KITTY_PID"))
}
cpargs, err := connection_sharing_args(kpid)
if err != nil {
return 1, err
}
cmd = slices.Insert(cmd, insertion_point, cpargs...)
}
use_kitty_askpass := host_opts.Askpass == Askpass_native || (host_opts.Askpass == Askpass_unless_set && os.Getenv("SSH_ASKPASS") == "")
need_to_request_data := true
if use_kitty_askpass {
need_to_request_data = set_askpass()
}
if need_to_request_data && host_opts.Share_connections {
check_cmd := slices.Insert(cmd, 1, "-O", "check")
err = exec.Command(check_cmd[0], check_cmd[1:]...).Run()
if err == nil {
need_to_request_data = false
}
}
term, err := tty.OpenControllingTerm(tty.SetNoEcho)
if err != nil {
return 1, fmt.Errorf("Failed to open controlling terminal with error: %w", err)
}
cd.echo_on = term.WasEchoOnOriginally()
cd.host_opts, cd.literal_env = host_opts, literal_env
cd.request_data = need_to_request_data
cd.hostname_for_match, cd.username = hostname_for_match, uname
escape_codes_to_set_colors, err := change_colors(cd.host_opts.Color_scheme)
if err == nil {
err = term.WriteAllString(escape_codes_to_set_colors + loop.SAVE_PRIVATE_MODE_VALUES + loop.HANDLE_TERMIOS_SIGNALS.EscapeCodeToSet())
}
if err != nil {
return 1, err
}
restore_escape_codes := loop.RESTORE_PRIVATE_MODE_VALUES
if escape_codes_to_set_colors != "" {
restore_escape_codes += "\x1b[#Q"
}
defer func() {
term.WriteAllString(restore_escape_codes)
term.RestoreAndClose()
}()
err = get_remote_command(&cd)
if err != nil {
return 1, err
}
cmd = append(cmd, cd.rcmd...)
c := exec.Command(cmd[0], cmd[1:]...)
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
err = c.Start()
if err != nil {
return 1, err
}
if !cd.request_data {
rq := fmt.Sprintf("id=%s:pwfile=%s:pw=%s", cd.replacements["REQUEST_ID"], cd.replacements["PASSWORD_FILENAME"], cd.replacements["DATA_PASSWORD"])
err := term.ApplyOperations(tty.TCSANOW, tty.SetNoEcho)
if err == nil {
var dcs string
dcs, err = tui.DCSToKitty("ssh", rq)
if err == nil {
err = term.WriteAllString(dcs)
}
}
if err != nil {
c.Process.Kill()
c.Wait()
return 1, err
}
}
err = c.Wait()
drain_potential_tty_garbage(term)
if err != nil {
var exit_err *exec.ExitError
if errors.As(err, &exit_err) {
return exit_err.ExitCode(), nil
}
return 1, err
}
return 0, nil
}
func main(cmd *cli.Command, o *Options, args []string) (rc int, err error) {
if len(args) > 0 {
switch args[0] {
case "use-python":
args = args[1:] // backwards compat from when we had a python implementation
case "-h", "--help":
cmd.ShowHelp()
return
}
}
ssh_args, server_args, passthrough, found_extra_args, err := ParseSSHArgs(args, "--kitten")
if err != nil {
var invargs *ErrInvalidSSHArgs
switch {
case errors.As(err, &invargs):
if invargs.Msg != "" {
fmt.Fprintln(os.Stderr, invargs.Msg)
}
return 1, unix.Exec(SSHExe(), []string{"ssh"}, os.Environ())
}
return 1, err
}
if passthrough {
if len(found_extra_args) > 0 {
return 1, fmt.Errorf("The SSH kitten cannot work with the options: %s", strings.Join(maps.Keys(PassthroughArgs()), " "))
}
return 1, unix.Exec(SSHExe(), append([]string{"ssh"}, args...), os.Environ())
}
if os.Getenv("KITTY_WINDOW_ID") == "" || os.Getenv("KITTY_PID") == "" {
return 1, fmt.Errorf("The SSH kitten is meant to run inside a kitty window")
}
if !tty.IsTerminal(os.Stdin.Fd()) {
return 1, fmt.Errorf("The SSH kitten is meant for interactive use only, STDIN must be a terminal")
}
return run_ssh(ssh_args, server_args, found_extra_args)
}
func EntryPoint(parent *cli.Command) {
create_cmd(parent, main)
}
func specialize_command(ssh *cli.Command) {
ssh.Usage = "arguments for the ssh command"
ssh.ShortDescription = "Truly convenient SSH"
ssh.HelpText = "The ssh kitten is a thin wrapper around the ssh command. It automatically enables shell integration on the remote host, re-uses existing connections to reduce latency, makes the kitty terminfo database available, etc. It's invocation is identical to the ssh command. For details on its usage, see :doc:`/kittens/ssh`."
ssh.IgnoreAllArgs = true
ssh.OnlyArgsAllowed = true
ssh.ArgCompleter = cli.CompletionForWrapper("ssh")
}
func test_integration_with_python(args []string) (rc int, err error) {
f, err := os.CreateTemp("", "*.conf")
if err != nil {
return 1, err
}
defer func() {
f.Close()
os.Remove(f.Name())
}()
_, err = io.Copy(f, os.Stdin)
if err != nil {
return 1, err
}
cd := &connection_data{
request_id: "testing", remote_args: []string{},
username: "testuser", hostname_for_match: "host.test", request_data: true,
test_script: args[0], echo_on: true,
}
opts, bad_lines, err := load_config(cd.hostname_for_match, cd.username, nil, f.Name())
if err == nil {
if len(bad_lines) > 0 {
return 1, fmt.Errorf("Bad config lines: %s with error: %s", bad_lines[0].Line, bad_lines[0].Err)
}
cd.host_opts = opts
err = get_remote_command(cd)
}
if err != nil {
return 1, err
}
data, err := json.Marshal(map[string]any{"cmd": cd.rcmd, "shm_name": cd.shm_name})
if err == nil {
_, err = os.Stdout.Write(data)
os.Stdout.Close()
}
if err != nil {
return 1, err
}
return
}
func TestEntryPoint(root *cli.Command) {
root.AddSubCommand(&cli.Command{
Name: "ssh",
OnlyArgsAllowed: true,
Run: func(cmd *cli.Command, args []string) (rc int, err error) {
return test_integration_with_python(args)
},
})
}

View File

@@ -1,155 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package ssh
import (
"encoding/binary"
"encoding/json"
"fmt"
"io/fs"
"kitty/tools/utils/shm"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"golang.org/x/sys/unix"
)
var _ = fmt.Print
func TestCloneEnv(t *testing.T) {
env := map[string]string{"a": "1", "b": "2"}
data, err := json.Marshal(env)
if err != nil {
t.Fatal(err)
}
mmap, err := shm.CreateTemp("", 128)
if err != nil {
t.Fatal(err)
}
defer mmap.Unlink()
copy(mmap.Slice()[4:], data)
binary.BigEndian.PutUint32(mmap.Slice(), uint32(len(data)))
mmap.Close()
x, err := add_cloned_env(mmap.Name())
if err != nil {
t.Fatal(err)
}
diff := cmp.Diff(env, x)
if diff != "" {
t.Fatalf("Failed to deserialize env\n%s", diff)
}
}
func basic_connection_data(overrides ...string) *connection_data {
ans := &connection_data{
script_type: "sh", request_id: "123-123", remote_args: []string{},
username: "testuser", hostname_for_match: "host.test",
dont_create_shm: true,
}
opts, bad_lines, err := load_config(ans.hostname_for_match, ans.username, overrides)
if err != nil {
panic(err)
}
if len(bad_lines) != 0 {
panic(fmt.Sprintf("Bad config lines: %s with error: %s", bad_lines[0].Line, bad_lines[0].Err))
}
ans.host_opts = opts
return ans
}
func TestSSHBootstrapScriptLimit(t *testing.T) {
cd := basic_connection_data()
err := get_remote_command(cd)
if err != nil {
t.Fatal(err)
}
total := 0
for _, x := range cd.rcmd {
total += len(x)
}
if total > 9000 {
t.Fatalf("Bootstrap script too large: %d bytes", total)
}
}
func TestSSHTarfile(t *testing.T) {
tdir := t.TempDir()
cd := basic_connection_data()
data, err := make_tarfile(cd, func(key string) (val string, found bool) { return })
if err != nil {
t.Fatal(err)
}
cmd := exec.Command("tar", "xpzf", "-", "-C", tdir)
cmd.Stderr = os.Stderr
inp, err := cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
err = cmd.Start()
if err != nil {
t.Fatal(err)
}
_, err = inp.Write(data)
if err != nil {
t.Fatal(err)
}
inp.Close()
err = cmd.Wait()
if err != nil {
t.Fatal(err)
}
seen := map[string]bool{}
err = filepath.WalkDir(tdir, func(name string, d fs.DirEntry, werr error) error {
if werr != nil {
return werr
}
rname, werr := filepath.Rel(tdir, name)
if werr != nil {
return werr
}
rname = strings.ReplaceAll(rname, "\\", "/")
if rname == "." {
return nil
}
fi, werr := d.Info()
if werr != nil {
return werr
}
if fi.Mode().Perm()&0o600 == 0 {
return fmt.Errorf("%s is not rw for its owner. Actual permissions: %s", rname, fi.Mode().String())
}
seen[rname] = true
return nil
})
if err != nil {
t.Fatal(err)
}
if !seen["data.sh"] {
t.Fatalf("data.sh missing")
}
for _, x := range []string{".terminfo/kitty.terminfo", ".terminfo/x/xterm-kitty"} {
if !seen["home/"+x] {
t.Fatalf("%s missing", x)
}
}
for _, x := range []string{"shell-integration/bash/kitty.bash", "shell-integration/fish/vendor_completions.d/kitty.fish"} {
if !seen[path.Join("home", cd.host_opts.Remote_dir, x)] {
t.Fatalf("%s missing", x)
}
}
for _, x := range []string{"kitty", "kitten"} {
p := filepath.Join(tdir, "home", cd.host_opts.Remote_dir, "kitty", "bin", x)
if err = unix.Access(p, unix.X_OK); err != nil {
t.Fatalf("Cannot execute %s with error: %s", x, err)
}
}
if seen[path.Join("home", cd.host_opts.Remote_dir, "shell-integration", "ssh", "kitten")] {
t.Fatalf("Contents of shell-integration/ssh not excluded")
}
}

View File

@@ -1,240 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package ssh
import (
"fmt"
"io"
"kitty"
"kitty/tools/config"
"kitty/tools/utils"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
)
var _ = fmt.Print
var SSHExe = (&utils.Once[string]{Run: func() string {
return utils.FindExe("ssh")
}}).Get
var SSHOptions = (&utils.Once[map[string]string]{Run: func() (ssh_options map[string]string) {
defer func() {
if ssh_options == nil {
ssh_options = map[string]string{
"4": "", "6": "", "A": "", "a": "", "C": "", "f": "", "G": "", "g": "", "K": "", "k": "",
"M": "", "N": "", "n": "", "q": "", "s": "", "T": "", "t": "", "V": "", "v": "", "X": "",
"x": "", "Y": "", "y": "", "B": "bind_interface", "b": "bind_address", "c": "cipher_spec",
"D": "[bind_address:]port", "E": "log_file", "e": "escape_char", "F": "configfile", "I": "pkcs11",
"i": "identity_file", "J": "[user@]host[:port]", "L": "address", "l": "login_name", "m": "mac_spec",
"O": "ctl_cmd", "o": "option", "p": "port", "Q": "query_option", "R": "address",
"S": "ctl_path", "W": "host:port", "w": "local_tun[:remote_tun]",
}
}
}()
cmd := exec.Command(SSHExe())
stderr, err := cmd.StderrPipe()
if err != nil {
return
}
if err := cmd.Start(); err != nil {
return
}
raw, err := io.ReadAll(stderr)
if err != nil {
return
}
text := utils.UnsafeBytesToString(raw)
ssh_options = make(map[string]string, 32)
for {
pos := strings.IndexByte(text, '[')
if pos < 0 {
break
}
num := 1
epos := pos
for num > 0 {
epos++
switch text[epos] {
case '[':
num += 1
case ']':
num -= 1
}
}
q := text[pos+1 : epos]
text = text[epos:]
if len(q) < 2 || !strings.HasPrefix(q, "-") {
continue
}
opt, desc, found := strings.Cut(q, " ")
if found {
ssh_options[opt[1:]] = desc
} else {
for _, ch := range opt[1:] {
ssh_options[string(ch)] = ""
}
}
}
return
}}).Get
func GetSSHCLI() (boolean_ssh_args *utils.Set[string], other_ssh_args *utils.Set[string]) {
other_ssh_args, boolean_ssh_args = utils.NewSet[string](32), utils.NewSet[string](32)
for k, v := range SSHOptions() {
k = "-" + k
if v == "" {
boolean_ssh_args.Add(k)
} else {
other_ssh_args.Add(k)
}
}
return
}
func is_extra_arg(arg string, extra_args []string) string {
for _, x := range extra_args {
if arg == x || strings.HasPrefix(arg, x+"=") {
return x
}
}
return ""
}
type ErrInvalidSSHArgs struct {
Msg string
}
func (self *ErrInvalidSSHArgs) Error() string {
return self.Msg
}
func PassthroughArgs() map[string]bool {
return map[string]bool{"-N": true, "-n": true, "-f": true, "-G": true, "-T": true}
}
func ParseSSHArgs(args []string, extra_args ...string) (ssh_args []string, server_args []string, passthrough bool, found_extra_args []string, err error) {
if extra_args == nil {
extra_args = []string{}
}
if len(args) == 0 {
passthrough = true
return
}
passthrough_args := PassthroughArgs()
boolean_ssh_args, other_ssh_args := GetSSHCLI()
ssh_args, server_args, found_extra_args = make([]string, 0, 16), make([]string, 0, 16), make([]string, 0, 16)
expecting_option_val := false
stop_option_processing := false
expecting_extra_val := ""
for _, argument := range args {
if len(server_args) > 1 || stop_option_processing {
server_args = append(server_args, argument)
continue
}
if strings.HasPrefix(argument, "-") && !expecting_option_val {
if argument == "--" {
stop_option_processing = true
continue
}
if len(extra_args) > 0 {
matching_ex := is_extra_arg(argument, extra_args)
if matching_ex != "" {
_, exval, found := strings.Cut(argument, "=")
if found {
found_extra_args = append(found_extra_args, matching_ex, exval)
} else {
expecting_extra_val = matching_ex
expecting_option_val = true
}
continue
}
}
// could be a multi-character option
all_args := []rune(argument[1:])
for i, ch := range all_args {
arg := "-" + string(ch)
if passthrough_args[arg] {
passthrough = true
}
if boolean_ssh_args.Has(arg) {
ssh_args = append(ssh_args, arg)
continue
}
if other_ssh_args.Has(arg) {
ssh_args = append(ssh_args, arg)
if i+1 < len(all_args) {
ssh_args = append(ssh_args, string(all_args[i+1:]))
} else {
expecting_option_val = true
}
break
}
err = &ErrInvalidSSHArgs{Msg: "unknown option -- " + arg[1:]}
return
}
continue
}
if expecting_option_val {
if expecting_extra_val != "" {
found_extra_args = append(found_extra_args, expecting_extra_val, argument)
} else {
ssh_args = append(ssh_args, argument)
}
expecting_option_val = false
continue
}
server_args = append(server_args, argument)
}
if len(server_args) == 0 && !passthrough {
err = &ErrInvalidSSHArgs{Msg: ""}
}
return
}
type SSHVersion struct{ Major, Minor int }
func (self SSHVersion) SupportsAskpassRequire() bool {
return self.Major > 8 || (self.Major == 8 && self.Minor >= 4)
}
var GetSSHVersion = (&utils.Once[SSHVersion]{Run: func() SSHVersion {
b, err := exec.Command(SSHExe(), "-V").CombinedOutput()
if err != nil {
return SSHVersion{}
}
m := regexp.MustCompile(`OpenSSH_(\d+).(\d+)`).FindSubmatch(b)
if len(m) == 3 {
maj, _ := strconv.Atoi(utils.UnsafeBytesToString(m[1]))
min, _ := strconv.Atoi(utils.UnsafeBytesToString(m[2]))
return SSHVersion{Major: maj, Minor: min}
}
return SSHVersion{}
}}).Get
type KittyOpts struct {
Term, Shell_integration string
}
func read_relevant_kitty_opts(path string) KittyOpts {
ans := KittyOpts{Term: kitty.KittyConfigDefaults.Term, Shell_integration: kitty.KittyConfigDefaults.Shell_integration}
handle_line := func(key, val string) error {
switch key {
case "term":
ans.Term = strings.TrimSpace(val)
case "shell_integration":
ans.Shell_integration = strings.TrimSpace(val)
}
return nil
}
cp := config.ConfigParser{LineHandler: handle_line}
cp.ParseFiles(path)
return ans
}
var RelevantKittyOpts = (&utils.Once[KittyOpts]{Run: func() KittyOpts {
return read_relevant_kitty_opts(filepath.Join(utils.ConfigDir(), "kitty.conf"))
}}).Get

View File

@@ -1,68 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package ssh
import (
"fmt"
"os"
"path/filepath"
"testing"
"kitty/tools/utils/shlex"
"github.com/google/go-cmp/cmp"
)
var _ = fmt.Print
func TestGetSSHOptions(t *testing.T) {
m := SSHOptions()
if m["w"] != "local_tun[:remote_tun]" {
t.Fatalf("Unexpected set of SSH options: %#v", m)
}
}
func TestParseSSHArgs(t *testing.T) {
split := func(x string) []string {
ans, err := shlex.Split(x)
if err != nil {
t.Fatal(err)
}
return ans
}
p := func(args, expected_ssh_args, expected_server_args, expected_extra_args string, expected_passthrough bool) {
ssh_args, server_args, passthrough, extra_args, err := ParseSSHArgs(split(args), "--kitten")
if err != nil {
t.Fatal(err)
}
check := func(a, b any) {
diff := cmp.Diff(a, b)
if diff != "" {
t.Fatalf("Unexpected value for args: %s\n%s", args, diff)
}
}
check(split(expected_ssh_args), ssh_args)
check(split(expected_server_args), server_args)
check(split(expected_extra_args), extra_args)
check(expected_passthrough, passthrough)
}
p(`localhost`, ``, `localhost`, ``, false)
p(`-- localhost`, ``, `localhost`, ``, false)
p(`-46p23 localhost sh -c "a b"`, `-4 -6 -p 23`, `localhost sh -c "a b"`, ``, false)
p(`-46p23 -S/moose -W x:6 -- localhost sh -c "a b"`, `-4 -6 -p 23 -S /moose -W x:6`, `localhost sh -c "a b"`, ``, false)
p(`--kitten=abc -np23 --kitten xyz host`, `-n -p 23`, `host`, `--kitten abc --kitten xyz`, true)
}
func TestRelevantKittyOpts(t *testing.T) {
tdir := t.TempDir()
path := filepath.Join(tdir, "kitty.conf")
os.WriteFile(path, []byte("term XXX\nshell_integration changed\nterm abcd"), 0o600)
rko := read_relevant_kitty_opts(path)
if rko.Term != "abcd" {
t.Fatalf("Unexpected TERM: %s", RelevantKittyOpts().Term)
}
if rko.Shell_integration != "changed" {
t.Fatalf("Unexpected shell_integration: %s", RelevantKittyOpts().Shell_integration)
}
}

View File

@@ -1,101 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package themes
import (
"fmt"
"kitty/tools/themes"
"kitty/tools/tty"
"kitty/tools/utils"
"kitty/tools/wcswidth"
)
var _ = fmt.Print
var DebugPrintln = tty.DebugPrintln
type ThemesList struct {
themes, all_themes *themes.Themes
current_search string
display_strings []string
widths []int
max_width, current_idx int
}
func (self *ThemesList) Len() int {
if self.themes == nil {
return 0
}
return self.themes.Len()
}
func (self *ThemesList) Next(delta int, allow_wrapping bool) bool {
if len(self.display_strings) == 0 {
return false
}
idx := self.current_idx + delta
if !allow_wrapping && (idx < 0 || idx > self.Len()) {
return false
}
for idx < 0 {
idx += self.Len()
}
self.current_idx = idx % self.Len()
return true
}
func limit_lengths(text string) string {
t, x := wcswidth.TruncateToVisualLengthWithWidth(text, 31)
if x >= len(text) {
return text
}
return t + "…"
}
func (self *ThemesList) UpdateThemes(themes *themes.Themes) {
self.themes, self.all_themes = themes, themes
if self.current_search != "" {
self.themes = self.all_themes.Copy()
self.display_strings = utils.Map(limit_lengths, self.themes.ApplySearch(self.current_search))
} else {
self.display_strings = utils.Map(limit_lengths, self.themes.Names())
}
self.widths = utils.Map(wcswidth.Stringwidth, self.display_strings)
self.max_width = utils.Max(0, self.widths...)
self.current_idx = 0
}
func (self *ThemesList) UpdateSearch(query string) bool {
if query == self.current_search || self.all_themes == nil {
return false
}
self.current_search = query
self.UpdateThemes(self.all_themes)
return true
}
type Line struct {
text string
width int
is_current bool
}
func (self *ThemesList) Lines(num_rows int) []Line {
if num_rows < 1 {
return nil
}
ans := make([]Line, 0, len(self.display_strings))
before_num := utils.Min(self.current_idx, num_rows-1)
start := self.current_idx - before_num
for i := start; i < utils.Min(start+num_rows, len(self.display_strings)); i++ {
ans = append(ans, Line{self.display_strings[i], self.widths[i], i == self.current_idx})
}
return ans
}
func (self *ThemesList) CurrentTheme() *themes.Theme {
if self.themes == nil {
return nil
}
return self.themes.At(self.current_idx)
}

View File

@@ -1,98 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package themes
import (
"fmt"
"strings"
"time"
"kitty/tools/cli"
"kitty/tools/themes"
"kitty/tools/tui/loop"
"kitty/tools/utils"
)
var _ = fmt.Print
func complete_themes(completions *cli.Completions, word string, arg_num int) {
themes.CompleteThemes(completions, word, arg_num)
}
func non_interactive(opts *Options, theme_name string) (rc int, err error) {
themes, closer, err := themes.LoadThemes(time.Duration(opts.CacheAge * float64(time.Hour*24)))
if err != nil {
return 1, err
}
defer closer.Close()
theme := themes.ThemeByName(theme_name)
if theme == nil {
theme_name = strings.ReplaceAll(theme_name, `\`, ``)
theme = themes.ThemeByName(theme_name)
if theme == nil {
return 1, fmt.Errorf("No theme named: %s", theme_name)
}
}
if opts.DumpTheme {
code, err := theme.Code()
if err != nil {
return 1, err
}
fmt.Println(code)
} else {
err = theme.SaveInConf(utils.ConfigDir(), opts.ReloadIn, opts.ConfigFileName)
if err != nil {
return 1, err
}
}
return
}
func main(_ *cli.Command, opts *Options, args []string) (rc int, err error) {
if len(args) > 1 {
args = []string{strings.Join(args, ` `)}
}
if len(args) == 1 {
return non_interactive(opts, args[0])
}
lp, err := loop.New()
if err != nil {
return 1, err
}
cv := utils.NewCachedValues("unicode-input", &CachedData{Category: "All"})
h := &handler{lp: lp, opts: opts, cached_data: cv.Load()}
defer cv.Save()
lp.OnInitialize = func() (string, error) {
lp.AllowLineWrapping(false)
lp.SetWindowTitle(`Choose a theme for kitty`)
h.initialize()
return "", nil
}
lp.OnWakeup = h.on_wakeup
lp.OnFinalize = func() string {
h.finalize()
lp.SetCursorVisible(true)
return ``
}
lp.OnResize = func(_, _ loop.ScreenSize) error {
h.draw_screen()
return nil
}
lp.OnKeyEvent = h.on_key_event
lp.OnText = h.on_text
err = lp.Run()
if err != nil {
return 1, err
}
ds := lp.DeathSignalName()
if ds != "" {
fmt.Println("Killed by signal: ", ds)
lp.KillIfSignalled()
return 1, nil
}
return
}
func EntryPoint(parent *cli.Command) {
create_cmd(parent, main)
}

View File

@@ -1,610 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package themes
import (
"fmt"
"io"
"path/filepath"
"regexp"
"strings"
"time"
"kitty/tools/config"
"kitty/tools/themes"
"kitty/tools/tui/loop"
"kitty/tools/tui/readline"
"kitty/tools/utils"
"kitty/tools/wcswidth"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
)
var _ = fmt.Print
type State int
const (
FETCHING State = iota
BROWSING
SEARCHING
ACCEPTING
)
const SEPARATOR = "║"
type CachedData struct {
Recent []string `json:"recent"`
Category string `json:"category"`
}
type fetch_data struct {
themes *themes.Themes
err error
closer io.Closer
}
var category_filters = map[string]func(*themes.Theme) bool{
"all": func(*themes.Theme) bool { return true },
"dark": func(t *themes.Theme) bool { return t.IsDark() },
"light": func(t *themes.Theme) bool { return !t.IsDark() },
"user": func(t *themes.Theme) bool { return t.IsUserDefined() },
}
func recent_filter(items []string) func(*themes.Theme) bool {
allowed := utils.NewSetWithItems(items...)
return func(t *themes.Theme) bool {
return allowed.Has(t.Name())
}
}
type handler struct {
lp *loop.Loop
opts *Options
cached_data *CachedData
state State
fetch_result chan fetch_data
all_themes *themes.Themes
themes_closer io.Closer
themes_list *ThemesList
category_filters map[string]func(*themes.Theme) bool
colors_set_once bool
tabs []string
rl *readline.Readline
}
// fetching {{{
func (self *handler) fetch_themes() {
r := fetch_data{}
r.themes, r.closer, r.err = themes.LoadThemes(time.Duration(self.opts.CacheAge * float64(time.Hour*24)))
self.lp.WakeupMainThread()
self.fetch_result <- r
}
func (self *handler) on_fetching_key_event(ev *loop.KeyEvent) error {
if ev.MatchesPressOrRepeat("esc") {
self.lp.Quit(0)
ev.Handled = true
}
return nil
}
func (self *handler) on_wakeup() error {
r := <-self.fetch_result
if r.err != nil {
return r.err
}
self.state = BROWSING
self.all_themes = r.themes
self.themes_closer = r.closer
self.redraw_after_category_change()
return nil
}
func (self *handler) draw_fetching_screen() {
self.lp.Println("Downloading themes from repository, please wait...")
}
// }}}
func (self *handler) finalize() {
t := self.themes_closer
if t != nil {
t.Close()
self.themes_closer = nil
}
}
func (self *handler) initialize() {
self.tabs = strings.Split("all dark light recent user", " ")
self.rl = readline.New(self.lp, readline.RlInit{DontMarkPrompts: true, Prompt: "/"})
self.themes_list = &ThemesList{}
self.fetch_result = make(chan fetch_data)
self.category_filters = make(map[string]func(*themes.Theme) bool, len(category_filters)+1)
maps.Copy(self.category_filters, category_filters)
self.category_filters["recent"] = recent_filter(self.cached_data.Recent)
go self.fetch_themes()
self.draw_screen()
}
func (self *handler) enforce_cursor_state() {
self.lp.SetCursorVisible(self.state == FETCHING)
}
func (self *handler) draw_screen() {
self.lp.StartAtomicUpdate()
defer self.lp.EndAtomicUpdate()
self.lp.ClearScreen()
self.enforce_cursor_state()
switch self.state {
case FETCHING:
self.draw_fetching_screen()
case BROWSING, SEARCHING:
self.draw_browsing_screen()
case ACCEPTING:
self.draw_accepting_screen()
}
}
func (self *handler) current_category() string {
ans := self.cached_data.Category
if self.category_filters[ans] == nil {
ans = "all"
}
return ans
}
func (self *handler) set_current_category(category string) {
if self.category_filters[category] == nil {
category = "all"
}
self.cached_data.Category = category
}
func ReadKittyColorSettings() map[string]string {
settings := make(map[string]string, 512)
handle_line := func(key, val string) error {
if themes.AllColorSettingNames[key] {
settings[key] = val
}
return nil
}
cp := config.ConfigParser{LineHandler: handle_line}
cp.ParseFiles(filepath.Join(utils.ConfigDir(), "kitty.conf"))
return settings
}
func (self *handler) set_colors_to_current_theme() bool {
if self.themes_list == nil && self.colors_set_once {
return false
}
self.colors_set_once = true
if self.themes_list != nil {
t := self.themes_list.CurrentTheme()
if t != nil {
raw, err := t.AsEscapeCodes()
if err == nil {
self.lp.QueueWriteString(raw)
return true
}
}
}
self.lp.QueueWriteString(themes.ColorSettingsAsEscapeCodes(ReadKittyColorSettings()))
return true
}
func (self *handler) redraw_after_category_change() {
self.themes_list.UpdateThemes(self.all_themes.Filtered(self.category_filters[self.current_category()]))
self.set_colors_to_current_theme()
self.draw_screen()
}
func (self *handler) on_key_event(ev *loop.KeyEvent) error {
switch self.state {
case FETCHING:
return self.on_fetching_key_event(ev)
case BROWSING:
return self.on_browsing_key_event(ev)
case SEARCHING:
return self.on_searching_key_event(ev)
case ACCEPTING:
return self.on_accepting_key_event(ev)
}
return nil
}
// browsing ... {{{
func (self *handler) next_category(delta int) {
idx := slices.Index(self.tabs, self.current_category()) + delta + len(self.tabs)
self.set_current_category(self.tabs[idx%len(self.tabs)])
self.redraw_after_category_change()
}
func (self *handler) next(delta int, allow_wrapping bool) {
if self.themes_list.Next(delta, allow_wrapping) {
self.set_colors_to_current_theme()
self.draw_screen()
} else {
self.lp.Beep()
}
}
func (self *handler) on_browsing_key_event(ev *loop.KeyEvent) error {
if ev.MatchesPressOrRepeat("esc") || ev.MatchesPressOrRepeat("q") {
self.lp.Quit(0)
ev.Handled = true
return nil
}
for _, cat := range self.tabs {
if ev.MatchesPressOrRepeat(cat[0:1]) || ev.MatchesPressOrRepeat("alt+"+cat[0:1]) {
ev.Handled = true
if cat != self.current_category() {
self.set_current_category(cat)
self.redraw_after_category_change()
return nil
}
}
}
if ev.MatchesPressOrRepeat("left") || ev.MatchesPressOrRepeat("shift+tab") {
self.next_category(-1)
ev.Handled = true
return nil
}
if ev.MatchesPressOrRepeat("right") || ev.MatchesPressOrRepeat("tab") {
self.next_category(1)
ev.Handled = true
return nil
}
if ev.MatchesPressOrRepeat("j") || ev.MatchesPressOrRepeat("down") {
self.next(1, true)
ev.Handled = true
return nil
}
if ev.MatchesPressOrRepeat("k") || ev.MatchesPressOrRepeat("up") {
self.next(-1, true)
ev.Handled = true
return nil
}
if ev.MatchesPressOrRepeat("page_down") {
ev.Handled = true
sz, err := self.lp.ScreenSize()
if err == nil {
self.next(int(sz.HeightCells)-3, false)
}
return nil
}
if ev.MatchesPressOrRepeat("page_up") {
ev.Handled = true
sz, err := self.lp.ScreenSize()
if err == nil {
self.next(3-int(sz.HeightCells), false)
}
return nil
}
if ev.MatchesPressOrRepeat("s") || ev.MatchesPressOrRepeat("/") {
ev.Handled = true
self.start_search()
return nil
}
if ev.MatchesPressOrRepeat("c") || ev.MatchesPressOrRepeat("enter") {
ev.Handled = true
if self.themes_list == nil || self.themes_list.Len() == 0 {
self.lp.Beep()
} else {
self.state = ACCEPTING
self.draw_screen()
}
}
return nil
}
func (self *handler) start_search() {
self.state = SEARCHING
self.rl.SetText(self.themes_list.current_search)
self.draw_screen()
}
func (self *handler) draw_browsing_screen() {
self.draw_tab_bar()
sz, err := self.lp.ScreenSize()
if err != nil {
return
}
num_rows := int(sz.HeightCells) - 2
mw := self.themes_list.max_width + 1
green_fg, _, _ := strings.Cut(self.lp.SprintStyled("fg=green", "|"), "|")
for _, l := range self.themes_list.Lines(num_rows) {
line := l.text
if l.is_current {
line = strings.ReplaceAll(line, themes.MARK_AFTER, green_fg)
self.lp.PrintStyled("fg=green", ">")
self.lp.PrintStyled("fg=green bold", line)
} else {
self.lp.PrintStyled("fg=green", " ")
self.lp.QueueWriteString(line)
}
self.lp.MoveCursorHorizontally(mw - l.width)
self.lp.Println(SEPARATOR)
}
if self.themes_list != nil && self.themes_list.Len() > 0 {
self.draw_theme_demo()
}
if self.state == BROWSING {
self.draw_bottom_bar()
} else {
self.draw_search_bar()
}
}
func (self *handler) draw_bottom_bar() {
sz, err := self.lp.ScreenSize()
if err != nil {
return
}
self.lp.MoveCursorTo(1, int(sz.HeightCells))
self.lp.PrintStyled("reverse", strings.Repeat(" ", int(sz.WidthCells)))
self.lp.QueueWriteString("\r")
draw_tab := func(t, sc string) {
text := self.mark_shortcut(utils.Capitalize(t), sc)
self.lp.PrintStyled("reverse", " "+text+" ")
}
draw_tab("search (/)", "s")
draw_tab("accept (⏎)", "c")
self.lp.QueueWriteString("\x1b[m")
}
func (self *handler) draw_search_bar() {
sz, err := self.lp.ScreenSize()
if err != nil {
return
}
self.lp.MoveCursorTo(1, int(sz.HeightCells))
self.lp.ClearToEndOfLine()
self.rl.RedrawNonAtomic()
}
func (self *handler) mark_shortcut(text, acc string) string {
acc_idx := strings.Index(strings.ToLower(text), strings.ToLower(acc))
return text[:acc_idx] + self.lp.SprintStyled("underline bold", text[acc_idx:acc_idx+1]) + text[acc_idx+1:]
}
func (self *handler) draw_tab_bar() {
sz, err := self.lp.ScreenSize()
if err != nil {
return
}
self.lp.PrintStyled("reverse", strings.Repeat(` `, int(sz.WidthCells)))
self.lp.QueueWriteString("\r")
cc := self.current_category()
draw_tab := func(text, name, acc string) {
is_active := name == cc
if is_active {
text := self.lp.SprintStyled("italic", fmt.Sprintf("%s #%d", text, self.themes_list.Len()))
self.lp.Printf(" %s ", text)
} else {
text = self.mark_shortcut(text, acc)
self.lp.PrintStyled("reverse", " "+text+" ")
}
}
for _, title := range self.tabs {
draw_tab(utils.Capitalize(title), title, string([]rune(title)[0]))
}
self.lp.Println("\x1b[m")
}
func center_string(x string, width int) string {
l := wcswidth.Stringwidth(x)
spaces := int(float64(width-l) / 2)
return strings.Repeat(" ", spaces) + x + strings.Repeat(" ", width-(spaces+l))
}
func (self *handler) draw_theme_demo() {
ssz, err := self.lp.ScreenSize()
if err != nil {
return
}
theme := self.themes_list.CurrentTheme()
if theme == nil {
return
}
xstart := self.themes_list.max_width + 3
sz := int(ssz.WidthCells) - xstart
if sz < 20 {
return
}
sz--
y := 0
colors := strings.Split(`black red green yellow blue magenta cyan white`, ` `)
trunc := sz/8 - 1
pat := regexp.MustCompile(`\s+`)
next_line := func() {
self.lp.QueueWriteString("\r")
y++
self.lp.MoveCursorTo(xstart, y+1)
self.lp.QueueWriteString(SEPARATOR + " ")
}
write_para := func(text string) {
text = pat.ReplaceAllLiteralString(text, " ")
for text != "" {
t, sp := wcswidth.TruncateToVisualLengthWithWidth(text, sz)
self.lp.QueueWriteString(t)
next_line()
text = text[sp:]
}
}
write_colors := func(bg string) {
for _, intense := range []bool{false, true} {
buf := strings.Builder{}
buf.Grow(1024)
for _, c := range colors {
s := c
if intense {
s = "bright-" + s
}
if len(c) > trunc {
c = c[:trunc]
}
buf.WriteString(self.lp.SprintStyled("fg="+c, c))
buf.WriteString(" ")
}
text := strings.TrimSpace(buf.String())
if bg == "" {
self.lp.QueueWriteString(text)
} else {
s := bg
if intense {
s = "bright-" + s
}
self.lp.PrintStyled("bg="+s, text)
}
next_line()
}
next_line()
}
self.lp.MoveCursorTo(1, 1)
next_line()
self.lp.PrintStyled("fg=green bold", center_string(theme.Name(), sz))
next_line()
if theme.Author() != "" {
self.lp.PrintStyled("italic", center_string(theme.Author(), sz))
next_line()
}
if theme.Blurb() != "" {
next_line()
write_para(theme.Blurb())
next_line()
}
write_colors("")
for _, bg := range colors {
write_colors(bg)
}
}
// }}}
// accepting {{{
func (self *handler) on_accepting_key_event(ev *loop.KeyEvent) error {
if ev.MatchesPressOrRepeat("q") || ev.MatchesPressOrRepeat("esc") {
ev.Handled = true
self.lp.Quit(0)
return nil
}
if ev.MatchesPressOrRepeat("a") {
ev.Handled = true
self.state = BROWSING
self.draw_screen()
return nil
}
if ev.MatchesPressOrRepeat("p") {
ev.Handled = true
self.themes_list.CurrentTheme().SaveInDir(utils.ConfigDir())
self.update_recent()
self.lp.Quit(0)
return nil
}
if ev.MatchesPressOrRepeat("m") {
ev.Handled = true
self.themes_list.CurrentTheme().SaveInConf(utils.ConfigDir(), self.opts.ReloadIn, self.opts.ConfigFileName)
self.update_recent()
self.lp.Quit(0)
return nil
}
return nil
}
func (self *handler) update_recent() {
if self.themes_list != nil {
recent := slices.Clone(self.cached_data.Recent)
name := self.themes_list.CurrentTheme().Name()
recent = utils.Remove(recent, name)
recent = append([]string{name}, recent...)
self.cached_data.Recent = recent[:20]
}
}
func (self *handler) draw_accepting_screen() {
name := self.themes_list.CurrentTheme().Name()
name = self.lp.SprintStyled("fg=green bold", name)
kc := self.lp.SprintStyled("italic", self.opts.ConfigFileName)
ac := func(x string) string {
return self.lp.SprintStyled("fg=red", x)
}
self.lp.AllowLineWrapping(true)
defer self.lp.AllowLineWrapping(false)
self.lp.Printf(`You have chosen the %s theme`, name)
self.lp.Println()
self.lp.Println()
self.lp.Println(`What would you like to do?`)
self.lp.Println()
self.lp.Printf(` %sodify %s to load %s`, ac("M"), kc, name)
self.lp.Println()
self.lp.Println()
self.lp.Printf(` %slace the theme file in %s but do not modify %s`, ac("P"), utils.ConfigDir(), kc)
self.lp.Println()
self.lp.Println()
self.lp.Printf(` %sbort and return to list of themes`, ac("A"))
self.lp.Println()
self.lp.Println()
self.lp.Printf(` %suit`, ac("Q"))
self.lp.Println()
}
// }}}
// searching {{{
func (self *handler) update_search() {
text := self.rl.AllText()
if self.themes_list.UpdateSearch(text) {
self.set_colors_to_current_theme()
self.draw_screen()
} else {
self.draw_search_bar()
}
}
func (self *handler) on_text(text string, a, b bool) error {
if self.state == SEARCHING {
err := self.rl.OnText(text, a, b)
if err != nil {
return err
}
self.update_search()
}
return nil
}
func (self *handler) on_searching_key_event(ev *loop.KeyEvent) error {
if ev.MatchesPressOrRepeat("enter") {
ev.Handled = true
self.state = BROWSING
self.draw_bottom_bar()
return nil
}
if ev.MatchesPressOrRepeat("esc") {
ev.Handled = true
self.state = BROWSING
self.themes_list.UpdateSearch("")
self.set_colors_to_current_theme()
self.draw_screen()
return nil
}
err := self.rl.OnKeyEvent(ev)
if err != nil {
return err
}
if ev.Handled {
self.update_search()
}
return nil
}
// }}}

View File

@@ -5,19 +5,19 @@ package tool
import (
"fmt"
"kitty/kittens/ask"
"kitty/kittens/clipboard"
"kitty/kittens/diff"
"kitty/kittens/hints"
"kitty/kittens/hyperlinked_grep"
"kitty/kittens/icat"
"kitty/kittens/ssh"
"kitty/kittens/themes"
"kitty/kittens/unicode_input"
"kitty/tools/cli"
"kitty/tools/cmd/ask"
"kitty/tools/cmd/at"
"kitty/tools/cmd/clipboard"
"kitty/tools/cmd/diff"
"kitty/tools/cmd/edit_in_kitty"
"kitty/tools/cmd/hints"
"kitty/tools/cmd/hyperlinked_grep"
"kitty/tools/cmd/icat"
"kitty/tools/cmd/pytest"
"kitty/tools/cmd/ssh"
"kitty/tools/cmd/themes"
"kitty/tools/cmd/unicode_input"
"kitty/tools/cmd/update_self"
"kitty/tools/tui"
)

View File

@@ -1,633 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package unicode_input
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"unicode"
"kitty/tools/cli"
"kitty/tools/tui"
"kitty/tools/tui/loop"
"kitty/tools/tui/readline"
"kitty/tools/unicode_names"
"kitty/tools/utils"
"kitty/tools/utils/style"
"kitty/tools/wcswidth"
"golang.org/x/exp/slices"
)
var _ = fmt.Print
const INDEX_CHAR string = "."
const INDEX_BASE = 36
const InvalidChar rune = unicode.MaxRune + 1
const default_set_of_symbols string = `
«» 😀😛😇😈😉😍😎😮👍👎 §©® ·°±×÷¼½½¾
µ¢£¿¡¨´¸ˆ˜ ÀÁÂÃÄÅÆÇÈÉÊË ÌÍÎÏÐÑÒÓÔÕÖØ ŒŠÙÚÛÜÝŸÞßàá âãäåæçèéêëìí
îïðñòóôõöøœš ùúûüýÿþªºαΩ
`
var DEFAULT_SET []rune
var EMOTICONS_SET []rune
const DEFAULT_MODE string = "HEX"
func build_sets() {
DEFAULT_SET = make([]rune, 0, len(default_set_of_symbols))
for _, ch := range default_set_of_symbols {
if !unicode.IsSpace(ch) {
DEFAULT_SET = append(DEFAULT_SET, ch)
}
}
EMOTICONS_SET = make([]rune, 0, 0x1f64f-0x1f600+1)
for i := 0x1f600; i <= 0x1f64f; i++ {
DEFAULT_SET = append(DEFAULT_SET, rune(i))
}
}
func codepoint_ok(code rune) bool {
return !(code <= 32 || code == 127 || (128 <= code && code <= 159) || (0xd800 <= code && code <= 0xdbff) || (0xDC00 <= code && code <= 0xDFFF) || code > unicode.MaxRune)
}
func parse_favorites(raw string) (ans []rune) {
ans = make([]rune, 0, 128)
for _, line := range utils.Splitlines(raw) {
line = strings.TrimSpace(line)
if len(line) == 0 || strings.HasPrefix(line, "#") {
continue
}
idx := strings.Index(line, "#")
if idx > -1 {
line = line[:idx]
}
code_text, _, _ := strings.Cut(line, " ")
code, err := strconv.ParseUint(code_text, 16, 32)
if err == nil && codepoint_ok(rune(code)) {
ans = append(ans, rune(code))
}
}
return
}
func serialize_favorites(favs []rune) string {
b := strings.Builder{}
b.Grow(8192)
b.WriteString(`# Favorite characters for unicode input
# Enter the hex code for each favorite character on a new line. Blank lines are
# ignored and anything after a # is considered a comment.
`)
for _, ch := range favs {
b.WriteString(fmt.Sprintf("%x # %s %s\n", ch, string(ch), unicode_names.NameForCodePoint(ch)))
}
return b.String()
}
var loaded_favorites []rune
func favorites_path() string {
return filepath.Join(utils.ConfigDir(), "unicode-input-favorites.conf")
}
func load_favorites(refresh bool) []rune {
if refresh || loaded_favorites == nil {
raw, err := os.ReadFile(favorites_path())
if err == nil {
loaded_favorites = parse_favorites(utils.UnsafeBytesToString(raw))
} else {
loaded_favorites = DEFAULT_SET
}
}
return loaded_favorites
}
type CachedData struct {
Recent []rune `json:"recent,omitempty"`
Mode string `json:"mode,omitempty"`
}
var cached_data *CachedData
type Mode int
const (
HEX Mode = iota
NAME
EMOTICONS
FAVORITES
)
type ModeData struct {
mode Mode
key string
title string
}
var all_modes [4]ModeData
type checkpoints_key struct {
mode Mode
text string
codepoints []rune
index_word int
}
func (self *checkpoints_key) clear() {
*self = checkpoints_key{}
}
func (self *checkpoints_key) is_equal(other checkpoints_key) bool {
return self.mode == other.mode && self.text == other.text && slices.Equal(self.codepoints, other.codepoints) && self.index_word == other.index_word
}
type handler struct {
mode Mode
recent []rune
current_char rune
err error
lp *loop.Loop
ctx style.Context
rl *readline.Readline
choice_line string
emoji_variation string
checkpoints_key checkpoints_key
table table
current_tab_formatter, tab_bar_formatter, chosen_formatter, chosen_name_formatter, dim_formatter func(...any) string
}
func (self *handler) initialize() {
self.ctx.AllowEscapeCodes = true
self.checkpoints_key.index_word = -1
self.table.initialize(self.emoji_variation, self.ctx)
self.lp.SetWindowTitle("Unicode input")
self.current_char = InvalidChar
self.current_tab_formatter = self.ctx.SprintFunc("reverse=false bold=true")
self.tab_bar_formatter = self.ctx.SprintFunc("reverse=true")
self.chosen_formatter = self.ctx.SprintFunc("fg=green")
self.chosen_name_formatter = self.ctx.SprintFunc("italic=true dim=true")
self.dim_formatter = self.ctx.SprintFunc("dim=true")
self.rl = readline.New(self.lp, readline.RlInit{Prompt: "> ", DontMarkPrompts: true})
self.rl.Start()
self.refresh()
}
func (self *handler) finalize() string {
self.rl.End()
self.rl.Shutdown()
return ""
}
func (self *handler) resolved_char() string {
if self.current_char == InvalidChar {
return ""
}
return resolved_char(self.current_char, self.emoji_variation)
}
func is_index(word string) bool {
if !strings.HasPrefix(word, INDEX_CHAR) {
return false
}
word = strings.TrimLeft(word, INDEX_CHAR)
_, err := strconv.ParseUint(word, INDEX_BASE, 32)
return err == nil
}
func (self *handler) update_codepoints() {
var index_word uint64
var q checkpoints_key
q.mode = self.mode
q.index_word = -1
switch self.mode {
case HEX:
q.codepoints = self.recent
if len(q.codepoints) == 0 {
q.codepoints = DEFAULT_SET
}
case EMOTICONS:
q.codepoints = EMOTICONS_SET
case FAVORITES:
q.codepoints = load_favorites(false)
case NAME:
q.text = self.rl.AllText()
if !q.is_equal(self.checkpoints_key) {
words := strings.Split(q.text, " ")
words = utils.RemoveAll(words, INDEX_CHAR)
if len(words) > 1 {
for i, w := range words {
if i > 0 && is_index(w) {
iw := words[i]
words = words[:i]
index_word, _ = strconv.ParseUint(strings.TrimLeft(iw, INDEX_CHAR), INDEX_BASE, 32)
q.index_word = int(index_word)
break
}
}
}
query := strings.Join(words, " ")
if len(query) > 1 {
words = words[1:]
q.codepoints = unicode_names.CodePointsForQuery(query)
}
}
}
if !q.is_equal(self.checkpoints_key) {
self.checkpoints_key = q
self.table.set_codepoints(q.codepoints, self.mode, q.index_word)
}
}
func (self *handler) update_current_char() {
self.update_codepoints()
self.current_char = InvalidChar
text := self.rl.AllText()
switch self.mode {
case HEX:
if strings.HasPrefix(text, INDEX_CHAR) {
if len(text) > 1 {
self.current_char = self.table.codepoint_at_hint(text[1:])
}
} else if len(text) > 0 {
code, err := strconv.ParseUint(text, 16, 32)
if err == nil && code <= unicode.MaxRune {
self.current_char = rune(code)
}
}
case NAME:
cc := self.table.current_codepoint()
if cc > 0 && cc <= unicode.MaxRune {
self.current_char = rune(cc)
}
default:
if len(text) > 0 {
self.current_char = self.table.codepoint_at_hint(strings.TrimLeft(text, INDEX_CHAR))
}
}
if !codepoint_ok(self.current_char) {
self.current_char = InvalidChar
}
}
func (self *handler) update_prompt() {
self.update_current_char()
ch := "??"
color := "red"
self.choice_line = ""
if self.current_char != InvalidChar {
ch, color = self.resolved_char(), "green"
self.choice_line = fmt.Sprintf(
"Chosen: %s U+%x %s", self.chosen_formatter(ch), self.current_char,
self.chosen_name_formatter(title(unicode_names.NameForCodePoint(self.current_char))))
}
prompt := fmt.Sprintf("%s> ", self.ctx.SprintFunc("fg="+color)(ch))
self.rl.SetPrompt(prompt)
}
func (self *handler) draw_title_bar() {
self.lp.AllowLineWrapping(false)
entries := make([]string, 0, len(all_modes))
for _, md := range all_modes {
entry := fmt.Sprintf(" %s (%s) ", md.title, md.key)
if md.mode == self.mode {
entry = self.current_tab_formatter(entry)
}
entries = append(entries, entry)
}
sz, _ := self.lp.ScreenSize()
text := fmt.Sprintf("Search by:%s", strings.Join(entries, ""))
extra := int(sz.WidthCells) - wcswidth.Stringwidth(text)
if extra > 0 {
text += strings.Repeat(" ", extra)
}
self.lp.Println(self.tab_bar_formatter(text))
}
func (self *handler) draw_screen() {
self.lp.StartAtomicUpdate()
defer self.lp.EndAtomicUpdate()
self.lp.ClearScreen()
self.draw_title_bar()
y := 1
writeln := func(text ...any) {
self.lp.Println(text...)
y += 1
}
switch self.mode {
case NAME:
writeln("Enter words from the name of the character")
case HEX:
writeln("Enter the hex code for the character")
default:
writeln("Enter the index for the character you want from the list below")
}
self.rl.RedrawNonAtomic()
self.lp.AllowLineWrapping(false)
self.lp.SaveCursorPosition()
defer self.lp.RestoreCursorPosition()
writeln()
writeln(self.choice_line)
sz, _ := self.lp.ScreenSize()
write_help := func(x string) {
lines := style.WrapTextAsLines(x, "", int(sz.WidthCells)-1)
for _, line := range lines {
if line != "" {
writeln(self.dim_formatter(line))
}
}
}
switch self.mode {
case HEX:
write_help(fmt.Sprintf("Type %s followed by the index for the recent entries below", INDEX_CHAR))
case NAME:
write_help(fmt.Sprintf("Use Tab or arrow keys to choose a character. Type space and %s to select by index", INDEX_CHAR))
case FAVORITES:
write_help("Press F12 to edit the list of favorites")
}
q := self.table.layout(int(sz.HeightCells)-y, int(sz.WidthCells))
if q != "" {
self.lp.QueueWriteString(q)
}
}
func (self *handler) on_text(text string, from_key_event, in_bracketed_paste bool) error {
err := self.rl.OnText(text, from_key_event, in_bracketed_paste)
if err != nil {
return err
}
self.refresh()
return nil
}
func (self *handler) switch_mode(mode Mode) {
if self.mode != mode {
self.mode = mode
self.rl.ResetText()
self.current_char = InvalidChar
self.choice_line = ""
}
}
func (self *handler) handle_hex_key_event(event *loop.KeyEvent) {
text := self.rl.AllText()
val, err := strconv.ParseUint(text, 16, 32)
new_val := -1
if err != nil {
return
}
if event.MatchesPressOrRepeat("tab") {
new_val = int(val) + 10
} else if event.MatchesPressOrRepeat("up") {
new_val = int(val) + 1
} else if event.MatchesPressOrRepeat("down") {
new_val = utils.Max(32, int(val)-1)
}
if new_val > -1 {
event.Handled = true
self.rl.SetText(fmt.Sprintf("%x", new_val))
}
}
func (self *handler) handle_name_key_event(event *loop.KeyEvent) {
if event.MatchesPressOrRepeat("shift+tab") || event.MatchesPressOrRepeat("left") {
event.Handled = true
self.table.move_current(0, -1)
} else if event.MatchesPressOrRepeat("tab") || event.MatchesPressOrRepeat("right") {
event.Handled = true
self.table.move_current(0, 1)
} else if event.MatchesPressOrRepeat("up") {
event.Handled = true
self.table.move_current(-1, 0)
} else if event.MatchesPressOrRepeat("down") {
event.Handled = true
self.table.move_current(1, 0)
}
}
func (self *handler) handle_emoticons_key_event(event *loop.KeyEvent) {
}
func (self *handler) handle_favorites_key_event(event *loop.KeyEvent) {
if event.MatchesPressOrRepeat("f12") {
event.Handled = true
exe, err := os.Executable()
if err != nil {
self.err = err
self.lp.Quit(1)
return
}
raw := serialize_favorites(load_favorites(false))
fp := favorites_path()
err = os.MkdirAll(filepath.Dir(fp), 0o755)
if err != nil {
self.err = fmt.Errorf("Failed to create config directory to store favorites in: %w", err)
self.lp.Quit(1)
return
}
err = utils.AtomicUpdateFile(fp, utils.UnsafeStringToBytes(raw), 0o600)
if err != nil {
self.err = fmt.Errorf("Failed to write to favorites file %s with error: %w", fp, err)
self.lp.Quit(1)
return
}
resume, err := self.lp.Suspend()
if err != nil {
self.err = err
self.lp.Quit(1)
return
}
defer resume()
cmd := exec.Command(exe, "edit-in-kitty", "--type=overlay", fp)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = cmd.Run()
if err == nil {
load_favorites(true)
} else {
fmt.Fprintln(os.Stderr, err)
fmt.Fprintln(os.Stderr, "Failed to run edit-in-kitty, favorites have not been changed. Press Enter to continue.")
var ln string
fmt.Scanln(&ln)
}
}
}
func (self *handler) next_mode(delta int) {
for num, md := range all_modes {
if md.mode == self.mode {
idx := (num + delta + len(all_modes)) % len(all_modes)
md = all_modes[idx]
self.switch_mode(md.mode)
break
}
}
}
func (self *handler) on_key_event(event *loop.KeyEvent) (err error) {
if event.MatchesPressOrRepeat("esc") || event.MatchesPressOrRepeat("ctrl+c") {
return fmt.Errorf("Canceled by user")
}
if event.MatchesPressOrRepeat("f1") || event.MatchesPressOrRepeat("ctrl+1") {
event.Handled = true
self.switch_mode(HEX)
} else if event.MatchesPressOrRepeat("f2") || event.MatchesPressOrRepeat("ctrl+2") {
event.Handled = true
self.switch_mode(NAME)
} else if event.MatchesPressOrRepeat("f3") || event.MatchesPressOrRepeat("ctrl+3") {
event.Handled = true
self.switch_mode(EMOTICONS)
} else if event.MatchesPressOrRepeat("f4") || event.MatchesPressOrRepeat("ctrl+4") {
event.Handled = true
self.switch_mode(FAVORITES)
} else if event.MatchesPressOrRepeat("tab") || event.MatchesPressOrRepeat("ctrl+]") {
event.Handled = true
self.next_mode(1)
} else if event.MatchesPressOrRepeat("shift+tab") || event.MatchesPressOrRepeat("ctrl+[") {
event.Handled = true
self.next_mode(-1)
}
if !event.Handled {
switch self.mode {
case HEX:
self.handle_hex_key_event(event)
case NAME:
self.handle_name_key_event(event)
case EMOTICONS:
self.handle_emoticons_key_event(event)
case FAVORITES:
self.handle_favorites_key_event(event)
}
}
if !event.Handled {
err = self.rl.OnKeyEvent(event)
if err != nil {
if err == readline.ErrAcceptInput {
self.refresh()
self.lp.Quit(0)
return nil
}
return err
}
}
if event.Handled {
self.refresh()
}
return
}
func (self *handler) refresh() {
self.update_prompt()
self.draw_screen()
}
func run_loop(opts *Options) (lp *loop.Loop, err error) {
output := tui.KittenOutputSerializer()
lp, err = loop.New()
if err != nil {
return
}
cv := utils.NewCachedValues("unicode-input", &CachedData{Recent: DEFAULT_SET, Mode: DEFAULT_MODE})
cached_data = cv.Load()
defer cv.Save()
h := handler{recent: cached_data.Recent, lp: lp, emoji_variation: opts.EmojiVariation}
switch cached_data.Mode {
case "HEX":
h.mode = HEX
case "NAME":
h.mode = NAME
case "EMOTICONS":
h.mode = EMOTICONS
case "FAVORITES":
h.mode = FAVORITES
}
all_modes[0] = ModeData{mode: HEX, title: "Code", key: "F1"}
all_modes[1] = ModeData{mode: NAME, title: "Name", key: "F2"}
all_modes[2] = ModeData{mode: EMOTICONS, title: "Emoticons", key: "F3"}
all_modes[3] = ModeData{mode: FAVORITES, title: "Favorites", key: "F4"}
lp.OnInitialize = func() (string, error) {
h.initialize()
lp.SendOverlayReady()
return "", nil
}
lp.OnResize = func(old_size, new_size loop.ScreenSize) error {
h.refresh()
return nil
}
lp.OnResumeFromStop = func() error {
h.refresh()
return nil
}
lp.OnText = h.on_text
lp.OnFinalize = h.finalize
lp.OnKeyEvent = h.on_key_event
err = lp.Run()
if err != nil {
return
}
if h.err == nil {
switch h.mode {
case HEX:
cached_data.Mode = "HEX"
case NAME:
cached_data.Mode = "NAME"
case EMOTICONS:
cached_data.Mode = "EMOTICONS"
case FAVORITES:
cached_data.Mode = "FAVORITES"
}
if h.current_char != InvalidChar {
cached_data.Recent = h.recent
idx := slices.Index(cached_data.Recent, h.current_char)
if idx > -1 {
cached_data.Recent = slices.Delete(cached_data.Recent, idx, idx+1)
}
cached_data.Recent = slices.Insert(cached_data.Recent, 0, h.current_char)[:len(DEFAULT_SET)]
ans := h.resolved_char()
o, err := output(ans)
if err != nil {
return lp, err
}
fmt.Println(o)
}
}
err = h.err
return
}
func main(cmd *cli.Command, o *Options, args []string) (rc int, err error) {
go unicode_names.Initialize() // start parsing name data in the background
build_sets()
lp, err := run_loop(o)
if err != nil {
return 1, err
}
ds := lp.DeathSignalName()
if ds != "" {
fmt.Println("Killed by signal: ", ds)
lp.KillIfSignalled()
return 1, nil
}
return
}
func EntryPoint(parent *cli.Command) {
create_cmd(parent, main)
}

View File

@@ -1,246 +0,0 @@
// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package unicode_input
import (
"fmt"
"strconv"
"strings"
"kitty/tools/unicode_names"
"kitty/tools/utils"
"kitty/tools/utils/style"
"kitty/tools/wcswidth"
"golang.org/x/exp/slices"
)
var _ = fmt.Print
func resolved_char(ch rune, emoji_variation string) string {
ans := string(ch)
if wcswidth.IsEmojiPresentationBase(ch) {
switch emoji_variation {
case "text":
ans += "\ufe0e"
case "graphic":
ans += "\ufe0f"
}
}
return ans
}
func decode_hint(text string) int {
x, err := strconv.ParseUint(text, INDEX_BASE, 32)
if err != nil {
return -1
}
return int(x)
}
func encode_hint(num int) string {
return strconv.FormatUint(uint64(num), INDEX_BASE)
}
func ljust(s string, sz int) string {
x := wcswidth.Stringwidth(s)
if x < sz {
s += strings.Repeat(" ", sz-x)
}
return s
}
type table struct {
emoji_variation string
layout_dirty bool
last_rows, last_cols int
codepoints []rune
current_idx, scroll_rows int
text string
num_cols, num_rows int
mode Mode
green, reversed, intense_gray func(...any) string
}
func (self *table) initialize(emoji_variation string, ctx style.Context) {
self.emoji_variation = emoji_variation
self.layout_dirty = true
self.last_cols, self.last_rows = -1, -1
self.green = ctx.SprintFunc("fg=green")
self.reversed = ctx.SprintFunc("reverse=true")
self.intense_gray = ctx.SprintFunc("fg=intense-gray")
}
func (self *table) current_codepoint() rune {
if len(self.codepoints) > 0 {
return self.codepoints[self.current_idx]
}
return InvalidChar
}
func (self *table) set_codepoints(codepoints []rune, mode Mode, current_idx int) {
self.codepoints = codepoints
if self.codepoints != nil {
slices.Sort(self.codepoints)
}
self.mode = mode
self.layout_dirty = true
if current_idx > -1 && current_idx < len(self.codepoints) {
self.current_idx = current_idx
}
if self.current_idx >= len(self.codepoints) {
self.current_idx = 0
}
self.scroll_rows = 0
}
func (self *table) codepoint_at_hint(hint string) rune {
idx := decode_hint(hint)
if idx >= 0 && idx < len(self.codepoints) {
return self.codepoints[idx]
}
return InvalidChar
}
type cell_data struct {
idx, ch, desc string
}
func title(x string) string {
if len(x) > 1 {
x = strings.ToUpper(x[:1]) + x[1:]
}
return x
}
func (self *table) layout(rows, cols int) string {
if !self.layout_dirty && self.last_cols == cols && self.last_rows == rows {
return self.text
}
self.last_cols, self.last_rows = cols, rows
self.layout_dirty = false
var as_parts func(int, rune) cell_data
var cell func(int, cell_data)
var idx_size, space_for_desc int
output := strings.Builder{}
output.Grow(4096)
switch self.mode {
case NAME:
as_parts = func(i int, codepoint rune) cell_data {
return cell_data{idx: ljust(encode_hint(i), idx_size), ch: resolved_char(codepoint, self.emoji_variation), desc: title(unicode_names.NameForCodePoint(codepoint))}
}
cell = func(i int, cd cell_data) {
is_current := i == self.current_idx
text := self.green(cd.idx) + " " + cd.ch + " "
w := wcswidth.Stringwidth(cd.ch)
if w < 2 {
text += strings.Repeat(" ", (2 - w))
}
desc_width := wcswidth.Stringwidth(cd.desc)
if desc_width > space_for_desc {
text += cd.desc[:space_for_desc-1] + "…"
} else {
text += cd.desc
extra := space_for_desc - desc_width
if extra > 0 {
text += strings.Repeat(" ", extra)
}
}
if is_current {
text = self.reversed(text)
}
output.WriteString(text)
}
default:
as_parts = func(i int, codepoint rune) cell_data {
return cell_data{idx: ljust(encode_hint(i), idx_size), ch: resolved_char(codepoint, self.emoji_variation)}
}
cell = func(i int, cd cell_data) {
output.WriteString(self.green(cd.idx))
output.WriteString(" ")
output.WriteString(self.intense_gray(cd.ch))
w := wcswidth.Stringwidth(cd.ch)
if w < 2 {
output.WriteString(strings.Repeat(" ", (2 - w)))
}
}
}
num := len(self.codepoints)
if num < 1 {
self.text = ""
self.num_cols = 0
self.num_rows = 0
return self.text
}
idx_size = len(encode_hint(num - 1))
parts := make([]cell_data, len(self.codepoints))
for i, ch := range self.codepoints {
parts[i] = as_parts(i, ch)
}
longest := 0
switch self.mode {
case NAME:
for _, p := range parts {
longest = utils.Max(longest, idx_size+2+len(p.desc)+2)
}
default:
longest = idx_size + 3
}
col_width := longest + 2
col_width = utils.Min(col_width, 40)
space_for_desc = col_width - 2 - idx_size - 4
self.num_cols = utils.Max(cols/col_width, 1)
self.num_rows = rows
rows_left := rows
skip_scroll := self.scroll_rows * self.num_cols
for i, cd := range parts {
if skip_scroll > 0 {
skip_scroll -= 1
continue
}
cell(i, cd)
output.WriteString(" ")
if i > 0 && (i+1)%self.num_cols == 0 {
rows_left -= 1
if rows_left == 0 {
break
}
output.WriteString("\r\n")
}
}
self.text = output.String()
return self.text
}
func (self *table) move_current(rows, cols int) {
if len(self.codepoints) == 0 {
return
}
if cols != 0 {
self.current_idx = (self.current_idx + len(self.codepoints) + cols) % len(self.codepoints)
self.layout_dirty = true
}
if rows != 0 {
amt := rows * self.num_cols
self.current_idx += amt
self.current_idx = utils.Max(0, utils.Min(self.current_idx, len(self.codepoints)-1))
self.layout_dirty = true
}
first_visible := self.scroll_rows * self.num_cols
last_visible := first_visible + ((self.num_cols * self.num_rows) - 1)
scroll_amount := self.num_rows
if self.current_idx < first_visible {
self.scroll_rows = utils.Max(self.scroll_rows-scroll_amount, 0)
}
if self.current_idx > last_visible {
self.scroll_rows += scroll_amount
}
}