Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
142 lines
4.8 KiB
TypeScript
142 lines
4.8 KiB
TypeScript
const REMOTE_CLIPBOARD_IMAGE_DIR = ".netcatty-paste-images";
|
|
|
|
type ClipboardImageFile = {
|
|
path: string;
|
|
name: string;
|
|
mediaType: string;
|
|
size?: number;
|
|
};
|
|
|
|
export type RemoteClipboardImageBridge = Pick<
|
|
NetcattyBridge,
|
|
"readClipboardImage" | "openSftpForSession" | "startStreamTransfer"
|
|
> & Pick<Partial<NetcattyBridge>, "closeSftp" | "deleteTempFile">;
|
|
|
|
type TerminalLike = {
|
|
focus?: () => void;
|
|
};
|
|
|
|
type HandleRemoteClipboardImagePasteOptions = {
|
|
bridge?: RemoteClipboardImageBridge;
|
|
createTransferId?: () => string;
|
|
getRemoteCwd: () => Promise<string | null | undefined>;
|
|
isSensitiveInput?: () => boolean;
|
|
scrollToBottomAfterProgrammaticInput?: (data: string) => void;
|
|
sessionId: string | null | undefined;
|
|
terminalBackend: {
|
|
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean; sensitive?: boolean }) => void;
|
|
};
|
|
term?: TerminalLike | null;
|
|
};
|
|
|
|
export type RemoteClipboardImageUploadResult =
|
|
| { ok: true; remotePath: string; pastedPath: string }
|
|
| { ok: false; reason: "unsupported" | "no-session" | "no-image" | "no-cwd" | "upload-failed" };
|
|
|
|
export function getRemoteClipboardImageUploadErrorMessageKey(
|
|
result: RemoteClipboardImageUploadResult,
|
|
): "terminal.clipboardImageUpload.noImage" | "terminal.clipboardImageUpload.failed" | null {
|
|
if (result.ok === true) return null;
|
|
return result.reason === "no-image"
|
|
? "terminal.clipboardImageUpload.noImage"
|
|
: "terminal.clipboardImageUpload.failed";
|
|
}
|
|
|
|
const shellSafePathPattern = /^[A-Za-z0-9_./~:@%+=,-]+$/;
|
|
|
|
export function sanitizeRemoteClipboardImageName(name: string): string {
|
|
const fallback = "netcatty-paste.png";
|
|
const trimmed = name.trim() || fallback;
|
|
const sanitized = trimmed
|
|
.replace(/[\0/\\]/g, "_")
|
|
.replace(/[^A-Za-z0-9._-]+/g, "_")
|
|
.replace(/_+/g, "_")
|
|
.replace(/^_+|_+$/g, "");
|
|
|
|
return sanitized || fallback;
|
|
}
|
|
|
|
export function buildRemoteClipboardImagePath(cwd: string | null | undefined, fileName: string): string {
|
|
const safeFileName = sanitizeRemoteClipboardImageName(fileName);
|
|
const normalizedCwd = typeof cwd === "string" ? cwd.trim() : "";
|
|
if (!normalizedCwd) return "";
|
|
const base = normalizedCwd.replace(/\/+$/g, "") || "/";
|
|
|
|
if (base === "/") {
|
|
return `/${REMOTE_CLIPBOARD_IMAGE_DIR}/${safeFileName}`;
|
|
}
|
|
|
|
return `${base}/${REMOTE_CLIPBOARD_IMAGE_DIR}/${safeFileName}`;
|
|
}
|
|
|
|
export function quoteRemotePathForShell(remotePath: string): string {
|
|
if (shellSafePathPattern.test(remotePath)) return remotePath;
|
|
return `'${remotePath.replace(/'/g, "'\\''")}'`;
|
|
}
|
|
|
|
function defaultTransferId(): string {
|
|
const uuid = globalThis.crypto?.randomUUID?.();
|
|
return uuid ? `clipboard-image-${uuid}` : `clipboard-image-${Date.now()}`;
|
|
}
|
|
|
|
export async function handleRemoteClipboardImageUpload({
|
|
bridge,
|
|
createTransferId = defaultTransferId,
|
|
getRemoteCwd,
|
|
isSensitiveInput,
|
|
scrollToBottomAfterProgrammaticInput,
|
|
sessionId,
|
|
terminalBackend,
|
|
term,
|
|
}: HandleRemoteClipboardImagePasteOptions): Promise<RemoteClipboardImageUploadResult> {
|
|
if (!sessionId) return { ok: false, reason: "no-session" };
|
|
if (!bridge?.readClipboardImage || !bridge.openSftpForSession || !bridge.startStreamTransfer) {
|
|
return { ok: false, reason: "unsupported" };
|
|
}
|
|
|
|
let image: ClipboardImageFile | null;
|
|
try {
|
|
image = await bridge.readClipboardImage();
|
|
} catch {
|
|
// A clipboard read failure is indistinguishable from an empty clipboard —
|
|
// treat it as "no image" so callers can fall back to a normal paste.
|
|
return { ok: false, reason: "no-image" };
|
|
}
|
|
if (!image?.path || !image.name) return { ok: false, reason: "no-image" };
|
|
|
|
let sftpId: string | undefined;
|
|
try {
|
|
const remoteCwd = await getRemoteCwd();
|
|
const targetPath = buildRemoteClipboardImagePath(remoteCwd, image.name);
|
|
if (!targetPath) return { ok: false, reason: "no-cwd" };
|
|
const transferId = createTransferId();
|
|
|
|
sftpId = await bridge.openSftpForSession(sessionId);
|
|
const transferResult = await bridge.startStreamTransfer({
|
|
transferId,
|
|
sourcePath: image.path,
|
|
targetPath,
|
|
sourceType: "local",
|
|
targetType: "sftp",
|
|
targetSftpId: sftpId,
|
|
totalBytes: image.size,
|
|
});
|
|
if (!transferResult || transferResult.error) return { ok: false, reason: "upload-failed" };
|
|
|
|
const pastedPath = quoteRemotePathForShell(targetPath);
|
|
terminalBackend.writeToSession(sessionId, pastedPath, {
|
|
sensitive: isSensitiveInput?.() === true,
|
|
});
|
|
scrollToBottomAfterProgrammaticInput?.(pastedPath);
|
|
term?.focus?.();
|
|
return { ok: true, remotePath: targetPath, pastedPath };
|
|
} finally {
|
|
if (sftpId && bridge.closeSftp) {
|
|
await bridge.closeSftp(sftpId).catch(() => undefined);
|
|
}
|
|
if (bridge.deleteTempFile) {
|
|
await bridge.deleteTempFile(image.path).catch(() => undefined);
|
|
}
|
|
}
|
|
}
|