Please provide the code changes or file diffs you would like me to summarize.

This commit is contained in:
liumangmang
2026-05-07 13:43:31 +08:00
parent f24d0f69ed
commit 3f0ebe24e0
8 changed files with 713 additions and 310 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"java.compile.nullAnalysis.mode": "automatic"
}
+14 -9
View File
@@ -1,22 +1,28 @@
.PHONY: help build up down restart logs ps .PHONY: help build up up-build down restart logs ps
COMPOSE_FILE := docker/docker-compose.yml COMPOSE_FILE := docker/docker-compose.yml
COMPOSE := docker compose -f $(COMPOSE_FILE) COMPOSE := docker compose -f $(COMPOSE_FILE)
help: help:
@printf "Available targets:\n" @printf "Available targets:\n"
@printf " make build Build Docker images\n" @printf " make build Build Docker images (with cache)\n"
@printf " make up Build and start services in background\n" @printf " make up Start services (NO rebuild, reuse existing image)\n"
@printf " make down Stop and remove services\n" @printf " make up-build Rebuild image then start (use after code changes)\n"
@printf " make restart Restart services\n" @printf " make down Stop and remove services\n"
@printf " make logs Follow service logs\n" @printf " make restart Restart services without rebuild\n"
@printf " make ps Show service status\n" @printf " make logs Follow service logs\n"
@printf " make ps Show service status\n"
@printf " Note: do not use 'docker compose down -v' in daily usage (it removes persistent volumes)\n" @printf " Note: do not use 'docker compose down -v' in daily usage (it removes persistent volumes)\n"
build: build:
$(COMPOSE) build $(COMPOSE) build
# 日常启动:直接用已有镜像,不重新构建(秒启动)
up: up:
$(COMPOSE) up -d
# 代码有改动时使用:重新构建镜像再启动
up-build:
$(COMPOSE) build $(COMPOSE) build
$(COMPOSE) up -d $(COMPOSE) up -d
@@ -24,8 +30,7 @@ down:
$(COMPOSE) down $(COMPOSE) down
restart: restart:
$(COMPOSE) down $(COMPOSE) restart
$(COMPOSE) up -d
logs: logs:
$(COMPOSE) logs -f $(COMPOSE) logs -f
@@ -1,5 +1,5 @@
package com.sshmanager.service; package com.sshmanager.service;
import com.jcraft.jsch.ChannelSftp; import com.jcraft.jsch.ChannelSftp;
import com.jcraft.jsch.JSch; import com.jcraft.jsch.JSch;
import com.jcraft.jsch.Session; import com.jcraft.jsch.Session;
@@ -8,12 +8,12 @@ import com.jcraft.jsch.SftpException;
import com.jcraft.jsch.SftpProgressMonitor; import com.jcraft.jsch.SftpProgressMonitor;
import com.sshmanager.entity.Connection; import com.sshmanager.entity.Connection;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.io.PipedInputStream; import java.io.PipedInputStream;
import java.io.PipedOutputStream; import java.io.PipedOutputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Vector; import java.util.Vector;
@@ -21,86 +21,86 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.Future; import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
@Service @Service
public class SftpService { public class SftpService {
private ExecutorService executorService = Executors.newFixedThreadPool(2); private ExecutorService executorService = Executors.newCachedThreadPool();
public void setExecutorService(ExecutorService executorService) { public void setExecutorService(ExecutorService executorService) {
this.executorService = executorService; this.executorService = executorService;
} }
public SftpSession connect(Connection conn, String password, String privateKey, String passphrase) public SftpSession connect(Connection conn, String password, String privateKey, String passphrase)
throws Exception { throws Exception {
JSch jsch = new JSch(); JSch jsch = new JSch();
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) { if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8); byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8);
byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty()) byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty())
? passphrase.getBytes(StandardCharsets.UTF_8) : null; ? passphrase.getBytes(StandardCharsets.UTF_8) : null;
jsch.addIdentity("key", keyBytes, null, passphraseBytes); jsch.addIdentity("key", keyBytes, null, passphraseBytes);
} }
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort()); Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
session.setConfig("StrictHostKeyChecking", "no"); session.setConfig("StrictHostKeyChecking", "no");
// Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE // Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE
session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1"); session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1");
if (conn.getAuthType() == Connection.AuthType.PASSWORD) { if (conn.getAuthType() == Connection.AuthType.PASSWORD) {
if (password == null || password.isEmpty()) { if (password == null || password.isEmpty()) {
throw new IllegalArgumentException("Password is required for password authentication"); throw new IllegalArgumentException("Password is required for password authentication");
} }
session.setConfig("PreferredAuthentications", "password"); session.setConfig("PreferredAuthentications", "password");
session.setPassword(password); session.setPassword(password);
} else { } else {
session.setConfig("PreferredAuthentications", "publickey"); session.setConfig("PreferredAuthentications", "publickey");
} }
session.connect(10000); session.connect(10000);
ChannelSftp channel = (ChannelSftp) session.openChannel("sftp"); ChannelSftp channel = (ChannelSftp) session.openChannel("sftp");
channel.connect(5000); channel.connect(5000);
return new SftpSession(session, channel); return new SftpSession(session, channel);
} }
public static class SftpSession { public static class SftpSession {
private final Session session; private final Session session;
private final ChannelSftp channel; private final ChannelSftp channel;
public SftpSession(Session session, ChannelSftp channel) { public SftpSession(Session session, ChannelSftp channel) {
this.session = session; this.session = session;
this.channel = channel; this.channel = channel;
} }
public ChannelSftp getChannel() { public ChannelSftp getChannel() {
return channel; return channel;
} }
public void disconnect() { public void disconnect() {
if (channel != null && channel.isConnected()) { if (channel != null && channel.isConnected()) {
channel.disconnect(); channel.disconnect();
} }
if (session != null && session.isConnected()) { if (session != null && session.isConnected()) {
session.disconnect(); session.disconnect();
} }
} }
public boolean isConnected() { public boolean isConnected() {
return channel != null && channel.isConnected(); return channel != null && channel.isConnected();
} }
} }
public static class FileInfo { public static class FileInfo {
public String name; public String name;
public boolean directory; public boolean directory;
public long size; public long size;
public long mtime; public long mtime;
public FileInfo(String name, boolean directory, long size, long mtime) { public FileInfo(String name, boolean directory, long size, long mtime) {
this.name = name; this.name = name;
this.directory = directory; this.directory = directory;
this.size = size; this.size = size;
this.mtime = mtime; this.mtime = mtime;
} }
} }
@@ -117,125 +117,126 @@ public class SftpService {
void onProgress(long transferredBytes, long totalBytes); void onProgress(long transferredBytes, long totalBytes);
} }
public List<FileInfo> listFiles(SftpSession sftpSession, String path) throws Exception { public List<FileInfo> listFiles(SftpSession sftpSession, String path) throws Exception {
String listPath = (path == null || path.trim().isEmpty()) ? "." : path.trim(); String listPath = (path == null || path.trim().isEmpty()) ? "." : path.trim();
try { try {
Vector<?> entries = sftpSession.getChannel().ls(listPath); Vector<?> entries = sftpSession.getChannel().ls(listPath);
List<FileInfo> result = new ArrayList<>(); List<FileInfo> result = new ArrayList<>();
for (Object obj : entries) { for (Object obj : entries) {
ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj; ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj;
String name = entry.getFilename(); String name = entry.getFilename();
if (".".equals(name) || "..".equals(name)) continue; if (".".equals(name) || "..".equals(name)) continue;
result.add(new FileInfo( result.add(new FileInfo(
name, name,
entry.getAttrs().isDir(), entry.getAttrs().isDir(),
entry.getAttrs().getSize(), entry.getAttrs().getSize(),
entry.getAttrs().getMTime() * 1000L entry.getAttrs().getMTime() * 1000L
)); ));
} }
return result; return result;
} catch (SftpException e) { } catch (SftpException e) {
String msg = formatSftpExceptionMessage(e, listPath, "list"); String msg = formatSftpExceptionMessage(e, listPath, "list");
throw new RuntimeException(msg, e); throw new RuntimeException(msg, e);
} }
} }
/** /**
* Build a user-visible message from JSch SftpException (getMessage() is often null). * Build a user-visible message from JSch SftpException (getMessage() is often null).
*/ */
public static String formatSftpExceptionMessage(SftpException e, String path, String operation) { public static String formatSftpExceptionMessage(SftpException e, String path, String operation) {
int id = e.id; int id = e.id;
String serverMsg = e.getMessage(); String serverMsg = e.getMessage();
String reason = sftpErrorCodeToMessage(id); String reason = sftpErrorCodeToMessage(id);
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append(reason); sb.append(reason);
if (path != null && !path.isEmpty()) { if (path != null && !path.isEmpty()) {
sb.append(": ").append(path); sb.append(": ").append(path);
} }
if (serverMsg != null && !serverMsg.trim().isEmpty()) { if (serverMsg != null && !serverMsg.trim().isEmpty()) {
sb.append(" (").append(serverMsg).append(")"); sb.append(" (").append(serverMsg).append(")");
} else { } else {
sb.append(" [SFTP status ").append(id).append("]"); sb.append(" [SFTP status ").append(id).append("]");
} }
return sb.toString(); return sb.toString();
} }
private static String sftpErrorCodeToMessage(int id) { private static String sftpErrorCodeToMessage(int id) {
switch (id) { switch (id) {
case 2: return "No such file or directory"; case 2: return "No such file or directory";
case 3: return "Permission denied"; case 3: return "Permission denied";
case 4: return "Operation failed"; case 4: return "Operation failed";
case 5: return "Bad message"; case 5: return "Bad message";
case 6: return "No connection"; case 6: return "No connection";
case 7: return "Connection lost"; case 7: return "Connection lost";
case 8: return "Operation not supported"; case 8: return "Operation not supported";
default: return "SFTP error"; default: return "SFTP error";
} }
} }
public void download(SftpSession sftpSession, String remotePath, OutputStream out) throws Exception { public void download(SftpSession sftpSession, String remotePath, OutputStream out) throws Exception {
sftpSession.getChannel().get(remotePath, out); sftpSession.getChannel().get(remotePath, out);
} }
public void upload(SftpSession sftpSession, String remotePath, InputStream in) throws Exception { public void upload(SftpSession sftpSession, String remotePath, InputStream in) throws Exception {
sftpSession.getChannel().put(in, remotePath); sftpSession.getChannel().put(in, remotePath);
}
public void upload(SftpSession sftpSession, String remotePath, InputStream in, TransferProgressListener progressListener) throws Exception {
sftpSession.getChannel().put(in, remotePath, new SftpProgressMonitor() {
@Override
public void init(int op, String src, String dest, long max) {
if (progressListener != null) {
progressListener.onStart(max);
}
}
@Override
public boolean count(long count) {
if (progressListener != null) {
progressListener.onProgress(count, 0);
}
return true;
}
@Override
public void end() {
// Progress listener will be notified by controller
}
}, ChannelSftp.OVERWRITE);
} }
public void delete(SftpSession sftpSession, String remotePath, boolean isDir) throws Exception { public void upload(SftpSession sftpSession, String remotePath, InputStream in, TransferProgressListener progressListener) throws Exception {
if (isDir) { sftpSession.getChannel().put(in, remotePath, new SftpProgressMonitor() {
sftpSession.getChannel().rmdir(remotePath); @Override
} else { public void init(int op, String src, String dest, long max) {
sftpSession.getChannel().rm(remotePath); if (progressListener != null) {
} progressListener.onStart(max);
} }
}
public void mkdir(SftpSession sftpSession, String remotePath) throws Exception {
sftpSession.getChannel().mkdir(remotePath); @Override
} public boolean count(long count) {
if (progressListener != null) {
public void rename(SftpSession sftpSession, String oldPath, String newPath) throws Exception { progressListener.onProgress(count, 0);
sftpSession.getChannel().rename(oldPath, newPath); }
} return true;
}
public String pwd(SftpSession sftpSession) throws Exception {
return sftpSession.getChannel().pwd(); @Override
} public void end() {
// Progress listener will be notified by controller
public void cd(SftpSession sftpSession, String path) throws Exception { }
sftpSession.getChannel().cd(path); }, ChannelSftp.OVERWRITE);
} }
/** public void delete(SftpSession sftpSession, String remotePath, boolean isDir) throws Exception {
* Transfer a single file from source session to target session (streaming, no full file in memory). if (isDir) {
* Fails if sourcePath is a directory. sftpSession.getChannel().rmdir(remotePath);
*/ } else {
sftpSession.getChannel().rm(remotePath);
}
}
public void mkdir(SftpSession sftpSession, String remotePath) throws Exception {
sftpSession.getChannel().mkdir(remotePath);
}
public void rename(SftpSession sftpSession, String oldPath, String newPath) throws Exception {
sftpSession.getChannel().rename(oldPath, newPath);
}
public String pwd(SftpSession sftpSession) throws Exception {
return sftpSession.getChannel().pwd();
}
public void cd(SftpSession sftpSession, String path) throws Exception {
sftpSession.getChannel().cd(path);
}
/**
* Transfer a file or directory from source session to target session.
* If sourcePath is a directory, transfers recursively (the source directory itself is
* recreated under targetPath — "plan A" / scp -r behaviour).
*/
public void transferRemote(SftpSession source, String sourcePath, SftpSession target, String targetPath) public void transferRemote(SftpSession source, String sourcePath, SftpSession target, String targetPath)
throws Exception { throws Exception {
transferRemote(source, sourcePath, target, targetPath, null); transferRemote(source, sourcePath, target, targetPath, null);
} }
@@ -246,8 +247,161 @@ public class SftpService {
TransferProgressListener progressListener) throws Exception { TransferProgressListener progressListener) throws Exception {
SftpATTRS attrs = source.getChannel().stat(sourcePath); SftpATTRS attrs = source.getChannel().stat(sourcePath);
if (attrs.isDir()) { if (attrs.isDir()) {
throw new IllegalArgumentException("Source path is a directory; only single file transfer is supported"); final long totalBytes = calculateTotalSize(source, sourcePath);
if (progressListener != null) {
progressListener.onStart(totalBytes);
}
AtomicLong transferred = new AtomicLong(0);
transferDirectoryRemote(source, sourcePath, target, targetPath, totalBytes, transferred, progressListener);
} else {
String finalTargetPath = targetPath;
try {
SftpATTRS targetAttrs = target.getChannel().stat(targetPath);
if (targetAttrs.isDir()) {
String fileName = sourcePath.contains("/") ? sourcePath.substring(sourcePath.lastIndexOf('/') + 1) : sourcePath;
finalTargetPath = targetPath.endsWith("/") ? targetPath + fileName : targetPath + "/" + fileName;
}
} catch (SftpException e) {
if (targetPath.endsWith("/")) {
String fileName = sourcePath.contains("/") ? sourcePath.substring(sourcePath.lastIndexOf('/') + 1) : sourcePath;
finalTargetPath = targetPath + fileName;
}
}
transferSingleFileRemote(source, sourcePath, target, finalTargetPath, progressListener);
} }
}
/** Recursively calculate total byte size of all files under a remote path. */
private long calculateTotalSize(SftpSession session, String path) throws Exception {
SftpATTRS attrs = session.getChannel().stat(path);
if (!attrs.isDir()) {
return attrs.getSize();
}
long total = 0;
Vector<?> entries = session.getChannel().ls(path);
for (Object obj : entries) {
ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj;
String name = entry.getFilename();
if (".".equals(name) || "..".equals(name)) continue;
String childPath = path.endsWith("/") ? path + name : path + "/" + name;
total += calculateTotalSize(session, childPath);
}
return total;
}
/** Recursively create a directory and its parents on a remote session (mkdir -p). */
private void mkdirRecursive(SftpSession session, String path) throws Exception {
try {
SftpATTRS attrs = session.getChannel().stat(path);
if (attrs.isDir()) return;
throw new IllegalStateException("Path exists but is not a directory: " + path);
} catch (SftpException e) {
if (e.id != ChannelSftp.SSH_FX_NO_SUCH_FILE) throw e;
}
int slash = path.lastIndexOf('/');
if (slash > 0) {
mkdirRecursive(session, path.substring(0, slash));
}
try {
session.getChannel().mkdir(path);
} catch (SftpException e) {
// Another thread may have created it concurrently; SSH_FX_FAILURE is returned in that case
if (e.id != ChannelSftp.SSH_FX_FAILURE) throw e;
}
}
/**
* Recursively transfer a directory.
* The source directory name is recreated under targetParentPath (plan A / scp -r behaviour).
*/
private void transferDirectoryRemote(SftpSession source,
String sourceDirPath,
SftpSession target,
String targetParentPath,
long totalBytes,
AtomicLong transferred,
TransferProgressListener progressListener) throws Exception {
String dirName = sourceDirPath.contains("/")
? sourceDirPath.substring(sourceDirPath.lastIndexOf('/') + 1)
: sourceDirPath;
String targetDirPath = targetParentPath.endsWith("/")
? targetParentPath + dirName
: targetParentPath + "/" + dirName;
mkdirRecursive(target, targetDirPath);
Vector<?> entries = source.getChannel().ls(sourceDirPath);
for (Object obj : entries) {
ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj;
String name = entry.getFilename();
if (".".equals(name) || "..".equals(name)) continue;
String childSourcePath = sourceDirPath.endsWith("/")
? sourceDirPath + name
: sourceDirPath + "/" + name;
if (entry.getAttrs().isDir()) {
transferDirectoryRemote(source, childSourcePath, target, targetDirPath,
totalBytes, transferred, progressListener);
} else {
String childTargetPath = targetDirPath + "/" + name;
transferSingleFileAccumulating(source, childSourcePath,
target, childTargetPath, totalBytes, transferred, progressListener);
}
}
}
/** Stream a single file and accumulate progress into a shared counter (used during directory transfer). */
private void transferSingleFileAccumulating(SftpSession source,
String sourcePath,
SftpSession target,
String targetFilePath,
long totalBytes,
AtomicLong transferred,
TransferProgressListener progressListener) throws Exception {
final int pipeBufferSize = 65536;
PipedOutputStream pos = new PipedOutputStream();
PipedInputStream pis = new PipedInputStream(pos, pipeBufferSize);
Future<?> putFuture = executorService.submit(() -> {
try {
target.getChannel().put(pis, targetFilePath, new SftpProgressMonitor() {
@Override public void init(int op, String src, String dest, long max) {}
@Override
public boolean count(long count) {
long current = transferred.addAndGet(count);
if (progressListener != null) {
progressListener.onProgress(current, totalBytes);
}
return true;
}
@Override public void end() {}
}, ChannelSftp.OVERWRITE);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
try { pis.close(); } catch (Exception ignored) {}
}
});
try {
source.getChannel().get(sourcePath, pos);
} finally {
try { pos.close(); } catch (Exception ignored) {}
}
try {
putFuture.get();
} finally {
try { pis.close(); } catch (Exception ignored) {}
}
}
/** Transfer a single file with its own independent progress tracking. */
private void transferSingleFileRemote(SftpSession source,
String sourcePath,
SftpSession target,
String targetPath,
TransferProgressListener progressListener) throws Exception {
SftpATTRS attrs = source.getChannel().stat(sourcePath);
final long totalBytes = attrs.getSize(); final long totalBytes = attrs.getSize();
final int pipeBufferSize = 65536; final int pipeBufferSize = 65536;
PipedOutputStream pos = new PipedOutputStream(); PipedOutputStream pos = new PipedOutputStream();
@@ -263,46 +417,36 @@ public class SftpService {
target.getChannel().put(pis, targetPath, new SftpProgressMonitor() { target.getChannel().put(pis, targetPath, new SftpProgressMonitor() {
@Override @Override
public void init(int op, String src, String dest, long max) { public void init(int op, String src, String dest, long max) {
if (progressListener != null) { if (progressListener != null) progressListener.onStart(totalBytes);
progressListener.onStart(totalBytes);
}
} }
@Override @Override
public boolean count(long count) { public boolean count(long count) {
long current = transferredBytes.addAndGet(count); long current = transferredBytes.addAndGet(count);
if (progressListener != null) { if (progressListener != null) progressListener.onProgress(current, totalBytes);
progressListener.onProgress(current, totalBytes);
}
return true; return true;
} }
@Override @Override
public void end() { public void end() {
if (progressListener != null) { if (progressListener != null) progressListener.onProgress(totalBytes, totalBytes);
progressListener.onProgress(totalBytes, totalBytes);
}
} }
}, ChannelSftp.OVERWRITE); }, ChannelSftp.OVERWRITE);
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException(e); throw new RuntimeException(e);
} finally {
try { pis.close(); } catch (Exception ignored) {}
} }
}); });
try { try {
source.getChannel().get(sourcePath, pos); source.getChannel().get(sourcePath, pos);
} finally { } finally {
try { try { pos.close(); } catch (Exception ignored) {}
pos.close();
} catch (Exception ignored) {
}
} }
try { try {
putFuture.get(); putFuture.get();
} finally { } finally {
try { try { pis.close(); } catch (Exception ignored) {}
pis.close();
} catch (Exception ignored) {
}
} }
} }
@@ -0,0 +1,63 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import SftpPane from './SftpPane'
import type { Connection } from '../types'
export default function SftpFileSelectorModal({
open,
connection,
onSelect,
onClose,
}: {
open: boolean
connection: Connection
onSelect: (path: string) => void
onClose: () => void
}) {
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
<div className="flex h-[80vh] w-[80vw] max-w-5xl flex-col overflow-hidden rounded-2xl border border-slate-700 bg-[#0d1117] shadow-2xl shadow-black/60">
{/* Header */}
<div className="flex shrink-0 items-center justify-between border-b border-slate-800 bg-slate-900 px-5 py-3.5">
<div>
<h2 className="text-sm font-semibold text-white"></h2>
<p className="mt-0.5 text-xs text-slate-400">
<span className="text-purple-400">{connection.name}</span>
 · "确认选择"
</p>
</div>
<button
className="rounded-xl p-2 text-slate-500 transition hover:bg-slate-800 hover:text-white"
onClick={onClose}
aria-label="关闭"
>
<X size={18} />
</button>
</div>
{/* SftpPane in selection mode */}
<div className="min-h-0 flex-1 overflow-hidden">
<SftpPane
connection={connection}
selectedFiles={selectedFiles}
onSelectedFilesChange={setSelectedFiles}
selectionMode
onConfirmSelection={(path) => {
onSelect(path)
onClose()
}}
/>
</div>
</div>
</div>
)
}
+28
View File
@@ -70,11 +70,16 @@ export default function SftpPane({
selectedFiles, selectedFiles,
onSelectedFilesChange, onSelectedFilesChange,
onRefreshSignal, onRefreshSignal,
selectionMode,
onConfirmSelection,
}: { }: {
connection: Connection connection: Connection
selectedFiles: string[] selectedFiles: string[]
onSelectedFilesChange: (files: string[]) => void onSelectedFilesChange: (files: string[]) => void
onRefreshSignal?: (refresh: () => Promise<void>) => void onRefreshSignal?: (refresh: () => Promise<void>) => void
/** When true, shows a bottom bar for path confirmation */
selectionMode?: boolean
onConfirmSelection?: (fullPath: string) => void
}) { }) {
const fileInputRef = useRef<HTMLInputElement | null>(null) const fileInputRef = useRef<HTMLInputElement | null>(null)
const uploadMenuRef = useRef<HTMLDivElement | null>(null) const uploadMenuRef = useRef<HTMLDivElement | null>(null)
@@ -978,6 +983,29 @@ export default function SftpPane({
onClose={() => setCreateDirectoryModalOpen(false)} onClose={() => setCreateDirectoryModalOpen(false)}
onSubmit={handleCreateDir} onSubmit={handleCreateDir}
/> />
{selectionMode ? (() => {
const singleSelected = selectedFiles.length === 1 ? selectedFiles[0] : null
const confirmPath = singleSelected
? joinPath(currentPath, singleSelected)
: currentPath
return (
<div className="shrink-0 border-t border-purple-900/50 bg-purple-950/30 px-4 py-3">
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<p className="text-xs text-slate-400">{singleSelected ? '已选择文件/文件夹' : '将选择当前目录'}</p>
<p className="mt-0.5 truncate font-mono text-xs text-purple-300">{confirmPath}</p>
</div>
<button
className="shrink-0 rounded-xl bg-purple-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-purple-500"
onClick={() => onConfirmSelection?.(confirmPath)}
>
</button>
</div>
</div>
)
})() : null}
</div> </div>
) )
} }
+2 -4
View File
@@ -142,10 +142,8 @@ export default function TerminalPane({
}, [visible]) }, [visible])
return ( return (
<div className="flex h-full flex-col overflow-hidden bg-slate-900"> <div className="flex h-full flex-col overflow-hidden bg-black">
<div className="flex-1 bg-black p-2 font-mono"> <div ref={containerRef} className="h-full w-full overflow-hidden" />
<div ref={containerRef} className="h-full w-full overflow-hidden rounded-2xl border border-slate-900 bg-black" />
</div>
</div> </div>
) )
} }
+247 -86
View File
@@ -1,6 +1,18 @@
import { useState } from 'react' import { useState } from 'react'
import { CheckCircle2, FileUp, ListTree, Monitor, RefreshCw, Server, Target, Upload, Zap } from 'lucide-react' import {
CheckCircle2,
FileUp,
FolderOpen,
ListTree,
Monitor,
RefreshCw,
Server,
Target,
Upload,
Zap,
} from 'lucide-react'
import Modal from './Modal' import Modal from './Modal'
import SftpFileSelectorModal from './SftpFileSelectorModal'
import { import {
cancelRemoteTransferTask, cancelRemoteTransferTask,
createRemoteTransferTask, createRemoteTransferTask,
@@ -8,7 +20,7 @@ import {
subscribeUploadProgress, subscribeUploadProgress,
uploadFile, uploadFile,
} from '../services/sftp' } from '../services/sftp'
import type { Connection, TransferTaskGroup, TransferTaskItem } from '../types' import type { Connection, ConnectionReachabilityStatus, TransferTaskGroup, TransferTaskItem } from '../types'
function aggregate(items: TransferTaskItem[]) { function aggregate(items: TransferTaskItem[]) {
const total = items.length || 1 const total = items.length || 1
@@ -20,18 +32,32 @@ function aggregate(items: TransferTaskItem[]) {
return { progress, status: status as TransferTaskGroup['status'] } return { progress, status: status as TransferTaskGroup['status'] }
} }
function StatusDot({ status }: { status: ConnectionReachabilityStatus | undefined }) {
if (status === 'online')
return <span className="h-2 w-2 shrink-0 rounded-full bg-emerald-400" title="在线" />
if (status === 'offline')
return <span className="h-2 w-2 shrink-0 rounded-full bg-red-400" title="离线" />
if (status === 'checking')
return <span className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-yellow-400" title="检测中" />
return <span className="h-2 w-2 shrink-0 rounded-full bg-slate-600" title="未知" />
}
export default function TransferCenterModal({ export default function TransferCenterModal({
open, open,
connections, connections,
connectionStatuses,
tasks, tasks,
onClose, onClose,
onTasksChange, onTasksChange,
}: { }: {
open: boolean open: boolean
connections: Connection[] connections: Connection[]
connectionStatuses: Record<number, ConnectionReachabilityStatus>
tasks: TransferTaskGroup[] tasks: TransferTaskGroup[]
onClose: () => void onClose: () => void
onTasksChange: (tasks: TransferTaskGroup[]) => void onTasksChange: (updater: TransferTaskGroup[] | ((prev: TransferTaskGroup[]) => TransferTaskGroup[])) => void
}) { }) {
const [tab, setTab] = useState<'local' | 'remote'>('local') const [tab, setTab] = useState<'local' | 'remote'>('local')
const [targetIds, setTargetIds] = useState<number[]>(connections.length ? [connections[0].id] : []) const [targetIds, setTargetIds] = useState<number[]>(connections.length ? [connections[0].id] : [])
@@ -40,11 +66,13 @@ export default function TransferCenterModal({
const [remoteSourceId, setRemoteSourceId] = useState<number>(connections[0]?.id ?? 0) const [remoteSourceId, setRemoteSourceId] = useState<number>(connections[0]?.id ?? 0)
const [remoteSourcePath, setRemoteSourcePath] = useState('/opt/app/build.tar.gz') const [remoteSourcePath, setRemoteSourcePath] = useState('/opt/app/build.tar.gz')
const [remoteTargetPath, setRemoteTargetPath] = useState('/data/deploy/') const [remoteTargetPath, setRemoteTargetPath] = useState('/data/deploy/')
const [showBrowser, setShowBrowser] = useState(false)
if (!open) return null if (!open) return null
function updateTaskGroup(groupId: string, updater: (group: TransferTaskGroup) => TransferTaskGroup) { function updateTaskGroup(groupId: string, updater: (group: TransferTaskGroup) => TransferTaskGroup) {
onTasksChange(tasks.map((task) => (task.id === groupId ? updater(task) : task))) // Use functional update to always read latest state, avoiding stale-closure bugs in async callbacks
onTasksChange((prev) => prev.map((task) => (task.id === groupId ? updater(task) : task)))
} }
async function handleStartLocal() { async function handleStartLocal() {
@@ -66,7 +94,7 @@ export default function TransferCenterModal({
message: '等待上传', message: '等待上传',
})), })),
} }
onTasksChange([group, ...tasks]) onTasksChange((prev) => [group, ...prev])
targetIds.forEach(async (targetId) => { targetIds.forEach(async (targetId) => {
const response = await uploadFile(targetId, localTargetPath, file, { overwrite: true }) const response = await uploadFile(targetId, localTargetPath, file, { overwrite: true })
@@ -87,7 +115,7 @@ export default function TransferCenterModal({
...item, ...item,
progress: task.progress, progress: task.progress,
status: task.status, status: task.status,
message: task.error || (task.status === 'success' ? '上传完成' : '正在传输...'), message: task.error || (task.status === 'success' ? '上传完成' : task.status === 'error' ? '上传失败' : task.status === 'cancelled' ? '已取消' : '正在传输...'),
} }
: item, : item,
) )
@@ -104,10 +132,11 @@ export default function TransferCenterModal({
async function handleStartRemote() { async function handleStartRemote() {
if (!remoteSourceId || targetIds.length === 0 || !remoteSourcePath.trim()) return if (!remoteSourceId || targetIds.length === 0 || !remoteSourcePath.trim()) return
const groupId = String(Date.now()) const groupId = String(Date.now())
const sourceName = remoteSourcePath.trim().split('/').filter(Boolean).pop() || remoteSourcePath.trim()
const group: TransferTaskGroup = { const group: TransferTaskGroup = {
id: groupId, id: groupId,
mode: 'REMOTE_TO_MANY', mode: 'REMOTE_TO_MANY',
title: '跨主机文件同步', title: `跨主机分发 · ${sourceName}`,
status: 'running', status: 'running',
progress: 0, progress: 0,
createdAt: new Date().toLocaleTimeString(), createdAt: new Date().toLocaleTimeString(),
@@ -119,7 +148,7 @@ export default function TransferCenterModal({
message: '等待创建任务', message: '等待创建任务',
})), })),
} }
onTasksChange([group, ...tasks]) onTasksChange((prev) => [group, ...prev])
targetIds.forEach(async (targetId) => { targetIds.forEach(async (targetId) => {
const response = await createRemoteTransferTask(remoteSourceId, remoteSourcePath, targetId, remoteTargetPath) const response = await createRemoteTransferTask(remoteSourceId, remoteSourcePath, targetId, remoteTargetPath)
@@ -140,7 +169,7 @@ export default function TransferCenterModal({
...item, ...item,
progress: task.progress, progress: task.progress,
status: task.status, status: task.status,
message: task.error || (task.status === 'success' ? '同步完成' : '正在同步...'), message: task.error || (task.status === 'success' ? '同步完成' : task.status === 'error' ? '同步失败' : task.status === 'cancelled' ? '已取消' : '正在同步...'),
} }
: item, : item,
) )
@@ -155,16 +184,24 @@ export default function TransferCenterModal({
} }
return ( return (
<Modal title="文件传输中心" onClose={onClose} maxWidth="max-w-7xl"> <>
<Modal title="文件传输中心" onClose={onClose} maxWidth="max-w-7xl">
<div className="flex h-[74vh] flex-col overflow-hidden rounded-2xl border border-slate-800 bg-[#0d1117]"> <div className="flex h-[74vh] flex-col overflow-hidden rounded-2xl border border-slate-800 bg-[#0d1117]">
{/* Tabs */}
<div className="flex gap-8 border-b border-slate-800 bg-slate-900 px-6 pt-4"> <div className="flex gap-8 border-b border-slate-800 bg-slate-900 px-6 pt-4">
<button className={`border-b-2 pb-3 text-sm font-medium ${tab === 'local' ? 'border-blue-500 text-blue-400' : 'border-transparent text-slate-400'}`} onClick={() => setTab('local')}> <button
className={`border-b-2 pb-3 text-sm font-medium ${tab === 'local' ? 'border-blue-500 text-blue-400' : 'border-transparent text-slate-400'}`}
onClick={() => setTab('local')}
>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Monitor size={16} /> <Monitor size={16} />
</span> </span>
</button> </button>
<button className={`border-b-2 pb-3 text-sm font-medium ${tab === 'remote' ? 'border-purple-500 text-purple-400' : 'border-transparent text-slate-400'}`} onClick={() => setTab('remote')}> <button
className={`border-b-2 pb-3 text-sm font-medium ${tab === 'remote' ? 'border-purple-500 text-purple-400' : 'border-transparent text-slate-400'}`}
onClick={() => { setTab('remote'); setShowBrowser(false) }}
>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Server size={16} /> <Server size={16} />
@@ -173,7 +210,8 @@ export default function TransferCenterModal({
</div> </div>
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
<div className="flex flex-1 overflow-auto border-r border-slate-800 bg-slate-900/70 p-6"> {/* Left panel */}
<div className="flex flex-1 overflow-hidden border-r border-slate-800 bg-slate-900/70 p-6">
{tab === 'local' ? ( {tab === 'local' ? (
<div className="flex flex-1 flex-col space-y-6"> <div className="flex flex-1 flex-col space-y-6">
<label className="space-y-2"> <label className="space-y-2">
@@ -196,71 +234,19 @@ export default function TransferCenterModal({
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<span className="text-sm text-slate-300">3. </span> <span className="text-sm text-slate-300">3. </span>
<div className="rounded-2xl border border-slate-700 bg-black p-2"> <div className="rounded-2xl border border-slate-700 bg-black p-2">
{connections.map((server) => ( {connections.map((server) => {
<label key={server.id} className="flex items-center gap-2 rounded-xl px-3 py-2 hover:bg-slate-800"> const st = connectionStatuses[server.id]
<input const isOnline = st === 'online'
type="checkbox" return (
checked={targetIds.includes(server.id)} <label
onChange={() => key={server.id}
setTargetIds((prev) => className={`flex items-center gap-2 rounded-xl px-3 py-2 ${
prev.includes(server.id) ? prev.filter((item) => item !== server.id) : [...prev, server.id], isOnline ? 'cursor-pointer hover:bg-slate-800' : 'cursor-not-allowed opacity-40'
) }`}
} >
/>
<Server size={14} className="text-slate-500" />
<span className="text-sm text-slate-300">{server.name}</span>
</label>
))}
</div>
</div>
<button className="flex w-full items-center justify-center gap-2 rounded-xl bg-blue-600 py-3 font-medium text-white transition hover:bg-blue-500" onClick={() => void handleStartLocal()}>
<Upload size={18} />
</button>
</div>
) : (
<div className="flex flex-1 gap-6">
<div className="flex w-1/2 flex-col gap-5 border-r border-slate-800 pr-6">
<h3 className="flex items-center gap-2 text-sm font-bold text-purple-400">
<Zap size={16} />
</h3>
<label className="space-y-2">
<span className="text-sm text-slate-300"></span>
<select className="w-full rounded-xl border border-slate-700 bg-black px-4 py-3 text-sm text-white" value={remoteSourceId} onChange={(event) => setRemoteSourceId(Number(event.target.value))}>
{connections.map((server) => (
<option key={server.id} value={server.id}>
{server.name}
</option>
))}
</select>
</label>
<label className="flex flex-1 flex-col space-y-2">
<span className="text-sm text-slate-300"></span>
<textarea className="min-h-[180px] flex-1 rounded-xl border border-slate-700 bg-black px-4 py-3 font-mono text-sm text-white" value={remoteSourcePath} onChange={(event) => setRemoteSourcePath(event.target.value)} />
</label>
</div>
<div className="flex w-1/2 flex-col gap-5">
<h3 className="flex items-center gap-2 text-sm font-bold text-blue-400">
<Target size={16} />
</h3>
<label className="space-y-2">
<span className="text-sm text-slate-300"></span>
<input className="w-full rounded-xl border border-slate-700 bg-black px-4 py-3 text-sm text-white" value={remoteTargetPath} onChange={(event) => setRemoteTargetPath(event.target.value)} />
</label>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-300"></span>
<button className="text-xs text-blue-400" onClick={() => setTargetIds(connections.map((item) => item.id))}>
</button>
</div>
<div className="rounded-2xl border border-slate-700 bg-black p-2">
{connections.map((server) => (
<label key={server.id} className="flex items-center gap-2 rounded-xl px-3 py-2 hover:bg-slate-800">
<input <input
type="checkbox" type="checkbox"
disabled={!isOnline}
checked={targetIds.includes(server.id)} checked={targetIds.includes(server.id)}
onChange={() => onChange={() =>
setTargetIds((prev) => setTargetIds((prev) =>
@@ -268,20 +254,182 @@ export default function TransferCenterModal({
) )
} }
/> />
<StatusDot status={st} />
<Server size={14} className={isOnline ? 'text-emerald-500' : 'text-slate-600'} />
<span className="text-sm text-slate-300">{server.name}</span> <span className="text-sm text-slate-300">{server.name}</span>
</label> </label>
))} )
})}
</div>
</div>
<button
className={`flex w-full items-center justify-center gap-2 rounded-xl py-3 font-medium transition ${
!localFiles?.length || targetIds.length === 0
? 'cursor-not-allowed bg-slate-800 text-slate-500'
: 'bg-blue-600 text-white hover:bg-blue-500'
}`}
disabled={!localFiles?.length || targetIds.length === 0}
onClick={() => void handleStartLocal()}
>
<Upload size={18} />
{!localFiles?.length ? '请选择本地文件' : targetIds.length === 0 ? '请选择目标服务器' : '开始分发'}
</button>
</div>
) : (
<div className="flex h-full flex-1 gap-6 overflow-hidden">
{/* Source config */}
<div className="flex w-1/2 shrink-0 flex-col gap-4 overflow-y-auto border-r border-slate-800 pr-6">
<h3 className="flex items-center gap-2 text-sm font-bold text-purple-400">
<Zap size={16} />
</h3>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-300"></span>
{(() => {
const st = connectionStatuses[remoteSourceId]
if (st === 'online') return <span className="flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-xs font-medium text-emerald-400"><span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />线</span>
if (st === 'offline') return <span className="flex items-center gap-1 rounded-full bg-red-500/15 px-2 py-0.5 text-xs font-medium text-red-400"><span className="h-1.5 w-1.5 rounded-full bg-red-400" />线</span>
if (st === 'checking') return <span className="flex items-center gap-1 rounded-full bg-yellow-500/15 px-2 py-0.5 text-xs font-medium text-yellow-400"><span className="h-1.5 w-1.5 animate-pulse rounded-full bg-yellow-400" /></span>
return <span className="flex items-center gap-1 rounded-full bg-slate-700/60 px-2 py-0.5 text-xs font-medium text-slate-500"><span className="h-1.5 w-1.5 rounded-full bg-slate-500" /></span>
})()}
</div>
<select
className={`w-full rounded-xl border bg-black px-4 py-3 text-sm text-white ${
connectionStatuses[remoteSourceId] === 'online'
? 'border-emerald-700'
: connectionStatuses[remoteSourceId] === 'offline'
? 'border-red-800'
: 'border-slate-700'
}`}
value={remoteSourceId}
onChange={(event) => {
setRemoteSourceId(Number(event.target.value))
setShowBrowser(false)
}}
>
{connections.map((server) => {
const st = connectionStatuses[server.id]
const isOnline = st === 'online'
const label = st === 'online' ? '● ' : st === 'offline' ? '● ' : st === 'checking' ? '◔ ' : '◦ '
return (
<option key={server.id} value={server.id} disabled={!isOnline}>
{label}{server.name}{!isOnline ? (st === 'offline' ? ' (离线)' : ' (未知)') : ''}
</option>
)
})}
</select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-300"> / </span>
<button
className={`flex items-center gap-1 rounded-lg px-2 py-1 text-xs transition ${
showBrowser
? 'bg-purple-600 text-white'
: 'border border-slate-700 text-slate-400 hover:border-purple-500 hover:text-purple-400'
}`}
onClick={() => setShowBrowser((v) => !v)}
>
<FolderOpen size={12} />
{showBrowser ? '收起' : '浏览...'}
</button>
</div>
<input
className="w-full rounded-xl border border-slate-700 bg-black px-4 py-3 font-mono text-sm text-white"
value={remoteSourcePath}
onChange={(event) => setRemoteSourcePath(event.target.value)}
placeholder="/opt/app"
/>
</div>
<div className="mt-auto rounded-xl border border-slate-800 bg-slate-950/50 px-4 py-3 text-xs text-slate-500">
<p className="font-medium text-slate-400">📁 </p>
<p className="mt-1"></p>
</div>
</div>
{/* Target config */}
<div className="flex min-w-0 flex-1 flex-col gap-4 overflow-hidden">
<h3 className="flex items-center gap-2 text-sm font-bold text-blue-400">
<Target size={16} />
</h3>
<label className="space-y-2">
<span className="text-sm text-slate-300"></span>
<input
className="w-full rounded-xl border border-slate-700 bg-black px-4 py-3 text-sm text-white"
value={remoteTargetPath}
onChange={(event) => setRemoteTargetPath(event.target.value)}
/>
</label>
<div className="flex min-h-0 flex-1 flex-col space-y-2">
<div className="flex shrink-0 items-center justify-between">
<span className="text-sm text-slate-300"></span>
<button
className="text-xs text-blue-400"
onClick={() => setTargetIds(connections.filter((c) => connectionStatuses[c.id] === 'online').map((c) => c.id))}
>
线
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-slate-700 bg-black p-2">
{connections.map((server) => {
const st = connectionStatuses[server.id]
const isOnline = st === 'online'
return (
<label
key={server.id}
className={`flex items-center gap-2 rounded-xl px-3 py-2 ${
isOnline ? 'cursor-pointer hover:bg-slate-800' : 'cursor-not-allowed opacity-40'
}`}
>
<input
type="checkbox"
disabled={!isOnline}
checked={targetIds.includes(server.id)}
onChange={() =>
setTargetIds((prev) =>
prev.includes(server.id) ? prev.filter((item) => item !== server.id) : [...prev, server.id],
)
}
/>
<StatusDot status={st} />
<span className="text-sm text-slate-300">{server.name}</span>
</label>
)
})}
</div> </div>
</div> </div>
<button className="flex w-full items-center justify-center gap-2 rounded-xl bg-purple-600 py-3 font-medium text-white transition hover:bg-purple-500" onClick={() => void handleStartRemote()}>
<button
className={`flex w-full items-center justify-center gap-2 rounded-xl py-3 font-medium transition ${
!remoteSourceId || targetIds.length === 0 || !remoteSourcePath.trim()
? 'cursor-not-allowed bg-slate-800 text-slate-500'
: 'bg-purple-600 text-white hover:bg-purple-500'
}`}
disabled={!remoteSourceId || targetIds.length === 0 || !remoteSourcePath.trim()}
onClick={() => void handleStartRemote()}
>
<Zap size={18} fill="currentColor" /> <Zap size={18} fill="currentColor" />
{!remoteSourceId
? '请选择源服务器'
: !remoteSourcePath.trim()
? '请填写源路径'
: targetIds.length === 0
? '请选择目标服务器'
: '跨服同步分发'}
</button> </button>
</div> </div>
</div> </div>
)} )}
</div> </div>
{/* Task status panel */}
<div className="flex w-[360px] shrink-0 flex-col bg-[#0d1117]"> <div className="flex w-[360px] shrink-0 flex-col bg-[#0d1117]">
<div className="flex items-center justify-between border-b border-slate-800 bg-slate-900 px-4 py-4"> <div className="flex items-center justify-between border-b border-slate-800 bg-slate-900 px-4 py-4">
<h4 className="flex items-center gap-2 text-sm font-medium text-slate-200"> <h4 className="flex items-center gap-2 text-sm font-medium text-slate-200">
@@ -317,12 +465,8 @@ export default function TransferCenterModal({
</button> </button>
) : null} ) : null}
</div> </div>
<div className="mb-1 flex justify-between text-xs text-slate-500"> <div className="text-xs text-slate-500">
<span>{task.createdAt}</span> {task.createdAt}
<span className={task.status === 'success' ? 'text-emerald-400' : 'text-blue-400'}>{task.progress}%</span>
</div>
<div className="h-1.5 rounded-full border border-slate-700 bg-slate-950">
<div className={`h-1.5 rounded-full ${task.status === 'success' ? 'bg-emerald-500' : 'bg-blue-500'}`} style={{ width: `${task.progress}%` }} />
</div> </div>
</div> </div>
<div className="max-h-48 space-y-1 overflow-auto bg-slate-900/60 p-2"> <div className="max-h-48 space-y-1 overflow-auto bg-slate-900/60 p-2">
@@ -330,7 +474,10 @@ export default function TransferCenterModal({
<div key={item.id} className="rounded-xl p-2 hover:bg-slate-800"> <div key={item.id} className="rounded-xl p-2 hover:bg-slate-800">
<div className="flex justify-between text-xs"> <div className="flex justify-between text-xs">
<span className="truncate text-slate-300">{item.label}</span> <span className="truncate text-slate-300">{item.label}</span>
<span className="text-slate-500">{item.message}</span> <div className="flex items-center gap-2">
<span className="text-slate-500">{item.message}</span>
<span className={item.status === 'success' ? 'text-emerald-400' : 'text-blue-400'}>{item.progress}%</span>
</div>
</div> </div>
<div className="mt-2 flex items-center gap-2"> <div className="mt-2 flex items-center gap-2">
<div className="h-1 flex-1 rounded-full bg-black"> <div className="h-1 flex-1 rounded-full bg-black">
@@ -356,5 +503,19 @@ export default function TransferCenterModal({
</div> </div>
</div> </div>
</Modal> </Modal>
{/* SFTP file selector popup — mounted outside the main modal so it overlays on top */}
{showBrowser && (
<SftpFileSelectorModal
open={showBrowser}
connection={connections.find((c) => c.id === remoteSourceId) ?? connections[0]}
onSelect={(path) => {
setRemoteSourcePath(path)
setShowBrowser(false)
}}
onClose={() => setShowBrowser(false)}
/>
)}
</>
) )
} }
+3 -2
View File
@@ -757,8 +757,8 @@ export default function WorkspacePage({
'rounded-md p-2 transition', 'rounded-md p-2 transition',
layout === 'split' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white', layout === 'split' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white',
)} )}
onClick={() => setLayout('split')} onClick={() => setLayout(layout === 'split' ? 'terminal' : 'split')}
title="分屏" title={layout === 'split' ? '切换到终端' : '分屏'}
> >
<SplitSquareHorizontal size={15} /> <SplitSquareHorizontal size={15} />
</button> </button>
@@ -857,6 +857,7 @@ export default function WorkspacePage({
<TransferCenterModal <TransferCenterModal
open={showTransferModal} open={showTransferModal}
connections={connections} connections={connections}
connectionStatuses={connectionStatuses}
tasks={transferTasks} tasks={transferTasks}
onTasksChange={setTransferTasks} onTasksChange={setTransferTasks}
onClose={() => setShowTransferModal(false)} onClose={() => setShowTransferModal(false)}