diff --git a/src/index.ts b/src/index.ts
index 3852518..45d314e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -153,6 +153,117 @@ async function resolveGitPath(cwd: string, raw: string, selected?: string): Prom
/** How many leading bytes a binary read returns for client-side detect sniffing. */
const READ_HEAD_LIMIT = 4096
+/** Best-effort text decode of a raw file slice: BOM sniffing (UTF-8 / UTF-16LE /
+ * UTF-16BE), then strict UTF-8 validation, then a GB18030 fallback for ANSI
+ * files written by CJK Windows tools (GBK / GB2312 are subsets of GB18030).
+ * Truncated reads trim an incomplete trailing UTF-8 sequence before the strict
+ * validation so a cut multi-byte char cannot trigger a false GBK fallback. */
+function decodeTextSmart(slice: Buffer): { text: string; encoding: string } {
+ const fallback = (): { text: string; encoding: string } => ({ text: slice.toString('utf8'), encoding: 'utf-8' })
+ try {
+ if (slice.length >= 3 && slice[0] === 0xef && slice[1] === 0xbb && slice[2] === 0xbf)
+ return { text: new TextDecoder('utf-8').decode(slice.subarray(3)), encoding: 'utf-8-bom' }
+ if (slice.length >= 2 && slice[0] === 0xff && slice[1] === 0xfe)
+ return { text: new TextDecoder('utf-16le').decode(slice.subarray(2)), encoding: 'utf-16le' }
+ if (slice.length >= 2 && slice[0] === 0xfe && slice[1] === 0xff)
+ return { text: new TextDecoder('utf-16be').decode(slice.subarray(2)), encoding: 'utf-16be' }
+ let end = slice.length
+ let lead = end - 1
+ while (lead >= 0 && (slice[lead] & 0xc0) === 0x80) lead--
+ if (lead >= 0) {
+ const b = slice[lead]
+ const need = b >= 0xf0 ? 3 : b >= 0xe0 ? 2 : b >= 0xc0 ? 1 : 0
+ if (end - 1 - lead < need) end = lead
+ }
+ try {
+ new TextDecoder('utf-8', { fatal: true }).decode(slice.subarray(0, end))
+ return { text: new TextDecoder('utf-8').decode(slice), encoding: 'utf-8' }
+ } catch {}
+ try {
+ return { text: new TextDecoder('gb18030').decode(slice), encoding: 'gb18030' }
+ } catch {}
+ return fallback()
+ } catch {
+ return fallback()
+ }
+}
+
+/** Save-preserves-encoding support: remember the encoding detected at read
+ * time per absolute path, so fs.write can save the file back in the SAME
+ * encoding instead of always writing UTF-8. Bounded LRU, best-effort only
+ * (a miss falls back to UTF-8, e.g. after a host restart or for new files). */
+const READ_ENCODING_MEMO_LIMIT = 512
+const readEncodingByPath = new Map<string, string>()
+function memoKeyOf(path: string): string {
+ return process.platform === 'win32' ? path.toLowerCase() : path
+}
+function memoReadEncoding(path: string, encoding: string): void {
+ const key = memoKeyOf(path)
+ readEncodingByPath.delete(key)
+ readEncodingByPath.set(key, encoding)
+ while (readEncodingByPath.size > READ_ENCODING_MEMO_LIMIT) {
+ const oldest = readEncodingByPath.keys().next()
+ if (oldest.done) break
+ readEncodingByPath.delete(oldest.value)
+ }
+}
+function readEncodingOf(path: string): string | undefined {
+ return readEncodingByPath.get(memoKeyOf(path))
+}
+/** Inverse table char -> [lead, trail] built once from TextDecoder('gb18030')
+ * itself: decode every valid double-byte sequence and keep the mapping. Zero
+ * dependencies, ~24k entries, builds in tens of milliseconds. Chars outside
+ * GB18030's double-byte space (e.g. emoji) are not covered by design. */
+let gbkEncodeTable: Map<string, [number, number]> | null = null
+function ensureGbkEncodeTable(): Map<string, [number, number]> {
+ if (gbkEncodeTable !== null) return gbkEncodeTable
+ const table = new Map<string, [number, number]>()
+ const decoder = new TextDecoder('gb18030')
+ const probe = Buffer.alloc(2)
+ for (let lead = 0x81; lead <= 0xfe; lead++) {
+ probe[0] = lead
+ for (let trail = 0x40; trail <= 0xfe; trail++) {
+ if (trail === 0x7f) continue
+ probe[1] = trail
+ const ch = decoder.decode(probe)
+ if (ch === '\uFFFD') continue
+ if (!table.has(ch)) table.set(ch, [lead, trail])
+ }
+ }
+ gbkEncodeTable = table
+ return table
+}
+/** Encode editor text back to the file's original detected encoding. */
+function encodeSavedText(text: string, encoding: string | undefined): Buffer {
+ switch (encoding) {
+ case 'utf-8-bom':
+ return Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(text, 'utf8')])
+ case 'utf-16le':
+ return Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(text, 'utf16le')])
+ case 'utf-16be': {
+ const be = Buffer.from(text, 'utf16le')
+ be.swap16()
+ return Buffer.concat([Buffer.from([0xfe, 0xff]), be])
+ }
+ case 'gb18030': {
+ ensureGbkEncodeTable()
+ const out: number[] = []
+ for (let i = 0; i < text.length; i++) {
+ const code = text.charCodeAt(i)
+ if (code < 0x80) { out.push(code); continue }
+ let ch = text[i]
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) { ch = text.substring(i, i + 2); i++ }
+ const bytes = gbkEncodeTable!.get(ch)
+ if (bytes) out.push(bytes[0], bytes[1])
+ else out.push(0x3f)
+ }
+ return Buffer.from(out)
+ }
+ default:
+ return Buffer.from(text, 'utf8')
+ }
+}
+
/** Text read of a file with the size cap; binary detection via NUL probe.
* Binary reads also return the first {@link READ_HEAD_LIMIT} bytes (base64)
* so the client can re-match viewers by content (`detect`). */
@@ -182,13 +293,9 @@ async function readText(path: string, readLimit: number): Promise<{
const head = binary
? slice.subarray(0, Math.min(slice.length, READ_HEAD_LIMIT)).toString('base64')
: undefined
- return {
- content: binary ? '' : slice.toString('utf8'),
- truncated,
- binary,
- size,
- head,
- }
+ const decoded = binary ? undefined : decodeTextSmart(slice)
+ if (decoded) memoReadEncoding(path, decoded.encoding)
+ return { content: binary ? '' : decoded.text, truncated, binary, size, head }
} finally {
await handle.close()
}
@@ -330,7 +437,7 @@ function buildApi(
const tmp = `${path}.dsh-sidebar-tmp-${process.pid}`
try {
await mkdir(dirname(path), { recursive: true })
- await writeFile(tmp, content, 'utf8')
+ await writeFile(tmp, encodeSavedText(content, readEncodingOf(path)))
await rename(tmp, path)
} catch (error) {
await rm(tmp, { force: true }).catch(() => {})
现象
右侧面板文件预览/编辑器对 ANSI 编码文本有两个问题:
.bat脚本、部分工具导出的.log/.csv/.ini等)打开后中文全部乱码;UTF-8(含带 BOM)文件不受影响。
复现步骤
.txt;根因
主机端两处写死 UTF-8(
src/index.ts):GBK 双字节序列不构成合法 UTF-8,读取时逐字节产生替换符/错位字符。
建议修复(零新增依赖)
随附 patch 在
src/index.ts内实现四件事:decodeTextSmart():BOM 嗅探(UTF-8 / UTF-16LE / UTF-16BE)→TextDecoder('utf-8', { fatal: true })严格校验 → 失败才回退 GB18030(GBK/GB2312 的官方超集)。GBK 文本几乎不可能恰好是合法 UTF-8,误判率极低;Node 自带 full-ICU 的 TextDecoder 原生支持该标签,无需 iconv-lite。readText()按readLimit截断读取时,先裁掉尾部不完整的 UTF-8 多字节序列再校验,避免切断字符导致整份文件被误判为非 UTF-8。fs.write写回前查表,用原编码编码后写入——不再悄悄转码。表未命中(宿主重启后的首次保存、新建文件)自动回退 UTF-8,行为可预期。buffer.transcode不支持、WHATWG TextEncoder 只出 UTF-8),patch 利用已有的TextDecoder('gb18030')反推编码表——枚举全部合法双字节序列建立「字符→字节」映射(23939 条,实测构建仅 17ms),保存时查表编码。验证
f9153df(v0.16.1)的src/index.ts,共 +115/-8。行为与边界说明
?,与 VS Code 存遗留编码的行为一致;📦 完整补丁 ansi-gbk-fallback.patch(git am --3way 可直接应用)
🧪 往返测试脚本 test-roundtrip.mjs(node 直接运行)
English summary
The sidebar editor mojibakes ANSI text files on CJK Windows (ANSI = GBK there) and silently transcodes them to UTF-8 on save. Root cause: host-side
readText()decodes every slice as UTF-8 unconditionally, andfs.writealways writes UTF-8. Attached zero-dependency patch (+115/-8, against mainf9153df): BOM sniffing → strict UTF-8 validation (TextDecoder('utf-8', { fatal: true })) → GB18030 fallback; truncated reads trim incomplete trailing sequences before validation to avoid false negatives; detected encoding is memoized per path (LRU 512) so saves round-trip in the ORIGINAL encoding; the GBK encoder is built by invertingTextDecoder('gb18030')itself (~24k mappings, builds in ~17ms) since Node cannot encode legacy CJK codepages natively. Verified locally on 0.15.2 with restart (read + write round-trip). Unrepresentable chars degrade to?; memo miss falls back to UTF-8.