dsh-plugin-mobile-link 0.6.6 → 0.8.0
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/README.md +28 -13
- package/cordis.patch.yml +11 -0
- package/lib/cli.js +3 -4
- package/lib/client.js +560 -75
- package/lib/dir-listing.js +167 -0
- package/lib/dlp.js +33 -1
- package/lib/enroll.js +249 -35
- package/lib/files.js +118 -12
- package/lib/git.js +20 -5
- package/lib/hello.js +23 -1
- package/lib/history/compress.js +40 -0
- package/lib/history/coverage.js +21 -0
- package/lib/history/fold-summary.js +472 -0
- package/lib/history/fold.js +184 -0
- package/lib/history/session-snapshot.js +75 -24
- package/lib/history/summary-window.js +168 -0
- package/lib/history.js +171 -9
- package/lib/index.js +38 -2
- package/lib/jobs-roster.js +377 -0
- package/lib/link.js +238 -11
- package/lib/logs.js +242 -0
- package/lib/pairing.js +1 -1
- package/lib/router.js +171 -5
- package/lib/routes.js +147 -0
- package/lib/session-digest.js +73 -4
- package/lib/state.js +6 -6
- package/lib/status.js +30 -2
- package/lib/unicode.js +116 -0
- package/package.json +1 -1
package/lib/files.js
CHANGED
|
@@ -26,6 +26,24 @@ export function isFileMethod(method) {
|
|
|
26
26
|
return method === FILE_BEGIN || method === FILE_CHUNK || method === FILE_END
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* 多久没有新分片就认定这次上传已经没人要了。
|
|
31
|
+
*
|
|
32
|
+
* 这个时限不是"请求超时":正在慢慢传的上传每来一片就重新计时。它要解决的是
|
|
33
|
+
* 一种**线上没有信号**的中断——relay 的 `PUT /files/up` 被手机掐掉之后,两个
|
|
34
|
+
* 中转(Python 与 Go)都只在本地摘掉桥接,**不会给连接器发任何取消帧**,也不会
|
|
35
|
+
* 断开 agent 的 WebSocket。连接器因此永远等不到下一次 `fsPutChunk`/`fsPutEnd`,
|
|
36
|
+
* `.part` 的 fd 就一直开着(M8 文件联调实测)。
|
|
37
|
+
*
|
|
38
|
+
* 取值要能容下慢速链路:1 MiB 一片,即便链路慢到 100 KB/s 也约 10 秒一片;
|
|
39
|
+
* 10 分钟的空档只可能是手机侧被系统挂起或请求早已死掉。真被清扫时,这次上传
|
|
40
|
+
* 会以 `fsErr`/HTTP 错误明确失败,用户重传即可,不会静默写出半截文件。
|
|
41
|
+
*/
|
|
42
|
+
export const DEFAULT_TRANSFER_IDLE_MS = 10 * 60 * 1000
|
|
43
|
+
|
|
44
|
+
/** 空闲清扫的周期:只决定"最晚多久之后"释放,不决定"多久算空闲"。 */
|
|
45
|
+
export const DEFAULT_SWEEP_MS = 30 * 1000
|
|
46
|
+
|
|
29
47
|
/**
|
|
30
48
|
* One in-flight transfer.
|
|
31
49
|
*
|
|
@@ -40,7 +58,10 @@ export function isFileMethod(method) {
|
|
|
40
58
|
* checks out, so a half-sent file is never mistaken for a complete one.
|
|
41
59
|
*/
|
|
42
60
|
class Transfer {
|
|
43
|
-
|
|
61
|
+
/** 注入的时钟;FileInbox 把它传给每次 Transfer,保证"空闲多久"只有一份口径。 */
|
|
62
|
+
#now
|
|
63
|
+
|
|
64
|
+
constructor({ sessionId, name, bytes, now = Date.now }) {
|
|
44
65
|
this.sessionId = sessionId
|
|
45
66
|
this.name = basename(name || 'file')
|
|
46
67
|
this.bytes = Number.isFinite(bytes) ? bytes : undefined
|
|
@@ -51,6 +72,12 @@ class Transfer {
|
|
|
51
72
|
this.directory = join(dshHome(), 'inbox', sessionId)
|
|
52
73
|
mkdirSync(this.directory, { recursive: true, mode: 0o700 })
|
|
53
74
|
this.staging = join(this.directory, `.${this.name}.part`)
|
|
75
|
+
// 最后一次收到分片的时刻(空闲清扫用)。时钟由 FileInbox 注入,测试里可精确推进。
|
|
76
|
+
this.#now = now
|
|
77
|
+
this.lastActivityAt = now()
|
|
78
|
+
// 释放只能发生一次:fd 与 `.part` 是同一份资源的两个面,重复释放要么是空操作、
|
|
79
|
+
// 要么会删到别人刚建好的同路径文件(重传接管时正好是这种情况)。
|
|
80
|
+
this.released = false
|
|
54
81
|
// Opened once and kept: reopening per chunk would be a syscall per chunk for
|
|
55
82
|
// no benefit. `w` truncates, which is what a retried transfer wants.
|
|
56
83
|
this.fd = undefined
|
|
@@ -72,6 +99,7 @@ class Transfer {
|
|
|
72
99
|
writeSync(this.#handle(), buffer)
|
|
73
100
|
this.received += buffer.length
|
|
74
101
|
this.expectedSeq += 1
|
|
102
|
+
this.lastActivityAt = this.#now()
|
|
75
103
|
}
|
|
76
104
|
|
|
77
105
|
finish() {
|
|
@@ -95,7 +123,15 @@ class Transfer {
|
|
|
95
123
|
}
|
|
96
124
|
}
|
|
97
125
|
|
|
126
|
+
/**
|
|
127
|
+
* 放弃这次上传:关句柄、删暂存文件,幂等。
|
|
128
|
+
*
|
|
129
|
+
* **只碰 `.part`**:`finish()` 已把暂存改名成成品,这里再调一次也只是对一个
|
|
130
|
+
* 不存在的暂存路径做 no-op,成品不会被误删(`已完成的文件不会被清理路径误删` 用例守着)。
|
|
131
|
+
*/
|
|
98
132
|
dispose() {
|
|
133
|
+
if (this.released) return
|
|
134
|
+
this.released = true
|
|
99
135
|
this.#close()
|
|
100
136
|
rmSync(this.staging, { force: true })
|
|
101
137
|
}
|
|
@@ -105,9 +141,56 @@ class Transfer {
|
|
|
105
141
|
export class FileInbox {
|
|
106
142
|
#transfers = new Map()
|
|
107
143
|
#logger
|
|
144
|
+
#idleMs
|
|
145
|
+
#now
|
|
146
|
+
#sweeper
|
|
108
147
|
|
|
109
|
-
|
|
148
|
+
/**
|
|
149
|
+
* @param {object} [logger]
|
|
150
|
+
* @param {object} [options]
|
|
151
|
+
* @param {number} [options.idleMs] 多久没有新分片算"没人要了"
|
|
152
|
+
* @param {number} [options.sweepMs] 空闲清扫周期
|
|
153
|
+
* @param {() => number} [options.now] 注入时钟(测试用;生产就是 `Date.now`)
|
|
154
|
+
*/
|
|
155
|
+
constructor(logger, {
|
|
156
|
+
idleMs = DEFAULT_TRANSFER_IDLE_MS,
|
|
157
|
+
sweepMs = DEFAULT_SWEEP_MS,
|
|
158
|
+
now = Date.now,
|
|
159
|
+
} = {}) {
|
|
110
160
|
this.#logger = logger
|
|
161
|
+
this.#idleMs = idleMs
|
|
162
|
+
this.#now = now
|
|
163
|
+
// 定时器是**兜底信号**:relay 从不为上传发取消帧,也不在上传中断时断开
|
|
164
|
+
// agent 的 WebSocket,所以"再也不会来分片"只能由时间发现。`unref` 掉,
|
|
165
|
+
// 别让一个空的收件箱按住进程退出(与 link.js / jobs-roster.js 同一口径)。
|
|
166
|
+
this.#sweeper = setInterval(() => this.sweep(), Math.max(1, sweepMs))
|
|
167
|
+
this.#sweeper.unref?.()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 释放空闲超过 `idleMs` 的上传;返回本次释放的个数。
|
|
172
|
+
*
|
|
173
|
+
* 单独暴露是为了让测试给出确定时刻,而不是 sleep 等定时器;生产里由构造时
|
|
174
|
+
* 安好的 interval 调用。
|
|
175
|
+
*/
|
|
176
|
+
sweep(now = this.#now()) {
|
|
177
|
+
let released = 0
|
|
178
|
+
for (const [transferId, transfer] of this.#transfers) {
|
|
179
|
+
const idle = now - transfer.lastActivityAt
|
|
180
|
+
if (idle < this.#idleMs) continue
|
|
181
|
+
this.#release(transferId, transfer)
|
|
182
|
+
this.#logger.warn?.(
|
|
183
|
+
`mobile-link: file transfer ${transferId} released after ${Math.round(idle / 1000)}s idle`,
|
|
184
|
+
)
|
|
185
|
+
released += 1
|
|
186
|
+
}
|
|
187
|
+
return released
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 收掉一次在传上传;释放本身幂等,重复到达的取消/清扫都是空操作。 */
|
|
191
|
+
#release(transferId, transfer) {
|
|
192
|
+
transfer.dispose()
|
|
193
|
+
this.#transfers.delete(transferId)
|
|
111
194
|
}
|
|
112
195
|
|
|
113
196
|
/** Answers one reserved call. Throws `Error` for the router to report. */
|
|
@@ -118,12 +201,21 @@ export class FileInbox {
|
|
|
118
201
|
if (method === FILE_BEGIN) {
|
|
119
202
|
const sessionId = String(args.sessionId ?? '')
|
|
120
203
|
if (!sessionId) throw new Error('sessionId is required')
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
204
|
+
const transfer = new Transfer({
|
|
205
|
+
sessionId, name: args.name, bytes: Number(args.bytes), now: this.#now,
|
|
206
|
+
})
|
|
207
|
+
// 一条 `.part` 同时只能有一个写者。两种"重来"都要在这里把旧的收掉:
|
|
208
|
+
// 同一个 bid 重来,以及手机换了新 bid 但落盘路径相同(中断后重传)。
|
|
209
|
+
// 不收的话旧 fd 会一直握着一个已被 unlink 的 inode——M8 联调实测的泄漏。
|
|
210
|
+
for (const [id, existing] of this.#transfers) {
|
|
211
|
+
if (id === transferId || existing.staging === transfer.staging) {
|
|
212
|
+
this.#release(id, existing)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
this.#transfers.set(transferId, transfer)
|
|
216
|
+
this.#logger.debug?.(
|
|
217
|
+
`mobile-link: file transfer ${transferId} started for ${sessionId}`,
|
|
125
218
|
)
|
|
126
|
-
this.#logger.debug?.(`mobile-link: file transfer ${transferId} started for ${sessionId}`)
|
|
127
219
|
return { accepted: true }
|
|
128
220
|
}
|
|
129
221
|
|
|
@@ -131,7 +223,14 @@ export class FileInbox {
|
|
|
131
223
|
if (!transfer) throw new Error(`unknown transfer ${transferId}`)
|
|
132
224
|
|
|
133
225
|
if (method === FILE_CHUNK) {
|
|
134
|
-
|
|
226
|
+
try {
|
|
227
|
+
transfer.append(Number(args.seq ?? 0), String(args.data ?? ''))
|
|
228
|
+
} catch (error) {
|
|
229
|
+
// 这一片已经给这次上传判了死(调用方随后回 `fsErr`):句柄与 `.part`
|
|
230
|
+
// 一起收掉,别等空闲清扫——它可能永远等不到下一次触发。
|
|
231
|
+
this.#release(transferId, transfer)
|
|
232
|
+
throw error
|
|
233
|
+
}
|
|
135
234
|
return { received: transfer.received }
|
|
136
235
|
}
|
|
137
236
|
|
|
@@ -143,14 +242,21 @@ export class FileInbox {
|
|
|
143
242
|
)
|
|
144
243
|
return done
|
|
145
244
|
} finally {
|
|
146
|
-
transfer
|
|
147
|
-
this.#transfers.delete(transferId)
|
|
245
|
+
this.#release(transferId, transfer)
|
|
148
246
|
}
|
|
149
247
|
}
|
|
150
248
|
|
|
249
|
+
/** 放弃一次上传(relay 的 `fsPutCancel`)。未知 id 是空操作,与 `fsGetCancel` 同口径。 */
|
|
250
|
+
cancel(transferId) {
|
|
251
|
+
const id = String(transferId ?? '')
|
|
252
|
+
const transfer = this.#transfers.get(id)
|
|
253
|
+
if (transfer) this.#release(id, transfer)
|
|
254
|
+
}
|
|
255
|
+
|
|
151
256
|
/** Drops every in-flight transfer, e.g. when the link goes down. */
|
|
152
257
|
clear() {
|
|
153
|
-
for (const transfer of this.#transfers
|
|
154
|
-
|
|
258
|
+
for (const [transferId, transfer] of this.#transfers) {
|
|
259
|
+
this.#release(transferId, transfer)
|
|
260
|
+
}
|
|
155
261
|
}
|
|
156
262
|
}
|
package/lib/git.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { execFile } from 'node:child_process'
|
|
20
20
|
import { statSync } from 'node:fs'
|
|
21
21
|
import { isAbsolute, resolve } from 'node:path'
|
|
22
|
+
import { truncateSafe } from './unicode.js'
|
|
22
23
|
|
|
23
24
|
/** Reserved method names this module answers; never forwarded to the Host. */
|
|
24
25
|
export const GIT_STATUS = '_link/gitStatus'
|
|
@@ -44,10 +45,24 @@ export const LIMITS = {
|
|
|
44
45
|
readTimeoutMs: 15_000,
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
/** The environment every git call runs in.
|
|
48
|
-
|
|
48
|
+
/** The environment every git call runs in.
|
|
49
|
+
*
|
|
50
|
+
* 导出给单测的 fixture 使用:测试自己也要起 git,两边必须是同一份环境口径,
|
|
51
|
+
* 否则 hook 里跑测试时 fixture 会写进外层仓库。
|
|
52
|
+
*/
|
|
53
|
+
export function gitEnv() {
|
|
54
|
+
const env = { ...process.env }
|
|
55
|
+
// "看哪个仓库"只由 `-C <dir>` 决定。调用方环境里可能带着仓库定位变量(从
|
|
56
|
+
// .githooks 里启动 DSH、或在 git rebase 里启动时都会这样),它们会盖掉 `-C`,
|
|
57
|
+
// 让手机看到另一个仓库、甚至报 "nothing to commit"。这里清掉,归属唯一。
|
|
58
|
+
for (const key of [
|
|
59
|
+
'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_PREFIX', 'GIT_NAMESPACE',
|
|
60
|
+
'GIT_COMMON_DIR', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
61
|
+
]) {
|
|
62
|
+
delete env[key]
|
|
63
|
+
}
|
|
49
64
|
return {
|
|
50
|
-
...
|
|
65
|
+
...env,
|
|
51
66
|
// Never wait for a credential prompt or open an editor: a phone review
|
|
52
67
|
// must not be able to wedge a process on the computer.
|
|
53
68
|
GIT_TERMINAL_PROMPT: '0',
|
|
@@ -400,7 +415,7 @@ export class GitBridge {
|
|
|
400
415
|
staged,
|
|
401
416
|
untracked,
|
|
402
417
|
binary,
|
|
403
|
-
text: binary ? '' : text
|
|
418
|
+
text: binary ? '' : truncateSafe(text, LIMITS.diffBytes),
|
|
404
419
|
truncated: !binary && buffer.length > LIMITS.diffBytes,
|
|
405
420
|
}
|
|
406
421
|
}
|
|
@@ -450,7 +465,7 @@ export class GitBridge {
|
|
|
450
465
|
sha,
|
|
451
466
|
path,
|
|
452
467
|
binary,
|
|
453
|
-
text: binary ? '' : text
|
|
468
|
+
text: binary ? '' : truncateSafe(text, LIMITS.diffBytes),
|
|
454
469
|
truncated: !binary && buffer.length > LIMITS.diffBytes,
|
|
455
470
|
}
|
|
456
471
|
}
|
package/lib/hello.js
CHANGED
|
@@ -65,7 +65,7 @@ export function hostVersion() {
|
|
|
65
65
|
* - `qr-pairing` this connector renders the pairing QR itself (the `svg`
|
|
66
66
|
* field of `POST /mobile-link/pair-code`), so the DSH client
|
|
67
67
|
* needs no QR library of its own
|
|
68
|
-
* - `self-enroll` this connector can register itself
|
|
68
|
+
* - `self-enroll` this connector can register itself without an invite code
|
|
69
69
|
* - `git` git status / diff / log / show can be read from the work
|
|
70
70
|
* tree on this computer (see git.js)
|
|
71
71
|
* - `background-transfer`
|
|
@@ -98,6 +98,28 @@ export const SERVER_CAPABILITIES = [
|
|
|
98
98
|
// search-progress: 扫描式搜索的每一页都带 `totalChunks` / `remainingChunks`,
|
|
99
99
|
// 界面据此从第一页起就能显示「已扫 k/N 片」(026 之后的 App 才读)。
|
|
100
100
|
'search-progress',
|
|
101
|
+
// history-compression: `_link/sessionWindow` 可按要求把回包压成
|
|
102
|
+
// `deflate-raw` 的 base64 信封(见 history/compress.js)。手机只在看到这条
|
|
103
|
+
// 能力时请求;旧客户端不传 encoding,回包与改动前逐字一致。
|
|
104
|
+
'history-compression',
|
|
105
|
+
// history-fold-summary: `_link/sessionWindow` 接受 `fold: true`,把已完成轮次的
|
|
106
|
+
// 过程事件折成一条 `+link/fold` 摘要(见 history/fold.js);手机展开时用普通请求取回。
|
|
107
|
+
'history-fold-summary',
|
|
108
|
+
// history-fold-summary-v2: `_link/sessionWindow` 另接受 `fold: "summary"`——概要先行
|
|
109
|
+
// 取数(只回讲话 + `+link/fold` v2 概要 + 正式回答,按整轮切页,见
|
|
110
|
+
// history/fold-summary.js 与 history/summary-window.js)。**独立能力位**:
|
|
111
|
+
// 不声明 `"summary"` 的老客户端拿到的回包与改动前逐字节相同。
|
|
112
|
+
'history-fold-summary-v2',
|
|
113
|
+
'history-coverage',
|
|
114
|
+
// jobs-roster: 连接器答 `_link/jobRoster`(逐活会话聚合宿主任务),并推
|
|
115
|
+
// `link/jobs` 帧(spec 018)。手机**只按这条能力**决定是否渲染任务面板、列表行
|
|
116
|
+
// 右侧计数与卡片新行:无此能力的旧连接器,手机整块不出现(界面与升级前逐屏一致)。
|
|
117
|
+
'jobs-roster',
|
|
118
|
+
// dir-mtimes: 连接器答 `_link/dirListing`——宿主 `workspaceFiles/list` 的同形副本,
|
|
119
|
+
// 在电脑本机对每个条目补一个 `mtimeMs`(宿主类型里没有时间字段,`stat` 的 version
|
|
120
|
+
// 是 opaque token,推不出时间)。手机**只按这条能力**决定要不要走新方法:没有它的
|
|
121
|
+
// 旧连接器仍走 `workspaceFiles/list`,文件页与升级前逐屏一致(只是没有时间),不报错。
|
|
122
|
+
'dir-mtimes',
|
|
101
123
|
'device-subprotocol-auth', 'persistent-pair-code', 'account-state', 'account-state-push',
|
|
102
124
|
]
|
|
103
125
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `_link/sessionWindow` 的可选压缩(能力 `history-compression`)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么在应用层压:中转的 WebSocket 关闭了 permessage-deflate(`compress=False`),
|
|
5
|
+
* iOS 的 `URLSessionWebSocketTask` 也不支持协商压缩。要在不动中转的前提下减少
|
|
6
|
+
* 公网字节,只能在连接器把 value 压成 DEFLATE、手机侧自己解(与 spec 021 的
|
|
7
|
+
* `_link/sessionDigest` 同一套信封与同一种编码)。
|
|
8
|
+
*
|
|
9
|
+
* 编码选 `deflate-raw`(RFC1951)而不是 gzip:Apple `Compression` 框架的
|
|
10
|
+
* `COMPRESSION_ZLIB` 直接解原始 DEFLATE,gzip 包装还得引 libz。
|
|
11
|
+
*
|
|
12
|
+
* 兼容:不传 `encoding` 或传了不认识的值时原样返回 JSON,老 App 行为不变;
|
|
13
|
+
* 新 App 只在 `_link/hello` 声明了 `history-compression` 时才请求压缩。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { deflateRawSync } from 'node:zlib'
|
|
17
|
+
|
|
18
|
+
/// 与 `session-digest.js` 的 `DEFLATE_ENCODING` 同一词表(协议里只能有一个名字)。
|
|
19
|
+
export const HISTORY_COMPRESSION_ENCODING = 'deflate-raw'
|
|
20
|
+
|
|
21
|
+
/// 能力名:连接器在 `_link/hello` 里声明,手机据此决定要不要请求压缩。
|
|
22
|
+
export const HISTORY_COMPRESSION_CAPABILITY = 'history-compression'
|
|
23
|
+
|
|
24
|
+
/// 把一页的值包成 `{encoding, value(base64), rawBytes}` 信封。
|
|
25
|
+
///
|
|
26
|
+
/// `rawBytes` 是解压后的字节数:`compression_decode_buffer` 必须先知道输出多大,
|
|
27
|
+
/// 让连接器一次给足,手机就不用"猜容量、失败再翻倍"。
|
|
28
|
+
export function compressHistoryValue(value) {
|
|
29
|
+
const raw = Buffer.from(JSON.stringify(value ?? null), 'utf8')
|
|
30
|
+
return {
|
|
31
|
+
encoding: HISTORY_COMPRESSION_ENCODING,
|
|
32
|
+
value: deflateRawSync(raw).toString('base64'),
|
|
33
|
+
rawBytes: raw.length,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// 按调用方要求决定压不压。只有明确要 `deflate-raw` 才压。
|
|
38
|
+
export function maybeCompressHistoryValue(value, encoding) {
|
|
39
|
+
return encoding === HISTORY_COMPRESSION_ENCODING ? compressHistoryValue(value) : value
|
|
40
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 已知为空的推导:一次窗口回包里,哪些号确认没有内容。
|
|
3
|
+
*
|
|
4
|
+
* 只在"从最老一条返回到窗口上界"之间下结论:窗口下界到最老返回之间可能被
|
|
5
|
+
* 字节/条数截断,那一段没有被完整扫描,不能判空。上界取 `before - 1`。
|
|
6
|
+
*/
|
|
7
|
+
export function emptySpans(records, lower, upper) {
|
|
8
|
+
const seqs = (records ?? [])
|
|
9
|
+
.map((record) => record?.event?.seq)
|
|
10
|
+
.filter((seq) => Number.isInteger(seq) && seq >= lower && seq <= upper)
|
|
11
|
+
.sort((a, b) => a - b)
|
|
12
|
+
if (seqs.length === 0) return []
|
|
13
|
+
const spans = []
|
|
14
|
+
let cursor = seqs[0]
|
|
15
|
+
for (const seq of seqs) {
|
|
16
|
+
if (seq > cursor) spans.push([cursor, seq - 1])
|
|
17
|
+
cursor = seq + 1
|
|
18
|
+
}
|
|
19
|
+
if (upper >= cursor) spans.push([cursor, upper])
|
|
20
|
+
return spans
|
|
21
|
+
}
|