[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,399 @@
import React from "react";
import { Loader2, Trash2 } from "lucide-react";
import { Button } from "../ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { getFileName, getParentPath } from "../../application/state/sftp/utils";
import { SftpHostPicker } from "./SftpHostPicker";
import type { Host } from "../../types";
interface SftpPaneDialogsProps {
t: (key: string, params?: Record<string, unknown>) => string;
hostLabel?: string;
currentPath?: string;
// New folder
showNewFolderDialog: boolean;
setShowNewFolderDialog: (open: boolean) => void;
newFolderName: string;
setNewFolderName: (value: string) => void;
handleCreateFolder: () => void;
isCreating: boolean;
// New file
showNewFileDialog: boolean;
setShowNewFileDialog: (open: boolean) => void;
newFileName: string;
setNewFileName: (value: string) => void;
fileNameError: string | null;
setFileNameError: (value: string | null) => void;
handleCreateFile: () => void;
isCreatingFile: boolean;
// Overwrite confirm
showOverwriteConfirm: boolean;
setShowOverwriteConfirm: (open: boolean) => void;
overwriteTarget: string | null;
handleOverwriteConfirm: () => void;
// Rename
showRenameDialog: boolean;
setShowRenameDialog: (open: boolean) => void;
renameName: string;
setRenameName: (value: string) => void;
handleRename: () => void;
isRenaming: boolean;
// Delete
showDeleteConfirm: boolean;
setShowDeleteConfirm: (open: boolean) => void;
deleteTargets: string[];
handleDelete: () => void;
isDeleting: boolean;
// Host picker (connected view)
showHostPicker: boolean;
setShowHostPicker: (open: boolean) => void;
hosts: Host[];
connectedHosts?: import("../../domain/sftpConnectedHosts").SftpConnectedHostEntry[];
side: "left" | "right";
hostSearch: string;
setHostSearch: (value: string) => void;
onConnect: (
host: Host | "local",
options?: { sourceSessionId?: string },
) => void;
onDisconnect: () => Promise<boolean>;
}
const HostHint: React.FC<{ label?: string }> = ({ label }) =>
label ? (
<div className="text-xs text-muted-foreground truncate mb-1">{label}</div>
) : null;
export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
t,
hostLabel,
currentPath,
showNewFolderDialog,
setShowNewFolderDialog,
newFolderName,
setNewFolderName,
handleCreateFolder,
isCreating,
showNewFileDialog,
setShowNewFileDialog,
newFileName,
setNewFileName,
fileNameError,
setFileNameError,
handleCreateFile,
isCreatingFile,
showOverwriteConfirm,
setShowOverwriteConfirm,
overwriteTarget,
handleOverwriteConfirm,
showRenameDialog,
setShowRenameDialog,
renameName,
setRenameName,
handleRename,
isRenaming,
showDeleteConfirm,
setShowDeleteConfirm,
deleteTargets,
handleDelete,
isDeleting,
showHostPicker,
setShowHostPicker,
hosts,
connectedHosts = [],
side,
hostSearch,
setHostSearch,
onConnect,
onDisconnect,
}) => {
// Focus the confirm button when a confirmation dialog opens so Enter confirms it.
// These dialogs are opened from a context menu, whose focus-return can otherwise
// leave focus outside the dialog, making Enter do nothing.
const deleteConfirmButtonRef = React.useRef<HTMLButtonElement>(null);
const overwriteConfirmButtonRef = React.useRef<HTMLButtonElement>(null);
const isSingleDeleteTarget = deleteTargets.length === 1;
const deletePath = (() => {
if (isSingleDeleteTarget) {
return deleteTargets[0];
}
const uniquePaths = Array.from(new Set(deleteTargets.map((target) => getParentPath(target)).filter(Boolean)));
if (uniquePaths.length === 1) return uniquePaths[0];
if (uniquePaths.length > 1) return "Multiple locations";
return currentPath;
})();
const showDeleteList = deleteTargets.length > 1;
const deleteListItems = (() => {
if (!showDeleteList) return [];
const uniquePaths = Array.from(new Set(deleteTargets.map((target) => getParentPath(target)).filter(Boolean)));
if (uniquePaths.length === 1) {
return deleteTargets.map((target) => getFileName(target) || target);
}
return deleteTargets;
})();
return (
<>
{/* Dialogs */}
<Dialog open={showNewFolderDialog} onOpenChange={setShowNewFolderDialog}>
<DialogContent className="max-w-sm">
<DialogHeader>
<HostHint label={hostLabel} />
<DialogTitle>{t("sftp.newFolder")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("sftp.folderName")}</Label>
<Input
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder={t("sftp.folderName.placeholder")}
onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()}
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowNewFolderDialog(false)}
>
{t("common.cancel")}
</Button>
<Button
onClick={handleCreateFolder}
disabled={!newFolderName.trim() || isCreating}
>
{isCreating && (
<Loader2 size={14} className="mr-2 animate-spin" />
)}
{t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showNewFileDialog} onOpenChange={(open) => {
setShowNewFileDialog(open);
if (!open) {
setFileNameError(null);
}
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<HostHint label={hostLabel} />
<DialogTitle>{t("sftp.newFile")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("sftp.fileName")}</Label>
<Input
value={newFileName}
onChange={(e) => {
setNewFileName(e.target.value);
setFileNameError(null);
}}
placeholder={t("sftp.fileName.placeholder")}
onKeyDown={(e) => e.key === "Enter" && handleCreateFile()}
autoFocus
/>
{fileNameError && (
<div className="text-xs text-destructive">{fileNameError}</div>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowNewFileDialog(false)}
>
{t("common.cancel")}
</Button>
<Button
onClick={handleCreateFile}
disabled={!newFileName.trim() || isCreatingFile}
>
{isCreatingFile && (
<Loader2 size={14} className="mr-2 animate-spin" />
)}
{t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Overwrite Confirmation Dialog */}
<Dialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
<DialogContent
className="max-w-sm"
onOpenAutoFocus={(e) => {
e.preventDefault();
overwriteConfirmButtonRef.current?.focus();
}}
>
<DialogHeader>
<HostHint label={hostLabel} />
<DialogTitle>{t("sftp.overwrite.title")}</DialogTitle>
<DialogDescription>
{t("sftp.overwrite.desc", { name: overwriteTarget || "" })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowOverwriteConfirm(false)}
>
{t("common.cancel")}
</Button>
<Button
ref={overwriteConfirmButtonRef}
variant="destructive"
onClick={handleOverwriteConfirm}
>
{t("sftp.overwrite.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
<DialogContent className="max-w-sm">
<DialogHeader>
<HostHint label={hostLabel} />
<DialogTitle>{t("sftp.rename.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("sftp.rename.newName")}</Label>
<Input
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
placeholder={t("sftp.rename.placeholder")}
onKeyDown={(e) => e.key === "Enter" && handleRename()}
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowRenameDialog(false)}
>
{t("common.cancel")}
</Button>
<Button
onClick={handleRename}
disabled={!renameName.trim() || isRenaming}
>
{isRenaming && (
<Loader2 size={14} className="mr-2 animate-spin" />
)}
{t("common.rename")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent
className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-sm"
onOpenAutoFocus={(e) => {
e.preventDefault();
deleteConfirmButtonRef.current?.focus();
}}
>
<DialogHeader className="min-w-0 pr-6">
<DialogTitle className="truncate">
{t("sftp.deleteConfirm.title", { count: deleteTargets.length })}
</DialogTitle>
<DialogDescription className="break-words [overflow-wrap:anywhere]">
{t(showDeleteList ? "sftp.deleteConfirm.desc" : "sftp.deleteConfirm.descSingle")}
</DialogDescription>
</DialogHeader>
<div className="min-w-0 space-y-3">
{hostLabel || deletePath ? (
<div className="min-w-0 space-y-1.5 text-xs text-muted-foreground">
{hostLabel ? (
<div className="flex min-w-0 items-start gap-2">
<span className="font-medium text-foreground/80 shrink-0">{t("sftp.deleteConfirm.host")}:</span>
<span className="min-w-0 break-words [overflow-wrap:anywhere]">{hostLabel}</span>
</div>
) : null}
{deletePath ? (
<div className="flex min-w-0 items-start gap-2">
<span className="font-medium text-foreground/80 shrink-0">{t("sftp.deleteConfirm.path")}:</span>
<span className="min-w-0 break-words [overflow-wrap:anywhere]">{deletePath}</span>
</div>
) : null}
</div>
) : null}
{showDeleteList ? (
<div className="max-h-32 min-w-0 space-y-1 overflow-auto text-sm">
{deleteListItems.map((name) => (
<div
key={name}
className="flex min-w-0 items-center gap-2 text-muted-foreground"
>
<Trash2 size={12} className="shrink-0" />
<span className="min-w-0 truncate">{name}</span>
</div>
))}
</div>
) : null}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
>
{t("common.cancel")}
</Button>
<Button
ref={deleteConfirmButtonRef}
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting && (
<Loader2 size={14} className="mr-2 animate-spin" />
)}
{t("action.delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<SftpHostPicker
open={showHostPicker}
onOpenChange={setShowHostPicker}
hosts={hosts}
connectedHosts={connectedHosts}
side={side}
hostSearch={hostSearch}
onHostSearchChange={setHostSearch}
onSelectLocal={async () => {
// Only connect to the new target if the disconnect actually happened.
// A cancel on the dirty-editor prompt must keep the user on the
// current host instead of silently switching and stranding tabs.
const ok = await onDisconnect();
if (ok) onConnect("local");
}}
onSelectHost={async (host, options) => {
const ok = await onDisconnect();
if (ok) onConnect(host, options);
}}
/>
</>
);
};