Skip to content

Commit 94cca5c

Browse files
feat: add SFTP browser
- New SFTP Browser tab — right-click any SSH connection → Open SFTP Browser - Full file manager UI: navigate directories with breadcrumb, folder double-click, and up/back navigation - Upload files to remote (multi-select via system dialog) - Download files from remote (choose local folder via system dialog) - Create folders, delete files/folders, rename inline (click pencil icon) - Real-time transfer progress streamed from main process - Reuses existing SSH credentials (password / key) — no extra setup - 3-dot (⋯) menu button on hover in sidebar — same menu as right-click - SFTP tabs show folder icon and "SFTP · name" label in TabBar - Dedicated IPC layer: sftp:connect, sftp:list, sftp:home, sftp:download, sftp:upload, sftp:delete, sftp:rename, sftp:mkdir, sftp:disconnect
1 parent ffc83db commit 94cca5c

11 files changed

Lines changed: 930 additions & 49 deletions

File tree

src/main/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { setupFileDialogHandlers } from './fileDialog'
1010
import { setupLogHandlers } from './logger'
1111
import { setupMasterPasswordHandlers } from './masterPassword'
1212
import { setupAiHandlers } from './ai'
13+
import { setupSftpHandlers } from './sftp'
1314
import { setupLicenseHandlers } from './license'
1415
import { setupAutoUpdater } from './updater'
1516
import * as Sentry from '@sentry/electron/main'
@@ -112,7 +113,7 @@ app.whenReady().then(() => {
112113
optimizer.watchWindowShortcuts(window)
113114
})
114115

115-
nativeTheme.themeSource = 'dark'
116+
nativeTheme.themeSource = 'light'
116117

117118
// macOS "About" panel — show app icon + correct version
118119
if (process.platform === 'darwin') {
@@ -139,6 +140,7 @@ app.whenReady().then(() => {
139140

140141
setupStoreHandlers(ipcMain)
141142
setupSshHandlers(ipcMain, () => mainWindow)
143+
setupSftpHandlers(ipcMain, () => mainWindow)
142144
setupTelnetHandlers(ipcMain, () => mainWindow)
143145
setupAiHandlers(ipcMain, () => mainWindow)
144146
setupLicenseHandlers(ipcMain)

src/main/sftp.ts

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import { IpcMain, BrowserWindow, dialog } from 'electron'
2+
import { Client, ConnectConfig, SFTPWrapper } from 'ssh2'
3+
import * as path from 'path'
4+
5+
export interface SftpEntry {
6+
name: string
7+
path: string
8+
size: number
9+
isDirectory: boolean
10+
modifyTime: number
11+
permissions: number
12+
}
13+
14+
interface SftpSession {
15+
client: Client
16+
sftp: SFTPWrapper
17+
}
18+
19+
const sftpSessions = new Map<string, SftpSession>()
20+
21+
function teardownSftpSession(sessionId: string): void {
22+
const session = sftpSessions.get(sessionId)
23+
if (!session) return
24+
session.client.removeAllListeners()
25+
try { session.client.end() } catch { /* already closed */ }
26+
sftpSessions.delete(sessionId)
27+
}
28+
29+
export function setupSftpHandlers(ipcMain: IpcMain, getWindow: () => BrowserWindow | null): void {
30+
31+
ipcMain.handle('sftp:connect', (_, payload: {
32+
sessionId: string
33+
host: string
34+
port: number
35+
username: string
36+
password?: string
37+
privateKey?: string
38+
passphrase?: string
39+
}) => {
40+
return new Promise<{ success: boolean; error?: string }>((resolve) => {
41+
if (sftpSessions.has(payload.sessionId)) {
42+
teardownSftpSession(payload.sessionId)
43+
}
44+
45+
const client = new Client()
46+
47+
client.on('error', (err) => {
48+
resolve({ success: false, error: err.message })
49+
})
50+
51+
client.on('ready', () => {
52+
client.sftp((err, sftp) => {
53+
if (err) {
54+
client.end()
55+
return resolve({ success: false, error: err.message })
56+
}
57+
sftpSessions.set(payload.sessionId, { client, sftp })
58+
59+
client.removeAllListeners('error')
60+
client.on('error', () => {
61+
teardownSftpSession(payload.sessionId)
62+
getWindow()?.webContents.send('sftp:closed', payload.sessionId)
63+
})
64+
65+
resolve({ success: true })
66+
})
67+
})
68+
69+
const cfg: ConnectConfig = {
70+
host: payload.host,
71+
port: payload.port,
72+
username: payload.username,
73+
readyTimeout: 30000,
74+
keepaliveInterval: 30000,
75+
}
76+
if (payload.privateKey) {
77+
cfg.privateKey = payload.privateKey
78+
if (payload.passphrase) cfg.passphrase = payload.passphrase
79+
} else if (payload.password) {
80+
cfg.password = payload.password
81+
}
82+
83+
client.connect(cfg)
84+
})
85+
})
86+
87+
ipcMain.handle('sftp:home', (_, sessionId: string) => {
88+
return new Promise<{ success: boolean; path?: string; error?: string }>((resolve) => {
89+
const session = sftpSessions.get(sessionId)
90+
if (!session) return resolve({ success: false, error: 'Not connected' })
91+
92+
session.sftp.realpath('.', (err, resolvedPath) => {
93+
if (err) resolve({ success: true, path: '/' })
94+
else resolve({ success: true, path: resolvedPath })
95+
})
96+
})
97+
})
98+
99+
ipcMain.handle('sftp:list', (_, sessionId: string, remotePath: string) => {
100+
return new Promise<{ success: boolean; entries?: SftpEntry[]; error?: string }>((resolve) => {
101+
const session = sftpSessions.get(sessionId)
102+
if (!session) return resolve({ success: false, error: 'Not connected' })
103+
104+
session.sftp.readdir(remotePath, (err, list) => {
105+
if (err) return resolve({ success: false, error: err.message })
106+
107+
const entries: SftpEntry[] = list
108+
.filter(item => item.filename !== '.' && item.filename !== '..')
109+
.map((item) => {
110+
const mode = item.attrs.mode ?? 0
111+
const isDirectory = (mode & 0o170000) === 0o040000
112+
return {
113+
name: item.filename,
114+
path: remotePath === '/' ? `/${item.filename}` : `${remotePath}/${item.filename}`,
115+
size: item.attrs.size ?? 0,
116+
isDirectory,
117+
modifyTime: (item.attrs.mtime ?? 0) * 1000,
118+
permissions: mode,
119+
}
120+
})
121+
.sort((a, b) => {
122+
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1
123+
return a.name.localeCompare(b.name)
124+
})
125+
126+
resolve({ success: true, entries })
127+
})
128+
})
129+
})
130+
131+
ipcMain.handle('sftp:download', async (_, sessionId: string, remotePaths: string[]) => {
132+
const session = sftpSessions.get(sessionId)
133+
if (!session) return { success: false, error: 'Not connected' }
134+
135+
const win = getWindow()
136+
if (!win) return { success: false, error: 'No window' }
137+
138+
const result = await dialog.showOpenDialog(win, {
139+
properties: ['openDirectory', 'createDirectory'],
140+
title: 'Choose download folder',
141+
})
142+
if (result.canceled || !result.filePaths[0]) return { success: false, canceled: true }
143+
144+
const localDir = result.filePaths[0]
145+
146+
for (const remotePath of remotePaths) {
147+
const fileName = path.basename(remotePath)
148+
const localPath = path.join(localDir, fileName)
149+
150+
try {
151+
await new Promise<void>((resolve, reject) => {
152+
session.sftp.fastGet(remotePath, localPath, {
153+
step: (transferred: number, _chunk: number, total: number) => {
154+
win.webContents.send('sftp:progress', sessionId, remotePath, transferred, total)
155+
},
156+
}, (err) => {
157+
if (err) reject(err)
158+
else resolve()
159+
})
160+
})
161+
} catch (err) {
162+
return { success: false, error: (err as Error).message }
163+
}
164+
}
165+
166+
return { success: true, localDir }
167+
})
168+
169+
ipcMain.handle('sftp:upload', async (_, sessionId: string, remotePath: string) => {
170+
const session = sftpSessions.get(sessionId)
171+
if (!session) return { success: false, error: 'Not connected' }
172+
173+
const win = getWindow()
174+
if (!win) return { success: false, error: 'No window' }
175+
176+
const result = await dialog.showOpenDialog(win, {
177+
properties: ['openFile', 'multiSelections'],
178+
title: 'Choose files to upload',
179+
})
180+
if (result.canceled || !result.filePaths.length) return { success: false, canceled: true }
181+
182+
for (const localPath of result.filePaths) {
183+
const fileName = path.basename(localPath)
184+
const remoteFilePath = remotePath === '/' ? `/${fileName}` : `${remotePath}/${fileName}`
185+
186+
try {
187+
await new Promise<void>((resolve, reject) => {
188+
session.sftp.fastPut(localPath, remoteFilePath, {
189+
step: (transferred: number, _chunk: number, total: number) => {
190+
win.webContents.send('sftp:progress', sessionId, localPath, transferred, total)
191+
},
192+
}, (err) => {
193+
if (err) reject(err)
194+
else resolve()
195+
})
196+
})
197+
} catch (err) {
198+
return { success: false, error: (err as Error).message }
199+
}
200+
}
201+
202+
return { success: true }
203+
})
204+
205+
ipcMain.handle('sftp:delete', (_, sessionId: string, remotePath: string, isDirectory: boolean) => {
206+
return new Promise<{ success: boolean; error?: string }>((resolve) => {
207+
const session = sftpSessions.get(sessionId)
208+
if (!session) return resolve({ success: false, error: 'Not connected' })
209+
210+
if (isDirectory) {
211+
session.sftp.rmdir(remotePath, (err) => {
212+
if (err) resolve({ success: false, error: err.message })
213+
else resolve({ success: true })
214+
})
215+
} else {
216+
session.sftp.unlink(remotePath, (err) => {
217+
if (err) resolve({ success: false, error: err.message })
218+
else resolve({ success: true })
219+
})
220+
}
221+
})
222+
})
223+
224+
ipcMain.handle('sftp:rename', (_, sessionId: string, oldPath: string, newPath: string) => {
225+
return new Promise<{ success: boolean; error?: string }>((resolve) => {
226+
const session = sftpSessions.get(sessionId)
227+
if (!session) return resolve({ success: false, error: 'Not connected' })
228+
229+
session.sftp.rename(oldPath, newPath, (err) => {
230+
if (err) resolve({ success: false, error: err.message })
231+
else resolve({ success: true })
232+
})
233+
})
234+
})
235+
236+
ipcMain.handle('sftp:mkdir', (_, sessionId: string, remotePath: string) => {
237+
return new Promise<{ success: boolean; error?: string }>((resolve) => {
238+
const session = sftpSessions.get(sessionId)
239+
if (!session) return resolve({ success: false, error: 'Not connected' })
240+
241+
session.sftp.mkdir(remotePath, (err) => {
242+
if (err) resolve({ success: false, error: err.message })
243+
else resolve({ success: true })
244+
})
245+
})
246+
})
247+
248+
ipcMain.handle('sftp:disconnect', (_, sessionId: string) => {
249+
teardownSftpSession(sessionId)
250+
return true
251+
})
252+
}

src/preload/index.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,30 @@ const api = {
175175
getDeviceId: () => ipcRenderer.invoke('license:device-id'),
176176
},
177177

178+
// SFTP
179+
sftp: {
180+
connect: (payload: unknown) => ipcRenderer.invoke('sftp:connect', payload),
181+
home: (sessionId: string) => ipcRenderer.invoke('sftp:home', sessionId),
182+
list: (sessionId: string, remotePath: string) => ipcRenderer.invoke('sftp:list', sessionId, remotePath),
183+
download: (sessionId: string, remotePaths: string[]) => ipcRenderer.invoke('sftp:download', sessionId, remotePaths),
184+
upload: (sessionId: string, remotePath: string) => ipcRenderer.invoke('sftp:upload', sessionId, remotePath),
185+
delete: (sessionId: string, remotePath: string, isDirectory: boolean) => ipcRenderer.invoke('sftp:delete', sessionId, remotePath, isDirectory),
186+
rename: (sessionId: string, oldPath: string, newPath: string) => ipcRenderer.invoke('sftp:rename', sessionId, oldPath, newPath),
187+
mkdir: (sessionId: string, remotePath: string) => ipcRenderer.invoke('sftp:mkdir', sessionId, remotePath),
188+
disconnect: (sessionId: string) => ipcRenderer.invoke('sftp:disconnect', sessionId),
189+
onProgress: (cb: (sessionId: string, filePath: string, transferred: number, total: number) => void) => {
190+
const handler = (_: unknown, sessionId: string, filePath: string, transferred: number, total: number) =>
191+
cb(sessionId, filePath, transferred, total)
192+
ipcRenderer.on('sftp:progress', handler)
193+
return () => ipcRenderer.removeListener('sftp:progress', handler)
194+
},
195+
onClosed: (cb: (sessionId: string) => void) => {
196+
const handler = (_: unknown, sessionId: string) => cb(sessionId)
197+
ipcRenderer.on('sftp:closed', handler)
198+
return () => ipcRenderer.removeListener('sftp:closed', handler)
199+
},
200+
},
201+
178202
// AI Copilot
179203
ai: {
180204
chat: (payload: unknown) => ipcRenderer.invoke('ai:chat', payload),

0 commit comments

Comments
 (0)