feat: harden local shell, SFTP, and desktop session reliability
Finder-launched terminals now inherit a login PATH so tools like ollama resolve, destructive SFTP/editor actions require confirmation, and terminal search plus window dragging work again. SSH forwards and RDP sessions keep listeners and survive reconnect without FastPath crashes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
188f3556ed
commit
08696896f9
223 files changed
+13493
-3423
No files matched your search
@@ -70,8 +70,8 @@ describe('SFTP basic transfer acceptance', () => {
|
||||
})
|
||||
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
|
||||
await screen.findByRole('button', { name: '上传' })
|
||||
await user.click(screen.getByRole('button', { name: '上传' }))
|
||||
await screen.findByRole('button', { name: '上传到远程' })
|
||||
await user.click(screen.getByRole('button', { name: '上传到远程' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledWith(
|
||||
@@ -86,9 +86,9 @@ describe('SFTP basic transfer acceptance', () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
|
||||
|
||||
await screen.findByRole('button', { name: '上传' })
|
||||
await screen.findByRole('button', { name: '上传到远程' })
|
||||
await user.click(await screen.findByText('remote.txt'))
|
||||
await user.click(screen.getByRole('button', { name: '下载' }))
|
||||
await user.click(screen.getByRole('button', { name: '下载到本地' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpDownload).toHaveBeenCalledWith(
|
||||
@@ -115,23 +115,31 @@ describe('SFTP basic transfer acceptance', () => {
|
||||
})
|
||||
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
|
||||
await screen.findByRole('button', { name: '上传' })
|
||||
await user.click(screen.getByRole('button', { name: '上传' }))
|
||||
await screen.findByRole('button', { name: '上传到远程' })
|
||||
await user.click(screen.getByRole('button', { name: '上传到远程' }))
|
||||
|
||||
expect(await screen.findByText(/权限不足|Permission denied/)).toBeVisible()
|
||||
expect(await screen.findByText(/权限|Permission denied|失败/)).toBeVisible()
|
||||
})
|
||||
|
||||
it('uploads by dragging a local row onto the remote panel', async () => {
|
||||
it('uploads by pointer-dragging a local row onto the remote panel', async () => {
|
||||
vi.mocked(mockDesktopApi.localReadDir).mockResolvedValue({
|
||||
success: true,
|
||||
files: [{ name: 'local.txt', path: '/Users/test/local.txt', type: 'file', size: 8, modified: 1_700_000_000 }],
|
||||
})
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
|
||||
await screen.findByRole('button', { name: '上传' })
|
||||
await screen.findByRole('button', { name: '上传到远程' })
|
||||
|
||||
const transfer = createDataTransfer()
|
||||
fireEvent.dragStart(await screen.findByText('local.txt'), { dataTransfer: transfer })
|
||||
fireEvent.drop(screen.getByLabelText('远程文件面板'), { dataTransfer: transfer })
|
||||
const localRow = await screen.findByText('local.txt')
|
||||
const remotePanel = screen.getByLabelText('远程文件面板')
|
||||
const originalElementFromPoint = document.elementFromPoint
|
||||
Object.defineProperty(document, 'elementFromPoint', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => remotePanel),
|
||||
})
|
||||
|
||||
fireEvent.pointerDown(localRow, { button: 0, pointerId: 11, clientX: 10, clientY: 10 })
|
||||
fireEvent.pointerMove(localRow, { pointerId: 11, clientX: 30, clientY: 30 })
|
||||
fireEvent.pointerUp(localRow, { button: 0, pointerId: 11, clientX: 30, clientY: 30 })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledWith(
|
||||
@@ -140,15 +148,28 @@ describe('SFTP basic transfer acceptance', () => {
|
||||
'/root/local.txt'
|
||||
)
|
||||
})
|
||||
|
||||
Object.defineProperty(document, 'elementFromPoint', {
|
||||
configurable: true,
|
||||
value: originalElementFromPoint,
|
||||
})
|
||||
})
|
||||
|
||||
it('downloads by dragging a remote row onto the local panel', async () => {
|
||||
it('downloads by pointer-dragging a remote row onto the local panel', async () => {
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
|
||||
await screen.findByRole('button', { name: '上传' })
|
||||
await screen.findByRole('button', { name: '上传到远程' })
|
||||
|
||||
const transfer = createDataTransfer()
|
||||
fireEvent.dragStart(await screen.findByText('remote.txt'), { dataTransfer: transfer })
|
||||
fireEvent.drop(screen.getByLabelText('本地文件面板'), { dataTransfer: transfer })
|
||||
const remoteRow = await screen.findByText('remote.txt')
|
||||
const localPanel = screen.getByLabelText('本地文件面板')
|
||||
const originalElementFromPoint = document.elementFromPoint
|
||||
Object.defineProperty(document, 'elementFromPoint', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => localPanel),
|
||||
})
|
||||
|
||||
fireEvent.pointerDown(remoteRow, { button: 0, pointerId: 12, clientX: 50, clientY: 50 })
|
||||
fireEvent.pointerMove(remoteRow, { pointerId: 12, clientX: 70, clientY: 70 })
|
||||
fireEvent.pointerUp(remoteRow, { button: 0, pointerId: 12, clientX: 70, clientY: 70 })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpDownload).toHaveBeenCalledWith(
|
||||
@@ -157,5 +178,132 @@ describe('SFTP basic transfer acceptance', () => {
|
||||
'/Users/test/remote.txt'
|
||||
)
|
||||
})
|
||||
|
||||
Object.defineProperty(document, 'elementFromPoint', {
|
||||
configurable: true,
|
||||
value: originalElementFromPoint,
|
||||
})
|
||||
})
|
||||
|
||||
it('still accepts OS file HTML5 drops onto the remote panel', async () => {
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
|
||||
await screen.findByRole('button', { name: '上传到远程' })
|
||||
|
||||
const file = new File(['hello'], 'from-os.txt', { type: 'text/plain' })
|
||||
Object.defineProperty(file, 'path', { value: '/Users/test/from-os.txt' })
|
||||
const transfer = createDataTransfer()
|
||||
Object.defineProperty(transfer, 'types', { value: ['Files'] })
|
||||
Object.defineProperty(transfer, 'files', {
|
||||
value: {
|
||||
length: 1,
|
||||
0: file,
|
||||
item: (index: number) => (index === 0 ? file : null),
|
||||
[Symbol.iterator]: function* () {
|
||||
yield file
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.dragEnter(screen.getByLabelText('远程文件面板'), { dataTransfer: transfer })
|
||||
fireEvent.drop(screen.getByLabelText('远程文件面板'), { dataTransfer: transfer })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledWith(
|
||||
'sftp-native',
|
||||
'/Users/test/from-os.txt',
|
||||
'/root/from-os.txt'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('transfers both ways through pointer dragging when the WebView does not deliver HTML5 drop', async () => {
|
||||
vi.mocked(mockDesktopApi.localReadDir).mockResolvedValue({
|
||||
success: true,
|
||||
files: [{ name: 'local.txt', path: '/Users/test/local.txt', type: 'file', size: 8, modified: 1_700_000_000 }],
|
||||
})
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
|
||||
const localRow = await screen.findByText('local.txt')
|
||||
const remoteRow = await screen.findByText('remote.txt')
|
||||
const localPanel = screen.getByLabelText('本地文件面板')
|
||||
const remotePanel = screen.getByLabelText('远程文件面板')
|
||||
const hitTest = vi.fn<() => Element | null>(() => remotePanel)
|
||||
const originalElementFromPoint = document.elementFromPoint
|
||||
Object.defineProperty(document, 'elementFromPoint', {
|
||||
configurable: true,
|
||||
value: hitTest,
|
||||
})
|
||||
|
||||
fireEvent.pointerDown(localRow, { button: 0, pointerId: 1, clientX: 10, clientY: 10 })
|
||||
fireEvent.pointerMove(localRow, { pointerId: 1, clientX: 30, clientY: 30 })
|
||||
fireEvent.pointerUp(localRow, { button: 0, pointerId: 1, clientX: 30, clientY: 30 })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledWith(
|
||||
'sftp-native',
|
||||
'/Users/test/local.txt',
|
||||
'/root/local.txt'
|
||||
)
|
||||
})
|
||||
|
||||
hitTest.mockReturnValue(localPanel)
|
||||
fireEvent.pointerDown(remoteRow, { button: 0, pointerId: 2, clientX: 50, clientY: 50 })
|
||||
fireEvent.pointerMove(remoteRow, { pointerId: 2, clientX: 70, clientY: 70 })
|
||||
fireEvent.pointerUp(remoteRow, { button: 0, pointerId: 2, clientX: 70, clientY: 70 })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpDownload).toHaveBeenCalledWith(
|
||||
'sftp-native',
|
||||
'/root/remote.txt',
|
||||
'/Users/test/remote.txt'
|
||||
)
|
||||
})
|
||||
|
||||
Object.defineProperty(document, 'elementFromPoint', {
|
||||
configurable: true,
|
||||
value: originalElementFromPoint,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the directory under the pointer as the transfer destination', async () => {
|
||||
vi.mocked(mockDesktopApi.localReadDir).mockResolvedValue({
|
||||
success: true,
|
||||
files: [{ name: 'local.txt', path: '/Users/test/local.txt', type: 'file', size: 8, modified: 1_700_000_000 }],
|
||||
})
|
||||
vi.mocked(mockDesktopApi.sftpListDirectory).mockResolvedValue({
|
||||
success: true,
|
||||
files: [
|
||||
remoteFile,
|
||||
{
|
||||
filename: 'archive',
|
||||
longname: 'drwxr-xr-x 1 root root 0 Jul 30 12:00 archive',
|
||||
attrs: { size: 0, mtime: 1_700_000_000, mode: 0o040755 },
|
||||
},
|
||||
],
|
||||
})
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
|
||||
const localRow = await screen.findByText('local.txt')
|
||||
const archiveRow = await screen.findByText('archive')
|
||||
const originalElementFromPoint = document.elementFromPoint
|
||||
Object.defineProperty(document, 'elementFromPoint', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => archiveRow),
|
||||
})
|
||||
|
||||
fireEvent.pointerDown(localRow, { button: 0, pointerId: 3, clientX: 10, clientY: 10 })
|
||||
fireEvent.pointerMove(localRow, { pointerId: 3, clientX: 40, clientY: 40 })
|
||||
fireEvent.pointerUp(localRow, { button: 0, pointerId: 3, clientX: 40, clientY: 40 })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledWith(
|
||||
'sftp-native',
|
||||
'/Users/test/local.txt',
|
||||
'/root/archive/local.txt'
|
||||
)
|
||||
})
|
||||
|
||||
Object.defineProperty(document, 'elementFromPoint', {
|
||||
configurable: true,
|
||||
value: originalElementFromPoint,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
clearForwardIntent,
|
||||
forgetForwardIntent,
|
||||
forwardIntentKey,
|
||||
listForwardIntent,
|
||||
rememberForwardIntent,
|
||||
resetForwardIntentForTests,
|
||||
restoreForwardIntent,
|
||||
tunnelCoversRule,
|
||||
} from '../../../src/components/connection/forwardIntent'
|
||||
import type { ForwardRule } from '../../../src/components/connection/forwardRules'
|
||||
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
|
||||
|
||||
const localRule: ForwardRule = {
|
||||
id: 'pf-local',
|
||||
type: 'local',
|
||||
name: 'db',
|
||||
localPort: 3306,
|
||||
remoteHost: '127.0.0.1',
|
||||
remotePort: 3306,
|
||||
}
|
||||
|
||||
function tunnel(overrides: Partial<SSHForwardStatus> = {}): SSHForwardStatus {
|
||||
return {
|
||||
forwardId: 'pf-local',
|
||||
connectionId: 'conn-1',
|
||||
kind: 'local',
|
||||
bindHost: '127.0.0.1',
|
||||
boundPort: 3306,
|
||||
startedAt: 1,
|
||||
running: true,
|
||||
activeConnections: 0,
|
||||
bytesUp: 0,
|
||||
bytesDown: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetForwardIntentForTests()
|
||||
})
|
||||
|
||||
describe('forward reconnect intent', () => {
|
||||
it('keys by session so a new connection id does not drop remembered rules', () => {
|
||||
expect(forwardIntentKey('sess-1', 'tab-1')).toBe('session:sess-1')
|
||||
expect(forwardIntentKey(undefined, 'tab-1')).toBe('tab:tab-1')
|
||||
})
|
||||
|
||||
it('remembers start intent and forgets explicit stop', () => {
|
||||
const key = forwardIntentKey('sess-1', 'tab-1')
|
||||
rememberForwardIntent(key, localRule)
|
||||
expect(listForwardIntent(key)).toEqual([localRule])
|
||||
forgetForwardIntent(key, localRule.id)
|
||||
expect(listForwardIntent(key)).toEqual([])
|
||||
})
|
||||
|
||||
it('matches a running tunnel by rule id or listen port after reconnect', () => {
|
||||
expect(tunnelCoversRule([tunnel()], localRule)).toBe(true)
|
||||
expect(
|
||||
tunnelCoversRule([tunnel({ forwardId: 'legacy', boundPort: 3306 })], localRule)
|
||||
).toBe(true)
|
||||
expect(
|
||||
tunnelCoversRule([tunnel({ running: false })], localRule)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('matches remote forwards by remote listen port, not local target port', () => {
|
||||
const remoteRule: ForwardRule = {
|
||||
id: 'pf-remote',
|
||||
type: 'remote',
|
||||
name: 'expose',
|
||||
localPort: 8080,
|
||||
remoteHost: '127.0.0.1',
|
||||
remotePort: 18080,
|
||||
}
|
||||
expect(
|
||||
tunnelCoversRule(
|
||||
[tunnel({ forwardId: 'x', kind: 'remote', boundPort: 18080 })],
|
||||
remoteRule
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
tunnelCoversRule(
|
||||
[tunnel({ forwardId: 'x', kind: 'remote', boundPort: 8080 })],
|
||||
remoteRule
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('clears remembered intent when a tab is disposed', () => {
|
||||
const key = forwardIntentKey('sess-1', 'tab-1')
|
||||
rememberForwardIntent(key, localRule)
|
||||
clearForwardIntent(key)
|
||||
expect(listForwardIntent(key)).toEqual([])
|
||||
})
|
||||
|
||||
it('restores only rules that are not already running', async () => {
|
||||
const key = forwardIntentKey('sess-1', 'tab-1')
|
||||
rememberForwardIntent(key, localRule)
|
||||
rememberForwardIntent(key, { ...localRule, id: 'pf-other', localPort: 8080 })
|
||||
const start = vi.fn().mockResolvedValue(true)
|
||||
|
||||
const restored = await restoreForwardIntent(
|
||||
key,
|
||||
(rule) => rule.id === 'pf-local',
|
||||
start
|
||||
)
|
||||
|
||||
expect(restored).toBe(1)
|
||||
expect(start).toHaveBeenCalledTimes(1)
|
||||
expect(start).toHaveBeenCalledWith(expect.objectContaining({ id: 'pf-other' }))
|
||||
})
|
||||
|
||||
it('shares one restore pass when StatusBar and PortForwardView race', async () => {
|
||||
const key = forwardIntentKey('sess-1', 'tab-1')
|
||||
rememberForwardIntent(key, localRule)
|
||||
let release!: (value: boolean) => void
|
||||
const start = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const first = restoreForwardIntent(key, () => false, start)
|
||||
const second = restoreForwardIntent(key, () => false, start)
|
||||
release(true)
|
||||
|
||||
await expect(first).resolves.toBe(1)
|
||||
await expect(second).resolves.toBe(1)
|
||||
expect(start).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -2,9 +2,13 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
duplicateForwardRule,
|
||||
formatHostPort,
|
||||
formatTransferBytes,
|
||||
parseForwardRules,
|
||||
parseHostPort,
|
||||
portForwardStorageKey,
|
||||
resolveForwardTargetHost,
|
||||
resolveRemoteBindHost,
|
||||
runtimeMap,
|
||||
} from '../../../src/components/connection/forwardRules'
|
||||
|
||||
@@ -59,4 +63,35 @@ describe('forward rule persistence', () => {
|
||||
expect(second.name).toBe('SOCKS5 副本 2')
|
||||
expect(second).not.toHaveProperty('isActive')
|
||||
})
|
||||
|
||||
it('parses and formats host:port listen/target addresses', () => {
|
||||
expect(parseHostPort('127.0.0.1:18080')).toEqual({ host: '127.0.0.1', port: 18080 })
|
||||
expect(parseHostPort('0.0.0.0:8080')).toEqual({ host: '0.0.0.0', port: 8080 })
|
||||
expect(parseHostPort('bad')).toBeNull()
|
||||
expect(formatHostPort('0.0.0.0', 8080)).toBe('0.0.0.0:8080')
|
||||
expect(resolveForwardTargetHost('0.0.0.0')).toBe('127.0.0.1')
|
||||
})
|
||||
|
||||
it('keeps remote listen bind including 0.0.0.0 for GatewayPorts', () => {
|
||||
const rules = parseForwardRules([
|
||||
{
|
||||
id: 'r1',
|
||||
type: 'remote',
|
||||
name: 'expose',
|
||||
localPort: 8080,
|
||||
remoteBindHost: '0.0.0.0',
|
||||
remoteHost: '192.168.1.10',
|
||||
remotePort: 18080,
|
||||
},
|
||||
])
|
||||
expect(rules[0]).toMatchObject({
|
||||
type: 'remote',
|
||||
remoteBindHost: '0.0.0.0',
|
||||
remoteHost: '192.168.1.10',
|
||||
remotePort: 18080,
|
||||
localPort: 8080,
|
||||
})
|
||||
expect(resolveRemoteBindHost('localhost')).toBe('127.0.0.1')
|
||||
expect(resolveRemoteBindHost('0.0.0.0')).toBe('0.0.0.0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fitContextMenuToViewport } from '../../../src/components/sftp/contextMenuPosition'
|
||||
|
||||
describe('fitContextMenuToViewport', () => {
|
||||
it('keeps the menu below/right of the click when there is room', () => {
|
||||
expect(fitContextMenuToViewport(100, 80, 180, 200, 1000, 800)).toEqual({
|
||||
left: 100,
|
||||
top: 80,
|
||||
})
|
||||
})
|
||||
|
||||
it('flips above the click when near the bottom edge', () => {
|
||||
// Click near bottom; menu taller than remaining space → open upward.
|
||||
expect(fitContextMenuToViewport(200, 540, 180, 280, 1000, 600)).toEqual({
|
||||
left: 200,
|
||||
top: 260, // 540 - 280
|
||||
})
|
||||
})
|
||||
|
||||
it('clamps into the viewport when flipped menu would still overflow', () => {
|
||||
expect(fitContextMenuToViewport(900, 540, 360, 400, 997, 592)).toEqual({
|
||||
left: 540, // 900 - 360 (flip left of click)
|
||||
top: 140, // 540 - 400 (flip above click), still within [8, 184]
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an oversized menu inside the top-left margin', () => {
|
||||
expect(fitContextMenuToViewport(20, 20, 400, 600, 320, 480)).toEqual({
|
||||
left: 8,
|
||||
top: 8,
|
||||
})
|
||||
})
|
||||
|
||||
it('flips left of the click when near the right edge', () => {
|
||||
expect(fitContextMenuToViewport(920, 100, 200, 120, 1000, 800)).toEqual({
|
||||
left: 720, // 920 - 200
|
||||
top: 100,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
clearSftpDragEntry,
|
||||
hasSftpDragEntry,
|
||||
isCrossPaneSftpDrag,
|
||||
peekSftpDragEntry,
|
||||
peekSftpDragEntries,
|
||||
primeSftpDragEntries,
|
||||
readSftpDragEntry,
|
||||
readSftpDragEntries,
|
||||
resolveSftpDragEntries,
|
||||
scheduleClearSftpDragEntry,
|
||||
SFTP_ENTRY_TEXT_PREFIX,
|
||||
writeSftpDragEntry,
|
||||
writeSftpDragEntries,
|
||||
} from '../../../src/components/sftp/dragDrop'
|
||||
|
||||
function mockDataTransfer(initialTypes: string[] = []): DataTransfer {
|
||||
const store = new Map<string, string>()
|
||||
const types = [...initialTypes]
|
||||
return {
|
||||
effectAllowed: 'none',
|
||||
dropEffect: 'none',
|
||||
files: [] as unknown as FileList,
|
||||
items: [] as unknown as DataTransferItemList,
|
||||
types,
|
||||
setData(format: string, data: string) {
|
||||
store.set(format, data)
|
||||
if (!types.includes(format)) types.push(format)
|
||||
},
|
||||
getData(format: string) {
|
||||
return store.get(format) || ''
|
||||
},
|
||||
clearData() {
|
||||
store.clear()
|
||||
types.length = 0
|
||||
},
|
||||
setDragImage() {},
|
||||
} as DataTransfer
|
||||
}
|
||||
|
||||
describe('sftp dragDrop', () => {
|
||||
afterEach(() => {
|
||||
clearSftpDragEntry()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('mirrors entry for dragover when getData is empty', () => {
|
||||
const dt = mockDataTransfer()
|
||||
writeSftpDragEntry(dt, {
|
||||
source: 'local',
|
||||
name: 'a.txt',
|
||||
path: '/tmp/a.txt',
|
||||
type: 'file',
|
||||
size: 12,
|
||||
})
|
||||
|
||||
// Simulate WebView dragover: types may be present but getData returns empty
|
||||
const over = mockDataTransfer([...(dt.types as string[])])
|
||||
expect(hasSftpDragEntry(over)).toBe(true)
|
||||
expect(peekSftpDragEntry()?.name).toBe('a.txt')
|
||||
expect(readSftpDragEntry(over)?.source).toBe('local')
|
||||
|
||||
clearSftpDragEntry()
|
||||
expect(peekSftpDragEntry()).toBeNull()
|
||||
})
|
||||
|
||||
it('writes text/plain prefix for WebKit-compatible drop payloads', () => {
|
||||
const dt = mockDataTransfer()
|
||||
writeSftpDragEntry(dt, {
|
||||
source: 'remote',
|
||||
name: 'b.txt',
|
||||
path: '/home/u/b.txt',
|
||||
type: 'file',
|
||||
size: 3,
|
||||
})
|
||||
|
||||
const plain = dt.getData('text/plain')
|
||||
expect(plain.startsWith(SFTP_ENTRY_TEXT_PREFIX)).toBe(true)
|
||||
|
||||
clearSftpDragEntry()
|
||||
// Drop path without mirror: parse text/plain
|
||||
expect(readSftpDragEntry(dt)?.path).toBe('/home/u/b.txt')
|
||||
})
|
||||
|
||||
it('only accepts cross-pane drops', () => {
|
||||
const dt = mockDataTransfer()
|
||||
writeSftpDragEntry(dt, {
|
||||
source: 'local',
|
||||
name: 'a.txt',
|
||||
path: '/tmp/a.txt',
|
||||
type: 'file',
|
||||
size: 1,
|
||||
})
|
||||
|
||||
expect(isCrossPaneSftpDrag(dt, false)).toBe(true) // remote pane
|
||||
expect(isCrossPaneSftpDrag(dt, true)).toBe(false) // local pane
|
||||
})
|
||||
|
||||
it('uses the pre-drag mirror when WebKit has not exposed DataTransfer data', () => {
|
||||
primeSftpDragEntries([
|
||||
{ source: 'remote', name: 'server.log', path: '/var/log/server.log', type: 'file', size: 9 },
|
||||
])
|
||||
|
||||
const hiddenWebKitTransfer = mockDataTransfer()
|
||||
expect(isCrossPaneSftpDrag(hiddenWebKitTransfer, true)).toBe(true)
|
||||
expect(resolveSftpDragEntries(hiddenWebKitTransfer).entries[0]?.name).toBe('server.log')
|
||||
})
|
||||
|
||||
it('keeps mirror readable until scheduled clear runs (drop/dragend race)', () => {
|
||||
vi.useFakeTimers()
|
||||
const dt = mockDataTransfer()
|
||||
writeSftpDragEntry(dt, {
|
||||
source: 'remote',
|
||||
name: 'b.txt',
|
||||
path: '/home/u/b.txt',
|
||||
type: 'file',
|
||||
size: 3,
|
||||
})
|
||||
|
||||
scheduleClearSftpDragEntry(50)
|
||||
expect(peekSftpDragEntry()?.name).toBe('b.txt')
|
||||
expect(readSftpDragEntry(mockDataTransfer())?.path).toBe('/home/u/b.txt')
|
||||
|
||||
vi.advanceTimersByTime(50)
|
||||
expect(peekSftpDragEntry()).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores stale clear timers after a new drag starts', () => {
|
||||
vi.useFakeTimers()
|
||||
const dt = mockDataTransfer()
|
||||
writeSftpDragEntry(dt, {
|
||||
source: 'local',
|
||||
name: 'old.txt',
|
||||
path: '/tmp/old.txt',
|
||||
type: 'file',
|
||||
size: 1,
|
||||
})
|
||||
scheduleClearSftpDragEntry(100)
|
||||
|
||||
writeSftpDragEntry(dt, {
|
||||
source: 'remote',
|
||||
name: 'new.txt',
|
||||
path: '/r/new.txt',
|
||||
type: 'file',
|
||||
size: 2,
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(peekSftpDragEntry()?.name).toBe('new.txt')
|
||||
})
|
||||
|
||||
it('keeps multi-selection batch in the drag mirror', () => {
|
||||
const dt = mockDataTransfer()
|
||||
writeSftpDragEntries(dt, [
|
||||
{ source: 'local', name: 'a.txt', path: '/tmp/a.txt', type: 'file', size: 1 },
|
||||
{ source: 'local', name: 'b.txt', path: '/tmp/b.txt', type: 'file', size: 2 },
|
||||
])
|
||||
expect(peekSftpDragEntries().map((e) => e.name)).toEqual(['a.txt', 'b.txt'])
|
||||
clearSftpDragEntry()
|
||||
expect(readSftpDragEntries(dt).map((e) => e.name)).toEqual(['a.txt', 'b.txt'])
|
||||
})
|
||||
|
||||
it('resolves drop payload via mirror or text/plain', () => {
|
||||
const dt = mockDataTransfer()
|
||||
writeSftpDragEntries(dt, [
|
||||
{ source: 'local', name: 'a.txt', path: '/tmp/a.txt', type: 'file', size: 1 },
|
||||
{ source: 'local', name: 'b.txt', path: '/tmp/b.txt', type: 'file', size: 2 },
|
||||
])
|
||||
expect(resolveSftpDragEntries(dt).via).toBe('mirror')
|
||||
expect(resolveSftpDragEntries(dt).entries).toHaveLength(2)
|
||||
|
||||
clearSftpDragEntry()
|
||||
expect(resolveSftpDragEntries(dt).via).toBe('text/plain')
|
||||
expect(resolveSftpDragEntries(dt).entries.map((e) => e.name)).toEqual(['a.txt', 'b.txt'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
conflictNamesFromListing,
|
||||
formatOverwriteMessage,
|
||||
transferDestinationPath,
|
||||
} from '../../../src/components/sftp/transferConflicts'
|
||||
|
||||
describe('transferConflicts', () => {
|
||||
it('builds remote and local destination paths', () => {
|
||||
expect(transferDestinationPath('upload', '/', 'a.txt')).toBe('/a.txt')
|
||||
expect(transferDestinationPath('upload', '/root/', 'a.txt')).toBe('/root/a.txt')
|
||||
expect(transferDestinationPath('download', '/Users/test', 'a.txt')).toBe('/Users/test/a.txt')
|
||||
})
|
||||
|
||||
it('finds conflicts from a listing', () => {
|
||||
expect(
|
||||
conflictNamesFromListing(['a.txt', 'b.txt', 'c.txt'], [{ name: 'b.txt' }, { name: 'c.txt' }])
|
||||
).toEqual(['b.txt', 'c.txt'])
|
||||
})
|
||||
|
||||
it('formats overwrite copy for one and many', () => {
|
||||
const t = (key: string, opts?: Record<string, unknown>) =>
|
||||
`${key}:${opts ? JSON.stringify(opts) : ''}`
|
||||
expect(formatOverwriteMessage(['only.log'], 'upload', t)).toContain('overwriteOneRemote')
|
||||
expect(formatOverwriteMessage(['a', 'b', 'c'], 'download', t)).toContain('overwriteManyLocal')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sortTransferTasks } from '../../../src/components/sftp/TransferPanel'
|
||||
import type { TransferTask } from '../../../shared/types/sftp.types'
|
||||
|
||||
function task(
|
||||
id: string,
|
||||
status: TransferTask['status']
|
||||
): TransferTask {
|
||||
return {
|
||||
id,
|
||||
type: 'upload',
|
||||
fileName: id,
|
||||
filePath: `/${id}`,
|
||||
status,
|
||||
progress: status === 'transferring' ? 40 : 0,
|
||||
total: 100,
|
||||
transferred: status === 'transferring' ? 40 : 0,
|
||||
}
|
||||
}
|
||||
|
||||
describe('sortTransferTasks', () => {
|
||||
it('puts transferring tasks first, then pending, then finished', () => {
|
||||
const ordered = sortTransferTasks([
|
||||
task('done', 'completed'),
|
||||
task('wait', 'pending'),
|
||||
task('active', 'transferring'),
|
||||
task('fail', 'failed'),
|
||||
task('wait2', 'pending'),
|
||||
])
|
||||
expect(ordered.map((t) => t.id)).toEqual(['active', 'wait', 'wait2', 'fail', 'done'])
|
||||
})
|
||||
|
||||
it('keeps relative order within the same status', () => {
|
||||
const ordered = sortTransferTasks([
|
||||
task('a', 'pending'),
|
||||
task('b', 'pending'),
|
||||
task('c', 'pending'),
|
||||
])
|
||||
expect(ordered.map((t) => t.id)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user