mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
* feat(native-chat): render agent file edits as inline diff cards An agent's file edit rendered as a flat list of every removed line followed by every added line, with no interleaving, no file header, and no line numbers. A Codex edit on the transcript lane rendered no diff at all: the patch arrives wrapped in the source string of its `exec` tool, which matched none of the shapes the old parser looked for. Adds one diff model shared by every edit shape the supported agents produce: - `native-chat-edit-lcs` interleaves a snippet pair, falling back to a linear prefix/suffix diff above the quadratic guard. - `native-chat-unified-patch` keeps the `@@` ranges as per-row line numbers instead of parsing them into display text and discarding them. - `native-chat-begin-patch` recovers the `*** Begin Patch` envelope from the JavaScript string literal Codex sends it in, so that lane renders a diff. - `native-chat-edit-normalize` folds all of it into one model, including the two Codex shapes that do not look like diffs: add and delete arrive as raw file content, and a rename is appended to the body as prose. Claude reports an edit as a snippet pair, which cannot locate the change in the file, so its result's resolved hunks are now carried on the tool-result block and preferred when present. The field is optional, so an older client reading a newer journal simply drops it. Where no resolved ranges exist the gutter stays blank rather than showing a snippet-relative number, which would read as a file position. The card renders the verb from the observed change kind rather than the tool name, pairs an edit's call and result into a single row, and takes its row and gutter grounds from new tokens derived from the git status palette, replacing the hardcoded Tailwind tints the old view used. Desktop only; mobile chat keeps its existing renderer and parser untouched. * fix(native-chat): stop the diff card from asserting an edit it cannot prove Every defect here shares one failure mode: the card stated something the input did not support, and stated it confidently. Parsing: - A hunk no longer ends on `--- `, `+++ ` or `\ No newline`. The first two are what a removed `-- comment` (SQL/Lua/Haskell) looks like once the marker is prepended, so they truncated the whole diff; the no-newline marker is emitted mid-hunk, between the removed old last line and the added new one. Real headers are recognised through `isFileHeaderPair`, lifted out of `native-chat-diff` so the rule has one home. - A `*** Begin Patch` envelope with no `*** End Patch` is declined. With no closing marker `indexOf` returned -1 and the slice swallowed the rest of the command line, so `… +y" && echo ok` rendered as file content the agent never wrote. - One splitter serves every shape, so a CRLF patch no longer keeps a `\r` on each row, in the phantom-row guard, or in the clipboard. It also tests for the trailing newline on the clipped body: on the un-clipped string that test deleted a real line whenever the slice fired. - Truncation is carried from each slice site to the card, so content past the character cap can no longer render as a complete unchanged file with no "Diff truncated" footer. Attribution: - A failed or still-running edit renders no card. It kept the generic tool view, whose result block carries the provider's own error — the card had been drawing "Edited file +1 −1" from the input while hiding the red error body, which is worse than what preceded this feature. - The result-as-patch fallback is scoped to `Diff`, the one tool whose call carries only a path. Any command tool's output could previously be read as a patch, so `git diff` through `exec` was reclassified as an edit of a file named "file" and its command line disappeared with the result. - A whole-content write claims a creation only on evidence — the editor tool's own `create` command, or the provider reporting one. Overwriting a large existing file had always read as "Added file". - `MultiEdit` reads its `edits[]`, and `NotebookEdit` leaves the set: it carries only the new cell source. Both previously fell through to the old renderer, so one turn could show two diff presentations at once. - Snippet-relative numbers are dropped at the model layer rather than hidden by a zero-width gutter, which the flex min-width floor re-exposed on top of the marker and the first characters of the row. The run memoizes its edit model, so a collapsed group no longer re-diffs on every streaming token, and the card's copy button says what it copies. * fix(native-chat): keep every edited file, and mark where the diff breaks A run of hunks was concatenated into one flat row list, so the gutter jumped from one region of the file to a distant one with nothing between them and the reader saw two unrelated spans as one continuous block. Rows now carry an explicit break: it holds no text and no position, counts toward neither side of the change, is trimmed from the end where it would mark nothing, and is left out of the copied text. The patch envelope lost files, and lost them silently: - An update chunk may carry no hunk header at all. The parser required one, returned nothing, and the caller dropped that file from a multi-file envelope with nothing to say it had gone. A header-less body now opens as a hunk of unknown position, and whether the rows are locatable is read off the rows themselves rather than off the header. - The envelope's own control lines rendered as content rows in the card. - A delete names its file and carries no body, which rendered as a card with an empty expandable row list. The header states the change and offers no disclosure behind it. - The header patterns are anchored and `.` excludes a carriage return, so a CRLF envelope matched no header at all and produced no card whatsoever. The envelope is split on both newline forms once, up front, rather than each pattern having to tolerate the extra character. A tool call's argument payload arrives as a string holding JSON. It was passed along undecoded, which is the only reason this code carried a hand-rolled string-literal unescaper. It is decoded once at the transcript decoder now — defensively, since the transcript is untrusted, so anything that is not a JSON object is left exactly as it arrived — and the unescaper is gone. Recovering the envelope no longer guesses at argument names either: it looks at the values, including the words of an argument vector, which is where the envelope actually sits once the payload is decoded. * fix(native-chat): only read a patch where a patch was actually run Recovering the patch envelope from any value of a tool's payload meant a write's own content was searched for one. A file documenting the patch format rendered a card for the file its example names, while the file actually written never appeared at all — the call and its result were consumed by that card, so nothing was left to correct it. Two changes: the envelope is recovered only for the tools that run one, never for a file edit whose payload is content; and only patch- or command-bearing arguments are searched, still including the words of an argument vector, which is where the envelope sits when a command tool applies it. The call payload is decoded back where it is needed rather than at the transcript decoder. Decoding it there changed the shape every reader of a tool's input sees, including the surface that recognises a question payload from any tool by shape alone: a tool whose arguments happened to carry that shape raised a question card pinned over the composer. That decode now happens inside the envelope recovery, the one consumer that needs the structure. A card also states an edit as made, so it now takes evidence that it landed — the provider reporting the call complete, or a result that is not an error. A turn that stopped before its call was answered reported an edit that may never have applied. This replaces the working-turn heuristic in the view, so the rule lives in one place. Two files still went missing. A multi-file patch has no per-file split, so it rendered as one card under the first file's name, with the later files' rows and their gutter numbers beneath it — a card asserting a false file position. Patch text is now split on its file boundaries, one card per file, each named by its own header, with a rename and a `/dev/null` side read from the same headers. And an envelope section that names a file but carries no body was dropped rather than reported, which is the same silent loss the delete case was fixed for. * fix(native-chat): type the patch-section scan and its test helper call The section under construction was only ever assigned inside the helper that opens one, which control-flow analysis does not see, so the variable stayed narrowed to its initial null and reading a field off it did not compile. The helper now only builds and records a section; the loop owns the assignment, which also fixes a real leak in the fall-through row: it opened a section it never made current, so the next row opened another one. The multi-file case also passed a possibly-undefined slice to a helper that takes an array or null. * fix(native-chat): stop the patch lane naming files it cannot name Splitting a patch into its files only ever looked for a boundary outside a hunk, and nothing reopened that state once the first hunk began, so every file after the first was swallowed as the first one's body. A `--- `/`+++ ` pair inside a hunk is now a boundary too, but only when a hunk header follows it immediately: a removed `-- x` over an added `++ y` is never followed by a column-0 header, which is what keeps the guard against reading content as structure intact. One producer cannot be recovered by any parser: it joins several files' patches and keeps a count where the path goes, so nothing in what reaches here names a file. That shape is refused rather than rendered under a name no file has. Recovering the per-file paths belongs to the producer and is filed separately. A clipped body carries its own marker in its text, and the bound that clips it is six times smaller than this module's, so it fires first. Read as content, the marker became a numbered line of the file and the rows before it were reported complete. It is recognised at the end of the text, removed, and reported as the truncation it is — the footer says so and the copied text no longer carries it. Also: a move appended to the body as prose is now read as a rename on every lane that carries the body as text, not just the one that also carries the destination as a field, where it had been rendering as a numbered line of the file it moved. The call's own path no longer wins over a rename's destination, which is only ever in the header, and only sections that name a file count toward deciding whether the call names the one file at hand. A command that merely quotes an envelope — writing documentation about the format — must now also invoke the tool that applies one. And two compared directories are no longer called a rename: only a header that states both sides as such is evidence of a move. * fix(native-chat): anchor the move marker to its own line The marker a producer appends to say where a file moved was matched anywhere on the body's last line, so a row whose own content mentions a move was cut in half at that point and the file it named claimed as the destination of a rename that never happened. It is now anchored to the start of the final line, on both lanes that carry the body as text. The command that applies a patch envelope has a second spelling the runner accepts and runs; requiring the first one refused a patch that really landed. Both are accepted, still matched against whole argument words rather than the payload at large. A clipped diff also said so only under its own rows, where a collapsed card — or one clipped down to no rows at all — showed nothing. It sits beside the change counts now, which are visible either way. * refactor(native-chat): tidy what the diff-card work left behind The copy text is joined from every row of the diff, which a collapsed card renders none of, and it was rebuilt on every render to seed a prop. It is memoized on the rows, matching how the run memoizes its edit model. The two scanners that read patch text kept the same file-section alternation verbatim, so they could drift apart while both looking correct; there is one definition now, beside the header-pair rule that already lives there. Also: the row that marks a break between regions is built in one place, so it is no longer exported; the move destination in the envelope reader was a function-wide binding written and read within one iteration, which read as if a move carried between sections; and a test comment named the wrong mechanism for keeping a card collapsed. Adds the missing pin on what the copy affordance actually copies. --------- Co-authored-by: Merge Sim <sim@local>
350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
import { editFilesFromBeginPatch, unwrapBeginPatch } from './native-chat-begin-patch'
|
|
import { editLinesFromContents } from './native-chat-edit-lcs'
|
|
import {
|
|
finalizeEditFile,
|
|
pushEditGap,
|
|
type NativeChatEditFile,
|
|
type NativeChatEditLine
|
|
} from './native-chat-edit-model'
|
|
import { stripBoundedTextMarker } from './structured-agent-session-projection'
|
|
import {
|
|
editLinesFromUnifiedPatch,
|
|
editLinesFromWholeFile,
|
|
unifiedPatchSections,
|
|
type UnifiedPatchSection
|
|
} from './native-chat-unified-patch'
|
|
import type { NativeChatEditPatch } from './native-chat-types'
|
|
|
|
// `NotebookEdit` is deliberately absent: its input carries only the new cell
|
|
// source, so a card would render an unchanged cell as wholly added. It falls
|
|
// through to the generic tool view instead.
|
|
const CLAUDE_EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'str_replace'])
|
|
/** Command tools, which run a patch as one of many things they can run, so a
|
|
* quoted envelope is not evidence that one was applied. */
|
|
const COMMAND_PATCH_TOOLS = new Set(['exec', 'shell', 'local_shell'])
|
|
/** Tools whose input may wrap a `*** Begin Patch` envelope. The dedicated patch
|
|
* tool applies whatever it is given; a command tool must say that it is. */
|
|
const PATCH_ENVELOPE_TOOLS = new Set(['apply_patch', ...COMMAND_PATCH_TOOLS])
|
|
/** A count standing in for a path, from a producer that joined several files'
|
|
* patches and kept no per-file path. */
|
|
const FILE_COUNT_PATH = /^\d+ files?$/
|
|
/** Tools whose whole payload is patch text. `Diff` reaches its patch only
|
|
* through the result, because the structured journal projects a diff item as a
|
|
* call carrying just the path. */
|
|
const PATCH_TEXT_TOOLS = new Set(['apply_patch', 'Diff'])
|
|
|
|
export function isEditToolName(name: string): boolean {
|
|
return CLAUDE_EDIT_TOOLS.has(name) || PATCH_ENVELOPE_TOOLS.has(name) || PATCH_TEXT_TOOLS.has(name)
|
|
}
|
|
|
|
function record(value: unknown): Record<string, unknown> | null {
|
|
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
|
|
}
|
|
|
|
function text(value: unknown): string | null {
|
|
return typeof value === 'string' ? value : null
|
|
}
|
|
|
|
/** Rows straight from resolved hunks, which is the only path with true numbers
|
|
* for a provider that reports its edits as a snippet pair. */
|
|
function linesFromEditPatch(patch: NativeChatEditPatch): NativeChatEditLine[] {
|
|
const lines: NativeChatEditLine[] = []
|
|
for (const hunk of patch.hunks) {
|
|
// Hunks are separate regions of the file; run together the gutter jumps
|
|
// from one to the next with nothing marking the skipped span.
|
|
pushEditGap(lines)
|
|
let oldNo = hunk.oldStart
|
|
let newNo = hunk.newStart
|
|
for (const raw of hunk.lines) {
|
|
if (raw.startsWith('+')) {
|
|
lines.push({ kind: 'add', text: raw.slice(1), oldLineNumber: null, newLineNumber: newNo })
|
|
newNo += 1
|
|
} else if (raw.startsWith('-')) {
|
|
lines.push({ kind: 'del', text: raw.slice(1), oldLineNumber: oldNo, newLineNumber: null })
|
|
oldNo += 1
|
|
} else {
|
|
lines.push({
|
|
kind: 'context',
|
|
text: raw.startsWith(' ') ? raw.slice(1) : raw,
|
|
oldLineNumber: oldNo,
|
|
newLineNumber: newNo
|
|
})
|
|
oldNo += 1
|
|
newNo += 1
|
|
}
|
|
}
|
|
}
|
|
return lines
|
|
}
|
|
|
|
/** A whole-content write looks identical whether it created the file or
|
|
* overwrote one, so only positive evidence may claim a creation. With no
|
|
* evidence either way this errs toward the weaker claim: calling a creation an
|
|
* edit is imprecise, while calling an overwrite a creation is false and paints
|
|
* an existing file as wholly new. */
|
|
const CREATED_FILE_RESULT = /^\s*File created successfully/
|
|
|
|
function wholeContentChangeKind(
|
|
input: Record<string, unknown>,
|
|
output: string | undefined
|
|
): 'added' | 'edited' {
|
|
if (text(input.command) === 'create') {
|
|
return 'added'
|
|
}
|
|
return output !== undefined && CREATED_FILE_RESULT.test(output) ? 'added' : 'edited'
|
|
}
|
|
|
|
/** `MultiEdit` carries its snippet pairs in `edits[]`, not at the top level. */
|
|
function multiEditFiles(input: Record<string, unknown>, path: string): NativeChatEditFile[] | null {
|
|
if (!Array.isArray(input.edits)) {
|
|
return null
|
|
}
|
|
const lines: NativeChatEditLine[] = []
|
|
let truncated = false
|
|
for (const entry of input.edits) {
|
|
const edit = record(entry)
|
|
const oldString = text(edit?.old_string) ?? text(edit?.oldString)
|
|
const newString = text(edit?.new_string) ?? text(edit?.newString)
|
|
if (oldString === null && newString === null) {
|
|
continue
|
|
}
|
|
// Each entry is its own snippet, so it starts a new region.
|
|
pushEditGap(lines)
|
|
const diffed = editLinesFromContents(oldString ?? '', newString ?? '')
|
|
lines.push(...diffed.lines)
|
|
truncated ||= diffed.truncated
|
|
}
|
|
if (lines.length === 0) {
|
|
return null
|
|
}
|
|
return [
|
|
finalizeEditFile({
|
|
path,
|
|
oldPath: null,
|
|
changeKind: 'edited',
|
|
lines,
|
|
// A snippet pair cannot say where in the file it sits.
|
|
lineNumbersKnown: false,
|
|
truncated
|
|
})
|
|
]
|
|
}
|
|
|
|
function claudeEditFiles(
|
|
name: string,
|
|
input: Record<string, unknown>,
|
|
output: string | undefined
|
|
): NativeChatEditFile[] | null {
|
|
const path = text(input.file_path) ?? text(input.path) ?? 'file'
|
|
if (name === 'MultiEdit') {
|
|
return multiEditFiles(input, path)
|
|
}
|
|
const oldString = text(input.old_string) ?? text(input.oldString)
|
|
const newString = text(input.new_string) ?? text(input.newString)
|
|
const content = text(input.content) ?? text(input.file_text)
|
|
if (oldString === null && content !== null) {
|
|
const whole = editLinesFromWholeFile(content, 'add')
|
|
return [
|
|
finalizeEditFile({
|
|
path,
|
|
oldPath: null,
|
|
changeKind: wholeContentChangeKind(input, output),
|
|
lines: whole.lines,
|
|
lineNumbersKnown: true,
|
|
truncated: whole.truncated
|
|
})
|
|
]
|
|
}
|
|
if (oldString === null && newString === null) {
|
|
return null
|
|
}
|
|
const diffed = editLinesFromContents(oldString ?? '', newString ?? content ?? '')
|
|
return [
|
|
finalizeEditFile({
|
|
path,
|
|
oldPath: null,
|
|
changeKind: 'edited',
|
|
lines: diffed.lines,
|
|
// A snippet pair cannot say where in the file it sits.
|
|
lineNumbersKnown: false,
|
|
truncated: diffed.truncated
|
|
})
|
|
]
|
|
}
|
|
|
|
/** A move is appended to the patch body as prose rather than a header field, on
|
|
* every lane that carries the body as text. Left in place it renders as a
|
|
* numbered line of the file it moved.
|
|
*
|
|
* Anchored to the start of the final line: unanchored, a row whose own content
|
|
* mentions a move was cut in half and the file it names claimed as a rename
|
|
* that never happened. */
|
|
const MOVE_MARKER = /(?:^|\n)Moved to: (.+)$/
|
|
|
|
function splitMoveMarker(patch: string): { body: string; movedTo: string | null } {
|
|
const match = MOVE_MARKER.exec(patch)
|
|
return match
|
|
? { body: patch.slice(0, match.index), movedTo: match[1]!.trim() }
|
|
: { body: patch, movedTo: null }
|
|
}
|
|
|
|
function codexChangeFiles(changes: unknown[]): NativeChatEditFile[] {
|
|
return changes.flatMap((entry) => {
|
|
const change = record(entry)
|
|
const path = text(change?.path)
|
|
const diff = text(change?.diff)
|
|
if (!change || !path || !diff) {
|
|
return []
|
|
}
|
|
const kind = record(change.kind)
|
|
const kindType = text(kind?.type) ?? text(change.kind) ?? 'update'
|
|
const movePath = text(kind?.move_path) ?? text(change.movePath)
|
|
if (kindType === 'add' || kindType === 'delete') {
|
|
// Add and delete arrive as raw file content, with no hunk header or signs.
|
|
const whole = editLinesFromWholeFile(diff, kindType === 'add' ? 'add' : 'del')
|
|
return [
|
|
finalizeEditFile({
|
|
path,
|
|
oldPath: null,
|
|
changeKind: kindType === 'add' ? 'added' : 'deleted',
|
|
lines: whole.lines,
|
|
lineNumbersKnown: true,
|
|
truncated: whole.truncated
|
|
})
|
|
]
|
|
}
|
|
const parsed = editLinesFromUnifiedPatch(splitMoveMarker(diff).body)
|
|
if (!parsed) {
|
|
return []
|
|
}
|
|
return [
|
|
finalizeEditFile({
|
|
path: movePath ?? path,
|
|
oldPath: movePath ? path : null,
|
|
changeKind: movePath ? 'renamed' : 'edited',
|
|
lines: parsed.lines,
|
|
lineNumbersKnown: parsed.lineNumbersKnown,
|
|
truncated: parsed.truncated
|
|
})
|
|
]
|
|
})
|
|
}
|
|
|
|
/** One diff model for a tool call and its result, across every shape the
|
|
* supported agents use to report a file edit. */
|
|
export function editFilesFromToolPair(pair: {
|
|
name: string
|
|
input: unknown
|
|
/** Provider lifecycle for the call, when the lane reports one. */
|
|
state?: 'running' | 'completed' | 'failed'
|
|
result?: { output?: string; isError?: boolean; editPatch?: NativeChatEditPatch }
|
|
}): NativeChatEditFile[] | null {
|
|
// A card states the edit as made, so it takes evidence that it landed: the
|
|
// provider reporting the call complete, or a result that is not an error.
|
|
// Anything else — failed, still running, or a turn that stopped before the
|
|
// call was answered — keeps the generic tool view and its error body.
|
|
if (pair.state === 'failed' || pair.state === 'running' || pair.result?.isError === true) {
|
|
return null
|
|
}
|
|
if (pair.state !== 'completed' && pair.result === undefined) {
|
|
return null
|
|
}
|
|
const input = record(pair.input)
|
|
const patch = pair.result?.editPatch
|
|
if (patch && patch.hunks.length > 0) {
|
|
return [
|
|
finalizeEditFile({
|
|
path: patch.filePath ?? text(input?.file_path) ?? 'file',
|
|
oldPath: null,
|
|
changeKind: 'edited',
|
|
lines: linesFromEditPatch(patch),
|
|
lineNumbersKnown: true
|
|
})
|
|
]
|
|
}
|
|
|
|
// Only a tool that runs a patch may be searched for an envelope: a file's own
|
|
// contents can quote one, and scanning a write's payload rendered a card for
|
|
// the quoted file while the file actually written never appeared.
|
|
if (PATCH_ENVELOPE_TOOLS.has(pair.name)) {
|
|
const envelope = unwrapBeginPatch(pair.input, {
|
|
requireApplyCommand: COMMAND_PATCH_TOOLS.has(pair.name)
|
|
})
|
|
const files = envelope ? editFilesFromBeginPatch(envelope) : []
|
|
if (files.length > 0) {
|
|
return files
|
|
}
|
|
}
|
|
|
|
if (input && Array.isArray(input.changes)) {
|
|
const files = codexChangeFiles(input.changes)
|
|
if (files.length > 0) {
|
|
return files
|
|
}
|
|
}
|
|
|
|
if (input && CLAUDE_EDIT_TOOLS.has(pair.name)) {
|
|
return claudeEditFiles(pair.name, input, pair.result?.output)
|
|
}
|
|
|
|
if (!PATCH_TEXT_TOOLS.has(pair.name)) {
|
|
return null
|
|
}
|
|
// The result fallback is scoped to `Diff`, whose call carries only a path.
|
|
// Reading any command tool's output as a patch reclassified `git diff` as a
|
|
// file edit and swallowed the command line with it.
|
|
const patchText =
|
|
text(input?.patch) ?? text(input?.diff) ?? (pair.name === 'Diff' ? pair.result?.output : null)
|
|
if (!patchText) {
|
|
return null
|
|
}
|
|
// The body carries its own marker when the journal clipped it. Read as
|
|
// content it becomes a numbered line of the file, and the rows that follow
|
|
// are reported complete.
|
|
const bounded = stripBoundedTextMarker(patchText)
|
|
const moved = splitMoveMarker(bounded.text)
|
|
// One card per file the patch touches: run together, the later files' rows
|
|
// and gutter numbers sit under the first file's name.
|
|
const split = unifiedPatchSections(moved.body)
|
|
const callerPath = text(input?.path) ?? text(input?.file_path)
|
|
if (callerPath !== null && FILE_COUNT_PATH.test(callerPath)) {
|
|
// The producer joined several files' patches and kept a count in place of a
|
|
// path, so nothing here can name a file. Naming the card after the count
|
|
// would assert a file that does not exist.
|
|
return null
|
|
}
|
|
// A patch that names one file is the file the call is reporting on, so the
|
|
// call's own path wins — it is the provider's, where the header's is relative
|
|
// to the patch. A patch naming several has no one path, and a rename's
|
|
// destination is only ever in the header. Sections that name nothing are
|
|
// preamble and must not change that count.
|
|
const namedSections = split.sections.filter((section) => section.path !== null).length
|
|
const named = (section: UnifiedPatchSection): string =>
|
|
(namedSections <= 1 && section.oldPath === null
|
|
? (callerPath ?? section.path)
|
|
: (section.path ?? callerPath)) ?? 'file'
|
|
const files = split.sections.flatMap((section) => {
|
|
const parsed = editLinesFromUnifiedPatch(section.body)
|
|
if (!parsed && section.path === null) {
|
|
return []
|
|
}
|
|
return [
|
|
finalizeEditFile({
|
|
path: named(section),
|
|
oldPath: section.oldPath,
|
|
changeKind: section.changeKind,
|
|
lines: parsed?.lines ?? [],
|
|
lineNumbersKnown: parsed?.lineNumbersKnown ?? false,
|
|
truncated: bounded.truncated || split.truncated || (parsed?.truncated ?? false)
|
|
})
|
|
]
|
|
})
|
|
// The move marker names where the whole patch moved, so it can only speak for
|
|
// a patch describing one file.
|
|
if (moved.movedTo !== null && files.length === 1 && files[0]) {
|
|
const only = files[0]
|
|
return [{ ...only, path: moved.movedTo, oldPath: only.path, changeKind: 'renamed' }]
|
|
}
|
|
return files.length > 0 ? files : null
|
|
}
|