79 lines
2.8 KiB
JavaScript
79 lines
2.8 KiB
JavaScript
// Text helpers shared by every edit mode: line splitting that remembers the
|
|
// trailing newline, snapshot tags, and compact numbered previews.
|
|
import crypto from 'node:crypto'
|
|
|
|
/** Normalize CRLF/CR to LF. */
|
|
export const toLF = (s) => String(s).replace(/\r\n?/g, '\n')
|
|
|
|
/** Split LF text into lines plus whether it ended with a newline. Empty text has no lines. */
|
|
export function splitLines(text) {
|
|
const t = toLF(text)
|
|
if (t === '') return { lines: [], eol: false }
|
|
const eol = t.endsWith('\n')
|
|
const lines = (eol ? t.slice(0, -1) : t).split('\n')
|
|
return { lines, eol }
|
|
}
|
|
|
|
/** Inverse of splitLines. A file that gained content keeps (or adopts) a trailing newline. */
|
|
export function joinLines(lines, eol) {
|
|
if (lines.length === 0) return ''
|
|
return lines.join('\n') + (eol ? '\n' : '')
|
|
}
|
|
|
|
/** Dominant line ending of the original file, so writes preserve CRLF files. */
|
|
export function detectEol(raw) {
|
|
const crlf = (raw.match(/\r\n/g) || []).length
|
|
const lf = (raw.match(/\n/g) || []).length - crlf
|
|
return crlf > lf ? '\r\n' : '\n'
|
|
}
|
|
export const restoreEol = (lfText, eol) => (eol === '\r\n' ? lfText.replace(/\n/g, '\r\n') : lfText)
|
|
|
|
/** Four uppercase hex characters derived from the whole LF-normalized file. */
|
|
export function fileTag(text) {
|
|
return crypto.createHash('sha256').update(toLF(text)).digest('hex').slice(0, 4).toUpperCase()
|
|
}
|
|
|
|
export const HEADER_RE = /^\[(.+?)#([0-9A-Fa-f]{4})\]\s*$/
|
|
|
|
export const formatHeader = (path, tag) => `[${path}#${tag}]`
|
|
export const numbered = (n, text) => `${n}:${text}`
|
|
|
|
/**
|
|
* Compact post-edit preview: changed regions of the new text (by line numbers)
|
|
* with `context` lines around them, separated by `…`.
|
|
* @param {string[]} lines new file lines
|
|
* @param {Array<[number, number]>} regions 1-based inclusive [start, end] in new numbering
|
|
*/
|
|
export function preview(lines, regions, { context = 2, maxLines = 80 } = {}) {
|
|
if (!regions.length || !lines.length) return []
|
|
const spans = regions
|
|
.map(([a, b]) => [Math.max(1, a - context), Math.min(lines.length, Math.max(a, b) + context)])
|
|
.sort((x, y) => x[0] - y[0])
|
|
const merged = []
|
|
for (const s of spans) {
|
|
const last = merged.at(-1)
|
|
if (last && s[0] <= last[1] + 1) last[1] = Math.max(last[1], s[1])
|
|
else merged.push([...s])
|
|
}
|
|
const out = []
|
|
for (const [a, b] of merged) {
|
|
if (out.length) out.push('…')
|
|
for (let n = a; n <= b; n++) {
|
|
if (out.length >= maxLines) { out.push('… (preview truncated; read the file for more)'); return out }
|
|
out.push(numbered(n, lines[n - 1]))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** Error type that carries a model-facing message. */
|
|
export class EditError extends Error {
|
|
constructor(message, extra = {}) {
|
|
super(message)
|
|
this.name = 'EditError'
|
|
Object.assign(this, extra)
|
|
}
|
|
}
|
|
|
|
export const jsonQuote = (s) => JSON.stringify(String(s))
|