dsh-linked-workspace 0.8.0 → 0.8.2

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,17 @@ 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
+
91
+ /** Clamp a requested page window to the listing limits. */
92
+ const pageWindow = (params = {}, total = 0) => {
93
+ const limit = Math.min(Math.max(Number(params.limit) || LIST_PAGE_SIZE, 1), LIST_PAGE_MAX)
94
+ const offset = Math.min(Math.max(Number(params.offset) || 0, 0), Math.max(total, 0))
95
+ return { limit, offset }
96
+ }
85
97
 
86
98
  const LOG_PREFIX = '[dsh-linked-workspace]'
87
99
 
@@ -505,10 +517,31 @@ function mapLocalFsError(error, fallback = 'INTERNAL') {
505
517
  if (code === 'ENOENT') return new ApiError('FS_NOT_FOUND', '本地路径不存在')
506
518
  if (code === 'EACCES' || code === 'EPERM') return new ApiError('FS_PERMISSION', '没有权限访问本地路径')
507
519
  if (code === 'ENOTDIR') return new ApiError('FS_NOT_DIR', '本地路径不是目录')
508
- if (code === 'EEXIST') return new ApiError('BAD_REQUEST', '同名文件夹已存在')
520
+ if (code === 'EEXIST') return new ApiError('FS_EXISTS', '同名文件或文件夹已存在')
509
521
  return new ApiError(fallback, error && error.message ? error.message : String(error))
510
522
  }
511
523
 
524
+ /**
525
+ * Read at most `maxBytes` from the start of a local file.
526
+ * Unlike readFileSync this never maps the whole file, so a peek at a huge
527
+ * (or larger-than-2-GiB) file stays cheap.
528
+ */
529
+ function readHeadSync(absolute, maxBytes) {
530
+ const fd = openSync(absolute, 'r')
531
+ try {
532
+ const buffer = Buffer.allocUnsafe(Math.max(1, Math.min(maxBytes, 16 * 1024 * 1024)))
533
+ let filled = 0
534
+ while (filled < buffer.length) {
535
+ const read = readSync(fd, buffer, filled, buffer.length - filled, filled)
536
+ if (read <= 0) break
537
+ filled += read
538
+ }
539
+ return buffer.subarray(0, filled)
540
+ } finally {
541
+ closeSync(fd)
542
+ }
543
+ }
544
+
512
545
  function localDrives() {
513
546
  if (process.platform !== 'win32') return []
514
547
  const drives = []
@@ -670,6 +703,7 @@ class HostPool {
670
703
  this.knownHostsFile = knownHostsFile
671
704
  this.records = new Map() // hostId -> HostRecord
672
705
  this.connections = new Map() // hostId -> { authority, remote: RemoteHost }
706
+ this.connecting = new Map() // hostId -> in-flight connect promise
673
707
  this.statuses = new Map() // hostId -> { state, message?, latencyMs?, checkedAt }
674
708
  }
675
709
 
@@ -703,8 +737,8 @@ class HostPool {
703
737
  }
704
738
 
705
739
  async remote(hostRecord) {
706
- const existing = this.connections.get(hostRecord.id)
707
740
  const authority = `${hostRecord.host}:${hostRecord.port}`
741
+ const existing = this.connections.get(hostRecord.id)
708
742
  if (existing && existing.authority === authority) {
709
743
  try {
710
744
  await existing.remote.ensureConnected()
@@ -715,15 +749,33 @@ class HostPool {
715
749
  throw error
716
750
  }
717
751
  }
752
+ // Two concurrent first requests for one host must share one connection;
753
+ // otherwise the second RemoteHost overwrites the map entry and the first
754
+ // SSH socket leaks.
755
+ const pending = this.connecting.get(hostRecord.id)
756
+ if (pending) return pending
718
757
  const remote = new RemoteHost({
719
758
  host: hostRecord,
720
759
  knownHostsFile: this.knownHostsFile,
721
760
  connectTimeoutMs: CONNECT_TIMEOUT_MS,
722
761
  logger: log,
723
762
  })
724
- this.connections.set(hostRecord.id, { authority, remote })
725
- await remote.connect()
726
- return remote
763
+ const entry = { authority, remote }
764
+ this.connections.set(hostRecord.id, entry)
765
+ const attempt = (async () => {
766
+ try {
767
+ await remote.connect()
768
+ return remote
769
+ } catch (error) {
770
+ if (this.connections.get(hostRecord.id) === entry) this.connections.delete(hostRecord.id)
771
+ remote.dispose().catch(() => {})
772
+ throw error
773
+ } finally {
774
+ this.connecting.delete(hostRecord.id)
775
+ }
776
+ })()
777
+ this.connecting.set(hostRecord.id, attempt)
778
+ return attempt
727
779
  }
728
780
 
729
781
  async disposeAll() {
@@ -1107,8 +1159,11 @@ export function apply(ctx) {
1107
1159
  const seen = new Set()
1108
1160
  const shouldStop = () => job.cancel || job.files >= job.maxFiles || job.bytes >= job.maxBytes
1109
1161
  const visit = async (directory, depth) => {
1110
- if (depth > 24 || shouldStop()) {
1111
- if (shouldStop() && !job.cancel) job.truncated = true
1162
+ // Hitting the depth limit makes the scan incomplete, exactly like the
1163
+ // file/byte caps: flag it so the removal pass is skipped and the UI
1164
+ // can say the archive is partial.
1165
+ if (depth > LINKED_SYNC_MAX_DEPTH || shouldStop()) {
1166
+ if (!job.cancel) job.truncated = true
1112
1167
  return
1113
1168
  }
1114
1169
  let listing
@@ -1325,7 +1380,15 @@ export function apply(ctx) {
1325
1380
  const remoteCopyRecursive = async (remote, source, target) => {
1326
1381
  const stat = await remote.stat(source)
1327
1382
  if (stat.type !== 'dir') {
1383
+ // readFile truncates silently, so an oversized source has to be refused
1384
+ // here: copying only its first 64 MiB would corrupt the destination.
1385
+ if (stat.size > LINKED_OPEN_FILE_CAP) {
1386
+ throw new ApiError('FS_TOO_LARGE', `${posix.basename(source)} 超过 64 MB,无法复制`)
1387
+ }
1328
1388
  const read = await remote.readFile(source, { maxBytes: LINKED_OPEN_FILE_CAP })
1389
+ if (read.truncated || read.data.length !== stat.size) {
1390
+ throw new ApiError('FS_TOO_LARGE', `${posix.basename(source)} 读取不完整,已取消复制`)
1391
+ }
1329
1392
  await remote.writeFileAtomic(target, read.data)
1330
1393
  return 1
1331
1394
  }
@@ -1805,16 +1868,22 @@ export function apply(ctx) {
1805
1868
  ? scope.root
1806
1869
  : scopeTarget(scope, params.path)
1807
1870
  const listing = await scope.remote.listDir(target)
1871
+ const all = listing.entries.map((entry) => ({
1872
+ name: entry.name,
1873
+ path: entry.path,
1874
+ type: entry.type,
1875
+ size: entry.size,
1876
+ mtime: entry.mtime,
1877
+ relative: scopeRelative(scope, entry.path),
1878
+ }))
1879
+ const page = pageWindow(params, all.length)
1808
1880
  return {
1809
1881
  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
- })),
1882
+ entries: all.slice(page.offset, page.offset + page.limit),
1883
+ total: all.length,
1884
+ offset: page.offset,
1885
+ limit: page.limit,
1886
+ hasMore: page.offset + page.limit < all.length,
1818
1887
  }
1819
1888
  },
1820
1889
 
@@ -1876,6 +1945,7 @@ export function apply(ctx) {
1876
1945
  if (apiError.code !== 'FS_NOT_FOUND') throw apiError
1877
1946
  current = undefined
1878
1947
  }
1948
+ if (params.createOnly === true && current) throw new ApiError('FS_EXISTS', '目标已存在,请使用其他名称')
1879
1949
  if (current && current.type === 'dir') throw new ApiError('FS_IS_DIR', '目标是目录,无法写入')
1880
1950
  if (current && typeof params.expectedMtime === 'number' && params.expectedMtime > 0) {
1881
1951
  const currentSeconds = Math.floor(current.mtime / 1000)
@@ -2059,6 +2129,17 @@ export function apply(ctx) {
2059
2129
  const scope = await requireScope(params)
2060
2130
  const target = scopeTarget(scope, params.path)
2061
2131
  if (target === scope.root) throw new ApiError('BAD_REQUEST', '不能创建工作区根目录')
2132
+ if (params.createOnly === true) {
2133
+ let current
2134
+ try {
2135
+ current = await scope.remote.stat(target)
2136
+ } catch (error) {
2137
+ const apiError = asApiError(error)
2138
+ if (apiError.code !== 'FS_NOT_FOUND') throw apiError
2139
+ current = undefined
2140
+ }
2141
+ if (current) throw new ApiError('FS_EXISTS', '目标已存在,请使用其他名称')
2142
+ }
2062
2143
  await scope.remote.mkdirp(target)
2063
2144
  return {}
2064
2145
  },
@@ -2187,32 +2268,43 @@ export function apply(ctx) {
2187
2268
  throw mapLocalFsError(error)
2188
2269
  }
2189
2270
  if (!info.isDirectory()) throw new ApiError('FS_NOT_DIR', '本地路径不是目录')
2190
- let rawEntries
2191
- try {
2192
- rawEntries = await readdir(target, { withFileTypes: true })
2193
- } catch (error) {
2271
+ const rawEntries = await readdir(target, { withFileTypes: true }).catch((error) => {
2194
2272
  throw mapLocalFsError(error)
2195
- }
2273
+ })
2196
2274
  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 })
2275
+ // Stat in small parallel batches: one await per entry is slow for big
2276
+ // folders, but an unbounded Promise.all can exhaust file descriptors.
2277
+ for (let start = 0; start < rawEntries.length; start += 128) {
2278
+ const batch = rawEntries.slice(start, start + 128)
2279
+ const stated = await Promise.all(batch.map(async (entry) => {
2280
+ const full = join(target, entry.name)
2281
+ let stat
2282
+ try {
2283
+ stat = await fsStat(full)
2284
+ } catch {
2285
+ return null
2286
+ }
2287
+ let type = 'file'
2288
+ if (stat.isDirectory()) type = 'dir'
2289
+ else if (stat.isSymbolicLink()) type = 'symlink'
2290
+ return { name: entry.name, path: full, type, size: stat.size, mtime: stat.mtimeMs }
2291
+ }))
2292
+ for (const item of stated) if (item) entries.push(item)
2209
2293
  }
2210
2294
  entries.sort((left, right) => {
2211
2295
  if (left.type === 'dir' && right.type !== 'dir') return -1
2212
2296
  if (right.type === 'dir' && left.type !== 'dir') return 1
2213
2297
  return left.name.localeCompare(right.name)
2214
2298
  })
2215
- return { path: target, entries }
2299
+ const page = pageWindow(params, entries.length)
2300
+ return {
2301
+ path: target,
2302
+ entries: entries.slice(page.offset, page.offset + page.limit),
2303
+ total: entries.length,
2304
+ offset: page.offset,
2305
+ limit: page.limit,
2306
+ hasMore: page.offset + page.limit < entries.length,
2307
+ }
2216
2308
  },
2217
2309
 
2218
2310
  async 'local.stat'(params = {}) {
@@ -2246,13 +2338,15 @@ export function apply(ctx) {
2246
2338
  if (info.isDirectory()) throw new ApiError('FS_IS_DIR', '目标是目录,无法作为文件读取')
2247
2339
  const tooLarge = info.size > EDITABLE_LIMIT
2248
2340
  const cap = tooLarge ? PEEK_LIMIT : READ_CAP
2341
+ // Read at most `cap` bytes. Reading the whole file first would pull
2342
+ // multi-gigabyte files into Host memory (and on Windows readFileSync
2343
+ // throws ERR_FS_FILE_TOO_LARGE above 2 GiB even for a peek).
2249
2344
  let data
2250
2345
  try {
2251
- data = readFileSync(absolute)
2346
+ data = readHeadSync(absolute, cap)
2252
2347
  } catch (error) {
2253
2348
  throw mapLocalFsError(error)
2254
2349
  }
2255
- if (data.length > cap) data = data.subarray(0, cap)
2256
2350
  const binary = looksBinary(data)
2257
2351
  return {
2258
2352
  path: absolute,
@@ -2640,7 +2734,6 @@ export function apply(ctx) {
2640
2734
  if (nextName.length > MAX_NAME_LENGTH) throw new ApiError('BAD_REQUEST', '工作区名称过长')
2641
2735
  const nextMirror = typeof params.mirror === 'boolean' ? params.mirror : record.mirror
2642
2736
  const renamed = nextName !== '' && nextName !== record.name
2643
- if (renamed && nextName === '') throw new ApiError('BAD_REQUEST', '名称不能为空')
2644
2737
  record.name = nextName !== '' ? nextName : record.name
2645
2738
  record.mirror = nextMirror
2646
2739
  if (nextMirror === false) {
@@ -2919,8 +3012,11 @@ export function apply(ctx) {
2919
3012
  return toolText(`${target} (${stat.size} B, mtime ${stat.mtime}) is a binary file; use list_dir/stat or download it locally.`)
2920
3013
  }
2921
3014
  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)
3015
+ const truncated = read.data.length < stat.size
3016
+ const suffix = truncated ? `\n...(truncated at ${read.data.length} of ${stat.size} bytes)` : ''
3017
+ // Only a complete read may be archived: mirroring a truncated prefix
3018
+ // would leave a silently corrupt copy under .remote.
3019
+ if (!truncated) await mirrorWrite(link, target, read.data)
2924
3020
  return toolText(`${target} (${stat.size} B, mtime ${stat.mtime})${suffix}\n\n${content}`)
2925
3021
  }
2926
3022
 
@@ -2993,7 +3089,9 @@ export function apply(ctx) {
2993
3089
  if (action === 'delete') {
2994
3090
  if (target === root) throw new ApiError('BAD_REQUEST', '不能删除联动工作区根目录')
2995
3091
  const stat = await remote.stat(target)
2996
- if (stat.type === 'dir') await remote.rmdir(target)
3092
+ // rmdir only removes empty directories (same as the UI's fs.delete,
3093
+ // which uses this recursive helper).
3094
+ if (stat.type === 'dir') await remoteDeleteRecursive(remote, target)
2997
3095
  else await remote.unlink(target)
2998
3096
  await linkedFs.removeMirrorPath(link.localPath, linkedRelative(link, target)).catch(() => {})
2999
3097
  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.0",
3
+ "version": "0.8.2",
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):