diff --git a/docs-master/Config.md b/docs-master/Config.md index 1d101be1863..9c0ecc2c9b9 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -869,6 +869,8 @@ os: Specify an external command to invoke when copying to clipboard is requested. `{{text}` will be replaced by text to be copied. Default is to copy to system clipboard. +Note that when no command is configured and no native clipboard utility (e.g. `xclip`, `xsel`, `wl-copy`) is available — as is typical on a headless host over SSH — lazygit falls back to using the clipboard of the surrounding tmux session (bidirectional, requires `set-clipboard on` in tmux for forwarding to the system clipboard), or, outside tmux, to emitting an OSC52 escape sequence for terminals that support it. Since most terminals refuse OSC52 clipboard reads for security reasons, pasting in the OSC52 case returns the last value copied within the lazygit session. So on such hosts a custom command is only needed if these fallbacks don't fit your setup. + If you are working on a terminal that supports OSC52, the following command will let you take advantage of it: ```yaml diff --git a/pkg/commands/oscommands/os.go b/pkg/commands/oscommands/os.go index f1ee86c4c9a..a486fe09c63 100644 --- a/pkg/commands/oscommands/os.go +++ b/pkg/commands/oscommands/os.go @@ -31,6 +31,13 @@ type OSCommand struct { Cmd *CmdObjBuilder tempDir string + + // osc52Clipboard remembers the last value copied through the OSC 52 + // fallback. OSC 52 has no reliable, widely-supported read-back, so paste + // returns this remembered value instead of querying the terminal. See + // osc52_clipboard.go. + osc52Clipboard string + osc52ClipboardMutex sync.Mutex } // Platform stores the os state @@ -284,16 +291,36 @@ func (c *OSCommand) CopyToClipboard(str string) error { return c.Cmd.NewShell(cmdStr, c.UserConfig().OS.ShellFunctionsFile).Run() } + // When no native clipboard utility is available (e.g. a headless SSH host + // with no xclip/xsel/wl-copy) but we are inside tmux, drive tmux as the + // clipboard so copying still works. See tmux_clipboard.go. + if clipboard.Unsupported && c.inTmux() { + return c.copyToClipboardTmux(str) + } + + // Last resort with no native utility and no tmux: emit an OSC 52 escape so + // terminals that support it still receive the copy. See osc52_clipboard.go. + if clipboard.Unsupported { + return c.copyToClipboardOSC52(str) + } + return clipboard.WriteAll(str) } func (c *OSCommand) PasteFromClipboard() (string, error) { var s string var err error - if c.UserConfig().OS.CopyToClipboardCmd != "" { + switch { + case c.UserConfig().OS.CopyToClipboardCmd != "": cmdStr := c.UserConfig().OS.ReadFromClipboardCmd s, err = c.Cmd.NewShell(cmdStr, c.UserConfig().OS.ShellFunctionsFile).RunWithOutput() - } else { + case clipboard.Unsupported && c.inTmux(): + // Mirror the copy path: read back the tmux paste buffer we wrote to. + s, err = c.pasteFromClipboardTmux() + case clipboard.Unsupported: + // OSC 52 read-back is unreliable, so return the value we last copied. + s = c.pasteFromClipboardOSC52() + default: s, err = clipboard.ReadAll() } diff --git a/pkg/commands/oscommands/osc52_clipboard.go b/pkg/commands/oscommands/osc52_clipboard.go new file mode 100644 index 00000000000..eebf7d9ca67 --- /dev/null +++ b/pkg/commands/oscommands/osc52_clipboard.go @@ -0,0 +1,138 @@ +package oscommands + +import ( + "encoding/base64" + "os" + + "github.com/go-errors/errors" +) + +// OSC 52 clipboard fallback. +// +// OSC 52 is a terminal escape sequence that lets a program set the system +// clipboard purely by writing to the terminal, with no external helper binary +// and no tmux. The terminal emulator decodes the base64 payload and updates the +// clipboard on the machine the terminal is running on. This is the last-resort +// clipboard path for a headless SSH session that is *not* inside tmux but whose +// terminal understands OSC 52. Neovim's and Helix's clipboard providers use +// the same mechanism as their final fallback. +// +// The catch is read-back: most terminals refuse OSC 52 clipboard *reads* for +// security reasons, so there is no dependable way to paste from the terminal +// clipboard. We therefore remember whatever we last copied and hand that back +// on paste, matching how Neovim's and Helix's clipboard providers behave. +// +// CRITICAL PATH: terminal escape emission -- human review recommended. These +// bytes are written verbatim to the controlling terminal; a malformed sequence +// corrupts the display for the rest of the session. + +const ( + // osc52SequencePrefix opens an OSC 52 "set clipboard" request targeting the + // `c` (clipboard) selection. The base64-encoded payload follows immediately. + osc52SequencePrefix = "\x1b]52;c;" + + // osc52SequenceSuffix is the String Terminator (ESC backslash). ST is the + // standard OSC terminator; terminals that accept OSC 52 accept it, whereas + // BEL is a legacy xterm alternative. + osc52SequenceSuffix = "\x1b\\" + + // controllingTerminalPath is the process's controlling terminal. We target + // it rather than os.Stdout so the sequence reaches the real terminal even + // while the gocui TUI owns stdout, and so it survives any stdout + // redirection. + controllingTerminalPath = "/dev/tty" + + // terminalOpenMode is the permission argument to os.OpenFile. It is ignored + // because we open without O_CREATE, but the call still requires a value. + terminalOpenMode os.FileMode = 0 +) + +// copyToClipboardOSC52 emits an OSC 52 escape sequence to the controlling +// terminal to set the system clipboard, then remembers the value so a later +// paste can return it. It returns an error if the terminal cannot be opened or +// the sequence cannot be fully written; on error the remembered value is left +// unchanged so paste never reports a copy that did not happen. +// +// Note: OSC 52 provides no acknowledgement, so a terminal that does not +// implement it silently drops the payload. A successful write here means the +// sequence was emitted, not that the clipboard was definitely updated. Very +// large payloads may also be truncated or rejected by the terminal; that limit +// is inherent to OSC 52 and is left to the terminal. +func (c *OSCommand) copyToClipboardOSC52(str string) error { + // An empty payload is a defined OSC 52 request to *clear* the clipboard, + // which a "copy" action never intends. Treat it as a no-op so we never wipe + // the user's clipboard by accident, but still remember the empty value. + if str == "" { + c.rememberOSC52Clipboard(str) + return nil + } + + sequence, err := buildOSC52Sequence(str) + if err != nil { + return err + } + + terminal, err := os.OpenFile(controllingTerminalPath, os.O_WRONLY, terminalOpenMode) + if err != nil { + return errors.Errorf("opening controlling terminal %s for OSC52 clipboard copy: %v", controllingTerminalPath, err) + } + defer terminal.Close() + + written, err := terminal.WriteString(sequence) + if err != nil { + return errors.Errorf("writing OSC52 clipboard sequence: %v", err) + } + + // Postcondition: a short write means the terminal received a truncated, + // malformed escape sequence. Surface it rather than pretend the copy worked. + if written != len(sequence) { + return errors.Errorf("short write emitting OSC52 clipboard sequence: wrote %d of %d bytes", written, len(sequence)) + } + + c.rememberOSC52Clipboard(str) + return nil +} + +// buildOSC52Sequence frames str into a complete OSC 52 set-clipboard escape +// sequence: prefix, base64-encoded payload, terminator. It is pure and +// side-effect free so the framing can be verified in isolation from the +// terminal write. str must be non-empty; an empty payload would assemble a +// clipboard-clearing request, which buildOSC52Sequence refuses. +func buildOSC52Sequence(str string) (string, error) { + if str == "" { + return "", errors.Errorf("refusing to build empty OSC52 clipboard payload") + } + + encoded := base64.StdEncoding.EncodeToString([]byte(str)) + sequence := osc52SequencePrefix + encoded + osc52SequenceSuffix + + // Postcondition: the framed sequence must be strictly longer than its + // prefix and suffix combined, otherwise the encoding step produced nothing. + if len(sequence) <= len(osc52SequencePrefix)+len(osc52SequenceSuffix) { + return "", errors.Errorf("assembled OSC52 sequence is missing its payload") + } + + return sequence, nil +} + +// pasteFromClipboardOSC52 returns the value most recently copied through the +// OSC 52 fallback. It never queries the terminal because OSC 52 read-back is +// not dependably supported. +func (c *OSCommand) pasteFromClipboardOSC52() string { + return c.recallOSC52Clipboard() +} + +// rememberOSC52Clipboard stores the last copied value under the mutex so copy +// and paste can be called from different goroutines safely. +func (c *OSCommand) rememberOSC52Clipboard(str string) { + c.osc52ClipboardMutex.Lock() + defer c.osc52ClipboardMutex.Unlock() + c.osc52Clipboard = str +} + +// recallOSC52Clipboard reads the last copied value under the mutex. +func (c *OSCommand) recallOSC52Clipboard() string { + c.osc52ClipboardMutex.Lock() + defer c.osc52ClipboardMutex.Unlock() + return c.osc52Clipboard +} diff --git a/pkg/commands/oscommands/osc52_clipboard_test.go b/pkg/commands/oscommands/osc52_clipboard_test.go new file mode 100644 index 00000000000..e680a70f6c3 --- /dev/null +++ b/pkg/commands/oscommands/osc52_clipboard_test.go @@ -0,0 +1,55 @@ +package oscommands + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestBuildOSC52Sequence verifies the escape sequence is framed correctly: +// the `\x1b]52;c;` prefix, the base64 of the payload, and the ST terminator. +func TestBuildOSC52Sequence(t *testing.T) { + const payload = "commit-hash-abc123" + + sequence, err := buildOSC52Sequence(payload) + assert.NoError(t, err) + + expected := osc52SequencePrefix + base64.StdEncoding.EncodeToString([]byte(payload)) + osc52SequenceSuffix + assert.Equal(t, expected, sequence) + + // Guard against accidental terminator/prefix changes that would silently + // break terminals which only accept the exact framing. + assert.Equal(t, "\x1b]52;c;", osc52SequencePrefix) + assert.Equal(t, "\x1b\\", osc52SequenceSuffix) +} + +// TestBuildOSC52SequenceRejectsEmpty verifies we never assemble an empty +// payload, which OSC 52 interprets as a request to clear the clipboard. +func TestBuildOSC52SequenceRejectsEmpty(t *testing.T) { + sequence, err := buildOSC52Sequence("") + assert.Error(t, err) + assert.Equal(t, "", sequence) +} + +// TestOSC52ClipboardCacheStartsEmpty verifies paste returns the empty string +// before anything has been copied, rather than panicking or erroring. +func TestOSC52ClipboardCacheStartsEmpty(t *testing.T) { + osCommand := NewDummyOSCommand() + + assert.Equal(t, "", osCommand.pasteFromClipboardOSC52()) +} + +// TestOSC52ClipboardCacheRoundTrip verifies the central fallback contract: +// because OSC 52 read-back is unreliable, paste must return exactly the value +// most recently copied. +func TestOSC52ClipboardCacheRoundTrip(t *testing.T) { + osCommand := NewDummyOSCommand() + + osCommand.rememberOSC52Clipboard("first-copied-value") + assert.Equal(t, "first-copied-value", osCommand.pasteFromClipboardOSC52()) + + // A subsequent copy must overwrite, not append or stick to the old value. + osCommand.rememberOSC52Clipboard("second-copied-value") + assert.Equal(t, "second-copied-value", osCommand.pasteFromClipboardOSC52()) +} diff --git a/pkg/commands/oscommands/tmux_clipboard.go b/pkg/commands/oscommands/tmux_clipboard.go new file mode 100644 index 00000000000..48eea69eab9 --- /dev/null +++ b/pkg/commands/oscommands/tmux_clipboard.go @@ -0,0 +1,75 @@ +package oscommands + +import ( + "os/exec" +) + +// tmux clipboard provider. +// +// On a headless SSH host there is no X11 or Wayland display, so none of xclip, +// xsel, or wl-copy can reach a clipboard. In that situation atotto/clipboard +// reports itself unsupported and lazygit's default copy path fails with +// "No clipboard utilities available". When we are running inside a tmux +// session, though, tmux itself can act as the clipboard: it keeps its own +// paste buffers and, with `set-clipboard on`, relays them to the outer +// terminal's system clipboard via tmux's own OSC 52 emission. +// +// Unlike a raw OSC 52 escape we could emit ourselves, the tmux buffer is +// readable, so paste works too -- `save-buffer` hands the buffer contents back +// on stdout. That gives real bidirectional clipboard support within the +// session, which is why we prefer it over a write-only OSC 52 fallback. + +const ( + // tmuxEnvVar is set by tmux for every process running inside a session. Its + // presence is how we detect that we are attached to a tmux server. + tmuxEnvVar = "TMUX" + + // tmuxBinaryName is the tmux executable we shell out to. It must be on PATH. + tmuxBinaryName = "tmux" + + // tmuxLoadBufferSubcommand fills a tmux paste buffer from a file argument. + tmuxLoadBufferSubcommand = "load-buffer" + + // tmuxSaveBufferSubcommand writes a tmux paste buffer to a file argument. + tmuxSaveBufferSubcommand = "save-buffer" + + // tmuxSetClipboardFlag asks tmux to also copy the buffer to the outer + // terminal's system clipboard (via tmux's OSC 52 emission) in addition to + // storing it in the tmux buffer. Requires `set-clipboard on` in tmux. + tmuxSetClipboardFlag = "-w" + + // tmuxStdioArg is tmux's conventional "-" file argument meaning "read from + // stdin" for load-buffer and "write to stdout" for save-buffer. + tmuxStdioArg = "-" +) + +// inTmux reports whether lazygit is running inside a tmux session that we can +// drive as a clipboard. Both conditions must hold: the TMUX environment +// variable is set (we are attached to a session), and the tmux binary is +// resolvable on PATH (we can actually invoke it). +func (c *OSCommand) inTmux() bool { + if c.getenvFn(tmuxEnvVar) == "" { + return false + } + + _, err := exec.LookPath(tmuxBinaryName) + return err == nil +} + +// copyToClipboardTmux stores str in a tmux paste buffer and, via the +// set-clipboard flag, forwards it to the outer terminal's system clipboard. +// The buffer contents are streamed in on stdin using the "-" file argument. +func (c *OSCommand) copyToClipboardTmux(str string) error { + return c.Cmd. + New([]string{tmuxBinaryName, tmuxLoadBufferSubcommand, tmuxSetClipboardFlag, tmuxStdioArg}). + SetStdin(str). + Run() +} + +// pasteFromClipboardTmux returns the contents of the most recent tmux paste +// buffer by streaming it out on stdout using the "-" file argument. +func (c *OSCommand) pasteFromClipboardTmux() (string, error) { + return c.Cmd. + New([]string{tmuxBinaryName, tmuxSaveBufferSubcommand, tmuxStdioArg}). + RunWithOutput() +} diff --git a/pkg/commands/oscommands/tmux_clipboard_test.go b/pkg/commands/oscommands/tmux_clipboard_test.go new file mode 100644 index 00000000000..e2ed1fd98cc --- /dev/null +++ b/pkg/commands/oscommands/tmux_clipboard_test.go @@ -0,0 +1,94 @@ +package oscommands + +import ( + "io" + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" +) + +// newTmuxTestOSCommand wires a dummy OSCommand to a fake command runner and a +// controllable environment lookup so we can assert on the exact tmux +// invocations without shelling out to a real tmux server. +func newTmuxTestOSCommand(runner *FakeCmdObjRunner, env map[string]string) *OSCommand { + osCommand := NewDummyOSCommandWithDeps(OSCommandDeps{ + GetenvFn: func(key string) string { return env[key] }, + }) + // NewDummyOSCommandWithDeps does not wire up Cmd, so attach the fake runner + // explicitly; every tmux invocation goes through it. + osCommand.Cmd = NewDummyCmdObjBuilder(runner) + return osCommand +} + +// TestInTmuxRequiresEnvVar verifies the first half of the detection guard: +// without the TMUX environment variable we must report that we are not in tmux, +// regardless of whether the tmux binary happens to be installed. +func TestInTmuxRequiresEnvVar(t *testing.T) { + osCommand := newTmuxTestOSCommand(NewFakeRunner(t), map[string]string{}) + + assert.False(t, osCommand.inTmux()) +} + +// TestInTmuxWithEnvVarTracksBinary verifies the second half of the guard: with +// the TMUX environment variable set, the result must follow whether the tmux +// binary is resolvable on PATH. We compute the expectation from the same +// lookup the implementation uses so the test is deterministic in any +// environment, with or without tmux installed. +func TestInTmuxWithEnvVarTracksBinary(t *testing.T) { + osCommand := newTmuxTestOSCommand(NewFakeRunner(t), map[string]string{tmuxEnvVar: "/tmp/tmux-1000/default,1234,0"}) + + _, lookErr := exec.LookPath(tmuxBinaryName) + tmuxBinaryPresent := lookErr == nil + + assert.Equal(t, tmuxBinaryPresent, osCommand.inTmux()) +} + +// TestCopyToClipboardTmux asserts that copying loads the text into a tmux +// buffer with the set-clipboard flag, streaming the payload in on stdin. +func TestCopyToClipboardTmux(t *testing.T) { + const payload = "branch-name-to-copy" + + runner := NewFakeRunner(t) + runner.ExpectFunc( + "tmux load-buffer with set-clipboard flag and payload on stdin", + func(cmdObj *CmdObj) bool { + expectedArgs := []string{tmuxBinaryName, tmuxLoadBufferSubcommand, tmuxSetClipboardFlag, tmuxStdioArg} + if !assert.ObjectsAreEqual(expectedArgs, cmdObj.GetCmd().Args) { + return false + } + + // The buffer contents must be piped in on stdin verbatim. + stdin, err := io.ReadAll(cmdObj.GetCmd().Stdin) + assert.NoError(t, err) + return string(stdin) == payload + }, + "", + nil, + ) + + osCommand := newTmuxTestOSCommand(runner, map[string]string{tmuxEnvVar: "session"}) + + assert.NoError(t, osCommand.copyToClipboardTmux(payload)) + runner.CheckForMissingCalls() +} + +// TestPasteFromClipboardTmux asserts that pasting saves the tmux buffer to +// stdout and returns its contents. +func TestPasteFromClipboardTmux(t *testing.T) { + const bufferContents = "previously-copied-text" + + runner := NewFakeRunner(t) + runner.ExpectArgs( + []string{tmuxBinaryName, tmuxSaveBufferSubcommand, tmuxStdioArg}, + bufferContents, + nil, + ) + + osCommand := newTmuxTestOSCommand(runner, map[string]string{tmuxEnvVar: "session"}) + + pasted, err := osCommand.pasteFromClipboardTmux() + assert.NoError(t, err) + assert.Equal(t, bufferContents, pasted) + runner.CheckForMissingCalls() +}