dsh-linked-workspace 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * addresses and workspace paths.
17
17
  */
18
18
  import { randomUUID } from 'node:crypto'
19
- import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
19
+ import { chmodSync, closeSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
20
20
  import { spawn } from 'node:child_process'
21
21
  import { mkdir, readdir, stat as fsStat } from 'node:fs/promises'
22
22
  import { homedir, tmpdir } from 'node:os'
@@ -61,6 +61,7 @@ const LINKED_SYNC_BYTES = 50 * 1024 * 1024
61
61
  const LINKED_SYNC_FILE = 20 * 1024 * 1024
62
62
  const LINKED_SYNC_EXCLUDES = new Set(['.git', 'node_modules', '__pycache__', '.conda', '.venv', '.mypy_cache', '.pytest_cache', '.ipynb_checkpoints', '.ruff_cache'])
63
63
  const LINKED_SYNC_TIMEOUT_MS = 30_000
64
+ const LINKED_SYNC_MAX_DEPTH = 24
64
65
  const LINKED_OPEN_FILE_CAP = 64 * 1024 * 1024
65
66
  const MAX_BODY_BYTES = 16 * 1024 * 1024
66
67
  const EDITABLE_LIMIT = 2 * 1024 * 1024
@@ -82,6 +83,32 @@ const IMAGE_MIME = Object.freeze({
82
83
  const MAX_NAME_LENGTH = 120
83
84
  const CONNECT_TIMEOUT_MS = 20_000
84
85
  const OP_TIMEOUT_MS = 60_000
86
+ // Directory listings are paged so a folder with tens of thousands of entries
87
+ // cannot stall the Host or flood the renderer.
88
+ const LIST_PAGE_SIZE = 500
89
+ const LIST_PAGE_MAX = 5000
90
+ // SFTP readdir has no paging, so a directory is always read in one call. A
91
+ // freshly read listing is kept briefly so "load more" does not repeat the same
92
+ // expensive scan; a first page (offset 0) is always re-read.
93
+ const LISTING_CACHE_TTL_MS = 5000
94
+ const LISTING_CACHE_LIMIT = 128
95
+ const listingCache = new Map()
96
+ const listingCacheKey = (scope, target) => `${scope.kind === 'linked' ? 'L' : 'W'}:${scope.id}:${target}`
97
+ const rememberListing = (key, listing) => {
98
+ listingCache.delete(key)
99
+ listingCache.set(key, { at: Date.now(), listing })
100
+ while (listingCache.size > LISTING_CACHE_LIMIT) {
101
+ listingCache.delete(listingCache.keys().next().value)
102
+ }
103
+ }
104
+ const forgetListings = () => listingCache.clear()
105
+
106
+ /** Clamp a requested page window to the listing limits. */
107
+ const pageWindow = (params = {}, total = 0) => {
108
+ const limit = Math.min(Math.max(Number(params.limit) || LIST_PAGE_SIZE, 1), LIST_PAGE_MAX)
109
+ const offset = Math.min(Math.max(Number(params.offset) || 0, 0), Math.max(total, 0))
110
+ return { limit, offset }
111
+ }
85
112
 
86
113
  const LOG_PREFIX = '[dsh-linked-workspace]'
87
114
 
@@ -99,6 +126,7 @@ const ERROR_STATUS = {
99
126
  FS_IS_DIR: 400,
100
127
  FS_NOT_DIR: 400,
101
128
  FS_TOO_LARGE: 413,
129
+ FS_INCOMPLETE: 500,
102
130
  FS_EXISTS: 409,
103
131
  CONFLICT: 409,
104
132
  INTERNAL: 500,
@@ -505,10 +533,31 @@ function mapLocalFsError(error, fallback = 'INTERNAL') {
505
533
  if (code === 'ENOENT') return new ApiError('FS_NOT_FOUND', '本地路径不存在')
506
534
  if (code === 'EACCES' || code === 'EPERM') return new ApiError('FS_PERMISSION', '没有权限访问本地路径')
507
535
  if (code === 'ENOTDIR') return new ApiError('FS_NOT_DIR', '本地路径不是目录')
508
- if (code === 'EEXIST') return new ApiError('BAD_REQUEST', '同名文件夹已存在')
536
+ if (code === 'EEXIST') return new ApiError('FS_EXISTS', '同名文件或文件夹已存在')
509
537
  return new ApiError(fallback, error && error.message ? error.message : String(error))
510
538
  }
511
539
 
540
+ /**
541
+ * Read at most `maxBytes` from the start of a local file.
542
+ * Unlike readFileSync this never maps the whole file, so a peek at a huge
543
+ * (or larger-than-2-GiB) file stays cheap.
544
+ */
545
+ function readHeadSync(absolute, maxBytes) {
546
+ const fd = openSync(absolute, 'r')
547
+ try {
548
+ const buffer = Buffer.allocUnsafe(Math.max(1, Math.min(maxBytes, 16 * 1024 * 1024)))
549
+ let filled = 0
550
+ while (filled < buffer.length) {
551
+ const read = readSync(fd, buffer, filled, buffer.length - filled, filled)
552
+ if (read <= 0) break
553
+ filled += read
554
+ }
555
+ return buffer.subarray(0, filled)
556
+ } finally {
557
+ closeSync(fd)
558
+ }
559
+ }
560
+
512
561
  function localDrives() {
513
562
  if (process.platform !== 'win32') return []
514
563
  const drives = []
@@ -670,6 +719,7 @@ class HostPool {
670
719
  this.knownHostsFile = knownHostsFile
671
720
  this.records = new Map() // hostId -> HostRecord
672
721
  this.connections = new Map() // hostId -> { authority, remote: RemoteHost }
722
+ this.connecting = new Map() // hostId -> { authority, promise }
673
723
  this.statuses = new Map() // hostId -> { state, message?, latencyMs?, checkedAt }
674
724
  }
675
725
 
@@ -684,6 +734,12 @@ class HostPool {
684
734
  this.connections.delete(hostId)
685
735
  }
686
736
  }
737
+ // In-flight connects for removed or re-addressed hosts must not be reused:
738
+ // the attempt itself notices the lost map entry and disposes its socket.
739
+ for (const [hostId, pending] of [...this.connecting]) {
740
+ const host = this.records.get(hostId)
741
+ if (!host || `${host.host}:${host.port}` !== pending.authority) this.connecting.delete(hostId)
742
+ }
687
743
  }
688
744
 
689
745
  host(hostId) {
@@ -703,8 +759,8 @@ class HostPool {
703
759
  }
704
760
 
705
761
  async remote(hostRecord) {
706
- const existing = this.connections.get(hostRecord.id)
707
762
  const authority = `${hostRecord.host}:${hostRecord.port}`
763
+ const existing = this.connections.get(hostRecord.id)
708
764
  if (existing && existing.authority === authority) {
709
765
  try {
710
766
  await existing.remote.ensureConnected()
@@ -715,15 +771,41 @@ class HostPool {
715
771
  throw error
716
772
  }
717
773
  }
774
+ // Two concurrent first requests for one host must share one connection;
775
+ // otherwise the second RemoteHost overwrites the map entry and the first
776
+ // SSH socket leaks. The pending attempt is bound to the address it dials,
777
+ // so a host edit during connect never hands the old socket to the new
778
+ // address.
779
+ const pending = this.connecting.get(hostRecord.id)
780
+ if (pending && pending.authority === authority) return pending.promise
718
781
  const remote = new RemoteHost({
719
782
  host: hostRecord,
720
783
  knownHostsFile: this.knownHostsFile,
721
784
  connectTimeoutMs: CONNECT_TIMEOUT_MS,
722
785
  logger: log,
723
786
  })
724
- this.connections.set(hostRecord.id, { authority, remote })
725
- await remote.connect()
726
- return remote
787
+ const entry = { authority, remote }
788
+ this.connections.set(hostRecord.id, entry)
789
+ const attempt = (async () => {
790
+ try {
791
+ await remote.connect()
792
+ // The host may have been removed while the socket was connecting: keep
793
+ // the map state authoritative so the unused connection is closed.
794
+ if (this.connections.get(hostRecord.id) !== entry) {
795
+ remote.dispose().catch(() => {})
796
+ }
797
+ return remote
798
+ } catch (error) {
799
+ if (this.connections.get(hostRecord.id) === entry) this.connections.delete(hostRecord.id)
800
+ remote.dispose().catch(() => {})
801
+ throw error
802
+ } finally {
803
+ const current = this.connecting.get(hostRecord.id)
804
+ if (current && current.promise === attempt) this.connecting.delete(hostRecord.id)
805
+ }
806
+ })()
807
+ this.connecting.set(hostRecord.id, { authority, promise: attempt })
808
+ return attempt
727
809
  }
728
810
 
729
811
  async disposeAll() {
@@ -1107,8 +1189,11 @@ export function apply(ctx) {
1107
1189
  const seen = new Set()
1108
1190
  const shouldStop = () => job.cancel || job.files >= job.maxFiles || job.bytes >= job.maxBytes
1109
1191
  const visit = async (directory, depth) => {
1110
- if (depth > 24 || shouldStop()) {
1111
- if (shouldStop() && !job.cancel) job.truncated = true
1192
+ // Hitting the depth limit makes the scan incomplete, exactly like the
1193
+ // file/byte caps: flag it so the removal pass is skipped and the UI
1194
+ // can say the archive is partial.
1195
+ if (depth > LINKED_SYNC_MAX_DEPTH || shouldStop()) {
1196
+ if (!job.cancel) job.truncated = true
1112
1197
  return
1113
1198
  }
1114
1199
  let listing
@@ -1325,7 +1410,15 @@ export function apply(ctx) {
1325
1410
  const remoteCopyRecursive = async (remote, source, target) => {
1326
1411
  const stat = await remote.stat(source)
1327
1412
  if (stat.type !== 'dir') {
1413
+ // readFile truncates silently, so an oversized source has to be refused
1414
+ // here: copying only its first 64 MiB would corrupt the destination.
1415
+ if (stat.size > LINKED_OPEN_FILE_CAP) {
1416
+ throw new ApiError('FS_TOO_LARGE', `${posix.basename(source)} 超过 64 MB,无法复制`)
1417
+ }
1328
1418
  const read = await remote.readFile(source, { maxBytes: LINKED_OPEN_FILE_CAP })
1419
+ if (read.truncated || read.data.length !== stat.size) {
1420
+ throw new ApiError('FS_INCOMPLETE', `${posix.basename(source)} 读取不完整,已取消复制`)
1421
+ }
1329
1422
  await remote.writeFileAtomic(target, read.data)
1330
1423
  return 1
1331
1424
  }
@@ -1532,6 +1625,7 @@ export function apply(ctx) {
1532
1625
  // ── document bridge (Markdown/PDF/DOCX/XLSX/PPTX) ──────────────────────
1533
1626
 
1534
1627
  const DOC_MAX_BYTES = 80 * 1024 * 1024
1628
+ const DOC_PDF_SUFFIX_MAX = 999
1535
1629
  const DOC_TOOL = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'doc_tool.py')
1536
1630
  const DOC_KIND_BY_EXT = new Map([
1537
1631
  ['.md', 'markdown'], ['.markdown', 'markdown'],
@@ -1804,17 +1898,34 @@ export function apply(ctx) {
1804
1898
  const target = params.path === undefined || params.path === ''
1805
1899
  ? scope.root
1806
1900
  : scopeTarget(scope, params.path)
1807
- const listing = await scope.remote.listDir(target)
1901
+ const limit = Math.min(Math.max(Number(params.limit) || LIST_PAGE_SIZE, 1), LIST_PAGE_MAX)
1902
+ const requestedOffset = Math.max(Number(params.offset) || 0, 0)
1903
+ const key = listingCacheKey(scope, target)
1904
+ let listing
1905
+ if (requestedOffset > 0) {
1906
+ const hit = listingCache.get(key)
1907
+ if (hit && Date.now() - hit.at < LISTING_CACHE_TTL_MS) listing = hit.listing
1908
+ }
1909
+ if (!listing) {
1910
+ listing = await scope.remote.listDir(target)
1911
+ rememberListing(key, listing)
1912
+ }
1913
+ const all = listing.entries.map((entry) => ({
1914
+ name: entry.name,
1915
+ path: entry.path,
1916
+ type: entry.type,
1917
+ size: entry.size,
1918
+ mtime: entry.mtime,
1919
+ relative: scopeRelative(scope, entry.path),
1920
+ }))
1921
+ const page = pageWindow({ limit, offset: requestedOffset }, all.length)
1808
1922
  return {
1809
1923
  path: target,
1810
- entries: listing.entries.map((entry) => ({
1811
- name: entry.name,
1812
- path: entry.path,
1813
- type: entry.type,
1814
- size: entry.size,
1815
- mtime: entry.mtime,
1816
- relative: scopeRelative(scope, entry.path),
1817
- })),
1924
+ entries: all.slice(page.offset, page.offset + page.limit),
1925
+ total: all.length,
1926
+ offset: page.offset,
1927
+ limit: page.limit,
1928
+ hasMore: page.offset + page.limit < all.length,
1818
1929
  }
1819
1930
  },
1820
1931
 
@@ -1876,6 +1987,7 @@ export function apply(ctx) {
1876
1987
  if (apiError.code !== 'FS_NOT_FOUND') throw apiError
1877
1988
  current = undefined
1878
1989
  }
1990
+ if (params.createOnly === true && current) throw new ApiError('FS_EXISTS', '目标已存在,请使用其他名称')
1879
1991
  if (current && current.type === 'dir') throw new ApiError('FS_IS_DIR', '目标是目录,无法写入')
1880
1992
  if (current && typeof params.expectedMtime === 'number' && params.expectedMtime > 0) {
1881
1993
  const currentSeconds = Math.floor(current.mtime / 1000)
@@ -1889,6 +2001,7 @@ export function apply(ctx) {
1889
2001
  mode: current ? current.mode : undefined,
1890
2002
  })
1891
2003
  await scopeMirror(scope, target, data)
2004
+ forgetListings()
1892
2005
  return { path: target, size: info.size, mtime: info.mtime }
1893
2006
  },
1894
2007
 
@@ -2013,19 +2126,21 @@ export function apply(ctx) {
2013
2126
  const directory = posix.dirname(outputPath)
2014
2127
  let candidate = outputPath
2015
2128
  let index = 1
2016
- while (await target.scope.remote.stat(candidate).catch(() => null)) {
2129
+ while (index <= DOC_PDF_SUFFIX_MAX && await target.scope.remote.stat(candidate).catch(() => null)) {
2017
2130
  candidate = posix.join(directory, posix.basename(base) + '-' + index + '.pdf')
2018
2131
  index += 1
2019
2132
  }
2133
+ if (index > DOC_PDF_SUFFIX_MAX) throw new ApiError('FS_EXISTS', '同名 PDF 过多,请先清理目录中的旧导出')
2020
2134
  outputPath = candidate
2021
2135
  } else {
2022
2136
  const directory = dirname(outputPath)
2023
2137
  let candidate = outputPath
2024
2138
  let index = 1
2025
- while (existsSync(candidate)) {
2139
+ while (index <= DOC_PDF_SUFFIX_MAX && existsSync(candidate)) {
2026
2140
  candidate = join(directory, basename(base) + '-' + index + '.pdf')
2027
2141
  index += 1
2028
2142
  }
2143
+ if (index > DOC_PDF_SUFFIX_MAX) throw new ApiError('FS_EXISTS', '同名 PDF 过多,请先清理目录中的旧导出')
2029
2144
  outputPath = candidate
2030
2145
  }
2031
2146
  const temp = await makeDocTemp()
@@ -2059,7 +2174,19 @@ export function apply(ctx) {
2059
2174
  const scope = await requireScope(params)
2060
2175
  const target = scopeTarget(scope, params.path)
2061
2176
  if (target === scope.root) throw new ApiError('BAD_REQUEST', '不能创建工作区根目录')
2177
+ if (params.createOnly === true) {
2178
+ let current
2179
+ try {
2180
+ current = await scope.remote.stat(target)
2181
+ } catch (error) {
2182
+ const apiError = asApiError(error)
2183
+ if (apiError.code !== 'FS_NOT_FOUND') throw apiError
2184
+ current = undefined
2185
+ }
2186
+ if (current) throw new ApiError('FS_EXISTS', '目标已存在,请使用其他名称')
2187
+ }
2062
2188
  await scope.remote.mkdirp(target)
2189
+ forgetListings()
2063
2190
  return {}
2064
2191
  },
2065
2192
 
@@ -2070,6 +2197,7 @@ export function apply(ctx) {
2070
2197
  if (from === scope.root) throw new ApiError('BAD_REQUEST', '不能移动工作区根目录')
2071
2198
  await scope.remote.rename(from, to)
2072
2199
  await scopeMirrorRemove(scope, from)
2200
+ forgetListings()
2073
2201
  return {}
2074
2202
  },
2075
2203
 
@@ -2091,6 +2219,7 @@ export function apply(ctx) {
2091
2219
  if (await scope.remote.stat(target).catch(() => null)) throw new ApiError('FS_EXISTS', '目标目录中已存在同名文件或文件夹')
2092
2220
  await scope.remote.rename(source, target)
2093
2221
  await scopeMirrorRemove(scope, source)
2222
+ forgetListings()
2094
2223
  return { path: target }
2095
2224
  },
2096
2225
 
@@ -2100,6 +2229,7 @@ export function apply(ctx) {
2100
2229
  if (target === scope.root) throw new ApiError('BAD_REQUEST', '不能删除工作区根目录')
2101
2230
  await remoteDeleteRecursive(scope.remote, target)
2102
2231
  await scopeMirrorRemove(scope, target)
2232
+ forgetListings()
2103
2233
  return {}
2104
2234
  },
2105
2235
 
@@ -2107,7 +2237,9 @@ export function apply(ctx) {
2107
2237
  const scope = await requireScope(params)
2108
2238
  if (scope.kind !== 'linked') throw new ApiError('BAD_REQUEST', '只有联动工作区支持文件传输')
2109
2239
  if (params.side === 'local') {
2110
- return await transferLocalToRemote(scope.record, scope.remote, params.path)
2240
+ const result = await transferLocalToRemote(scope.record, scope.remote, params.path)
2241
+ forgetListings()
2242
+ return result
2111
2243
  }
2112
2244
  const target = scopeTarget(scope, params.path)
2113
2245
  return await transferToMirror(scope.record, scope.remote, target)
@@ -2138,7 +2270,16 @@ export function apply(ctx) {
2138
2270
  throw new ApiError('BAD_REQUEST', '不能把目录复制到自身内部')
2139
2271
  }
2140
2272
  const target = await remoteUniqueTarget(scope.remote, parent, posix.basename(source))
2141
- const files = await remoteCopyRecursive(scope.remote, source, target)
2273
+ let files
2274
+ try {
2275
+ files = await remoteCopyRecursive(scope.remote, source, target)
2276
+ } catch (error) {
2277
+ // A half-copied tree is worse than a clean failure: remove what this
2278
+ // call created (the target name was unique, so nothing else lives there).
2279
+ await remoteDeleteRecursive(scope.remote, target).catch(() => {})
2280
+ throw error
2281
+ }
2282
+ forgetListings()
2142
2283
  return { path: target, files }
2143
2284
  },
2144
2285
 
@@ -2187,32 +2328,43 @@ export function apply(ctx) {
2187
2328
  throw mapLocalFsError(error)
2188
2329
  }
2189
2330
  if (!info.isDirectory()) throw new ApiError('FS_NOT_DIR', '本地路径不是目录')
2190
- let rawEntries
2191
- try {
2192
- rawEntries = await readdir(target, { withFileTypes: true })
2193
- } catch (error) {
2331
+ const rawEntries = await readdir(target, { withFileTypes: true }).catch((error) => {
2194
2332
  throw mapLocalFsError(error)
2195
- }
2333
+ })
2196
2334
  const entries = []
2197
- for (const entry of rawEntries) {
2198
- const full = join(target, entry.name)
2199
- let stat
2200
- try {
2201
- stat = await fsStat(full)
2202
- } catch {
2203
- continue
2204
- }
2205
- let type = 'file'
2206
- if (stat.isDirectory()) type = 'dir'
2207
- else if (stat.isSymbolicLink()) type = 'symlink'
2208
- entries.push({ name: entry.name, path: full, type, size: stat.size, mtime: stat.mtimeMs })
2335
+ // Stat in small parallel batches: one await per entry is slow for big
2336
+ // folders, but an unbounded Promise.all can exhaust file descriptors.
2337
+ for (let start = 0; start < rawEntries.length; start += 128) {
2338
+ const batch = rawEntries.slice(start, start + 128)
2339
+ const stated = await Promise.all(batch.map(async (entry) => {
2340
+ const full = join(target, entry.name)
2341
+ let stat
2342
+ try {
2343
+ stat = await fsStat(full)
2344
+ } catch {
2345
+ return null
2346
+ }
2347
+ let type = 'file'
2348
+ if (stat.isDirectory()) type = 'dir'
2349
+ else if (stat.isSymbolicLink()) type = 'symlink'
2350
+ return { name: entry.name, path: full, type, size: stat.size, mtime: stat.mtimeMs }
2351
+ }))
2352
+ for (const item of stated) if (item) entries.push(item)
2209
2353
  }
2210
2354
  entries.sort((left, right) => {
2211
2355
  if (left.type === 'dir' && right.type !== 'dir') return -1
2212
2356
  if (right.type === 'dir' && left.type !== 'dir') return 1
2213
2357
  return left.name.localeCompare(right.name)
2214
2358
  })
2215
- return { path: target, entries }
2359
+ const page = pageWindow(params, entries.length)
2360
+ return {
2361
+ path: target,
2362
+ entries: entries.slice(page.offset, page.offset + page.limit),
2363
+ total: entries.length,
2364
+ offset: page.offset,
2365
+ limit: page.limit,
2366
+ hasMore: page.offset + page.limit < entries.length,
2367
+ }
2216
2368
  },
2217
2369
 
2218
2370
  async 'local.stat'(params = {}) {
@@ -2246,13 +2398,15 @@ export function apply(ctx) {
2246
2398
  if (info.isDirectory()) throw new ApiError('FS_IS_DIR', '目标是目录,无法作为文件读取')
2247
2399
  const tooLarge = info.size > EDITABLE_LIMIT
2248
2400
  const cap = tooLarge ? PEEK_LIMIT : READ_CAP
2401
+ // Read at most `cap` bytes. Reading the whole file first would pull
2402
+ // multi-gigabyte files into Host memory (and on Windows readFileSync
2403
+ // throws ERR_FS_FILE_TOO_LARGE above 2 GiB even for a peek).
2249
2404
  let data
2250
2405
  try {
2251
- data = readFileSync(absolute)
2406
+ data = readHeadSync(absolute, cap)
2252
2407
  } catch (error) {
2253
2408
  throw mapLocalFsError(error)
2254
2409
  }
2255
- if (data.length > cap) data = data.subarray(0, cap)
2256
2410
  const binary = looksBinary(data)
2257
2411
  return {
2258
2412
  path: absolute,
@@ -2640,7 +2794,6 @@ export function apply(ctx) {
2640
2794
  if (nextName.length > MAX_NAME_LENGTH) throw new ApiError('BAD_REQUEST', '工作区名称过长')
2641
2795
  const nextMirror = typeof params.mirror === 'boolean' ? params.mirror : record.mirror
2642
2796
  const renamed = nextName !== '' && nextName !== record.name
2643
- if (renamed && nextName === '') throw new ApiError('BAD_REQUEST', '名称不能为空')
2644
2797
  record.name = nextName !== '' ? nextName : record.name
2645
2798
  record.mirror = nextMirror
2646
2799
  if (nextMirror === false) {
@@ -2919,8 +3072,11 @@ export function apply(ctx) {
2919
3072
  return toolText(`${target} (${stat.size} B, mtime ${stat.mtime}) is a binary file; use list_dir/stat or download it locally.`)
2920
3073
  }
2921
3074
  const content = decodeUtf8(read.data)
2922
- const suffix = read.data.length < stat.size ? `\n...(truncated at ${read.data.length} of ${stat.size} bytes)` : ''
2923
- await mirrorWrite(link, target, read.data)
3075
+ const truncated = read.data.length < stat.size
3076
+ const suffix = truncated ? `\n...(truncated at ${read.data.length} of ${stat.size} bytes)` : ''
3077
+ // Only a complete read may be archived: mirroring a truncated prefix
3078
+ // would leave a silently corrupt copy under .remote.
3079
+ if (!truncated) await mirrorWrite(link, target, read.data)
2924
3080
  return toolText(`${target} (${stat.size} B, mtime ${stat.mtime})${suffix}\n\n${content}`)
2925
3081
  }
2926
3082
 
@@ -2993,7 +3149,9 @@ export function apply(ctx) {
2993
3149
  if (action === 'delete') {
2994
3150
  if (target === root) throw new ApiError('BAD_REQUEST', '不能删除联动工作区根目录')
2995
3151
  const stat = await remote.stat(target)
2996
- if (stat.type === 'dir') await remote.rmdir(target)
3152
+ // rmdir only removes empty directories (same as the UI's fs.delete,
3153
+ // which uses this recursive helper).
3154
+ if (stat.type === 'dir') await remoteDeleteRecursive(remote, target)
2997
3155
  else await remote.unlink(target)
2998
3156
  await linkedFs.removeMirrorPath(link.localPath, linkedRelative(link, target)).catch(() => {})
2999
3157
  return toolText(`Deleted ${target}`)
package/lib/linked.js CHANGED
@@ -509,6 +509,35 @@ function isContained(realRoot, realTarget) {
509
509
  return offset !== '' && !offset.startsWith('..') && !isAbsolute(offset)
510
510
  }
511
511
 
512
+ /**
513
+ * Resolve a path the way the OS does. The JS `realpathSync` does not follow
514
+ * Windows junctions, so a junction pointing outside the workspace would pass a
515
+ * plain string containment test; the native call resolves it correctly.
516
+ */
517
+ function realPathOf(target) {
518
+ return typeof realpathSync.native === 'function' ? realpathSync.native(target) : realpathSync(target)
519
+ }
520
+
521
+ /**
522
+ * True for symlinks and for Windows junctions, which `lstatSync` reports as
523
+ * ordinary directories (mode 40666, `isSymbolicLink() === false`).
524
+ */
525
+ function isLinkLike(target, stats) {
526
+ if (stats && stats.isSymbolicLink()) return true
527
+ if (process.platform !== 'win32') return false
528
+ try {
529
+ return realPathOf(target) !== resolve(target)
530
+ } catch {
531
+ return false
532
+ }
533
+ }
534
+
535
+ function unsafeLinkError(target, detail) {
536
+ const error = new Error(`refusing to remove ${target}: ${detail}`)
537
+ error.code = 'ELINKED_UNSAFE'
538
+ return error
539
+ }
540
+
512
541
  /**
513
542
  * Remove empty parent directories of `target`, stopping at `stopAt` (exclusive).
514
543
  * Best effort: the first non-empty or unreadable directory ends the walk.
@@ -550,8 +579,8 @@ export async function removeMirrorPath(localPath, relativePath) {
550
579
  const mirrorRoot = join(root, MIRROR_DIR)
551
580
  const target = join(mirrorRoot, ...relativeValue.split('/'))
552
581
 
553
- // A symlinked `.remote` would redirect every operation outside the
554
- // workspace; refuse instead of deleting through it.
582
+ // A symlinked or junctioned `.remote` would redirect every operation
583
+ // outside the workspace; refuse instead of deleting through it.
555
584
  let mirrorStats
556
585
  try {
557
586
  mirrorStats = lstatSync(mirrorRoot)
@@ -559,12 +588,13 @@ export async function removeMirrorPath(localPath, relativePath) {
559
588
  if (error && error.code === 'ENOENT') return false
560
589
  throw error
561
590
  }
562
- if (mirrorStats.isSymbolicLink()) {
563
- const error = new Error(
564
- `refusing to remove ${target}: ${mirrorRoot} is a symbolic link`,
565
- )
566
- error.code = 'ELINKED_UNSAFE'
567
- throw error
591
+ if (isLinkLike(mirrorRoot, mirrorStats)) {
592
+ throw unsafeLinkError(target, `${mirrorRoot} is a symbolic link`)
593
+ }
594
+ const realRoot = realPathOf(root)
595
+ const realMirror = realPathOf(mirrorRoot)
596
+ if (!isContained(realRoot, realMirror)) {
597
+ throw unsafeLinkError(target, `${mirrorRoot} resolves outside ${root}`)
568
598
  }
569
599
 
570
600
  let stats
@@ -576,18 +606,13 @@ export async function removeMirrorPath(localPath, relativePath) {
576
606
  throw error
577
607
  }
578
608
 
579
- if (stats.isSymbolicLink()) {
609
+ if (isLinkLike(target, stats)) {
580
610
  // Delete the link itself, never what it points at.
581
611
  removeSymlink(target)
582
612
  } else {
583
- const realMirror = realpathSync(mirrorRoot)
584
- const realTarget = realpathSync(target)
613
+ const realTarget = realPathOf(target)
585
614
  if (!isContained(realMirror, realTarget)) {
586
- const error = new Error(
587
- `refusing to remove ${target}: it resolves outside ${mirrorRoot}`,
588
- )
589
- error.code = 'ELINKED_UNSAFE'
590
- throw error
615
+ throw unsafeLinkError(target, `it resolves outside ${mirrorRoot}`)
591
616
  }
592
617
  rmSync(target, { recursive: true, force: true, maxRetries: 3 })
593
618
  }
@@ -626,18 +651,15 @@ function removeOwnedDir(target, root) {
626
651
  if (error && error.code === 'ENOENT') return false
627
652
  throw error
628
653
  }
629
- if (stats.isSymbolicLink()) {
654
+ if (isLinkLike(target, stats)) {
655
+ // A junction/symlink owns only the link: remove it, keep the data outside.
630
656
  removeSymlink(target)
631
657
  return true
632
658
  }
633
- const realRoot = realpathSync(root)
634
- const realTarget = realpathSync(target)
659
+ const realRoot = realPathOf(root)
660
+ const realTarget = realPathOf(target)
635
661
  if (!isContained(realRoot, realTarget)) {
636
- const error = new Error(
637
- `refusing to remove ${target}: it resolves outside ${root}`,
638
- )
639
- error.code = 'ELINKED_UNSAFE'
640
- throw error
662
+ throw unsafeLinkError(target, `it resolves outside ${root}`)
641
663
  }
642
664
  rmSync(target, { recursive: true, force: true, maxRetries: 3 })
643
665
  return true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-linked-workspace",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Linked local ⇄ remote workspaces and SSH-backed remote directories for DeepSeek Harness: theme-aware split editor, file-type icons, and an agent tool that edits remote files directly.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -72,7 +72,7 @@
72
72
  },
73
73
  "scripts": {
74
74
  "build": "node scripts/build-client.mjs",
75
- "test": "node --test test/linked.test.mjs test/parts.test.mjs test/git.test.mjs && node test/client-smoke.mjs",
75
+ "test": "node --test test/linked.test.mjs test/parts.test.mjs test/git.test.mjs test/file-creation.test.mjs && node test/client-smoke.mjs",
76
76
  "prepack": "node scripts/build-client.mjs"
77
77
  },
78
78
  "repository": {
@@ -106,18 +106,25 @@ def read_xlsx(path):
106
106
  sheets = []
107
107
  for sheet in workbook.worksheets[:20]:
108
108
  rows = []
109
- max_row = min(sheet.max_row or 0, MAX_SHEET_ROWS)
110
- max_col = min(sheet.max_column or 0, MAX_SHEET_COLS)
109
+ total_row = sheet.max_row or 0
110
+ total_col = sheet.max_column or 0
111
+ max_row = min(total_row, MAX_SHEET_ROWS)
112
+ max_col = min(total_col, MAX_SHEET_COLS)
111
113
  for row in sheet.iter_rows(min_row=1, max_row=max_row, min_col=1, max_col=max_col):
112
114
  rows.append([json_value(cell.value) for cell in row])
113
115
  sheets.append({
114
116
  "name": sheet.title,
115
117
  "rows": rows,
118
+ # maxRow/maxCol stay the rendered window so the client can size the
119
+ # grid; totalRow/totalCol/truncated tell it the sheet is larger.
116
120
  "maxRow": max_row,
117
121
  "maxCol": max_col,
122
+ "totalRow": total_row,
123
+ "totalCol": total_col,
124
+ "truncated": total_row > max_row or total_col > max_col,
118
125
  "frozen": bool(sheet.freeze_panes),
119
126
  })
120
- emit({"kind": "xlsx", "sheets": sheets, "sheetCount": len(workbook.worksheets)})
127
+ emit({"kind": "xlsx", "sheets": sheets, "sheetCount": len(workbook.worksheets), "sheetLimit": 20})
121
128
 
122
129
 
123
130
  def write_xlsx(source, data, out):