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:
Logan.LiandCursor committed 2026-08-16 16:08:11 +08:00
1 parent 188f3556ed
commit 08696896f9
223 files changed
+13493 -3423

No files matched your search

+40
View File
@@ -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,
})
})
})
+177
View File
@@ -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'])
})
})