tabby-clip-image 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/index.js +250 -17
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ In an SSH tab, paste with any of `Ctrl+V`, `Cmd+V`, `Ctrl+Shift+V`, `Shift+Inser
|
|
|
10
10
|
| image **file(s)** copied in the file manager (png, jpg, gif, webp, bmp, tif, heic/heif, avif, svg, ico) | uploaded **unchanged**, original format kept, multiple files supported (Windows lists them via the built-in PowerShell) |
|
|
11
11
|
| text, or non-image files | normal paste |
|
|
12
12
|
|
|
13
|
-
the bytes go over the tab's **existing SSH connection** (new exec channel
|
|
13
|
+
the bytes go over the tab's **existing SSH connection** (built-in SSH tabs: new exec channel, no extra login) into `~/.cache/clip-recv/shots/shot-<time>-<pid>-<name>.<ext>` on the host, and the absolute path is pasted at the cursor.
|
|
14
14
|
|
|
15
15
|
**Nothing to install on the host**: it only uses POSIX `sh`, `mkdir`, `date`, `cat`, `chmod`, `find`. No X11/Wayland needed.
|
|
16
16
|
|
|
@@ -18,8 +18,20 @@ Client side: Linux (X11/Wayland), macOS, Windows — local tabs and text pastes
|
|
|
18
18
|
|
|
19
19
|
## Install
|
|
20
20
|
|
|
21
|
+
Manual: copy this folder to Tabby's user plugin dir — Windows `%APPDATA%\tabby\plugins\node_modules\tabby-clip-image`, macOS `~/Library/Application Support/tabby/plugins/node_modules/tabby-clip-image`, Linux `~/.config/tabby/plugins/node_modules/tabby-clip-image` — then quit and restart Tabby. Or run `install-tabby-plugin.sh` / `.ps1` from the repo. Works in Tabby's built-in **SSH tabs** and, since 0.5.0, in **local tabs where you run `ssh`** (see below).
|
|
22
|
+
|
|
23
|
+
Plugin manager:
|
|
24
|
+
|
|
21
25
|
Tabby → Settings → Plugins → search `clip-image` → Install → restart Tabby.
|
|
22
26
|
|
|
27
|
+
## Local tabs running `ssh` (since 0.5.0)
|
|
28
|
+
|
|
29
|
+
If you connect with the system `ssh` from a Tabby local shell (`ssh myhost`, aliases from `~/.ssh/config`, jump hosts, keys in `ssh-agent`), the plugin finds the `ssh` process that the tab's shell is running, reuses its options and starts a **second, non-interactive** `ssh` (`-T -o BatchMode=yes`) to upload the image. Therefore:
|
|
30
|
+
|
|
31
|
+
- the host must be reachable without a prompt: key/agent auth, or `ControlMaster`/`ControlPersist` in `~/.ssh/config` (password-only hosts fail with an error toast);
|
|
32
|
+
- `ssh` must be started directly from the tab's shell (not inside a *local* tmux/screen). `tmux` on the **remote** host is fine;
|
|
33
|
+
- if nothing is running `ssh` in the tab, pasting behaves exactly as before.
|
|
34
|
+
|
|
23
35
|
## Configuration
|
|
24
36
|
|
|
25
37
|
- Path prefix: `PATH_PREFIX` in `index.js` (default `''`). When the app enabled bracketed paste (Claude Code, Codex, …) each path is sent as a paste, exactly like drag-and-drop, so the TUI shows `[Image #1]`; otherwise (a shell prompt) the plain path is typed. Verified with real Claude Code and Codex; `'@'` only works as a file mention in Claude Code.
|
package/index.js
CHANGED
|
@@ -8,6 +8,19 @@
|
|
|
8
8
|
// the bytes are streamed over the tab's existing SSH connection to a fixed directory on the host
|
|
9
9
|
// (plain `sh` + `cat`; nothing to install there) and the absolute path is typed at the cursor.
|
|
10
10
|
// Anything else (text, non-image files, local tabs) pastes normally.
|
|
11
|
+
const VERSION = '0.5.0'
|
|
12
|
+
// Trace to the console AND to ~/.tabby-clip-image.log (capped) so problems can be diagnosed without DevTools.
|
|
13
|
+
const LOG_FILE = require('path').join(require('os').homedir(), '.tabby-clip-image.log')
|
|
14
|
+
const log = (...a) => {
|
|
15
|
+
console.log('[tabby-clip-image]', ...a)
|
|
16
|
+
try {
|
|
17
|
+
const fsx = require('fs')
|
|
18
|
+
try { if (fsx.statSync(LOG_FILE).size > 200 * 1024) { fsx.renameSync(LOG_FILE, LOG_FILE + '.old') } } catch { /* no file yet */ }
|
|
19
|
+
fsx.appendFileSync(LOG_FILE, `${new Date().toISOString()} ${a.map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ')}\n`)
|
|
20
|
+
} catch { /* never break the terminal because of logging */ }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
log('loaded v' + VERSION + ' on ' + process.platform + ' ' + process.arch)
|
|
11
24
|
const fs = require('fs')
|
|
12
25
|
const path = require('path')
|
|
13
26
|
const { fileURLToPath } = require('url')
|
|
@@ -106,7 +119,7 @@ function safeBase (file) {
|
|
|
106
119
|
* Each item: { base, ext, size?, load: () => Promise<Buffer> } or { error } for unusable files.
|
|
107
120
|
*/
|
|
108
121
|
function collectItems (cb, platform = process.platform, deps) {
|
|
109
|
-
if (!cb) { return [] }
|
|
122
|
+
if (!cb) { log('no electron clipboard available'); return [] }
|
|
110
123
|
const files = clipboardFilePaths(cb, platform, deps)
|
|
111
124
|
if (files.length) {
|
|
112
125
|
const items = []
|
|
@@ -129,9 +142,24 @@ function collectItems (cb, platform = process.platform, deps) {
|
|
|
129
142
|
} catch { return [] }
|
|
130
143
|
}
|
|
131
144
|
|
|
145
|
+
/** Human-readable reason when a paste in an SSH tab found nothing to upload and no text (else null). */
|
|
146
|
+
function describeNothingToPaste (cb) {
|
|
147
|
+
try {
|
|
148
|
+
if (!cb || cb.readText()) { return null }
|
|
149
|
+
const f = cb.availableFormats()
|
|
150
|
+
return f.length ? `clipboard has no text or usable image (formats: ${f.join(', ')})` : 'clipboard is empty (a screenshot saved to a file is not on the clipboard; use Cmd+Ctrl+Shift+4 on macOS)'
|
|
151
|
+
} catch { return null }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let announced = false
|
|
155
|
+
function notifyTab (tab, kind, msg) { try { tab.notifications?.[kind]?.(msg) } catch { /* ignore */ } }
|
|
156
|
+
|
|
132
157
|
// ---------- uploading ----------
|
|
133
158
|
|
|
134
|
-
|
|
159
|
+
const { spawn, execFile } = require('child_process')
|
|
160
|
+
|
|
161
|
+
/** Tabby's built-in SSH tab: new exec channel on the tab's existing connection. */
|
|
162
|
+
async function uploadViaBuiltinSsh (ssh, item) {
|
|
135
163
|
const bytes = await item.load()
|
|
136
164
|
if (!bytes.length) { throw new Error('empty image') }
|
|
137
165
|
const ch = await ssh.activateChannel(await ssh.openSessionChannel())
|
|
@@ -161,26 +189,182 @@ async function uploadOne (ssh, item) {
|
|
|
161
189
|
return m[2].trim()
|
|
162
190
|
}
|
|
163
191
|
|
|
192
|
+
// ---- local tabs that run the system `ssh` (e.g. `ssh myhost` typed in a Tabby local shell) ----
|
|
193
|
+
|
|
194
|
+
const SSH_ARG_FLAGS = new Set('BbcDEeFIiJLlmOopQRSWw'.split('')) // short ssh options that take a value
|
|
195
|
+
const SSH_DROP_FLAGS = new Set('tTNfnvqVGM'.split('')) // tty / background / verbosity / control flags we must not forward
|
|
196
|
+
|
|
197
|
+
function tokenizeCommandLine (s) {
|
|
198
|
+
const out = []
|
|
199
|
+
let cur = ''
|
|
200
|
+
let q = null
|
|
201
|
+
let has = false
|
|
202
|
+
for (const ch of String(s)) {
|
|
203
|
+
if (q) { if (ch === q) { q = null } else { cur += ch } continue }
|
|
204
|
+
if (ch === '"' || ch === "'") { q = ch; has = true; continue }
|
|
205
|
+
if (/\s/.test(ch)) { if (cur || has) { out.push(cur); cur = ''; has = false } continue }
|
|
206
|
+
cur += ch
|
|
207
|
+
}
|
|
208
|
+
if (cur || has) { out.push(cur) }
|
|
209
|
+
return out
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** tokens = ['ssh', ...args] -> { prefix: forwardable ssh options, dest } or null. The remote command (if any) is dropped. */
|
|
213
|
+
function parseSshCommand (tokens) {
|
|
214
|
+
const prefix = []
|
|
215
|
+
let i = 1
|
|
216
|
+
while (i < tokens.length) {
|
|
217
|
+
const t = tokens[i]
|
|
218
|
+
if (t === '--') { i++; break }
|
|
219
|
+
if (t[0] !== '-' || t.length === 1) { break }
|
|
220
|
+
for (let j = 1; j < t.length; j++) {
|
|
221
|
+
const ch = t[j]
|
|
222
|
+
if (SSH_ARG_FLAGS.has(ch)) {
|
|
223
|
+
const attached = t.slice(j + 1)
|
|
224
|
+
const val = attached || tokens[i + 1]
|
|
225
|
+
if (val === undefined) { return null }
|
|
226
|
+
prefix.push('-' + ch, val)
|
|
227
|
+
if (!attached) { i++ }
|
|
228
|
+
break
|
|
229
|
+
}
|
|
230
|
+
if (!SSH_DROP_FLAGS.has(ch)) { prefix.push('-' + ch) }
|
|
231
|
+
}
|
|
232
|
+
i++
|
|
233
|
+
}
|
|
234
|
+
const dest = tokens[i]
|
|
235
|
+
return dest ? { prefix, dest } : null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function execFileP (file, args, opts) {
|
|
239
|
+
return new Promise((resolve, reject) => execFile(file, args, { timeout: 4000, windowsHide: true, encoding: 'utf8', ...opts }, (e, so) => e ? reject(e) : resolve(so)))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Full command line of a process (Tabby only gives the name + pid on macOS/Windows). */
|
|
243
|
+
async function commandLineOf (pid) {
|
|
244
|
+
const n = Number(pid)
|
|
245
|
+
if (process.platform === 'win32') {
|
|
246
|
+
return (await execFileP('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `(Get-CimInstance Win32_Process -Filter 'ProcessId=${n}').CommandLine`])).trim()
|
|
247
|
+
}
|
|
248
|
+
return (await execFileP('ps', ['-ww', '-o', 'args=', '-p', String(n)])).trim()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** SSH_AUTH_SOCK of a running process (the GUI app may not have the agent socket that the user's shell exported). */
|
|
252
|
+
async function agentSockOf (pid) {
|
|
253
|
+
try {
|
|
254
|
+
const n = Number(pid)
|
|
255
|
+
if (process.platform === 'linux') {
|
|
256
|
+
const env = fs.readFileSync(`/proc/${n}/environ`, 'utf8').split('\0')
|
|
257
|
+
const hit = env.find(e => e.startsWith('SSH_AUTH_SOCK='))
|
|
258
|
+
return hit ? hit.slice('SSH_AUTH_SOCK='.length) : null
|
|
259
|
+
}
|
|
260
|
+
if (process.platform === 'darwin') {
|
|
261
|
+
const out = await execFileP('ps', ['-ww', '-E', '-o', 'command=', '-p', String(n)])
|
|
262
|
+
const m = /(?:^|\s)SSH_AUTH_SOCK=(\S+)/.exec(out)
|
|
263
|
+
return m ? m[1] : null
|
|
264
|
+
}
|
|
265
|
+
} catch { /* best effort */ }
|
|
266
|
+
return null
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The `ssh` the local shell of this tab is currently running, as { prefix, dest }, or null. */
|
|
270
|
+
async function findLocalSsh (tab, deps = { commandLineOf, agentSockOf }) {
|
|
271
|
+
let procs = []
|
|
272
|
+
try { procs = (await tab.session?.getChildProcesses?.()) || [] } catch (e) { log('getChildProcesses failed: ' + e); return null }
|
|
273
|
+
for (const p of procs) {
|
|
274
|
+
const exe = path.basename(String(p.command || '')).replace(/\.exe$/i, '')
|
|
275
|
+
if (exe !== 'ssh') { continue }
|
|
276
|
+
let tokens
|
|
277
|
+
if (Array.isArray(p.arguments) && p.arguments.length) { tokens = [p.command, ...p.arguments] } else {
|
|
278
|
+
try { tokens = tokenizeCommandLine(await deps.commandLineOf(p.pid)) } catch (e) { log('cannot read the ssh command line: ' + e); continue }
|
|
279
|
+
}
|
|
280
|
+
const parsed = parseSshCommand(tokens)
|
|
281
|
+
if (parsed) {
|
|
282
|
+
if (!process.env.SSH_AUTH_SOCK || process.platform === 'darwin') { parsed.agentSock = await (deps.agentSockOf || agentSockOf)(p.pid) }
|
|
283
|
+
return parsed
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return null
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Upload with a separate, non-interactive `ssh` that reuses the user's own options and ~/.ssh/config (ProxyJump, keys, ControlMaster). */
|
|
290
|
+
function uploadViaSystemSsh (target, item, sshBin = 'ssh') {
|
|
291
|
+
return item.load().then(bytes => new Promise((resolve, reject) => {
|
|
292
|
+
if (!bytes.length) { reject(new Error('empty image')); return }
|
|
293
|
+
const args = ['-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15', ...target.prefix, target.dest, `sh -c '${REMOTE_SH}' sh ${item.base} ${item.ext}`]
|
|
294
|
+
const env = target.agentSock ? { ...process.env, SSH_AUTH_SOCK: target.agentSock } : process.env
|
|
295
|
+
const child = spawn(sshBin, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, env })
|
|
296
|
+
let out = ''
|
|
297
|
+
let err = ''
|
|
298
|
+
let settled = false
|
|
299
|
+
const finish = (fn, v) => { if (!settled) { settled = true; clearTimeout(timer); fn(v) } }
|
|
300
|
+
const timer = setTimeout(() => { try { child.kill() } catch { /* ignore */ } finish(reject, new Error('timeout')) }, 20000 + bytes.length / 50)
|
|
301
|
+
child.stdout.on('data', d => { out += d })
|
|
302
|
+
child.stderr.on('data', d => { err += d })
|
|
303
|
+
child.on('error', e => finish(reject, e))
|
|
304
|
+
child.on('close', code => {
|
|
305
|
+
const m = /^ok (\S+) (.+)$/m.exec(out.trim())
|
|
306
|
+
if (m) { finish(resolve, m[2].trim()) } else {
|
|
307
|
+
const why = (err || out).trim().split('\n').filter(Boolean).slice(-2).join(' ')
|
|
308
|
+
finish(reject, new Error((why || `ssh exited with ${code}`) + (/permission denied|password|passphrase/i.test(why) ? ' (needs key/agent or ControlMaster: the upload ssh runs non-interactively)' : '')))
|
|
309
|
+
}
|
|
310
|
+
})
|
|
311
|
+
child.stdin.on('error', () => {})
|
|
312
|
+
child.stdin.end(Buffer.from(bytes))
|
|
313
|
+
}))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** How to upload for this tab: Tabby's SSH connection, the local shell's running `ssh`, or null (nothing to do). */
|
|
317
|
+
async function resolveTransport (tab) {
|
|
318
|
+
if (tab.sshSession) {
|
|
319
|
+
const ssh = tab.sshSession.ssh
|
|
320
|
+
if (ssh && typeof ssh.openSessionChannel === 'function') { return { kind: 'tabby-ssh', upload: item => uploadViaBuiltinSsh(ssh, item) } }
|
|
321
|
+
return { kind: 'tabby-ssh', upload: async () => { throw new Error('SSH session not ready') } }
|
|
322
|
+
}
|
|
323
|
+
const target = await findLocalSsh(tab)
|
|
324
|
+
if (target) { return { kind: 'system-ssh', target, upload: item => uploadViaSystemSsh(target, item) } }
|
|
325
|
+
return null
|
|
326
|
+
}
|
|
327
|
+
|
|
164
328
|
const busyTabs = new WeakSet()
|
|
329
|
+
const lastHandled = new WeakMap() // tab -> timestamp of the last paste this plugin took over
|
|
330
|
+
const markHandled = tab => lastHandled.set(tab, Date.now())
|
|
331
|
+
const handledRecently = tab => Date.now() - (lastHandled.get(tab) || 0) < 1500
|
|
332
|
+
|
|
333
|
+
// Diagnostics: do real Cmd/Ctrl+V key presses and DOM paste events reach the page? (logged to the trace file)
|
|
334
|
+
let diagInstalled = false
|
|
335
|
+
function installDiagnostics () {
|
|
336
|
+
if (diagInstalled || typeof document === 'undefined') { return }
|
|
337
|
+
diagInstalled = true
|
|
338
|
+
const where = el => el ? `${el.tagName}.${String(el.className || '').slice(0, 40)}` : 'null'
|
|
339
|
+
document.addEventListener('keydown', e => {
|
|
340
|
+
if ((e.key || '').toLowerCase() === 'v' && (e.metaKey || e.ctrlKey)) { log(`keydown v meta=${e.metaKey} ctrl=${e.ctrlKey} shift=${e.shiftKey} target=${where(e.target)} defaultPrevented=${e.defaultPrevented}`) }
|
|
341
|
+
}, true)
|
|
342
|
+
document.addEventListener('paste', e => {
|
|
343
|
+
log(`DOM paste event target=${where(e.target)} types=${e.clipboardData ? Array.from(e.clipboardData.types).join(',') : 'none'}`)
|
|
344
|
+
}, true)
|
|
345
|
+
}
|
|
346
|
+
installDiagnostics()
|
|
165
347
|
|
|
166
348
|
/**
|
|
167
349
|
* Upload all items and call emit(buffer) with the typed paths.
|
|
168
350
|
* Resolves true when at least one path was emitted.
|
|
169
351
|
*/
|
|
170
|
-
async function pasteItems (tab, items, emit) {
|
|
352
|
+
async function pasteItems (tab, items, emit, transport) {
|
|
171
353
|
const notify = (kind, msg) => { try { tab.notifications?.[kind]?.(msg) } catch { /* ignore */ } }
|
|
172
|
-
const
|
|
173
|
-
if (!
|
|
354
|
+
const tr = transport || await resolveTransport(tab)
|
|
355
|
+
if (!tr) { notify('error', 'Image paste: this tab is not connected to an SSH host'); return false }
|
|
356
|
+
log('uploading ' + items.length + ' item(s) via ' + tr.kind + (tr.target ? ' to ' + tr.target.dest : ''))
|
|
174
357
|
const paths = []
|
|
175
358
|
for (const item of items) {
|
|
176
359
|
if (item.error) { notify('error', `Image paste: ${item.error}`); continue }
|
|
177
|
-
try { paths.push(await
|
|
360
|
+
try { paths.push(await tr.upload(item)) } catch (e) { log('upload error: ' + (e && e.stack || e)); notify('error', `Image paste failed: ${e.message || e}`) }
|
|
178
361
|
}
|
|
179
362
|
if (!paths.length) { return false }
|
|
180
363
|
notify('notice', paths.length === 1 ? `Image saved on host: ${paths[0]}` : `${paths.length} images saved on host`)
|
|
181
364
|
// Like a drag-and-drop: one bracketed paste per path when the app enabled it (TUIs then attach the image),
|
|
182
365
|
// plain text otherwise (shell prompt).
|
|
183
366
|
const bracketed = !!(tab.frontend && tab.frontend.supportsBracketedPaste && tab.frontend.supportsBracketedPaste())
|
|
367
|
+
log('uploaded ' + paths.length + ' file(s), bracketed=' + bracketed)
|
|
184
368
|
emit(Buffer.from(paths.map(p => bracketed ? `\x1b[200~${PATH_PREFIX}${p} \x1b[201~` : `${PATH_PREFIX}${p} `).join('')))
|
|
185
369
|
return true
|
|
186
370
|
}
|
|
@@ -201,11 +385,17 @@ class ClipImageMiddleware extends SessionMiddleware {
|
|
|
201
385
|
if (this.busy) { this.queue.push(data); return }
|
|
202
386
|
if (data.length === 1 && data[0] === 0x16) {
|
|
203
387
|
const items = collectItems(this.getClipboard())
|
|
388
|
+
log('Ctrl+V: formats=' + (this.getClipboard()?.availableFormats?.().join(',') || '?') + ' -> ' + items.length + ' image(s)')
|
|
204
389
|
if (items.length) {
|
|
205
390
|
if (busyTabs.has(this.tab)) { return } // a paste is already uploading
|
|
206
391
|
this.busy = true
|
|
207
392
|
busyTabs.add(this.tab)
|
|
208
|
-
|
|
393
|
+
resolveTransport(this.tab)
|
|
394
|
+
.then(tr => {
|
|
395
|
+
if (!tr) { return false }
|
|
396
|
+
markHandled(this.tab)
|
|
397
|
+
return pasteItems(this.tab, items, buf => this.outputToSession.next(buf), tr)
|
|
398
|
+
})
|
|
209
399
|
.then(ok => { if (!ok) { this.outputToSession.next(data) } })
|
|
210
400
|
.catch(() => this.outputToSession.next(data))
|
|
211
401
|
.then(() => {
|
|
@@ -223,33 +413,76 @@ class ClipImageMiddleware extends SessionMiddleware {
|
|
|
223
413
|
|
|
224
414
|
class ClipImageDecorator extends TerminalDecorator {
|
|
225
415
|
attachToSession (tab) {
|
|
226
|
-
if (!tab.session || !tab.
|
|
416
|
+
if (!tab.session || !tab.session.middleware) { return }
|
|
227
417
|
tab.session.middleware.unshift(new ClipImageMiddleware(tab))
|
|
228
418
|
}
|
|
229
419
|
|
|
420
|
+
// Shared by tab.paste() and the DOM paste event: take over when there is something to upload and somewhere to upload it.
|
|
421
|
+
takeOver (tab, items, original) {
|
|
422
|
+
busyTabs.add(tab)
|
|
423
|
+
return resolveTransport(tab)
|
|
424
|
+
.then(tr => {
|
|
425
|
+
if (!tr) { busyTabs.delete(tab); return original() }
|
|
426
|
+
markHandled(tab)
|
|
427
|
+
return pasteItems(tab, items, buf => tab.sendInput(buf), tr)
|
|
428
|
+
.catch(() => false)
|
|
429
|
+
.then(ok => { busyTabs.delete(tab); if (!ok) { return original() } })
|
|
430
|
+
})
|
|
431
|
+
.catch(e => { busyTabs.delete(tab); log('takeOver error: ' + (e && e.stack || e)); return original() })
|
|
432
|
+
}
|
|
433
|
+
|
|
230
434
|
// Cmd+V, Ctrl+Shift+V, Shift+Insert, right-click and middle-click paste all call tab.paste().
|
|
231
435
|
hookPaste (tab) {
|
|
232
436
|
if (tab.__clipImageHooked || typeof tab.paste !== 'function') { return }
|
|
233
437
|
tab.__clipImageHooked = true
|
|
234
438
|
const original = tab.paste.bind(tab)
|
|
439
|
+
tab.__clipImageOriginalPaste = original
|
|
235
440
|
tab.paste = () => {
|
|
236
|
-
|
|
441
|
+
log('tab.paste() called, sshSession=' + !!tab.sshSession + ' busy=' + busyTabs.has(tab))
|
|
237
442
|
if (busyTabs.has(tab)) { return Promise.resolve() }
|
|
238
443
|
const items = collectItems(getClipboard())
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
.catch(() =>
|
|
243
|
-
|
|
444
|
+
log('paste(): formats=' + (getClipboard()?.availableFormats?.().join(',') || '?') + ' -> ' + items.length + ' image(s)')
|
|
445
|
+
if (!items.length) {
|
|
446
|
+
const why = describeNothingToPaste(getClipboard())
|
|
447
|
+
if (why) { resolveTransport(tab).then(tr => { if (tr) { notifyTab(tab, 'info', `Image paste: ${why}`) } }).catch(() => {}) } // 'info' stays ~5s; Tabby's 'notice' only 1s
|
|
448
|
+
return original()
|
|
449
|
+
}
|
|
450
|
+
return this.takeOver(tab, items, original)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// macOS can deliver Cmd+V as a native "Paste" (DOM paste event on xterm's textarea) without going through tab.paste().
|
|
455
|
+
hookDomPaste (tab) {
|
|
456
|
+
if (tab.__clipImageDom || typeof document === 'undefined') { return }
|
|
457
|
+
tab.__clipImageDom = true
|
|
458
|
+
const handler = ev => {
|
|
459
|
+
try {
|
|
460
|
+
const ta = tab.frontend && tab.frontend.xterm && tab.frontend.xterm.textarea
|
|
461
|
+
if (!ta || ev.target !== ta) { return }
|
|
462
|
+
if (busyTabs.has(tab) || handledRecently(tab)) { ev.preventDefault(); ev.stopImmediatePropagation(); log('DOM paste: already handled, swallowed'); return }
|
|
463
|
+
const items = collectItems(getClipboard())
|
|
464
|
+
if (!items.length) {
|
|
465
|
+
const why = describeNothingToPaste(getClipboard())
|
|
466
|
+
if (why) { resolveTransport(tab).then(tr => { if (tr) { notifyTab(tab, 'info', `Image paste: ${why}`) } }).catch(() => {}) }
|
|
467
|
+
return // let xterm paste text normally
|
|
468
|
+
}
|
|
469
|
+
ev.preventDefault(); ev.stopImmediatePropagation()
|
|
470
|
+
log('DOM paste: taking over, ' + items.length + ' item(s)')
|
|
471
|
+
this.takeOver(tab, items, tab.__clipImageOriginalPaste || (() => {}))
|
|
472
|
+
} catch (e) { log('DOM paste handler error: ' + (e && e.stack || e)) }
|
|
244
473
|
}
|
|
474
|
+
document.addEventListener('paste', handler, true)
|
|
475
|
+
try { tab.destroyed$?.subscribe?.(() => document.removeEventListener('paste', handler, true)) } catch { /* ignore */ }
|
|
245
476
|
}
|
|
246
477
|
|
|
247
478
|
attach (tab) {
|
|
248
|
-
|
|
479
|
+
log('attach tab=' + (tab && tab.constructor && tab.constructor.name) + ' hasSshSession=' + ('sshSession' in tab) + ' hasPaste=' + (typeof tab.paste))
|
|
249
480
|
this.hookPaste(tab)
|
|
481
|
+
this.hookDomPaste(tab)
|
|
482
|
+
if (!announced) { announced = true; setTimeout(() => notifyTab(tab, 'info', `tabby-clip-image ${VERSION} is active (SSH tabs, and local tabs running ssh): paste an image or copied image file`), 1500) }
|
|
250
483
|
setTimeout(() => {
|
|
251
484
|
this.attachToSession(tab)
|
|
252
|
-
this.subscribeUntilDetached(tab, tab.sessionChanged$.subscribe(() => this.attachToSession(tab)))
|
|
485
|
+
if (tab.sessionChanged$) { this.subscribeUntilDetached(tab, tab.sessionChanged$.subscribe(() => this.attachToSession(tab))) }
|
|
253
486
|
})
|
|
254
487
|
}
|
|
255
488
|
}
|
|
@@ -261,4 +494,4 @@ NgModule({
|
|
|
261
494
|
],
|
|
262
495
|
})(ClipImageModule)
|
|
263
496
|
|
|
264
|
-
module.exports = { default: ClipImageModule, ClipImageMiddleware, collectItems, clipboardFilePaths, pasteItems, REMOTE_SH }
|
|
497
|
+
module.exports = { default: ClipImageModule, ClipImageMiddleware, collectItems, clipboardFilePaths, pasteItems, REMOTE_SH, parseSshCommand, tokenizeCommandLine, findLocalSsh, resolveTransport, uploadViaSystemSsh }
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tabby-clip-image",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Paste images (screenshots or copied image files
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Paste images (screenshots or copied image files) in Tabby into remote TUIs (Claude Code, Codex...) over SSH: works in built-in SSH tabs AND in local tabs running ssh. Uploads to a fixed dir on the host and pastes the path. Nothing to install on the host.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"tabby-plugin",
|
|
7
7
|
"tabby",
|