latest changes
This commit is contained in:
@@ -9,6 +9,7 @@ import { connectionRoutes } from './routes/connections';
|
||||
import { profileRoutes } from './routes/profiles';
|
||||
import { sshWebsocket } from './websocket/ssh';
|
||||
import { rdpWebsocket } from './websocket/rdp';
|
||||
import { sftpWebsocket } from './websocket/sftp';
|
||||
import { encrypt } from './utils/encryption';
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -48,6 +49,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
// WebSocket routes
|
||||
await fastify.register(sshWebsocket);
|
||||
await fastify.register(rdpWebsocket);
|
||||
await fastify.register(sftpWebsocket);
|
||||
|
||||
return fastify;
|
||||
}
|
||||
|
||||
283
backend/src/websocket/sftp.ts
Normal file
283
backend/src/websocket/sftp.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { SocketStream } from '@fastify/websocket';
|
||||
import { WebSocket } from 'ws';
|
||||
import { Client as SshClient, ConnectConfig, SFTPWrapper } from 'ssh2';
|
||||
import { resolveCredentials } from '../utils/resolveCredentials';
|
||||
|
||||
interface JwtPayload {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
function sendJson(socket: WebSocket, msg: object) {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
export async function sftpWebsocket(fastify: FastifyInstance) {
|
||||
fastify.get(
|
||||
'/ws/sftp/:connectionId',
|
||||
{ websocket: true },
|
||||
async (connection: SocketStream, request: FastifyRequest) => {
|
||||
const socket = connection.socket;
|
||||
|
||||
// --- Auth via ?token= query param ---
|
||||
const query = request.query as { token?: string };
|
||||
let userId: string;
|
||||
|
||||
try {
|
||||
if (!query.token) throw new Error('No token');
|
||||
const payload = fastify.jwt.verify<JwtPayload>(query.token);
|
||||
userId = payload.id;
|
||||
} catch {
|
||||
socket.close(1008, 'Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Fetch connection ---
|
||||
const { connectionId } = request.params as { connectionId: string };
|
||||
const conn = await fastify.prisma.connection.findFirst({
|
||||
where: { id: connectionId, userId },
|
||||
include: { profile: true },
|
||||
});
|
||||
|
||||
if (!conn) {
|
||||
socket.close(1008, 'Connection not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Build SSH config ---
|
||||
const creds = resolveCredentials(conn);
|
||||
const sshConfig: ConnectConfig = {
|
||||
host: conn.host,
|
||||
port: conn.port,
|
||||
username: creds.username,
|
||||
readyTimeout: 10_000,
|
||||
};
|
||||
|
||||
if (creds.privateKey) {
|
||||
sshConfig.privateKey = creds.privateKey;
|
||||
} else if (creds.password) {
|
||||
sshConfig.password = creds.password;
|
||||
}
|
||||
|
||||
// --- Open SSH + SFTP session ---
|
||||
const ssh = new SshClient();
|
||||
let sftp: SFTPWrapper | null = null;
|
||||
|
||||
// Upload state
|
||||
let uploadPath: string | null = null;
|
||||
let uploadStream: ReturnType<SFTPWrapper['createWriteStream']> | null = null;
|
||||
|
||||
ssh.on('ready', () => {
|
||||
ssh.sftp((err, sftpSession) => {
|
||||
if (err) {
|
||||
sendJson(socket, { type: 'error', message: err.message });
|
||||
socket.close();
|
||||
ssh.end();
|
||||
return;
|
||||
}
|
||||
|
||||
sftp = sftpSession;
|
||||
|
||||
// Resolve home directory and send ready
|
||||
sftp.realpath('.', (rpErr, home) => {
|
||||
sendJson(socket, {
|
||||
type: 'ready',
|
||||
home: rpErr ? '/' : home,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Handle incoming messages ---
|
||||
socket.on('message', (message: Buffer | string, isBinary: boolean) => {
|
||||
if (!sftp) return;
|
||||
|
||||
// Binary frames → upload data
|
||||
if (isBinary) {
|
||||
if (uploadStream) {
|
||||
const buf = Buffer.isBuffer(message) ? message : Buffer.from(message as string);
|
||||
uploadStream.write(buf);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// JSON commands
|
||||
let msg: any;
|
||||
try {
|
||||
msg = JSON.parse(message.toString());
|
||||
} catch {
|
||||
sendJson(socket, { type: 'error', message: 'Invalid JSON' });
|
||||
return;
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case 'list': {
|
||||
const dirPath: string = msg.path || '/';
|
||||
sftp.readdir(dirPath, (err, list) => {
|
||||
if (err) {
|
||||
sendJson(socket, { type: 'error', message: err.message });
|
||||
return;
|
||||
}
|
||||
const entries = list
|
||||
.filter((item) => item.filename !== '.' && item.filename !== '..')
|
||||
.map((item) => ({
|
||||
name: item.filename,
|
||||
isDir: (item.attrs.mode! & 0o40000) !== 0,
|
||||
size: item.attrs.size ?? 0,
|
||||
modTime: (item.attrs.mtime ?? 0) * 1000,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
// Directories first, then alphabetical
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
sendJson(socket, { type: 'entries', path: dirPath, entries });
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'download': {
|
||||
const filePath: string = msg.path;
|
||||
if (!filePath) {
|
||||
sendJson(socket, { type: 'error', message: 'Missing path' });
|
||||
return;
|
||||
}
|
||||
sftp.stat(filePath, (err, stats) => {
|
||||
if (err) {
|
||||
sendJson(socket, { type: 'error', message: err.message });
|
||||
return;
|
||||
}
|
||||
const name = filePath.split('/').pop() || 'download';
|
||||
sendJson(socket, { type: 'downloadStart', name, size: stats.size });
|
||||
|
||||
const rs = sftp!.createReadStream(filePath);
|
||||
rs.on('data', (chunk: Buffer) => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(chunk);
|
||||
}
|
||||
});
|
||||
rs.on('end', () => {
|
||||
sendJson(socket, { type: 'downloadEnd' });
|
||||
});
|
||||
rs.on('error', (readErr: Error) => {
|
||||
sendJson(socket, { type: 'error', message: readErr.message });
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'upload': {
|
||||
const upPath: string = msg.path;
|
||||
if (!upPath) {
|
||||
sendJson(socket, { type: 'error', message: 'Missing path' });
|
||||
return;
|
||||
}
|
||||
uploadPath = upPath;
|
||||
uploadStream = sftp.createWriteStream(upPath);
|
||||
uploadStream.on('error', (writeErr: Error) => {
|
||||
sendJson(socket, { type: 'error', message: writeErr.message });
|
||||
uploadStream = null;
|
||||
uploadPath = null;
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'uploadEnd': {
|
||||
if (uploadStream) {
|
||||
uploadStream.end(() => {
|
||||
sendJson(socket, { type: 'ok' });
|
||||
uploadStream = null;
|
||||
uploadPath = null;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'mkdir': {
|
||||
const mkPath: string = msg.path;
|
||||
if (!mkPath) {
|
||||
sendJson(socket, { type: 'error', message: 'Missing path' });
|
||||
return;
|
||||
}
|
||||
sftp.mkdir(mkPath, (err) => {
|
||||
if (err) {
|
||||
sendJson(socket, { type: 'error', message: err.message });
|
||||
} else {
|
||||
sendJson(socket, { type: 'ok' });
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
const delPath: string = msg.path;
|
||||
if (!delPath) {
|
||||
sendJson(socket, { type: 'error', message: 'Missing path' });
|
||||
return;
|
||||
}
|
||||
sftp.stat(delPath, (statErr, stats) => {
|
||||
if (statErr) {
|
||||
sendJson(socket, { type: 'error', message: statErr.message });
|
||||
return;
|
||||
}
|
||||
const isDir = (stats.mode! & 0o40000) !== 0;
|
||||
const cb = (err: Error | null | undefined) => {
|
||||
if (err) {
|
||||
sendJson(socket, { type: 'error', message: err.message });
|
||||
} else {
|
||||
sendJson(socket, { type: 'ok' });
|
||||
}
|
||||
};
|
||||
if (isDir) {
|
||||
sftp!.rmdir(delPath, cb);
|
||||
} else {
|
||||
sftp!.unlink(delPath, cb);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'rename': {
|
||||
const { oldPath, newPath } = msg;
|
||||
if (!oldPath || !newPath) {
|
||||
sendJson(socket, { type: 'error', message: 'Missing oldPath or newPath' });
|
||||
return;
|
||||
}
|
||||
sftp.rename(oldPath, newPath, (err) => {
|
||||
if (err) {
|
||||
sendJson(socket, { type: 'error', message: err.message });
|
||||
} else {
|
||||
sendJson(socket, { type: 'ok' });
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
sendJson(socket, { type: 'error', message: `Unknown command: ${msg.type}` });
|
||||
}
|
||||
});
|
||||
|
||||
ssh.on('error', (err) => {
|
||||
fastify.log.error({ err }, 'SFTP SSH connection error');
|
||||
sendJson(socket, { type: 'error', message: err.message });
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.close(1011, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
ssh.connect(sshConfig);
|
||||
|
||||
socket.on('close', () => {
|
||||
if (uploadStream) {
|
||||
uploadStream.destroy();
|
||||
uploadStream = null;
|
||||
}
|
||||
ssh.end();
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -223,12 +223,23 @@ export const ConnectionTree: React.FC = () => {
|
||||
// --- Context menu items ---
|
||||
const getContextMenu = (node: ExtendedDataNode): MenuProps['items'] => {
|
||||
if (node.itemData.type === 'connection') {
|
||||
const conn = node.itemData.connection!;
|
||||
return [
|
||||
{
|
||||
key: 'connect',
|
||||
label: 'Connect',
|
||||
onClick: () => openSession(node.itemData.connection!),
|
||||
onClick: () => openSession(conn),
|
||||
},
|
||||
...(conn.protocol === 'ssh'
|
||||
? [
|
||||
{
|
||||
key: 'browseFiles',
|
||||
icon: <FolderOpenOutlined />,
|
||||
label: 'Browse Files',
|
||||
onClick: () => openSession(conn, 'sftp'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'edit',
|
||||
icon: <EditOutlined />,
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import React from 'react';
|
||||
import { Tabs, Typography } from 'antd';
|
||||
import { CodeOutlined, WindowsOutlined } from '@ant-design/icons';
|
||||
import { CodeOutlined, WindowsOutlined, FolderOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../../store';
|
||||
import { SshTab } from './SshTab';
|
||||
import { RdpTab } from './RdpTab';
|
||||
import { SftpTab } from './SftpTab';
|
||||
import { Session } from '../../types';
|
||||
|
||||
function tabLabel(session: Session) {
|
||||
const icon =
|
||||
session.connection.protocol === 'rdp' ? (
|
||||
const isSftp = session.mode === 'sftp';
|
||||
const icon = isSftp ? (
|
||||
<FolderOutlined />
|
||||
) : session.connection.protocol === 'rdp' ? (
|
||||
<WindowsOutlined />
|
||||
) : (
|
||||
<CodeOutlined />
|
||||
@@ -16,7 +19,7 @@ function tabLabel(session: Session) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
{icon}
|
||||
{session.connection.name}
|
||||
{session.connection.name}{isSftp ? ' (Files)' : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -68,7 +71,9 @@ export const SessionTabs: React.FC = () => {
|
||||
style: { height: '100%', padding: 0 },
|
||||
children: (
|
||||
<div style={{ height: '100%', overflow: 'hidden' }}>
|
||||
{session.connection.protocol === 'ssh' ? (
|
||||
{session.mode === 'sftp' ? (
|
||||
<SftpTab session={session} />
|
||||
) : session.connection.protocol === 'ssh' ? (
|
||||
<SshTab session={session} />
|
||||
) : (
|
||||
<RdpTab session={session} />
|
||||
|
||||
457
frontend/src/components/Tabs/SftpTab.tsx
Normal file
457
frontend/src/components/Tabs/SftpTab.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Table, Breadcrumb, Button, Space, Upload, Input, Modal, message, Tooltip, Typography } from 'antd';
|
||||
import {
|
||||
FolderOutlined,
|
||||
FileOutlined,
|
||||
DownloadOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
FolderAddOutlined,
|
||||
UploadOutlined,
|
||||
ReloadOutlined,
|
||||
HomeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useStore } from '../../store';
|
||||
import { Session } from '../../types';
|
||||
|
||||
interface Props {
|
||||
session: Session;
|
||||
}
|
||||
|
||||
interface FileEntry {
|
||||
name: string;
|
||||
isDir: boolean;
|
||||
size: number;
|
||||
modTime: number;
|
||||
}
|
||||
|
||||
function humanSize(bytes: number): string {
|
||||
if (bytes === 0) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const val = bytes / Math.pow(1024, i);
|
||||
return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function formatDate(ms: number): string {
|
||||
if (!ms) return '—';
|
||||
return new Date(ms).toLocaleString();
|
||||
}
|
||||
|
||||
function getWsUrl(connectionId: string, token: string): string {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
return `${proto}//${host}/ws/sftp/${connectionId}?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
function joinPath(base: string, name: string): string {
|
||||
if (base === '/') return '/' + name;
|
||||
return base + '/' + name;
|
||||
}
|
||||
|
||||
function parentPath(path: string): string {
|
||||
if (path === '/') return '/';
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
return '/' + parts.join('/');
|
||||
}
|
||||
|
||||
function pathSegments(path: string): { name: string; path: string }[] {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
const segments: { name: string; path: string }[] = [];
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
segments.push({
|
||||
name: parts[i],
|
||||
path: '/' + parts.slice(0, i + 1).join('/'),
|
||||
});
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
export const SftpTab: React.FC<Props> = ({ session }) => {
|
||||
const token = useStore((s) => s.token) ?? '';
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [currentPath, setCurrentPath] = useState('/');
|
||||
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [homePath, setHomePath] = useState('/');
|
||||
|
||||
// Download state
|
||||
const downloadRef = useRef<{ name: string; size: number; chunks: ArrayBuffer[] } | null>(null);
|
||||
|
||||
// Upload state
|
||||
const uploadingRef = useRef(false);
|
||||
|
||||
// New folder state
|
||||
const [mkdirModalOpen, setMkdirModalOpen] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
|
||||
// Rename state
|
||||
const [renameModalOpen, setRenameModalOpen] = useState(false);
|
||||
const [renameEntry, setRenameEntry] = useState<FileEntry | null>(null);
|
||||
const [renameName, setRenameName] = useState('');
|
||||
|
||||
const sendJson = useCallback((msg: object) => {
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const listDir = useCallback(
|
||||
(path: string) => {
|
||||
setLoading(true);
|
||||
sendJson({ type: 'list', path });
|
||||
},
|
||||
[sendJson]
|
||||
);
|
||||
|
||||
const navigateTo = useCallback(
|
||||
(path: string) => {
|
||||
setCurrentPath(path);
|
||||
listDir(path);
|
||||
},
|
||||
[listDir]
|
||||
);
|
||||
|
||||
// WebSocket lifecycle
|
||||
useEffect(() => {
|
||||
const url = getWsUrl(session.connection.id, token);
|
||||
const ws = new WebSocket(url);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// Binary frame → download chunk
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
if (downloadRef.current) {
|
||||
downloadRef.current.chunks.push(event.data as ArrayBuffer);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let msg: any;
|
||||
try {
|
||||
msg = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case 'ready':
|
||||
setConnected(true);
|
||||
setHomePath(msg.home || '/');
|
||||
setCurrentPath(msg.home || '/');
|
||||
setLoading(true);
|
||||
ws.send(JSON.stringify({ type: 'list', path: msg.home || '/' }));
|
||||
break;
|
||||
|
||||
case 'entries': {
|
||||
const serverEntries: FileEntry[] = msg.entries || [];
|
||||
if (msg.path !== '/') {
|
||||
serverEntries.unshift({ name: '..', isDir: true, size: 0, modTime: 0 });
|
||||
}
|
||||
setEntries(serverEntries);
|
||||
setCurrentPath(msg.path);
|
||||
setLoading(false);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'downloadStart':
|
||||
downloadRef.current = { name: msg.name, size: msg.size, chunks: [] };
|
||||
break;
|
||||
|
||||
case 'downloadEnd': {
|
||||
const dl = downloadRef.current;
|
||||
if (dl) {
|
||||
const blob = new Blob(dl.chunks);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = dl.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
downloadRef.current = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ok':
|
||||
if (uploadingRef.current) {
|
||||
uploadingRef.current = false;
|
||||
message.success('Upload complete');
|
||||
}
|
||||
// Refresh listing after any mutation
|
||||
listDir(currentPath);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
setLoading(false);
|
||||
uploadingRef.current = false;
|
||||
message.error(msg.message || 'SFTP error');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
message.error('SFTP WebSocket error');
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
};
|
||||
|
||||
return () => {
|
||||
ws.close();
|
||||
wsRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session.connection.id, token]);
|
||||
|
||||
// --- Handlers ---
|
||||
const handleDownload = (entry: FileEntry) => {
|
||||
sendJson({ type: 'download', path: joinPath(currentPath, entry.name) });
|
||||
};
|
||||
|
||||
const handleDelete = (entry: FileEntry) => {
|
||||
Modal.confirm({
|
||||
title: `Delete "${entry.name}"?`,
|
||||
content: entry.isDir ? 'This will delete the directory (must be empty).' : 'This file will be permanently deleted.',
|
||||
okText: 'Delete',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => {
|
||||
sendJson({ type: 'delete', path: joinPath(currentPath, entry.name) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRename = (entry: FileEntry) => {
|
||||
setRenameEntry(entry);
|
||||
setRenameName(entry.name);
|
||||
setRenameModalOpen(true);
|
||||
};
|
||||
|
||||
const commitRename = () => {
|
||||
if (renameEntry && renameName.trim() && renameName !== renameEntry.name) {
|
||||
sendJson({
|
||||
type: 'rename',
|
||||
oldPath: joinPath(currentPath, renameEntry.name),
|
||||
newPath: joinPath(currentPath, renameName.trim()),
|
||||
});
|
||||
}
|
||||
setRenameModalOpen(false);
|
||||
setRenameEntry(null);
|
||||
setRenameName('');
|
||||
};
|
||||
|
||||
const handleMkdir = () => {
|
||||
if (newFolderName.trim()) {
|
||||
sendJson({ type: 'mkdir', path: joinPath(currentPath, newFolderName.trim()) });
|
||||
}
|
||||
setMkdirModalOpen(false);
|
||||
setNewFolderName('');
|
||||
};
|
||||
|
||||
const handleUpload = (file: File) => {
|
||||
uploadingRef.current = true;
|
||||
const uploadPath = joinPath(currentPath, file.name);
|
||||
sendJson({ type: 'upload', path: uploadPath, size: file.size });
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN && reader.result instanceof ArrayBuffer) {
|
||||
// Send in 64KB chunks
|
||||
const data = new Uint8Array(reader.result);
|
||||
const CHUNK = 64 * 1024;
|
||||
for (let offset = 0; offset < data.length; offset += CHUNK) {
|
||||
ws.send(data.slice(offset, offset + CHUNK));
|
||||
}
|
||||
sendJson({ type: 'uploadEnd' });
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
return false; // Prevent Ant Upload from doing its own upload
|
||||
};
|
||||
|
||||
// --- Table columns ---
|
||||
const columns: ColumnsType<FileEntry> = [
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (name: string, record) => (
|
||||
<span
|
||||
style={{ cursor: record.isDir ? 'pointer' : 'default', display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||||
onClick={() => record.isDir && navigateTo(name === '..' ? parentPath(currentPath) : joinPath(currentPath, name))}
|
||||
>
|
||||
{record.isDir ? <FolderOutlined style={{ color: name === '..' ? undefined : '#faad14' }} /> : <FileOutlined />}
|
||||
<span style={{ color: record.isDir ? '#1677ff' : undefined }}>{name}</span>
|
||||
</span>
|
||||
),
|
||||
sorter: (a, b) => {
|
||||
if (a.name === '..') return -1;
|
||||
if (b.name === '..') return 1;
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
},
|
||||
defaultSortOrder: 'ascend',
|
||||
},
|
||||
{
|
||||
title: 'Size',
|
||||
dataIndex: 'size',
|
||||
key: 'size',
|
||||
width: 100,
|
||||
render: (size: number, record) => (record.isDir ? '—' : humanSize(size)),
|
||||
sorter: (a, b) => a.size - b.size,
|
||||
},
|
||||
{
|
||||
title: 'Modified',
|
||||
dataIndex: 'modTime',
|
||||
key: 'modTime',
|
||||
width: 180,
|
||||
render: (ms: number) => formatDate(ms),
|
||||
sorter: (a, b) => a.modTime - b.modTime,
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (_, record) => record.name === '..' ? null : (
|
||||
<Space size="small">
|
||||
{!record.isDir && (
|
||||
<Tooltip title="Download">
|
||||
<Button type="text" size="small" icon={<DownloadOutlined />} onClick={() => handleDownload(record)} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Rename">
|
||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => handleRename(record)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const segments = pathSegments(currentPath);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: '#fff' }}>
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Breadcrumb
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
items={[
|
||||
{
|
||||
title: (
|
||||
<span style={{ cursor: 'pointer' }} onClick={() => navigateTo('/')}>
|
||||
<HomeOutlined />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
...segments.map((seg) => ({
|
||||
title: (
|
||||
<span style={{ cursor: 'pointer' }} onClick={() => navigateTo(seg.path)}>
|
||||
{seg.name}
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<Space size="small">
|
||||
<Upload beforeUpload={handleUpload as any} showUploadList={false} multiple={false}>
|
||||
<Button size="small" icon={<UploadOutlined />}>
|
||||
Upload
|
||||
</Button>
|
||||
</Upload>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FolderAddOutlined />}
|
||||
onClick={() => {
|
||||
setNewFolderName('');
|
||||
setMkdirModalOpen(true);
|
||||
}}
|
||||
>
|
||||
New Folder
|
||||
</Button>
|
||||
<Tooltip title="Refresh">
|
||||
<Button size="small" icon={<ReloadOutlined />} loading={loading} onClick={() => listDir(currentPath)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* File table */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0 12px' }}>
|
||||
{!connected ? (
|
||||
<div style={{ textAlign: 'center', padding: 48 }}>
|
||||
<Typography.Text type="secondary">Connecting to SFTP...</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<Table<FileEntry>
|
||||
dataSource={entries}
|
||||
columns={columns}
|
||||
rowKey="name"
|
||||
size="small"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
locale={{ emptyText: 'Empty directory' }}
|
||||
onRow={(record) => ({
|
||||
onDoubleClick: () => {
|
||||
if (record.isDir) navigateTo(record.name === '..' ? parentPath(currentPath) : joinPath(currentPath, record.name));
|
||||
},
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Folder Modal */}
|
||||
<Modal
|
||||
title="New Folder"
|
||||
open={mkdirModalOpen}
|
||||
onOk={handleMkdir}
|
||||
onCancel={() => setMkdirModalOpen(false)}
|
||||
okText="Create"
|
||||
>
|
||||
<Input
|
||||
placeholder="Folder name"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
onPressEnter={handleMkdir}
|
||||
autoFocus
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* Rename Modal */}
|
||||
<Modal
|
||||
title={`Rename "${renameEntry?.name}"`}
|
||||
open={renameModalOpen}
|
||||
onOk={commitRename}
|
||||
onCancel={() => setRenameModalOpen(false)}
|
||||
okText="Rename"
|
||||
>
|
||||
<Input
|
||||
placeholder="New name"
|
||||
value={renameName}
|
||||
onChange={(e) => setRenameName(e.target.value)}
|
||||
onPressEnter={commitRename}
|
||||
autoFocus
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -27,7 +27,7 @@ interface AppState {
|
||||
// Open sessions (tabs)
|
||||
sessions: Session[];
|
||||
activeSessionId: string | null;
|
||||
openSession: (connection: Connection) => void;
|
||||
openSession: (connection: Connection, mode?: 'shell' | 'sftp') => void;
|
||||
closeSession: (sessionId: string) => void;
|
||||
setActiveSession: (sessionId: string) => void;
|
||||
}
|
||||
@@ -76,14 +76,17 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
sessions: [],
|
||||
activeSessionId: null,
|
||||
|
||||
openSession: (connection) => {
|
||||
const existing = get().sessions.find((s) => s.connection.id === connection.id);
|
||||
openSession: (connection, mode) => {
|
||||
const sessionMode = mode || (connection.protocol === 'rdp' ? undefined : 'shell');
|
||||
const existing = get().sessions.find(
|
||||
(s) => s.connection.id === connection.id && (s.mode || 'shell') === (sessionMode || 'shell')
|
||||
);
|
||||
if (existing) {
|
||||
set({ activeSessionId: existing.id });
|
||||
return;
|
||||
}
|
||||
const id = `session-${Date.now()}-${Math.random()}`;
|
||||
const session: Session = { id, connection };
|
||||
const session: Session = { id, connection, mode: sessionMode };
|
||||
set((state) => ({
|
||||
sessions: [...state.sessions, session],
|
||||
activeSessionId: id,
|
||||
|
||||
@@ -70,4 +70,5 @@ export interface Session {
|
||||
/** Unique ID for this open tab */
|
||||
id: string;
|
||||
connection: Connection;
|
||||
mode?: 'shell' | 'sftp';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user