commit f7fd540

delthas  ·  2026-04-21 13:13:54 +0000 UTC
parent 9a0fd3d
Add clipboard upload via /upload

/upload with no argument now streams the current clipboard content
instead of a file, using wl-paste on Wayland or xclip on X11.
Ctrl+Alt+V fills the editor with /upload as a shortcut; Ctrl+V shows
a one-time hint explaining the paste/upload shortcuts.

The upload path now accepts an io.Reader, filename and mimetype, and
supports unknown-size uploads (chunked transfer) for streaming from
the clipboard subprocess without buffering.
5 files changed,  +168, -20
M app.go
M app.go
+40, -14
  1@@ -17,7 +17,6 @@ import (
  2 	"net/url"
  3 	"os"
  4 	"os/exec"
  5-	"path/filepath"
  6 	"regexp"
  7 	"runtime/debug"
  8 	"strconv"
  9@@ -152,6 +151,7 @@ type App struct {
 10 	uploadingProgress *float64
 11 
 12 	shownBouncerNotice bool
 13+	shownPasteHint     bool
 14 
 15 	closing atomic.Bool
 16 
 17@@ -243,7 +243,7 @@ func NewApp(cfg Config) (app *App, err error) {
 18 }
 19 
 20 func (app *App) Close() {
 21-	app.win.Exit() // tell all instances of app.ircLoop to stop when possible
 22+	app.win.Exit()       // tell all instances of app.ircLoop to stop when possible
 23 	app.postEvent(event{ // tell app.eventLoop to stop
 24 		src:     "*",
 25 		content: nil,
 26@@ -1055,6 +1055,16 @@ func (app *App) handleAction(action string, args ...string) {
 27 			}
 28 			app.spellCheck()
 29 		}
 30+	case "paste-hint":
 31+		if !app.shownPasteHint {
 32+			app.shownPasteHint = true
 33+			netID, _ := app.win.CurrentBuffer()
 34+			app.addStatusLine(netID, ui.Line{
 35+				At:   time.Now(),
 36+				Head: ui.PlainString("--"),
 37+				Body: ui.PlainString("Use Control+Shift+V to paste text, or Control+Alt+V to upload clipboard content (e.g. images)"),
 38+			})
 39+		}
 40 	case "none":
 41 	default:
 42 		netID, buffer := app.win.CurrentBuffer()
 43@@ -1071,6 +1081,8 @@ var defaultCommands = map[string][]string{
 44 	"Control+c":       {"quit"},
 45 	"Control+f":       {"set-editor", "/search "},
 46 	"Control+k":       {"set-editor", "/buffer "},
 47+	"Control+v":       {"paste-hint"},
 48+	"Control+Alt+v":   {"set-editor", "/upload"},
 49 	"Control+a":       {"cursor-start"},
 50 	"Control+e":       {"cursor-end"},
 51 	"Control+l":       {"redraw"},
 52@@ -1349,15 +1361,17 @@ func (app *App) handleLinkEvent(ev *events.EventClickLink) {
 53 	}()
 54 }
 55 
 56-func (app *App) upload(url string, f *os.File, size int64) (string, error) {
 57-	defer f.Close()
 58+func (app *App) upload(url string, r io.Reader, size int64, filename, mimetype string) (string, error) {
 59 	c := http.Client{
 60 		Timeout: 30 * time.Second,
 61 	}
 62-	r := ReadProgress{
 63-		Reader: f,
 64+	rp := ReadProgress{
 65+		Reader: r,
 66 		period: 250 * time.Millisecond,
 67 		f: func(n int64) {
 68+			if size <= 0 {
 69+				return
 70+			}
 71 			app.postEvent(event{
 72 				src: "*",
 73 				content: &events.EventFileUpload{
 74@@ -1366,17 +1380,24 @@ func (app *App) upload(url string, f *os.File, size int64) (string, error) {
 75 			})
 76 		},
 77 	}
 78-	req, err := http.NewRequest("POST", url, &r)
 79+	req, err := http.NewRequest("POST", url, &rp)
 80 	if err != nil {
 81 		return "", fmt.Errorf("creating upload request: %v", err)
 82 	}
 83 	if app.cfg.Password != nil {
 84 		req.SetBasicAuth(app.cfg.User, *app.cfg.Password)
 85 	}
 86-	req.ContentLength = size
 87-	req.Header.Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{
 88-		"filename": filepath.Base(f.Name()),
 89-	}))
 90+	if size >= 0 {
 91+		req.ContentLength = size
 92+	}
 93+	if filename != "" {
 94+		req.Header.Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{
 95+			"filename": filename,
 96+		}))
 97+	}
 98+	if mimetype != "" {
 99+		req.Header.Set("Content-Type", mimetype)
100+	}
101 	res, err := c.Do(req)
102 	if err != nil {
103 		return "", fmt.Errorf("uploading: %v", err)
104@@ -1393,8 +1414,10 @@ func (app *App) upload(url string, f *os.File, size int64) (string, error) {
105 				maxSize = v
106 			}
107 		}
108-		if maxSize > 0 {
109+		if maxSize > 0 && size >= 0 {
110 			return "", fmt.Errorf("uploading: file too large: maximum %v per file (file was %v)", formatSize(maxSize), formatSize(size))
111+		} else if maxSize > 0 {
112+			return "", fmt.Errorf("uploading: file too large: maximum %v per file", formatSize(maxSize))
113 		} else {
114 			return "", fmt.Errorf("uploading: file too large")
115 		}
116@@ -1409,11 +1432,14 @@ func (app *App) upload(url string, f *os.File, size int64) (string, error) {
117 	return location.String(), nil
118 }
119 
120-func (app *App) handleUpload(url string, f *os.File, size int64) {
121+func (app *App) handleUpload(url string, r io.Reader, size int64, filename, mimetype string, closer io.Closer) {
122 	var progress float64 = 0
123 	app.uploadingProgress = &progress
124 	go func() {
125-		location, err := app.upload(url, f, size)
126+		if closer != nil {
127+			defer closer.Close()
128+		}
129+		location, err := app.upload(url, r, size, filename, mimetype)
130 		if err != nil {
131 			app.postEvent(event{
132 				src: "*",
+12, -0
 1@@ -0,0 +1,12 @@
 2+//go:build !linux
 3+
 4+package senpai
 5+
 6+import (
 7+	"fmt"
 8+	"io"
 9+)
10+
11+func readClipboard() (io.ReadCloser, string, error) {
12+	return nil, "", fmt.Errorf("clipboard access is not supported on this platform")
13+}
+96, -0
 1@@ -0,0 +1,96 @@
 2+//go:build linux
 3+
 4+package senpai
 5+
 6+import (
 7+	"fmt"
 8+	"io"
 9+	"os"
10+	"os/exec"
11+	"strings"
12+)
13+
14+type clipboardReader struct {
15+	io.ReadCloser
16+	cmd *exec.Cmd
17+}
18+
19+func (r *clipboardReader) Close() error {
20+	err := r.ReadCloser.Close()
21+	if waitErr := r.cmd.Wait(); err == nil {
22+		err = waitErr
23+	}
24+	return err
25+}
26+
27+func readClipboard() (io.ReadCloser, string, error) {
28+	if os.Getenv("WAYLAND_DISPLAY") != "" || os.Getenv("XDG_SESSION_TYPE") == "wayland" {
29+		return readClipboardWayland()
30+	}
31+	return readClipboardX11()
32+}
33+
34+func startClipboardCmd(cmd *exec.Cmd) (io.ReadCloser, error) {
35+	stdout, err := cmd.StdoutPipe()
36+	if err != nil {
37+		return nil, err
38+	}
39+	if err := cmd.Start(); err != nil {
40+		return nil, err
41+	}
42+	return &clipboardReader{ReadCloser: stdout, cmd: cmd}, nil
43+}
44+
45+func readClipboardWayland() (io.ReadCloser, string, error) {
46+	out, err := exec.Command("wl-paste", "--list-types").Output()
47+	if err != nil {
48+		return nil, "", fmt.Errorf("listing clipboard types: %v", err)
49+	}
50+	var mimetype string
51+	for _, line := range strings.Split(string(out), "\n") {
52+		line = strings.TrimSpace(line)
53+		if line != "" {
54+			mimetype = line
55+			break
56+		}
57+	}
58+	if mimetype == "" {
59+		return nil, "", fmt.Errorf("clipboard is empty")
60+	}
61+	rc, err := startClipboardCmd(exec.Command("wl-paste", "-t", mimetype))
62+	if err != nil {
63+		return nil, "", fmt.Errorf("reading clipboard: %v", err)
64+	}
65+	return rc, mimetype, nil
66+}
67+
68+var x11SkipTargets = map[string]bool{
69+	"TARGETS":      true,
70+	"TIMESTAMP":    true,
71+	"MULTIPLE":     true,
72+	"SAVE_TARGETS": true,
73+}
74+
75+func readClipboardX11() (io.ReadCloser, string, error) {
76+	out, err := exec.Command("xclip", "-selection", "clipboard", "-o", "-t", "TARGETS").Output()
77+	if err != nil {
78+		return nil, "", fmt.Errorf("listing clipboard types: %v", err)
79+	}
80+	var mimetype string
81+	for _, line := range strings.Split(string(out), "\n") {
82+		line = strings.TrimSpace(line)
83+		if line == "" || x11SkipTargets[line] {
84+			continue
85+		}
86+		mimetype = line
87+		break
88+	}
89+	if mimetype == "" {
90+		return nil, "", fmt.Errorf("clipboard is empty")
91+	}
92+	rc, err := startClipboardCmd(exec.Command("xclip", "-selection", "clipboard", "-o", "-t", mimetype))
93+	if err != nil {
94+		return nil, "", fmt.Errorf("reading clipboard: %v", err)
95+	}
96+	return rc, mimetype, nil
97+}
+13, -4
 1@@ -77,10 +77,9 @@ func init() {
 2 		},
 3 		"UPLOAD": {
 4 			AllowHome: true,
 5-			MinArgs:   1,
 6 			MaxArgs:   1,
 7-			Usage:     "<file path>",
 8-			Desc:      "upload a local file to the bouncer",
 9+			Usage:     "[file path]",
10+			Desc:      "upload a local file to the bouncer, or the current clipboard content if no path is given",
11 			Handle:    commandDoUpload,
12 		},
13 		"SCREENSHOT": {
14@@ -528,6 +527,16 @@ func commandDoUpload(app *App, args []string) (err error) {
15 	if upload == "" {
16 		return fmt.Errorf("file upload is not supported on this server; try using soju and enabling file upload")
17 	}
18+
19+	if len(args) == 0 {
20+		rc, mimetype, err := readClipboard()
21+		if err != nil {
22+			return err
23+		}
24+		app.handleUpload(upload, rc, -1, "", mimetype, rc)
25+		return nil
26+	}
27+
28 	path := args[0]
29 	if home, err := os.UserHomeDir(); err == nil && !filepath.IsAbs(path) {
30 		path = filepath.Join(home, path)
31@@ -542,7 +551,7 @@ func commandDoUpload(app *App, args []string) (err error) {
32 		return fmt.Errorf("opening file: %v", err)
33 	}
34 
35-	app.handleUpload(upload, f, fi.Size())
36+	app.handleUpload(upload, f, fi.Size(), filepath.Base(path), "", f)
37 	return nil
38 }
39 
+7, -2
 1@@ -111,6 +111,9 @@ These shortcuts can be customized in the configuration, see senpai(5).
 2 *CTRL-K*
 3 	Prepare for jumping to a buffer: add /buffer to input line.
 4 
 5+*CTRL-ALT-V*
 6+	Prepare for uploading the clipboard: add /upload to input line.
 7+
 8 *CTRL-U*, *PgUp*
 9 	Go up in the timeline.
10 
11@@ -230,8 +233,10 @@ _name_ is matched case-insensitively.  It can be one of the following:
12 	Send the current song that is being played on the system. Uses DBus/MPRIS
13 	internally.
14 
15-*UPLOAD* <file path>
16-	Upload a local file to the bouncer.
17+*UPLOAD* [file path]
18+	Upload a local file to the bouncer. If no path is given, upload the
19+	current clipboard content instead (requires *wl-paste* on Wayland or
20+	*xclip* on X11; Linux only).
21 
22 *SCREENSHOT*
23 	Take and upload a screenshot to the bouncer.