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
165 lines
4.9 KiB
TypeScript
165 lines
4.9 KiB
TypeScript
import type { SftpFileEntry } from "../../types";
|
|
import type { DropEntry } from "../../lib/sftpFileUtils";
|
|
import type { KeyBinding } from "../../domain/models";
|
|
import { joinPath } from "../../application/state/sftp/utils";
|
|
import { isNavigableDirectory } from "./utils";
|
|
|
|
export interface ClipboardLocalFile {
|
|
path: string;
|
|
name: string;
|
|
isDirectory: boolean;
|
|
size?: number;
|
|
}
|
|
|
|
export interface SftpClipboardUploadTreeSelection {
|
|
name: string;
|
|
path: string;
|
|
isDirectory: boolean;
|
|
}
|
|
|
|
export interface ResolveSftpClipboardUploadTargetParams {
|
|
currentPath: string;
|
|
selectedFileNames: string[];
|
|
files: SftpFileEntry[];
|
|
treeSelection: SftpClipboardUploadTreeSelection[];
|
|
}
|
|
|
|
export interface GetSftpClipboardSystemTextPathsParams {
|
|
currentPath: string;
|
|
selectedFileNames: string[];
|
|
treeSelection: SftpClipboardUploadTreeSelection[];
|
|
}
|
|
|
|
export function resolveSftpClipboardUploadTarget({
|
|
currentPath,
|
|
selectedFileNames,
|
|
files,
|
|
treeSelection,
|
|
}: ResolveSftpClipboardUploadTargetParams): string {
|
|
const selectedTreeFolders = treeSelection.filter((entry) => entry.isDirectory && entry.name !== "..");
|
|
if (selectedTreeFolders.length === 1) {
|
|
return selectedTreeFolders[0].path;
|
|
}
|
|
|
|
if (selectedFileNames.length === 1) {
|
|
const filesByName = new Map(files.map((entry) => [entry.name, entry]));
|
|
const selectedEntry = filesByName.get(selectedFileNames[0]);
|
|
if (selectedEntry && isNavigableDirectory(selectedEntry)) {
|
|
return joinPath(currentPath, selectedEntry.name);
|
|
}
|
|
}
|
|
|
|
return currentPath;
|
|
}
|
|
|
|
export function getSftpClipboardSystemTextPaths({
|
|
currentPath,
|
|
selectedFileNames,
|
|
treeSelection,
|
|
}: GetSftpClipboardSystemTextPathsParams): string[] {
|
|
if (treeSelection.length > 0) {
|
|
return treeSelection.map((entry) => entry.path);
|
|
}
|
|
|
|
return selectedFileNames.map((name) => joinPath(currentPath, name));
|
|
}
|
|
|
|
export function createDropEntriesFromClipboardFiles(files: ClipboardLocalFile[]): DropEntry[] {
|
|
return files.map((file) => ({
|
|
file: null,
|
|
localPath: file.path,
|
|
relativePath: file.name,
|
|
isDirectory: file.isDirectory,
|
|
size: file.size,
|
|
}));
|
|
}
|
|
|
|
export function getSupportedClipboardUploadFiles(files: ClipboardLocalFile[]): ClipboardLocalFile[] {
|
|
return files;
|
|
}
|
|
|
|
export function shouldLetNativePasteEventHandleSftpPaste(
|
|
action: string,
|
|
key: string | undefined,
|
|
): boolean {
|
|
if (action !== "sftpPaste" || !key) return false;
|
|
const normalized = key.toLowerCase().replace(/\s+/g, "");
|
|
return [
|
|
"ctrl+v",
|
|
"⌘+v",
|
|
"cmd+v",
|
|
"command+v",
|
|
].includes(normalized);
|
|
}
|
|
|
|
export function isSftpNativeClipboardPasteEnabled(
|
|
hotkeyScheme: "disabled" | "mac" | "pc",
|
|
keyBindings: KeyBinding[],
|
|
): boolean {
|
|
if (hotkeyScheme === "disabled") return false;
|
|
const pasteBinding = keyBindings.find((binding) => (
|
|
binding.category === "sftp" && binding.action === "sftpPaste"
|
|
));
|
|
if (!pasteBinding) return false;
|
|
const key = hotkeyScheme === "mac" ? pasteBinding.mac : pasteBinding.pc;
|
|
return shouldLetNativePasteEventHandleSftpPaste("sftpPaste", key);
|
|
}
|
|
|
|
export interface SftpClipboardUploadRequest {
|
|
scopeId: string;
|
|
side: "left" | "right";
|
|
targetPath: string;
|
|
files: ClipboardLocalFile[];
|
|
onConfirm: () => Promise<void>;
|
|
}
|
|
|
|
type ClipboardUploadListener = () => void;
|
|
|
|
let clipboardUploadRequest: SftpClipboardUploadRequest | null = null;
|
|
const clipboardUploadListeners = new Set<ClipboardUploadListener>();
|
|
|
|
const notifyClipboardUploadListeners = () => {
|
|
clipboardUploadListeners.forEach((listener) => listener());
|
|
};
|
|
|
|
export const sftpClipboardUploadStore = {
|
|
trigger: (request: SftpClipboardUploadRequest) => {
|
|
clipboardUploadRequest = request;
|
|
notifyClipboardUploadListeners();
|
|
},
|
|
clear: (request?: SftpClipboardUploadRequest | null) => {
|
|
if (request && clipboardUploadRequest !== request) return;
|
|
clipboardUploadRequest = null;
|
|
notifyClipboardUploadListeners();
|
|
},
|
|
getSnapshot: () => clipboardUploadRequest,
|
|
subscribe: (listener: ClipboardUploadListener) => {
|
|
clipboardUploadListeners.add(listener);
|
|
return () => clipboardUploadListeners.delete(listener);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Hand off a confirmed clipboard upload without holding the modal open.
|
|
* Clears the active request first, then runs the transfer (issue #2478).
|
|
*/
|
|
export async function confirmSftpClipboardUpload(params: {
|
|
request: SftpClipboardUploadRequest;
|
|
clear?: (request: SftpClipboardUploadRequest) => void;
|
|
onUploaded?: (targetPath: string) => void;
|
|
}): Promise<void> {
|
|
const { request, onUploaded } = params;
|
|
const clear = params.clear ?? sftpClipboardUploadStore.clear;
|
|
clear(request);
|
|
await request.onConfirm();
|
|
onUploaded?.(request.targetPath);
|
|
}
|
|
|
|
/** True when Upload should start for this request (blocks same-request double-click only). */
|
|
export function shouldStartClipboardUploadConfirm(
|
|
request: SftpClipboardUploadRequest | null | undefined,
|
|
alreadyStartedFor: SftpClipboardUploadRequest | null | undefined,
|
|
): boolean {
|
|
return !!request && alreadyStartedFor !== request;
|
|
}
|