latest changes

This commit is contained in:
felixg
2026-03-03 07:08:00 +01:00
parent eba699d7bc
commit 8e8b849ff8
7 changed files with 776 additions and 14 deletions

View File

@@ -9,6 +9,7 @@ import { connectionRoutes } from './routes/connections';
import { profileRoutes } from './routes/profiles'; import { profileRoutes } from './routes/profiles';
import { sshWebsocket } from './websocket/ssh'; import { sshWebsocket } from './websocket/ssh';
import { rdpWebsocket } from './websocket/rdp'; import { rdpWebsocket } from './websocket/rdp';
import { sftpWebsocket } from './websocket/sftp';
import { encrypt } from './utils/encryption'; import { encrypt } from './utils/encryption';
declare module 'fastify' { declare module 'fastify' {
@@ -48,6 +49,7 @@ async function buildApp(): Promise<FastifyInstance> {
// WebSocket routes // WebSocket routes
await fastify.register(sshWebsocket); await fastify.register(sshWebsocket);
await fastify.register(rdpWebsocket); await fastify.register(rdpWebsocket);
await fastify.register(sftpWebsocket);
return fastify; return fastify;
} }

View 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();
});
}
);
}

View File

@@ -223,12 +223,23 @@ export const ConnectionTree: React.FC = () => {
// --- Context menu items --- // --- Context menu items ---
const getContextMenu = (node: ExtendedDataNode): MenuProps['items'] => { const getContextMenu = (node: ExtendedDataNode): MenuProps['items'] => {
if (node.itemData.type === 'connection') { if (node.itemData.type === 'connection') {
const conn = node.itemData.connection!;
return [ return [
{ {
key: 'connect', key: 'connect',
label: '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', key: 'edit',
icon: <EditOutlined />, icon: <EditOutlined />,

View File

@@ -1,14 +1,17 @@
import React from 'react'; import React from 'react';
import { Tabs, Typography } from 'antd'; 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 { useStore } from '../../store';
import { SshTab } from './SshTab'; import { SshTab } from './SshTab';
import { RdpTab } from './RdpTab'; import { RdpTab } from './RdpTab';
import { SftpTab } from './SftpTab';
import { Session } from '../../types'; import { Session } from '../../types';
function tabLabel(session: Session) { function tabLabel(session: Session) {
const icon = const isSftp = session.mode === 'sftp';
session.connection.protocol === 'rdp' ? ( const icon = isSftp ? (
<FolderOutlined />
) : session.connection.protocol === 'rdp' ? (
<WindowsOutlined /> <WindowsOutlined />
) : ( ) : (
<CodeOutlined /> <CodeOutlined />
@@ -16,7 +19,7 @@ function tabLabel(session: Session) {
return ( return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{icon} {icon}
{session.connection.name} {session.connection.name}{isSftp ? ' (Files)' : ''}
</span> </span>
); );
} }
@@ -68,7 +71,9 @@ export const SessionTabs: React.FC = () => {
style: { height: '100%', padding: 0 }, style: { height: '100%', padding: 0 },
children: ( children: (
<div style={{ height: '100%', overflow: 'hidden' }}> <div style={{ height: '100%', overflow: 'hidden' }}>
{session.connection.protocol === 'ssh' ? ( {session.mode === 'sftp' ? (
<SftpTab session={session} />
) : session.connection.protocol === 'ssh' ? (
<SshTab session={session} /> <SshTab session={session} />
) : ( ) : (
<RdpTab session={session} /> <RdpTab session={session} />

View 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>
);
};

View File

@@ -27,7 +27,7 @@ interface AppState {
// Open sessions (tabs) // Open sessions (tabs)
sessions: Session[]; sessions: Session[];
activeSessionId: string | null; activeSessionId: string | null;
openSession: (connection: Connection) => void; openSession: (connection: Connection, mode?: 'shell' | 'sftp') => void;
closeSession: (sessionId: string) => void; closeSession: (sessionId: string) => void;
setActiveSession: (sessionId: string) => void; setActiveSession: (sessionId: string) => void;
} }
@@ -76,14 +76,17 @@ export const useStore = create<AppState>((set, get) => ({
sessions: [], sessions: [],
activeSessionId: null, activeSessionId: null,
openSession: (connection) => { openSession: (connection, mode) => {
const existing = get().sessions.find((s) => s.connection.id === connection.id); 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) { if (existing) {
set({ activeSessionId: existing.id }); set({ activeSessionId: existing.id });
return; return;
} }
const id = `session-${Date.now()}-${Math.random()}`; const id = `session-${Date.now()}-${Math.random()}`;
const session: Session = { id, connection }; const session: Session = { id, connection, mode: sessionMode };
set((state) => ({ set((state) => ({
sessions: [...state.sessions, session], sessions: [...state.sessions, session],
activeSessionId: id, activeSessionId: id,

View File

@@ -70,4 +70,5 @@ export interface Session {
/** Unique ID for this open tab */ /** Unique ID for this open tab */
id: string; id: string;
connection: Connection; connection: Connection;
mode?: 'shell' | 'sftp';
} }