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
+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.