fix(projects): require creation directories and show remote directory links
Require a root folder in the local project creation dialog and IPC while preserving updates to historical projects with blank paths. Show remote symlinks whose targets are directories and resolve their canonical paths when browsing. Validation: focused UI, contract, IPC and SSH tests passed; typecheck and lint passed. Verified directory links on the shared Linux Host. Full test run timed out after a Magic Notes storage timing failure. Release note: 新建本地项目必须选择目录;远程项目选择目录时可查看并进入指向目录的符号链接。
This commit is contained in:
@@ -76,6 +76,9 @@ function createSftp(options: {
|
||||
) => void
|
||||
) => callback(undefined, batches.shift() ?? [])
|
||||
),
|
||||
stat: vi.fn((_path, callback) => {
|
||||
callback(new Error('Target unavailable'))
|
||||
}),
|
||||
close: vi.fn(
|
||||
(
|
||||
_handle: Buffer,
|
||||
@@ -171,6 +174,114 @@ describe('bounded directory SFTP', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('includes only directory link targets and preserves their entry paths', async () => {
|
||||
const sftp = createSftp({
|
||||
canonicalPath: '/',
|
||||
batches: [[
|
||||
entry('relative', 'symbolic-link'),
|
||||
entry('absolute', 'symbolic-link'),
|
||||
entry('file-link', 'symbolic-link'),
|
||||
entry('broken', 'symbolic-link'),
|
||||
entry('denied', 'symbolic-link'),
|
||||
entry('loop', 'symbolic-link'),
|
||||
entry('../invalid', 'symbolic-link'),
|
||||
entry('regular')
|
||||
]]
|
||||
})
|
||||
sftp.stat = vi.fn((path, callback) => {
|
||||
if (path === '/relative' || path === '/absolute') {
|
||||
callback(undefined, entry('target').attrs)
|
||||
} else if (path === '/file-link') {
|
||||
callback(undefined, entry('target', 'file').attrs)
|
||||
} else {
|
||||
callback(Object.assign(new Error('Cannot stat target'), {
|
||||
code: path === '/denied' ? 3 : 2
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
const result = await listBoundedSftpDirectories(opener(sftp), '/')
|
||||
|
||||
expect(result.entries).toEqual([
|
||||
{ name: 'absolute', path: '/absolute' },
|
||||
{ name: 'regular', path: '/regular' },
|
||||
{ name: 'relative', path: '/relative' }
|
||||
])
|
||||
expect(sftp.stat).toHaveBeenCalledTimes(6)
|
||||
expect(sftp.realpath).toHaveBeenCalledTimes(2)
|
||||
expect(sftp.close).toHaveBeenCalledOnce()
|
||||
expect(sftp.end).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('bounds link lookups by the scanned-entry limit and keeps the return limit', async () => {
|
||||
const sftp = createSftp({ batches: [
|
||||
Array.from({ length: 2_100 }, (_, index) =>
|
||||
entry(`link-${index.toString().padStart(4, '0')}`, 'symbolic-link'))
|
||||
] })
|
||||
sftp.stat = vi.fn((_path, callback) => {
|
||||
callback(undefined, entry('target').attrs)
|
||||
})
|
||||
|
||||
const result = await listBoundedSftpDirectories(opener(sftp))
|
||||
|
||||
expect(sftp.stat).toHaveBeenCalledTimes(2_000)
|
||||
expect(sftp.readdir).toHaveBeenCalledOnce()
|
||||
expect(result.entries).toHaveLength(500)
|
||||
expect(result.entries[499]?.name).toBe('link-0499')
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(sftp.close).toHaveBeenCalledOnce()
|
||||
expect(sftp.end).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['abort', 'timeout', 'channel-error'] as const)(
|
||||
'cleans up a pending STAT on %s and ignores late results', async (reason) => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const sftp = createSftp({ batches: [[
|
||||
entry('first', 'symbolic-link'),
|
||||
entry('second', 'symbolic-link')
|
||||
]] })
|
||||
let callback: Parameters<DirectorySftp['stat']>[1] | undefined
|
||||
sftp.stat = vi.fn((_path, next) => { callback = next })
|
||||
const controller = new AbortController()
|
||||
const pending = listBoundedSftpDirectories(opener(sftp), undefined, controller.signal)
|
||||
const expected = expect(pending).rejects.toThrow(
|
||||
reason === 'timeout' ? '目录浏览超时' : reason
|
||||
)
|
||||
expect(sftp.stat).toHaveBeenCalledOnce()
|
||||
expect(sftp.readdir).toHaveBeenCalledOnce()
|
||||
if (reason === 'timeout') {
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
} else if (reason === 'abort') {
|
||||
controller.abort(new Error(reason))
|
||||
} else {
|
||||
(sftp as unknown as EventEmitter).emit('error', new Error(reason))
|
||||
}
|
||||
await expected
|
||||
callback?.(undefined, entry('target').attrs)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(sftp.stat).toHaveBeenCalledOnce()
|
||||
expect(sftp.readdir).toHaveBeenCalledOnce()
|
||||
expect(sftp.close).toHaveBeenCalledOnce()
|
||||
expect(sftp.end).toHaveBeenCalledOnce()
|
||||
expect((sftp as unknown as EventEmitter).listenerCount('error')).toBe(0)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('closes resources if issuing STAT throws', async () => {
|
||||
const sftp = createSftp({ batches: [[entry('link', 'symbolic-link')]] })
|
||||
sftp.stat = vi.fn(() => { throw new Error('STAT failed') })
|
||||
|
||||
await expect(listBoundedSftpDirectories(opener(sftp))).rejects.toThrow('STAT failed')
|
||||
expect(sftp.close).toHaveBeenCalledOnce()
|
||||
expect(sftp.end).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('sorts names by their UTF-8 bytes rather than UTF-16 code units', async () => {
|
||||
const sftp = createSftp({
|
||||
batches: [[entry('\u{10000}'), entry('\ue000')], []]
|
||||
|
||||
@@ -18,6 +18,7 @@ export type DirectorySftp = Pick<
|
||||
| 'realpath'
|
||||
| 'opendir'
|
||||
| 'readdir'
|
||||
| 'stat'
|
||||
| 'close'
|
||||
| 'end'
|
||||
| 'on'
|
||||
@@ -67,7 +68,7 @@ function parentPath(path: string): string | null {
|
||||
return separator === 0 ? '/' : path.slice(0, separator)
|
||||
}
|
||||
|
||||
function validDirectoryName(entry: FileEntryWithStats): boolean {
|
||||
function validDirectoryEntry(entry: FileEntryWithStats): boolean {
|
||||
const name = entry?.filename
|
||||
if (
|
||||
typeof name !== 'string' ||
|
||||
@@ -84,9 +85,9 @@ function validDirectoryName(entry: FileEntryWithStats): boolean {
|
||||
try {
|
||||
return (
|
||||
typeof entry.attrs?.isDirectory === 'function' &&
|
||||
entry.attrs.isDirectory() === true &&
|
||||
typeof entry.attrs.isSymbolicLink === 'function' &&
|
||||
entry.attrs.isSymbolicLink() === false
|
||||
(entry.attrs.isSymbolicLink() === true ||
|
||||
entry.attrs.isDirectory() === true)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
@@ -221,6 +222,38 @@ export function listBoundedSftpDirectories(
|
||||
finishError(new Error('SSH 目录浏览超时'))
|
||||
}, OPERATION_TIMEOUT_MS)
|
||||
|
||||
const inspectEntries = async (
|
||||
entries: FileEntryWithStats[]
|
||||
): Promise<void> => {
|
||||
for (const entry of entries) {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
if (!validDirectoryEntry(entry)) {
|
||||
continue
|
||||
}
|
||||
const path = joinPath(listingPath!, entry.filename)
|
||||
if (!isCanonicalAbsolutePosixPath(path)) {
|
||||
continue
|
||||
}
|
||||
if (entry.attrs.isSymbolicLink()) {
|
||||
// STAT follows links; keep the entry path for later realpath navigation.
|
||||
const isDirectory = await new Promise<boolean>((resolve) => {
|
||||
sftp!.stat(path, (error, attrs) => {
|
||||
resolve(!settled && !error && attrs?.isDirectory() === true)
|
||||
})
|
||||
})
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
if (!isDirectory) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
directories.add(entry.filename)
|
||||
}
|
||||
}
|
||||
|
||||
const readNextBatch = (): void => {
|
||||
if (settled || !sftp || !handle) {
|
||||
return
|
||||
@@ -267,26 +300,21 @@ export function listBoundedSftpDirectories(
|
||||
MAXIMUM_SCANNED_ENTRIES - scannedEntries
|
||||
const inspected = list.slice(0, remaining)
|
||||
scannedEntries += inspected.length
|
||||
for (const entry of inspected) {
|
||||
if (
|
||||
validDirectoryName(entry) &&
|
||||
isCanonicalAbsolutePosixPath(
|
||||
joinPath(listingPath!, entry.filename)
|
||||
)
|
||||
) {
|
||||
directories.add(entry.filename)
|
||||
void inspectEntries(inspected).then(() => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (
|
||||
list.length > inspected.length ||
|
||||
scannedEntries >= MAXIMUM_SCANNED_ENTRIES ||
|
||||
readdirCallbacks >= MAXIMUM_READDIR_CALLBACKS
|
||||
) {
|
||||
truncated = true
|
||||
finishSuccess()
|
||||
} else {
|
||||
readNextBatch()
|
||||
}
|
||||
if (
|
||||
list.length > inspected.length ||
|
||||
scannedEntries >= MAXIMUM_SCANNED_ENTRIES ||
|
||||
readdirCallbacks >= MAXIMUM_READDIR_CALLBACKS
|
||||
) {
|
||||
truncated = true
|
||||
finishSuccess()
|
||||
} else {
|
||||
readNextBatch()
|
||||
}
|
||||
}).catch(finishError)
|
||||
})
|
||||
} catch (error) {
|
||||
finishError(error)
|
||||
|
||||
Reference in New Issue
Block a user