Skip to content

文件预览/编辑器:ANSI(GBK/GB2312)文件打开乱码,保存会悄悄转成 UTF-8(附零依赖完整修复 patch) #406

Description

@liannnnnng

现象

右侧面板文件预览/编辑器对 ANSI 编码文本有两个问题:

  1. 打开乱码:中文 Windows 下 ANSI 即 GBK/GB2312,这类文件(老项目源码、.bat 脚本、部分工具导出的 .log/.csv/.ini 等)打开后中文全部乱码;
  2. 保存悄悄转码:即使借助外部手段看到内容,编辑保存后文件会被强制写成 UTF-8 —— 原编码信息丢失,依赖原编码的其他工具(老编译器、批处理脚本等)随之出错。

UTF-8(含带 BOM)文件不受影响。

复现步骤

  1. 记事本「另存为 ANSI」生成含中文的 .txt
  2. 在侧边栏资源管理器中打开 → 中文乱码;
  3. 编辑任意内容并 Ctrl+S → 用记事本重新打开,属性/另存为对话框可见编码已变为 UTF-8。

根因

主机端两处写死 UTF-8(src/index.ts):

// fs.read 路径:readText() 把文件切片硬按 UTF-8 解码
content: binary ? '' : slice.toString('utf8'),

// fs.write 路径:无论原编码一律按 UTF-8 写回
await writeFile(tmp, content, 'utf8')

GBK 双字节序列不构成合法 UTF-8,读取时逐字节产生替换符/错位字符。

建议修复(零新增依赖)

随附 patch 在 src/index.ts 内实现四件事:

  1. 智能解码 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。
  2. 截断安全readText()readLimit 截断读取时,先裁掉尾部不完整的 UTF-8 多字节序列再校验,避免切断字符导致整份文件被误判为非 UTF-8。
  3. 编码记忆:读取时把检测到的编码按绝对路径存入 LRU 表(512 条),fs.write 写回前查表,用原编码编码后写入——不再悄悄转码。表未命中(宿主重启后的首次保存、新建文件)自动回退 UTF-8,行为可预期。
  4. GB18030 编码器零依赖实现:Node 无法把字符串编码为 GBK(buffer.transcode 不支持、WHATWG TextEncoder 只出 UTF-8),patch 利用已有的 TextDecoder('gb18030') 反推编码表——枚举全部合法双字节序列建立「字符→字节」映射(23939 条,实测构建仅 17ms),保存时查表编码。

验证

  • 往返测试 8/8 通过(中文/混排/全角符号/emoji 降级/BOM/UTF-16LE/BE),测试脚本见文末;
  • 本机(Windows 10 x64 · Node v24 · 插件 0.15.2 构建产物打同逻辑补丁重启实测):ANSI 文件正常显示,编辑保存后仍为 GBK;UTF-8 文件读写不受影响;
  • patch 基于 main f9153df(v0.16.1)的 src/index.ts,共 +115/-8。

行为与边界说明

  • GBK 表示不了的字符(如 emoji)保存时降级为 ?,与 VS Code 存遗留编码的行为一致;
  • 宿主进程重启后编码记忆清空,此时保存回退 UTF-8(下次打开重新记忆);
  • 全文搜索(fs-search)仍按 UTF-8 读取,GBK 文件暂不能命中搜索,可作为后续改进;
  • 如维护者更倾向显式方案(设置里加「文件编码」选择器),patch 中的解码/编码函数可直接作为底层复用,欢迎按项目风格调整。
📦 完整补丁 ansi-gbk-fallback.patch(git am --3way 可直接应用)
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(() => {})
🧪 往返测试脚本 test-roundtrip.mjs(node 直接运行)
// Round-trip test: TextDecoder('gb18030') inverse-table encoder
// Build char->bytes table from the decoder itself, then verify
//   decode(encode(x)) === x   for GBK-representable text.
import { TextDecoder } from 'node:util'

function buildGbkEncodeTable() {
  const table = new Map()
  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])
    }
  }
  return table
}

function encodeGb18030(table, text) {
  const out = []
  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 = table.get(ch)
    if (bytes) out.push(bytes[0], bytes[1])
    else out.push(0x3f) // '?' for chars GBK cannot represent
  }
  return Buffer.from(out)
}

function encodeSavedText(table, text, encoding) {
  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': return encodeGb18030(table, text)
    default: return Buffer.from(text, 'utf8')
  }
}

const t0 = performance.now()
const table = buildGbkEncodeTable()
console.log(`table built: ${table.size} mappings in ${(performance.now() - t0).toFixed(1)}ms`)

const dec = new TextDecoder('gb18030')
const cases = [
  ['中文测试:侧边栏文件预览', true],
  ['Mixed 中英混排 with ASCII 123 !@#', true],
  ['常用汉字锟斤拷烫烫烫', true],
  ['全角符号,。!?【】《》', true],
  ['emoji 🦉 stays unrepreentable -> ?', false],
]
let pass = 0, total = 0
for (const [text, fullyRepresentable] of cases) {
  const bytes = encodeGb18030(table, text)
  const back = dec.decode(bytes)
  const expected = fullyRepresentable ? text : text.replace(/🦉/g, '?')
  const ok = back === expected
  total++; if (ok) pass++
  console.log(`${ok ? 'PASS' : 'FAIL'}  "${text.slice(0, 18)}..."`)
  if (!ok) console.log(`   want: ${JSON.stringify(expected)}\n   got:  ${JSON.stringify(back)}`)
}

// UTF-16 BE/LE/BOM roundtrips via WHATWG decoders
for (const enc of ['utf-8-bom', 'utf-16le', 'utf-16be']) {
  const text = '中文 abc 123'
  const bytes = encodeSavedText(table, text, enc)
  const label = enc === 'utf-8-bom' ? 'utf-8' : enc
  const slice = enc === 'utf-8-bom' ? bytes.subarray(3) : bytes.subarray(2)
  const back = new TextDecoder(label).decode(slice)
  total++; if (back === text) pass++
  console.log(`${back === text ? 'PASS' : 'FAIL'}  ${enc}: ${JSON.stringify(back)}`)
}

console.log(`\n${pass}/${total} passed`)

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, and fs.write always writes UTF-8. Attached zero-dependency patch (+115/-8, against main f9153df): 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 inverting TextDecoder('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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions