dsh-winrm 0.3.1 → 0.4.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.en.md +130 -0
- package/README.md +16 -5
- package/docs/screenshots/winrm-panel.png +0 -0
- package/lib/client.js +598 -74
- package/lib/index.js +827 -1
- package/lib/types/client/locales.d.ts +44 -0
- package/lib/types/client/tool/card-model.d.ts +71 -0
- package/lib/types/client/tool/cards.d.ts +9 -0
- package/lib/types/diff.d.ts +62 -0
- package/lib/types/engine.d.ts +51 -1
- package/lib/types/index.d.ts +4 -3
- package/lib/types/powershell.d.ts +64 -0
- package/lib/types/protocol.d.ts +54 -0
- package/lib/types/tools.d.ts +7 -1
- package/package.json +8 -4
- package/scripts/verify-live.mts +116 -0
- package/src/client/index.ts +17 -0
- package/src/client/locales.ts +44 -0
- package/src/client/tool/card-model.ts +151 -0
- package/src/client/tool/cards.module.css +128 -0
- package/src/client/tool/cards.tsx +335 -0
- package/src/diff.ts +128 -0
- package/src/engine.ts +213 -3
- package/src/index.ts +10 -3
- package/src/powershell.ts +253 -0
- package/src/protocol.ts +59 -0
- package/src/tools.ts +300 -1
- package/README.zh.md +0 -126
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript cards for `winrm_read`, `winrm_write` and `winrm_edit`.
|
|
3
|
+
*
|
|
4
|
+
* Without these the three tools render as the generic tool row, so the user
|
|
5
|
+
* reads the same envelope text the model reads. Every call becomes a collapsed
|
|
6
|
+
* disclosure with a one-line header — file-type icon, verb, remote path — that
|
|
7
|
+
* expands into a card. The header is drawn from the first dispatch, so settling
|
|
8
|
+
* only fills in the trailing figure instead of replacing the row: the line the
|
|
9
|
+
* user watched while it ran is the line they click to open the result.
|
|
10
|
+
*
|
|
11
|
+
* Collapsed by default is the harness's own convention for these rows: a long
|
|
12
|
+
* file would otherwise fill the transcript before the user asked for it.
|
|
13
|
+
*
|
|
14
|
+
* The header is `DisclosureRow` and the body is `ReadBlock` / `DiffBlock`, all
|
|
15
|
+
* from the harness's published `ui-primitives`, so a remote file looks and
|
|
16
|
+
* behaves exactly like a local one. Open state comes from the `useDisclosure`
|
|
17
|
+
* hook the Tool layer hands every toolview, which also resets it when the
|
|
18
|
+
* enclosing turn re-renders.
|
|
19
|
+
* @module dsh-winrm/src/client/tool/cards
|
|
20
|
+
*/
|
|
21
|
+
import type { ReactNode } from 'react'
|
|
22
|
+
import {
|
|
23
|
+
DiffBlock, DisclosureRow, FileTypeIcon, ReadBlock,
|
|
24
|
+
type DiffBlockLabels, type DiffHunk, type ReadBlockLabels,
|
|
25
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
26
|
+
import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
|
|
27
|
+
import { tt } from '../panel/helpers.ts'
|
|
28
|
+
import { diffCard, readCard, type DiffCard as DiffCardModel, type ReadCard as ReadCardModel } from './card-model.ts'
|
|
29
|
+
import css from './cards.module.css'
|
|
30
|
+
|
|
31
|
+
/** The Tool view props these cards read. */
|
|
32
|
+
export type WinrmFileCardProps = ToolCallViewProps
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Content lines a transcript card shows before the primitive collapses the
|
|
36
|
+
* middle. Half the primitive's own default (16), because a chat row summarizes
|
|
37
|
+
* inside a message flow while a dedicated panel is the reading surface. Mirrors
|
|
38
|
+
* the harness's `CHAT_READ_MAX_LINES` / `CHAT_DIFF_MAX_LINES`.
|
|
39
|
+
*/
|
|
40
|
+
const CHAT_READ_MAX_LINES = 8
|
|
41
|
+
const CHAT_DIFF_MAX_LINES = 9
|
|
42
|
+
|
|
43
|
+
/** Grammar hint from a remote path's extension; unknown extensions stay plain. */
|
|
44
|
+
const LANG_BY_EXTENSION: Readonly<Record<string, string>> = {
|
|
45
|
+
bat: 'bat', cmd: 'bat', ps1: 'powershell', psd1: 'powershell', psm1: 'powershell',
|
|
46
|
+
c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp', cs: 'csharp',
|
|
47
|
+
css: 'css', scss: 'scss', less: 'less',
|
|
48
|
+
go: 'go', rs: 'rust', java: 'java', kt: 'kotlin', swift: 'swift',
|
|
49
|
+
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
|
|
50
|
+
ts: 'typescript', mts: 'typescript', cts: 'typescript', tsx: 'typescript',
|
|
51
|
+
json: 'json', jsonc: 'jsonc', yml: 'yaml', yaml: 'yaml', toml: 'toml', ini: 'ini',
|
|
52
|
+
md: 'markdown', markdown: 'markdown', rst: 'rst',
|
|
53
|
+
py: 'python', rb: 'ruby', php: 'php', pl: 'perl', lua: 'lua',
|
|
54
|
+
sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash',
|
|
55
|
+
sql: 'sql', xml: 'xml', html: 'html', htm: 'html', vue: 'vue', svelte: 'svelte',
|
|
56
|
+
diff: 'diff', patch: 'diff', csv: 'csv', tsv: 'tsv',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Guess the grammar hint from a path's extension.
|
|
61
|
+
* @param path - the remote path as authored.
|
|
62
|
+
* @returns the language id, or undefined to keep the block plain.
|
|
63
|
+
*/
|
|
64
|
+
function langOf(path: string): string | undefined {
|
|
65
|
+
const dot = path.lastIndexOf('.')
|
|
66
|
+
if (dot < 0) return undefined
|
|
67
|
+
const ext = path.slice(dot + 1).toLowerCase()
|
|
68
|
+
return Object.hasOwn(LANG_BY_EXTENSION, ext) ? LANG_BY_EXTENSION[ext] : undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Localized chrome for {@link ReadBlock}. */
|
|
72
|
+
function readLabels(): ReadBlockLabels {
|
|
73
|
+
return {
|
|
74
|
+
codeLabel: tt('tool.codeLabel'),
|
|
75
|
+
wrapLabel: tt('tool.wrapLabel'),
|
|
76
|
+
unwrapLabel: tt('tool.unwrapLabel'),
|
|
77
|
+
window: (shown, total) => tt('tool.window', { shown, total }),
|
|
78
|
+
copy: tt('tool.copy'),
|
|
79
|
+
copied: tt('tool.copied'),
|
|
80
|
+
collapseAria: tt('tool.collapseAria'),
|
|
81
|
+
expandAria: hidden => tt('tool.expandAria', { hidden }),
|
|
82
|
+
collapse: tt('tool.collapse'),
|
|
83
|
+
expand: hidden => tt('tool.expand', { hidden }),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Localized chrome for {@link DiffBlock}. */
|
|
88
|
+
function diffLabels(): DiffBlockLabels {
|
|
89
|
+
return {
|
|
90
|
+
codeLabel: tt('tool.codeLabel'),
|
|
91
|
+
wrapLabel: tt('tool.wrapLabel'),
|
|
92
|
+
unwrapLabel: tt('tool.unwrapLabel'),
|
|
93
|
+
copy: tt('tool.copy'),
|
|
94
|
+
copied: tt('tool.copied'),
|
|
95
|
+
collapseAria: tt('tool.collapseAria'),
|
|
96
|
+
expandAria: hidden => tt('tool.expandAria', { hidden }),
|
|
97
|
+
collapse: tt('tool.collapse'),
|
|
98
|
+
expand: hidden => tt('tool.expand', { hidden }),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One-line row for a call that failed: the message replaces the card. */
|
|
103
|
+
function Row(props: { title: string; detail: string }) {
|
|
104
|
+
return (
|
|
105
|
+
<div className={css.rowError}>
|
|
106
|
+
<span className={css.title}>{props.title}</span>
|
|
107
|
+
{props.detail !== '' && <span className={css.detail}>{props.detail}</span>}
|
|
108
|
+
</div>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Read the persisted metadata off a settled result node. */
|
|
113
|
+
function metaOf(block: unknown): unknown {
|
|
114
|
+
if (typeof block !== 'object' || block === null) return undefined
|
|
115
|
+
return (block as { meta?: unknown }).meta
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The remote path a call named, read from its arguments.
|
|
120
|
+
*
|
|
121
|
+
* Available from the first dispatch onward, which is what keeps the header line
|
|
122
|
+
* stable across the running → settled transition: the row keeps saying which
|
|
123
|
+
* file it is working on instead of swapping its text when the result lands. A
|
|
124
|
+
* settled node carries the call head under `call`; a running one is the head.
|
|
125
|
+
* @param block - any phase of the Tool block.
|
|
126
|
+
* @returns the authored path, or undefined when none was named yet.
|
|
127
|
+
*/
|
|
128
|
+
function argsPath(block: unknown): string | undefined {
|
|
129
|
+
if (typeof block !== 'object' || block === null) return undefined
|
|
130
|
+
const record = block as { argsRaw?: unknown; call?: { argsRaw?: unknown } | null }
|
|
131
|
+
const head = record.call ?? record
|
|
132
|
+
if (typeof head.argsRaw !== 'string' || head.argsRaw === '') return undefined
|
|
133
|
+
let parsed: unknown
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(head.argsRaw)
|
|
136
|
+
} catch {
|
|
137
|
+
return undefined
|
|
138
|
+
}
|
|
139
|
+
if (typeof parsed !== 'object' || parsed === null) return undefined
|
|
140
|
+
const path = (parsed as { remotePath?: unknown }).remotePath
|
|
141
|
+
return typeof path === 'string' && path !== '' ? path : undefined
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Count the lines a hunk adds and removes. */
|
|
145
|
+
function changedLines(card: DiffCardModel): { added: number; removed: number } {
|
|
146
|
+
let added = 0
|
|
147
|
+
let removed = 0
|
|
148
|
+
for (const hunk of card.hunks) {
|
|
149
|
+
added += hunk.newText === '' ? 0 : hunk.newText.split('\n').length
|
|
150
|
+
removed += hunk.oldText === '' ? 0 : hunk.oldText.split('\n').length
|
|
151
|
+
}
|
|
152
|
+
return { added, removed }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The `+n -m` figure a collapsed write or edit row shows beside its path. */
|
|
156
|
+
function changedTotals(card: DiffCardModel): { added: number; removed: number } | undefined {
|
|
157
|
+
const totals = changedLines(card)
|
|
158
|
+
return totals.added === 0 && totals.removed === 0 ? undefined : totals
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The summary line a write or edit card shows under its diff. */
|
|
162
|
+
function diffSummary(card: DiffCardModel): string {
|
|
163
|
+
const parts: string[] = []
|
|
164
|
+
if (card.tool === 'write') {
|
|
165
|
+
parts.push(tt(card.operation === 'create' ? 'tool.created' : 'tool.updated', { bytes: card.bytes }))
|
|
166
|
+
} else {
|
|
167
|
+
parts.push(tt('tool.replacements', { count: card.replacements }))
|
|
168
|
+
}
|
|
169
|
+
if (card.encoding !== 'utf8') parts.push(card.encoding)
|
|
170
|
+
if (card.beyondBasis) parts.push(tt('tool.beyondBasis'))
|
|
171
|
+
return parts.join(' · ')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* One collapsed file row that expands into a card.
|
|
176
|
+
* @param props - the disclosure state, the header material, and the card body.
|
|
177
|
+
* @returns the disclosure row, body included only while expanded.
|
|
178
|
+
*/
|
|
179
|
+
function FileDisclosure(props: {
|
|
180
|
+
path: string
|
|
181
|
+
title: string
|
|
182
|
+
meta?: string | undefined
|
|
183
|
+
diff?: { added: number; removed: number } | undefined
|
|
184
|
+
open: boolean
|
|
185
|
+
expandable: boolean
|
|
186
|
+
running?: boolean
|
|
187
|
+
onToggle: () => void
|
|
188
|
+
children: ReactNode
|
|
189
|
+
}) {
|
|
190
|
+
const meta = props.meta ?? ''
|
|
191
|
+
const hasPath = props.path !== ''
|
|
192
|
+
return (
|
|
193
|
+
<div className={css.wrap}>
|
|
194
|
+
<DisclosureRow
|
|
195
|
+
icon={<FileTypeIcon path={props.path} size={14} />}
|
|
196
|
+
title={props.title}
|
|
197
|
+
open={props.open}
|
|
198
|
+
expandable={props.expandable}
|
|
199
|
+
running={props.running ?? false}
|
|
200
|
+
expandOnRowClick={props.expandable}
|
|
201
|
+
onToggle={props.onToggle}
|
|
202
|
+
collapsedContent={hasPath || meta !== '' ? (
|
|
203
|
+
<>
|
|
204
|
+
{hasPath && <span className={css.sep} aria-hidden />}
|
|
205
|
+
{hasPath && <span className={css.summary}>{props.path}</span>}
|
|
206
|
+
{meta !== '' && <span className={css.meta}>{meta}</span>}
|
|
207
|
+
{props.diff !== undefined && (
|
|
208
|
+
<span className={css.diffStat}>
|
|
209
|
+
<span className={css.added}>{`+${props.diff.added}`}</span>{' '}
|
|
210
|
+
<span className={css.removed}>{`-${props.diff.removed}`}</span>
|
|
211
|
+
</span>
|
|
212
|
+
)}
|
|
213
|
+
</>
|
|
214
|
+
) : undefined}
|
|
215
|
+
>
|
|
216
|
+
{props.children}
|
|
217
|
+
</DisclosureRow>
|
|
218
|
+
</div>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Render the settled read card. */
|
|
223
|
+
function ReadView(props: { card: ReadCardModel; open: boolean; onToggle: () => void }) {
|
|
224
|
+
const { card, open, onToggle } = props
|
|
225
|
+
const notes: string[] = []
|
|
226
|
+
if (card.truncatedByBytes) notes.push(tt('tool.truncatedByBytes'))
|
|
227
|
+
if (card.truncatedLines > 0) notes.push(tt('tool.truncatedLines', { count: card.truncatedLines }))
|
|
228
|
+
if (card.encoding !== 'utf8') notes.push(card.encoding)
|
|
229
|
+
return (
|
|
230
|
+
<FileDisclosure
|
|
231
|
+
path={card.path}
|
|
232
|
+
title={tt('tool.read')}
|
|
233
|
+
meta={tt('tool.lines', { count: card.totalLines })}
|
|
234
|
+
open={open}
|
|
235
|
+
expandable
|
|
236
|
+
onToggle={onToggle}
|
|
237
|
+
>
|
|
238
|
+
<ReadBlock
|
|
239
|
+
label={card.path}
|
|
240
|
+
lines={card.lines}
|
|
241
|
+
totalLines={card.totalLines}
|
|
242
|
+
lang={langOf(card.path)}
|
|
243
|
+
labels={readLabels()}
|
|
244
|
+
maxLines={CHAT_READ_MAX_LINES}
|
|
245
|
+
/>
|
|
246
|
+
{notes.length > 0 && <div className={css.note}>{notes.join(' · ')}</div>}
|
|
247
|
+
</FileDisclosure>
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Render the settled write or edit card. */
|
|
252
|
+
function DiffView(props: { card: DiffCardModel; open: boolean; onToggle: () => void }) {
|
|
253
|
+
const { card, open, onToggle } = props
|
|
254
|
+
// The primitive takes `oldText: null` to mean "no prior content", which is a
|
|
255
|
+
// create; the host writes '' for that case so the two stay JSON-friendly.
|
|
256
|
+
const diffs: DiffHunk[] = card.hunks.map(hunk => ({
|
|
257
|
+
path: card.path,
|
|
258
|
+
oldText: hunk.oldText === '' ? null : hunk.oldText,
|
|
259
|
+
newText: hunk.newText,
|
|
260
|
+
}))
|
|
261
|
+
return (
|
|
262
|
+
<FileDisclosure
|
|
263
|
+
path={card.path}
|
|
264
|
+
title={tt(card.tool === 'write' ? 'tool.write' : 'tool.edit')}
|
|
265
|
+
diff={changedTotals(card)}
|
|
266
|
+
open={open}
|
|
267
|
+
expandable={diffs.length > 0}
|
|
268
|
+
onToggle={onToggle}
|
|
269
|
+
>
|
|
270
|
+
{diffs.length > 0 && (
|
|
271
|
+
<DiffBlock diffs={diffs} labels={diffLabels()} maxLines={CHAT_DIFF_MAX_LINES} />
|
|
272
|
+
)}
|
|
273
|
+
<div className={css.note}>{diffSummary(card)}</div>
|
|
274
|
+
</FileDisclosure>
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Shared driver: one card per stage, with the model built from the metadata. */
|
|
279
|
+
function FileCardView(props: WinrmFileCardProps & { tool: 'read' | 'write' | 'edit' }) {
|
|
280
|
+
const { phase, block, tool, useDisclosure } = props
|
|
281
|
+
const disclosure = useDisclosure()
|
|
282
|
+
const title = tt(`tool.${tool}`)
|
|
283
|
+
const path = argsPath(block) ?? ''
|
|
284
|
+
if (phase !== 'result') {
|
|
285
|
+
return (
|
|
286
|
+
<FileDisclosure
|
|
287
|
+
path={path}
|
|
288
|
+
title={title}
|
|
289
|
+
meta={path === '' ? tt('tool.running') : undefined}
|
|
290
|
+
open={false}
|
|
291
|
+
expandable={false}
|
|
292
|
+
running
|
|
293
|
+
onToggle={() => {}}
|
|
294
|
+
>
|
|
295
|
+
{null}
|
|
296
|
+
</FileDisclosure>
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
const meta = metaOf(block)
|
|
300
|
+
const card = tool === 'read' ? readCard(meta, path) : diffCard(meta, path, tool)
|
|
301
|
+
if (card === undefined) {
|
|
302
|
+
return (
|
|
303
|
+
<FileDisclosure
|
|
304
|
+
path={path}
|
|
305
|
+
title={title}
|
|
306
|
+
meta={tt('tool.noDetail')}
|
|
307
|
+
open={false}
|
|
308
|
+
expandable={false}
|
|
309
|
+
onToggle={() => {}}
|
|
310
|
+
>
|
|
311
|
+
{null}
|
|
312
|
+
</FileDisclosure>
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
if (card.kind === 'error') return <Row title={title} detail={card.message} />
|
|
316
|
+
if (card.kind === 'read') {
|
|
317
|
+
return <ReadView card={card} open={disclosure.expanded} onToggle={disclosure.toggle} />
|
|
318
|
+
}
|
|
319
|
+
return <DiffView card={card} open={disclosure.expanded} onToggle={disclosure.toggle} />
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Card for `winrm_read`. */
|
|
323
|
+
export function WinrmReadCard(props: WinrmFileCardProps) {
|
|
324
|
+
return <FileCardView {...props} tool="read" />
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Card for `winrm_write`. */
|
|
328
|
+
export function WinrmWriteCard(props: WinrmFileCardProps) {
|
|
329
|
+
return <FileCardView {...props} tool="write" />
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Card for `winrm_edit`. */
|
|
333
|
+
export function WinrmEditCard(props: WinrmFileCardProps) {
|
|
334
|
+
return <FileCardView {...props} tool="edit" />
|
|
335
|
+
}
|
package/src/diff.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contextual diff derivation for the write/edit presentation metadata.
|
|
3
|
+
*
|
|
4
|
+
* The GUI renders a diff card from `tool/result` metadata rather than from the
|
|
5
|
+
* model-facing text, so the hunks are projected here, at result time, and
|
|
6
|
+
* persisted with the session log. This mirrors the harness's own file tools:
|
|
7
|
+
* one entry per applied change with a few surrounding context lines.
|
|
8
|
+
*
|
|
9
|
+
* The alignment is deliberately cheap rather than a full LCS: common leading
|
|
10
|
+
* and trailing lines are trimmed, and when the remaining halves have the same
|
|
11
|
+
* length each differing run becomes its own hunk. That covers the cases these
|
|
12
|
+
* two tools actually produce — an exact-string edit, and a rewrite that keeps
|
|
13
|
+
* most lines — without a diff dependency in a plugin whose point is a small
|
|
14
|
+
* footprint. A rewrite that changes the line count collapses to one hunk over
|
|
15
|
+
* the changed span, which is still a truthful summary.
|
|
16
|
+
* @module dsh-winrm/src/diff
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Context lines shown on each side of an applied hunk. */
|
|
20
|
+
export const DIFF_CONTEXT = 3
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Largest file whose text may serve as a diff basis. Above this the card shows
|
|
24
|
+
* the path and operation without hunks: fetching and projecting a bigger basis
|
|
25
|
+
* would cost another transfer and a large metadata payload for a summary the
|
|
26
|
+
* user reads at a glance.
|
|
27
|
+
*/
|
|
28
|
+
export const DIFF_BASIS_MAX_BYTES = 256 * 1024
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Largest serialized hunk list kept on one result. A pathological edit (a
|
|
32
|
+
* whole-file rewrite of a large file) can produce thousands of hunks; past
|
|
33
|
+
* this bound the card degrades to path-only instead of bloating the log.
|
|
34
|
+
*/
|
|
35
|
+
export const DIFF_META_MAX_BYTES = 512 * 1024
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One applied hunk: the fragment before the change and after it.
|
|
39
|
+
*
|
|
40
|
+
* Declared as a type alias rather than an interface so it satisfies the
|
|
41
|
+
* `JsonValue` index signature the tool-output metadata is typed against.
|
|
42
|
+
*/
|
|
43
|
+
export type RemoteFileDiff = {
|
|
44
|
+
/** Remote path the hunk belongs to (the authored argument). */
|
|
45
|
+
path: string
|
|
46
|
+
/** Prior content including context lines; empty for a pure insertion. */
|
|
47
|
+
oldText: string
|
|
48
|
+
/** Content after the change, including the shared context lines. */
|
|
49
|
+
newText: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Split text into lines, treating every terminator as a separator. */
|
|
53
|
+
function toLines(text: string): string[] {
|
|
54
|
+
return text === '' ? [] : text.split(/\r?\n/)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Compute the hunks between two texts.
|
|
59
|
+
* @param path - remote path stamped on every hunk.
|
|
60
|
+
* @param before - file text before the change.
|
|
61
|
+
* @param after - file text after the change.
|
|
62
|
+
* @returns one hunk per contiguous change in file order; empty when equal.
|
|
63
|
+
*/
|
|
64
|
+
export function computeHunkDiffs(path: string, before: string, after: string): RemoteFileDiff[] {
|
|
65
|
+
if (before === after) return []
|
|
66
|
+
const oldLines = toLines(before)
|
|
67
|
+
const newLines = toLines(after)
|
|
68
|
+
|
|
69
|
+
let head = 0
|
|
70
|
+
while (head < oldLines.length && head < newLines.length && oldLines[head] === newLines[head]) head += 1
|
|
71
|
+
let tail = 0
|
|
72
|
+
while (
|
|
73
|
+
tail < oldLines.length - head
|
|
74
|
+
&& tail < newLines.length - head
|
|
75
|
+
&& oldLines[oldLines.length - 1 - tail] === newLines[newLines.length - 1 - tail]
|
|
76
|
+
) tail += 1
|
|
77
|
+
|
|
78
|
+
const oldMid = oldLines.slice(head, oldLines.length - tail)
|
|
79
|
+
const newMid = newLines.slice(head, newLines.length - tail)
|
|
80
|
+
|
|
81
|
+
/** Build one hunk around the changed range `[from, to)` in the middle. */
|
|
82
|
+
const emit = (from: number, to: number): RemoteFileDiff => {
|
|
83
|
+
// Both mid arrays have the same length in the aligned branch below, so one
|
|
84
|
+
// clamped window addresses the old and the new side alike.
|
|
85
|
+
const start = Math.max(0, from - DIFF_CONTEXT)
|
|
86
|
+
const stop = Math.min(oldMid.length, to + DIFF_CONTEXT)
|
|
87
|
+
return {
|
|
88
|
+
path,
|
|
89
|
+
oldText: oldLines.slice(head + start, head + stop).join('\n'),
|
|
90
|
+
newText: newLines.slice(head + start, head + stop).join('\n'),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (oldMid.length !== newMid.length) {
|
|
95
|
+
// Unaligned rewrite: one hunk over the whole changed span.
|
|
96
|
+
return [{
|
|
97
|
+
path,
|
|
98
|
+
oldText: oldLines.slice(Math.max(0, head - DIFF_CONTEXT), oldLines.length - Math.max(0, tail - DIFF_CONTEXT)).join('\n'),
|
|
99
|
+
newText: newLines.slice(Math.max(0, head - DIFF_CONTEXT), newLines.length - Math.max(0, tail - DIFF_CONTEXT)).join('\n'),
|
|
100
|
+
}]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const diffs: RemoteFileDiff[] = []
|
|
104
|
+
let index = 0
|
|
105
|
+
while (index < oldMid.length) {
|
|
106
|
+
if (oldMid[index] === newMid[index]) {
|
|
107
|
+
index += 1
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
const from = index
|
|
111
|
+
while (index < oldMid.length && oldMid[index] !== newMid[index]) index += 1
|
|
112
|
+
diffs.push(emit(from, index))
|
|
113
|
+
}
|
|
114
|
+
return diffs
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Compute hunks and drop them when the serialized payload would exceed the cap.
|
|
119
|
+
* @param path - remote path stamped on every hunk.
|
|
120
|
+
* @param before - prior text.
|
|
121
|
+
* @param after - text after the change.
|
|
122
|
+
* @returns the kept hunks; empty when nothing changed or the payload is too big.
|
|
123
|
+
*/
|
|
124
|
+
export function boundedHunkDiffs(path: string, before: string, after: string): RemoteFileDiff[] {
|
|
125
|
+
const diffs = computeHunkDiffs(path, before, after)
|
|
126
|
+
if (diffs.length === 0) return diffs
|
|
127
|
+
return JSON.stringify(diffs).length > DIFF_META_MAX_BYTES ? [] : diffs
|
|
128
|
+
}
|