[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
141
components/terminal/clipboardImagePaste.ts
Normal file
141
components/terminal/clipboardImagePaste.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user