dsh-autodetect 0.1.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/LICENSE +21 -0
- package/README.md +163 -0
- package/lib/client.js +351 -0
- package/lib/index.js +128 -0
- package/package.json +65 -0
- package/python/autodetect_codec.py +207 -0
- package/requirements.txt +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Xiaoph123
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# AutoDetect
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<strong>DeepSeek Harness Web 官方侧边栏的 Windows 编码安全文件查看器</strong><br>
|
|
5
|
+
<sub>在官方文件标签页中查看和编辑脚本,同时保留原始编码、BOM 与换行格式。</sub>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
<a href="https://github.com/Xiaoph123/dsh-autodetect"><img src="https://img.shields.io/badge/DeepSeek-Harness%20Web-4b6bfb" alt="DeepSeek Harness Web"></a>
|
|
10
|
+
<a href="https://www.npmjs.com/package/dsh-autodetect"><img src="https://img.shields.io/npm/v/dsh-autodetect" alt="npm version"></a>
|
|
11
|
+
<a href="https://github.com/Xiaoph123/dsh-autodetect/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT license"></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
> [!IMPORTANT]
|
|
15
|
+
> **适配边界**:本项目是 **DeepSeek Harness Web 官方侧边栏插件**,使用 DSH 官方 `client-runtime`、`client-ui-slots` 和 `client-ui-sidebar-right` 扩展接口。它**不适配第三方 DeepSeek 侧边栏、浏览器扩展、桌面客户端或其他非 Harness Web 宿主**。
|
|
16
|
+
|
|
17
|
+
## 为什么需要 AutoDetect?
|
|
18
|
+
|
|
19
|
+
Windows 工具链里,`.bat`、`.cmd`、`.ini`、`.vbs` 和 `.ps1` 经常混用 UTF-8、UTF-16、GBK、GB18030 与不同换行格式。AutoDetect 让这些文件直接出现在 Harness Web 的官方右侧文件标签页中,编辑后尽量按原格式写回。
|
|
20
|
+
|
|
21
|
+
## 功能
|
|
22
|
+
|
|
23
|
+
| 能力 | 说明 |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| 编码检测 | UTF-8、UTF-16、UTF-32、GBK、GB18030、CP1252、Shift-JIS 等 |
|
|
26
|
+
| 格式保留 | 识别并保留 BOM 与 LF、CRLF、CR 换行 |
|
|
27
|
+
| 文件保护 | 二进制文件禁止编辑;路径限制在当前会话工作区 |
|
|
28
|
+
| 并发保护 | 保存前校验 SHA-256,检测磁盘外部修改 |
|
|
29
|
+
| 可靠写入 | 保存前生成 `.autodetect.bak`,使用临时文件和原子替换 |
|
|
30
|
+
| 对话协作 | 选中文本后可添加到当前 Harness 对话 |
|
|
31
|
+
|
|
32
|
+
## 安装
|
|
33
|
+
|
|
34
|
+
### 推荐:通过 npm 包安装
|
|
35
|
+
|
|
36
|
+
先确认已安装 Node.js、Python 3.10+ 和 DeepSeek Harness Web。发布到 npm 后,推荐直接执行这一条命令:
|
|
37
|
+
|
|
38
|
+
```powershell
|
|
39
|
+
dsh plugin --profile web install dsh-autodetect --replace --yes
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`dsh` 会从 npm 获取 `dsh-autodetect`,并把它注册到 **Harness Web 官方侧边栏**。安装或更新后重启 Harness Web。
|
|
43
|
+
|
|
44
|
+
如果你的 `dsh` 版本不支持从 npm 包名解析,也可以先下载包再使用本地目录:
|
|
45
|
+
|
|
46
|
+
```powershell
|
|
47
|
+
npm install dsh-autodetect
|
|
48
|
+
dsh plugin --profile web install .\node_modules\dsh-autodetect --replace --yes
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
> 如果当前版本的 `dsh` CLI 不接受 npm 包名,请使用兼容方式:
|
|
52
|
+
>
|
|
53
|
+
> ```powershell
|
|
54
|
+
> git clone https://github.com/Xiaoph123/dsh-autodetect.git
|
|
55
|
+
> dsh plugin --profile web install .\dsh-autodetect --replace --yes
|
|
56
|
+
> ```
|
|
57
|
+
|
|
58
|
+
### 开发链接安装
|
|
59
|
+
|
|
60
|
+
```powershell
|
|
61
|
+
git clone https://github.com/Xiaoph123/dsh-autodetect.git
|
|
62
|
+
cd dsh-autodetect
|
|
63
|
+
dsh plugin --profile web install . --link --replace --yes
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Python 依赖
|
|
67
|
+
|
|
68
|
+
插件后端需要 `charset-normalizer`:
|
|
69
|
+
|
|
70
|
+
```powershell
|
|
71
|
+
py -3 -m pip install -r requirements.txt
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
如果 Harness 使用的不是默认 Python,可以指定:
|
|
75
|
+
|
|
76
|
+
```powershell
|
|
77
|
+
$env:AUTODETECT_PYTHON = 'C:\Path\To\python.exe'
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## 使用
|
|
81
|
+
|
|
82
|
+
1. 启动 DeepSeek Harness Web。
|
|
83
|
+
2. 在工作区打开 `.bat`、`.cmd`、`.ini`、`.vbs` 或 `.ps1` 文件。
|
|
84
|
+
3. AutoDetect 会在官方右侧文件标签页中检测编码并显示内容。
|
|
85
|
+
4. 修改后使用宿主工具栏或插件的保存按钮写回文件。
|
|
86
|
+
|
|
87
|
+
保存会沿用读取时的编码、BOM 和换行格式。如果文件在编辑期间被其他程序修改,保存会被拒绝,请重新加载后再操作。
|
|
88
|
+
|
|
89
|
+
## 工作原理
|
|
90
|
+
|
|
91
|
+
插件由两个部分组成:
|
|
92
|
+
|
|
93
|
+
- **Node.js Host**:注册 `/autodetect/api/read` 和 `/autodetect/api/write`,执行会话工作区校验、SHA-256 并发检查和备份。
|
|
94
|
+
- **Python Codec Helper**:读取原始字节,检测编码、BOM 和换行格式,并按元数据原子写回。
|
|
95
|
+
|
|
96
|
+
客户端通过 Harness 官方 Sidebar slots 注册文件标签页,只匹配 `dsh-resource://file/**` 下的目标扩展名。
|
|
97
|
+
|
|
98
|
+
## 项目结构
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
dsh-autodetect/
|
|
102
|
+
├─ lib/
|
|
103
|
+
│ ├─ index.js # Host 路由和 Python Helper 调用
|
|
104
|
+
│ └─ client.js # Harness 官方 Sidebar 文件标签页
|
|
105
|
+
├─ python/
|
|
106
|
+
│ └─ autodetect_codec.py # 编码检测、读取和写入
|
|
107
|
+
├─ tests/
|
|
108
|
+
│ ├─ test_codec.py # 编码与文件写入测试
|
|
109
|
+
│ └─ test_package.mjs # npm 元数据和 README 回归测试
|
|
110
|
+
├─ cordis.patch.yml # DSH Web 插件挂载配置
|
|
111
|
+
├─ package.json
|
|
112
|
+
├─ requirements.txt
|
|
113
|
+
└─ README.md
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## 开发与测试
|
|
117
|
+
|
|
118
|
+
```powershell
|
|
119
|
+
npm test
|
|
120
|
+
npm run pack:check
|
|
121
|
+
node --check lib\index.js
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`npm run pack:check` 会检查最终 npm 包内容,不会把 `node_modules`、Python 缓存或备份文件打进去。
|
|
125
|
+
|
|
126
|
+
## 发布
|
|
127
|
+
|
|
128
|
+
维护者登录 npm 后执行:
|
|
129
|
+
|
|
130
|
+
```powershell
|
|
131
|
+
npm login
|
|
132
|
+
npm publish --access public
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
发布新版本前先更新 `package.json` 中的版本号,并运行:
|
|
136
|
+
|
|
137
|
+
```powershell
|
|
138
|
+
npm version patch
|
|
139
|
+
npm run pack:check
|
|
140
|
+
npm publish --access public
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## 常见问题
|
|
144
|
+
|
|
145
|
+
### 安装后看不到文件标签页
|
|
146
|
+
|
|
147
|
+
确认宿主是 **DeepSeek Harness Web**,插件安装目标是 `--profile web`,并重启 Harness Web。第三方侧边栏不在支持范围内。
|
|
148
|
+
|
|
149
|
+
### 保存时报文件已被修改
|
|
150
|
+
|
|
151
|
+
这是 SHA-256 并发保护触发。重新打开文件、确认内容后再保存,避免覆盖其他程序的修改。
|
|
152
|
+
|
|
153
|
+
### Python 依赖找不到
|
|
154
|
+
|
|
155
|
+
使用 Harness 实际调用的 Python 解释器安装 `requirements.txt`,必要时设置 `AUTODETECT_PYTHON`。
|
|
156
|
+
|
|
157
|
+
## 许可证
|
|
158
|
+
|
|
159
|
+
[MIT License](LICENSE)
|
|
160
|
+
|
|
161
|
+
## 反馈与贡献
|
|
162
|
+
|
|
163
|
+
欢迎通过 [GitHub Issues](https://github.com/Xiaoph123/dsh-autodetect/issues) 提交问题。反馈时请注明 Harness Web 版本、Node.js/Python 版本,以及是否使用了官方侧边栏宿主。
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-autodetect',
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
const React = require('react')
|
|
5
|
+
const { createPortal } = require('react-dom')
|
|
6
|
+
const { useEffect, useLayoutEffect, useMemo, useRef, useState } = React
|
|
7
|
+
const inject = ['slots', 'sidebarRightTabs']
|
|
8
|
+
const EXTENSIONS = ['bat', 'cmd', 'ini', 'vbs', 'ps1']
|
|
9
|
+
const style = { position: 'relative', display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, fontFamily: 'ui-monospace, Consolas, monospace' }
|
|
10
|
+
const editorLayoutStyle = { display: 'flex', flex: 1, minHeight: 0, overflow: 'hidden', background: 'transparent' }
|
|
11
|
+
const gutterStyle = { width: '48px', flex: '0 0 48px', overflow: 'hidden', padding: '12px 8px 24px 0', boxSizing: 'border-box', textAlign: 'right', userSelect: 'none', color: 'var(--dsw-alias-label-tertiary, #888)', font: 'var(--dsw-font-markdown-code-block-small, 12px/18px ui-monospace, SFMono-Regular, Menlo, monospace)', whiteSpace: 'pre', lineHeight: '18px' }
|
|
12
|
+
const editorStyle = { flex: 1, width: '0', minHeight: 0, overflow: 'auto', boxSizing: 'border-box', padding: '12px 16px 24px', outline: 0, background: 'transparent', color: 'var(--dsw-alias-label-primary, inherit)', font: 'var(--dsw-font-markdown-code-block-small, 12px/18px ui-monospace, SFMono-Regular, Menlo, monospace)', lineHeight: '18px', whiteSpace: 'pre', overflowWrap: 'normal' }
|
|
13
|
+
const barStyle = { display: 'flex', gap: '8px', alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid var(--border-color, #ddd)', fontFamily: 'system-ui, sans-serif', fontSize: '12px' }
|
|
14
|
+
const selectionButtonGap = 10
|
|
15
|
+
const selectionButtonStyle = { position: 'fixed', zIndex: 1000, padding: '5px 9px', border: '1px solid var(--border-color, #555)', borderRadius: '4px', background: 'var(--background-color, #222)', color: 'inherit', cursor: 'pointer', fontSize: '12px', whiteSpace: 'nowrap' }
|
|
16
|
+
|
|
17
|
+
function relativePath(cwd, path) {
|
|
18
|
+
if (!cwd) return path
|
|
19
|
+
const base = cwd.replace(/[\\/]+$/, '').replace(/\\/g, '/')
|
|
20
|
+
const full = path.replace(/\\/g, '/')
|
|
21
|
+
return full.toLowerCase().startsWith(`${base.toLowerCase()}/`) ? full.slice(base.length + 1) : path
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function lineNumber(source, index) {
|
|
25
|
+
return source.slice(0, index).split('\n').length
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function selectionInsert(path, cwd, source, start, end) {
|
|
29
|
+
const selected = source.slice(start, end)
|
|
30
|
+
const first = lineNumber(source, start)
|
|
31
|
+
const last = lineNumber(source, Math.max(start, end - 1))
|
|
32
|
+
const rel = relativePath(cwd, path)
|
|
33
|
+
const header = last > first ? `${rel}:${first}-${last}` : `${rel}:${first}`
|
|
34
|
+
return selected.length > 500 ? header : '```' + header + '\n' + selected + '\n```'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function appendToConversation(ctx, sessionId, text) {
|
|
38
|
+
try {
|
|
39
|
+
const scope = ctx?.sessions?.scope?.(sessionId)
|
|
40
|
+
const conversation = ctx?.get?.('conversation')
|
|
41
|
+
if (!scope || !conversation) return false
|
|
42
|
+
const input = conversation.input.for(scope)
|
|
43
|
+
const draft = input.state.getSnapshot().draft
|
|
44
|
+
input.setDraft(draft.trim() ? `${draft} ${text}` : text)
|
|
45
|
+
return true
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.warn('[dsh-autodetect] 添加选区到对话失败:', error)
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function endpoint(path, scope) {
|
|
53
|
+
const params = new URLSearchParams({ sessionId: scope.sessionId, path })
|
|
54
|
+
if (scope.cwd) params.set('cwd', scope.cwd)
|
|
55
|
+
return `/autodetect/api/read?${params}`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function addressInfo(address) {
|
|
59
|
+
try {
|
|
60
|
+
const url = new URL(address)
|
|
61
|
+
const parts = url.pathname.split('/').filter(Boolean)
|
|
62
|
+
if (url.protocol !== 'dsh-resource:' || url.hostname !== 'file' || parts[0] !== 'session' || parts.length < 3) return null
|
|
63
|
+
const sessionId = decodeURIComponent(parts[1])
|
|
64
|
+
let path = decodeURIComponent(parts.slice(2).join('/'))
|
|
65
|
+
if (/^\/[A-Za-z]:[\\/]/.test(path)) path = path.slice(1)
|
|
66
|
+
return { sessionId, path }
|
|
67
|
+
} catch {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function titleForAddress(address) {
|
|
73
|
+
const info = addressInfo(address)
|
|
74
|
+
if (!info) return 'AutoDetect'
|
|
75
|
+
const parts = info.path.replace(/\\/g, '/').split('/').filter(Boolean)
|
|
76
|
+
return parts[parts.length - 1] || info.path
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function load(path, scope, signal) {
|
|
80
|
+
const response = await fetch(endpoint(path, scope), { signal })
|
|
81
|
+
const value = await response.json()
|
|
82
|
+
if (!response.ok) throw new Error(value.error || `HTTP ${response.status}`)
|
|
83
|
+
return value
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function AutoDetectView(props) {
|
|
87
|
+
const data = props.customData || {}
|
|
88
|
+
const [content, setContent] = useState(data.content || '')
|
|
89
|
+
const [dirty, setDirty] = useState(false)
|
|
90
|
+
const [saveState, setSaveState] = useState('idle')
|
|
91
|
+
const [selection, setSelection] = useState(null)
|
|
92
|
+
const editorRef = useRef(null)
|
|
93
|
+
const gutterRef = useRef(null)
|
|
94
|
+
const popupRef = useRef(null)
|
|
95
|
+
const selectionButtonRef = useRef(null)
|
|
96
|
+
const lastDragPointRef = useRef(null)
|
|
97
|
+
const lockedMousePointRef = useRef(null)
|
|
98
|
+
const meta = useMemo(() => `${data.encoding || 'unknown'}${data.bom ? ' + BOM' : ''} · ${data.newline || 'lf'}`, [data])
|
|
99
|
+
|
|
100
|
+
const hideSelection = () => {
|
|
101
|
+
popupRef.current = null
|
|
102
|
+
setSelection(null)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const showSelection = (insert, point, anchor = 'direct') => {
|
|
106
|
+
const next = {
|
|
107
|
+
insert,
|
|
108
|
+
left: point.left,
|
|
109
|
+
top: point.top,
|
|
110
|
+
anchorLeft: point.left,
|
|
111
|
+
anchorTop: point.top,
|
|
112
|
+
anchor,
|
|
113
|
+
}
|
|
114
|
+
popupRef.current = next
|
|
115
|
+
setSelection(next)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
useLayoutEffect(() => {
|
|
119
|
+
if (!selection) return
|
|
120
|
+
const rect = selectionButtonRef.current?.getBoundingClientRect()
|
|
121
|
+
if (!rect) return
|
|
122
|
+
const edge = 8
|
|
123
|
+
const anchorLeft = selection.anchor === 'mouse' ? selection.anchorLeft - rect.width / 2 : selection.anchorLeft
|
|
124
|
+
const anchorTop = selection.anchor === 'mouse' ? selection.anchorTop - rect.height - selectionButtonGap : selection.anchorTop
|
|
125
|
+
const next = {
|
|
126
|
+
...selection,
|
|
127
|
+
left: Math.min(Math.max(edge, anchorLeft), Math.max(edge, window.innerWidth - rect.width - edge)),
|
|
128
|
+
top: Math.min(Math.max(edge, anchorTop), Math.max(edge, window.innerHeight - rect.height - edge)),
|
|
129
|
+
}
|
|
130
|
+
if (next.left === selection.left && next.top === selection.top) return
|
|
131
|
+
popupRef.current = next
|
|
132
|
+
setSelection(next)
|
|
133
|
+
}, [selection?.insert, selection?.anchor, selection?.anchorLeft, selection?.anchorTop])
|
|
134
|
+
|
|
135
|
+
const save = async () => {
|
|
136
|
+
if (saveState === 'saving') return
|
|
137
|
+
setSaveState('saving')
|
|
138
|
+
try {
|
|
139
|
+
const response = await fetch('/autodetect/api/write', {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: { 'content-type': 'application/json' },
|
|
142
|
+
body: JSON.stringify({ sessionId: props.scope.sessionId, cwd: props.scope.cwd, path: props.path, content, encoding: data.encoding, bom: data.bom, newline: data.newline, expectedSha256: data.sha256 }),
|
|
143
|
+
})
|
|
144
|
+
const value = await response.json()
|
|
145
|
+
if (!response.ok) throw new Error(value.error || `HTTP ${response.status}`)
|
|
146
|
+
setDirty(false)
|
|
147
|
+
setSaveState('saved')
|
|
148
|
+
} catch (error) {
|
|
149
|
+
setSaveState(`failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
props.onToolbarState?.({ modes: false, mode: 'edit', dirty, editable: !data.binary, saveState: saveState.startsWith('failed') ? 'failed' : saveState })
|
|
155
|
+
props.onToolbarControls?.({ setMode: () => {}, save })
|
|
156
|
+
return () => props.onToolbarControls?.(null)
|
|
157
|
+
}, [dirty, saveState, data.binary])
|
|
158
|
+
|
|
159
|
+
const updateSelection = (event) => {
|
|
160
|
+
const editor = editorRef.current
|
|
161
|
+
const browserSelection = window.getSelection()
|
|
162
|
+
if (!editor || !browserSelection || browserSelection.rangeCount === 0 || browserSelection.isCollapsed) {
|
|
163
|
+
hideSelection()
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
const range = browserSelection.getRangeAt(0)
|
|
167
|
+
if (!editor.contains(range.startContainer) || !editor.contains(range.endContainer)) {
|
|
168
|
+
hideSelection()
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
const before = document.createRange()
|
|
172
|
+
before.selectNodeContents(editor)
|
|
173
|
+
before.setEnd(range.startContainer, range.startOffset)
|
|
174
|
+
const selected = browserSelection.toString()
|
|
175
|
+
const start = before.toString().length
|
|
176
|
+
const end = start + selected.length
|
|
177
|
+
if (!selected.trim()) {
|
|
178
|
+
hideSelection()
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
const rect = range.getBoundingClientRect()
|
|
182
|
+
const hasMousePoint = (event?.type === 'mouseup' || event?.type === 'mousemove') && Number.isFinite(event.clientX) && Number.isFinite(event.clientY)
|
|
183
|
+
const lockedPoint = lockedMousePointRef.current
|
|
184
|
+
const mousePoint = hasMousePoint || Boolean(lockedPoint)
|
|
185
|
+
const point = mousePoint
|
|
186
|
+
? (hasMousePoint ? { left: event.clientX, top: event.clientY } : lockedPoint)
|
|
187
|
+
: rect && (rect.width > 0 || rect.height > 0)
|
|
188
|
+
? { left: rect.right, top: rect.bottom + 8 }
|
|
189
|
+
: { left: 12, top: 12 }
|
|
190
|
+
showSelection(selectionInsert(props.path, props.scope?.cwd, content, start, end), point, mousePoint ? 'mouse' : 'direct')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const addSelection = () => {
|
|
194
|
+
const current = popupRef.current
|
|
195
|
+
if (!current) return
|
|
196
|
+
if (appendToConversation(props.ctx, props.scope?.sessionId, current.insert)) hideSelection()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (!selection) return
|
|
201
|
+
const close = () => hideSelection()
|
|
202
|
+
const onMouseDown = (event) => {
|
|
203
|
+
if (event.target instanceof Node && selectionButtonRef.current?.contains(event.target)) return
|
|
204
|
+
close()
|
|
205
|
+
}
|
|
206
|
+
const onKeyDown = (event) => {
|
|
207
|
+
if (event.key === 'Escape') close()
|
|
208
|
+
}
|
|
209
|
+
const onSelectionChange = () => {
|
|
210
|
+
const browserSelection = window.getSelection()
|
|
211
|
+
if (!browserSelection || browserSelection.isCollapsed || browserSelection.toString().trim() === '') {
|
|
212
|
+
close()
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
const range = browserSelection.rangeCount > 0 ? browserSelection.getRangeAt(0) : null
|
|
216
|
+
if (!range || !editorRef.current?.contains(range.startContainer) || !editorRef.current?.contains(range.endContainer)) close()
|
|
217
|
+
}
|
|
218
|
+
const onVisibilityChange = () => {
|
|
219
|
+
if (document.visibilityState !== 'visible') close()
|
|
220
|
+
}
|
|
221
|
+
window.addEventListener('mousedown', onMouseDown)
|
|
222
|
+
window.addEventListener('resize', close)
|
|
223
|
+
window.addEventListener('blur', close)
|
|
224
|
+
window.addEventListener('keydown', onKeyDown)
|
|
225
|
+
document.addEventListener('selectionchange', onSelectionChange)
|
|
226
|
+
document.addEventListener('visibilitychange', onVisibilityChange)
|
|
227
|
+
return () => {
|
|
228
|
+
window.removeEventListener('mousedown', onMouseDown)
|
|
229
|
+
window.removeEventListener('resize', close)
|
|
230
|
+
window.removeEventListener('blur', close)
|
|
231
|
+
window.removeEventListener('keydown', onKeyDown)
|
|
232
|
+
document.removeEventListener('selectionchange', onSelectionChange)
|
|
233
|
+
document.removeEventListener('visibilitychange', onVisibilityChange)
|
|
234
|
+
}
|
|
235
|
+
}, [selection])
|
|
236
|
+
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
hideSelection()
|
|
239
|
+
}, [props.path, props.scope?.sessionId])
|
|
240
|
+
|
|
241
|
+
const syncGutter = () => {
|
|
242
|
+
if (editorRef.current && gutterRef.current) gutterRef.current.scrollTop = editorRef.current.scrollTop
|
|
243
|
+
hideSelection()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const handleMouseMove = (event) => {
|
|
247
|
+
if (!(event.buttons & 1)) return
|
|
248
|
+
const point = { left: event.clientX, top: event.clientY }
|
|
249
|
+
lastDragPointRef.current = point
|
|
250
|
+
lockedMousePointRef.current = point
|
|
251
|
+
updateSelection(event)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const handleMouseUp = (event) => {
|
|
255
|
+
const lastPoint = lastDragPointRef.current
|
|
256
|
+
lastDragPointRef.current = null
|
|
257
|
+
if (lastPoint) {
|
|
258
|
+
lockedMousePointRef.current = lastPoint
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
updateSelection(event)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const handleMouseDown = () => {
|
|
265
|
+
lastDragPointRef.current = null
|
|
266
|
+
lockedMousePointRef.current = null
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (data.binary) {
|
|
270
|
+
return React.createElement('div', { style }, React.createElement('div', { style: barStyle }, 'AutoDetect · 检测为二进制文件,已禁用编辑'))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return React.createElement('div', { style },
|
|
274
|
+
props.toolbar !== 'host' && React.createElement('div', { style: barStyle },
|
|
275
|
+
React.createElement('span', null, `AutoDetect · ${meta}`),
|
|
276
|
+
React.createElement('button', { type: 'button', onClick: save, disabled: !dirty || saveState === 'saving' }, '保存'),
|
|
277
|
+
saveState === 'saved' && React.createElement('span', null, '已保存'),
|
|
278
|
+
saveState.startsWith('failed') && React.createElement('span', null, saveState),
|
|
279
|
+
),
|
|
280
|
+
selection && createPortal(
|
|
281
|
+
React.createElement('button', { ref: selectionButtonRef, type: 'button', style: { ...selectionButtonStyle, left: selection.left, top: selection.top }, onMouseDown: (event) => { event.preventDefault() }, onClick: addSelection }, '添加到对话'),
|
|
282
|
+
document.body,
|
|
283
|
+
),
|
|
284
|
+
React.createElement('div', { style: editorLayoutStyle },
|
|
285
|
+
React.createElement('div', { ref: gutterRef, 'aria-hidden': true, style: gutterStyle }, content.split('\n').map((_, index) => `${index + 1}\n`).join('')),
|
|
286
|
+
React.createElement('div', { ref: editorRef, contentEditable: true, suppressContentEditableWarning: true, spellCheck: false, role: 'textbox', tabIndex: 0, onMouseDown: handleMouseDown, onMouseMove: handleMouseMove, onMouseUp: handleMouseUp, onKeyUp: updateSelection, onScroll: syncGutter, onInput: (event) => { setContent(event.currentTarget.textContent || ''); setDirty(true); updateSelection() }, style: editorStyle }, content),
|
|
287
|
+
),
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function apply(ctx) {
|
|
292
|
+
function OfficialAutoDetectView(props) {
|
|
293
|
+
const { tab } = props.useTabInfo()
|
|
294
|
+
const address = tab.navigation.address
|
|
295
|
+
const info = useMemo(() => addressInfo(address), [address])
|
|
296
|
+
const [state, setState] = useState({ address: null, status: 'loading', data: null, error: null })
|
|
297
|
+
|
|
298
|
+
useEffect(() => {
|
|
299
|
+
if (!info) {
|
|
300
|
+
setState({ address, status: 'error', data: null, error: new Error('无法解析官方资源地址') })
|
|
301
|
+
return undefined
|
|
302
|
+
}
|
|
303
|
+
const controller = new AbortController()
|
|
304
|
+
const onTabAbort = () => controller.abort()
|
|
305
|
+
if (tab.signal.aborted) controller.abort()
|
|
306
|
+
else tab.signal.addEventListener('abort', onTabAbort, { once: true })
|
|
307
|
+
setState({ address, status: 'loading', data: null, error: null })
|
|
308
|
+
load(info.path, { sessionId: info.sessionId }, controller.signal)
|
|
309
|
+
.then((data) => { if (!controller.signal.aborted) setState({ address, status: 'ready', data, error: null }) })
|
|
310
|
+
.catch((error) => { if (!controller.signal.aborted) setState({ address, status: 'error', data: null, error }) })
|
|
311
|
+
return () => {
|
|
312
|
+
tab.signal.removeEventListener('abort', onTabAbort)
|
|
313
|
+
controller.abort()
|
|
314
|
+
}
|
|
315
|
+
}, [address, info?.path, info?.sessionId, tab.signal])
|
|
316
|
+
|
|
317
|
+
if (state.address !== address || state.status === 'loading') return React.createElement('div', { style: { padding: '16px', fontFamily: 'system-ui, sans-serif' } }, '正在读取文件…')
|
|
318
|
+
if (state.status === 'error') return React.createElement('div', { style: { padding: '16px', color: '#c62828', fontFamily: 'system-ui, sans-serif', whiteSpace: 'pre-wrap' } }, `读取失败:${state.error instanceof Error ? state.error.message : String(state.error)}`)
|
|
319
|
+
return React.createElement(AutoDetectView, {
|
|
320
|
+
...props,
|
|
321
|
+
ctx,
|
|
322
|
+
path: info.path,
|
|
323
|
+
scope: { sessionId: info.sessionId },
|
|
324
|
+
customData: state.data,
|
|
325
|
+
toolbar: 'host',
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
ctx.effect(() => ctx.sidebarRightTabs.register({
|
|
330
|
+
id: 'dsh-autodetect',
|
|
331
|
+
kind: 'dsh-autodetect',
|
|
332
|
+
patterns: ['dsh-resource://file/**'],
|
|
333
|
+
priority: 'extension',
|
|
334
|
+
canOpen: (address) => {
|
|
335
|
+
const info = addressInfo(address)
|
|
336
|
+
if (!info) return false
|
|
337
|
+
const path = info.path.replace(/\\/g, '/').toLowerCase()
|
|
338
|
+
return EXTENSIONS.some((extension) => path.endsWith(`.${extension}`))
|
|
339
|
+
},
|
|
340
|
+
title: titleForAddress,
|
|
341
|
+
}), 'dsh-autodetect: tab type')
|
|
342
|
+
|
|
343
|
+
ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
|
|
344
|
+
name: 'sidebar.right.pane.tab',
|
|
345
|
+
key: 'dsh-autodetect',
|
|
346
|
+
}, OfficialAutoDetectView)), 'dsh-autodetect: tab body')
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return { apply, inject }
|
|
350
|
+
},
|
|
351
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { copyFile, readFile } from 'node:fs/promises'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { dirname, relative, resolve, sep } from 'node:path'
|
|
4
|
+
import { spawn } from 'node:child_process'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
const ROOT = dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
const HELPER = resolve(ROOT, '..', 'python', 'autodetect_codec.py')
|
|
9
|
+
const MAX_BODY = 12 * 1024 * 1024
|
|
10
|
+
const name = 'dsh-autodetect'
|
|
11
|
+
const inject = ['webServer', 'sessions']
|
|
12
|
+
|
|
13
|
+
function json(res, status, value) {
|
|
14
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
15
|
+
res.end(JSON.stringify(value))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function requestBody(req) {
|
|
19
|
+
const chunks = []
|
|
20
|
+
let size = 0
|
|
21
|
+
for await (const chunk of req) {
|
|
22
|
+
const buffer = Buffer.from(chunk)
|
|
23
|
+
size += buffer.length
|
|
24
|
+
if (size > MAX_BODY) throw new Error('request body is too large')
|
|
25
|
+
chunks.push(buffer)
|
|
26
|
+
}
|
|
27
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pythonCommand() {
|
|
31
|
+
const configured = process.env.AUTODETECT_PYTHON
|
|
32
|
+
if (configured) return { command: configured, args: [] }
|
|
33
|
+
if (process.platform === 'win32') {
|
|
34
|
+
// The Windows Store `python.exe` alias may shadow the real interpreter.
|
|
35
|
+
// Use the Python launcher so the installed charset-normalizer environment
|
|
36
|
+
// is selected consistently; AUTODETECT_PYTHON still overrides this.
|
|
37
|
+
return { command: 'py', args: ['-3'] }
|
|
38
|
+
}
|
|
39
|
+
return { command: 'python3', args: [] }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function runCodec(payload) {
|
|
43
|
+
return new Promise((resolvePromise, reject) => {
|
|
44
|
+
const python = pythonCommand()
|
|
45
|
+
const child = spawn(python.command, [...python.args, HELPER], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
46
|
+
let stdout = ''
|
|
47
|
+
let stderr = ''
|
|
48
|
+
child.stdout.setEncoding('utf8')
|
|
49
|
+
child.stderr.setEncoding('utf8')
|
|
50
|
+
child.stdout.on('data', (chunk) => { stdout += chunk })
|
|
51
|
+
child.stderr.on('data', (chunk) => { stderr += chunk })
|
|
52
|
+
child.once('error', reject)
|
|
53
|
+
child.once('close', (code) => {
|
|
54
|
+
if (code !== 0) return reject(new Error(stderr.trim() || `codec exited with ${code}`))
|
|
55
|
+
try { resolvePromise(JSON.parse(stdout)) } catch (error) { reject(error) }
|
|
56
|
+
})
|
|
57
|
+
child.stdin.end(JSON.stringify(payload))
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function sessionCwd(ctx, sessionId, suppliedCwd) {
|
|
62
|
+
const session = ctx.sessions?.get?.(sessionId)
|
|
63
|
+
const cwd = session?.header?.cwd || suppliedCwd
|
|
64
|
+
if (!cwd) throw new Error('session cwd is unavailable')
|
|
65
|
+
return resolve(cwd)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function safePath(cwd, requested) {
|
|
69
|
+
const candidate = resolve(cwd, requested)
|
|
70
|
+
const base = cwd.endsWith(sep) ? cwd : `${cwd}${sep}`
|
|
71
|
+
if (candidate !== cwd && !candidate.startsWith(base) && !candidate.toLowerCase().startsWith(base.toLowerCase())) {
|
|
72
|
+
throw new Error('path is outside the session workspace')
|
|
73
|
+
}
|
|
74
|
+
return candidate
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function handleRead(ctx, req, res, url) {
|
|
78
|
+
const cwd = sessionCwd(ctx, url.searchParams.get('sessionId'), url.searchParams.get('cwd'))
|
|
79
|
+
const path = safePath(cwd, url.searchParams.get('path') || '')
|
|
80
|
+
const result = await runCodec({ action: 'read', path })
|
|
81
|
+
json(res, 200, result)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function handleWrite(ctx, req, res) {
|
|
85
|
+
const payload = JSON.parse(await requestBody(req))
|
|
86
|
+
const cwd = sessionCwd(ctx, payload.sessionId, payload.cwd)
|
|
87
|
+
const path = safePath(cwd, payload.path || '')
|
|
88
|
+
const original = await readFile(path)
|
|
89
|
+
const currentSha256 = createHash('sha256').update(original).digest('hex')
|
|
90
|
+
if (payload.expectedSha256 && payload.expectedSha256 !== currentSha256) {
|
|
91
|
+
const error = new Error('file changed on disk; reload before saving')
|
|
92
|
+
error.statusCode = 409
|
|
93
|
+
throw error
|
|
94
|
+
}
|
|
95
|
+
const backup = `${path}.autodetect.bak`
|
|
96
|
+
await copyFile(path, backup)
|
|
97
|
+
const result = await runCodec({
|
|
98
|
+
action: 'write',
|
|
99
|
+
path,
|
|
100
|
+
content: payload.content || '',
|
|
101
|
+
encoding: payload.encoding,
|
|
102
|
+
bom: payload.bom,
|
|
103
|
+
newline: payload.newline,
|
|
104
|
+
expectedSha256: currentSha256,
|
|
105
|
+
})
|
|
106
|
+
json(res, 200, { ...result, backup })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function apply(ctx) {
|
|
110
|
+
console.error('[dsh-autodetect] host apply')
|
|
111
|
+
if (!ctx.webServer?.register) return
|
|
112
|
+
ctx.effect(() => ctx.webServer.register({
|
|
113
|
+
kind: 'prefix',
|
|
114
|
+
path: '/autodetect/api',
|
|
115
|
+
handler: async (req, res) => {
|
|
116
|
+
try {
|
|
117
|
+
const url = new URL(req.url || '/', 'http://dsh.internal')
|
|
118
|
+
if (req.method === 'GET' && url.pathname === '/autodetect/api/read') return await handleRead(ctx, req, res, url)
|
|
119
|
+
if (req.method === 'POST' && url.pathname === '/autodetect/api/write') return await handleWrite(ctx, req, res)
|
|
120
|
+
json(res, 405, { error: 'method not allowed' })
|
|
121
|
+
} catch (error) {
|
|
122
|
+
json(res, error?.statusCode || 400, { error: error instanceof Error ? error.message : String(error) })
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
}), 'dsh-autodetect: routes')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export { apply, inject, name }
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-autodetect",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Encoding-safe Windows script viewer for the official DSH sidebar",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"deepseek",
|
|
7
|
+
"harness",
|
|
8
|
+
"dsh",
|
|
9
|
+
"plugin",
|
|
10
|
+
"sidebar",
|
|
11
|
+
"encoding",
|
|
12
|
+
"windows"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/Xiaoph123/dsh-autodetect.git"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/Xiaoph123/dsh-autodetect#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/Xiaoph123/dsh-autodetect/issues"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test tests/test_package.mjs && py -3 -m unittest discover -s tests",
|
|
28
|
+
"pack:check": "npm pack --dry-run"
|
|
29
|
+
},
|
|
30
|
+
"type": "module",
|
|
31
|
+
"main": "./lib/index.js",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": "./lib/index.js",
|
|
34
|
+
"./client": "./lib/client.js",
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"lib",
|
|
39
|
+
"python/autodetect_codec.py",
|
|
40
|
+
"requirements.txt",
|
|
41
|
+
"README.md"
|
|
42
|
+
],
|
|
43
|
+
"dsh": {
|
|
44
|
+
"bundle": {
|
|
45
|
+
"patch": "./cordis.patch.yml"
|
|
46
|
+
},
|
|
47
|
+
"client": {
|
|
48
|
+
"inject": [
|
|
49
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
50
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
51
|
+
"@deepseek-ai/dsh-client-ui-sidebar-right"
|
|
52
|
+
],
|
|
53
|
+
"platform": "web"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"@deepseek-ai/dsh-client-runtime": "*",
|
|
58
|
+
"@deepseek-ai/dsh-client-ui-sidebar-right": "*",
|
|
59
|
+
"@deepseek-ai/dsh-client-ui-slots": "*",
|
|
60
|
+
"react": "^18.2.0"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"react": "^18.2.0"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Binary-safe Windows text detection and round-trip encoding helper."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from charset_normalizer import from_bytes
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if hasattr(sys.stdin, "reconfigure"):
|
|
18
|
+
sys.stdin.reconfigure(encoding="utf-8", errors="strict")
|
|
19
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
20
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="strict")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
BOMS = (
|
|
24
|
+
(b"\xff\xfe\x00\x00", "utf-32-le"),
|
|
25
|
+
(b"\x00\x00\xfe\xff", "utf-32-be"),
|
|
26
|
+
(b"\xff\xfe", "utf-16-le"),
|
|
27
|
+
(b"\xfe\xff", "utf-16-be"),
|
|
28
|
+
(b"\xef\xbb\xbf", "utf-8"),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
SUPPORTED_TEXT_EXTENSIONS = {".bat", ".cmd", ".ini", ".vbs", ".ps1"}
|
|
32
|
+
COMMON_ENCODINGS = {"utf-8", "gbk", "gb18030", "utf-16-le", "utf-16-be", "utf-32-le", "utf-32-be", "cp1252", "shift-jis"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def detect(data: bytes) -> tuple[str, bool, bytes]:
|
|
36
|
+
for marker, encoding in BOMS:
|
|
37
|
+
if data.startswith(marker):
|
|
38
|
+
return encoding, True, data[len(marker):]
|
|
39
|
+
if not data:
|
|
40
|
+
return "utf-8", False, data
|
|
41
|
+
if _is_valid(data, "utf-8"):
|
|
42
|
+
return "utf-8", False, data
|
|
43
|
+
# A BOM-less UTF-16 file can otherwise look like valid UTF-8 containing NULs.
|
|
44
|
+
for encoding in ("utf-16-le", "utf-16-be"):
|
|
45
|
+
if _looks_like_bomless_utf16(data, encoding):
|
|
46
|
+
return encoding, False, data
|
|
47
|
+
# GBK is a strict subset of GB18030. Prefer GBK when the bytes round-trip
|
|
48
|
+
# through it; only use GB18030 when the file contains four-byte sequences
|
|
49
|
+
# that GBK cannot represent.
|
|
50
|
+
if _is_valid(data, "gbk"):
|
|
51
|
+
return "gbk", False, data
|
|
52
|
+
if _is_valid(data, "gb18030"):
|
|
53
|
+
return "gb18030", False, data
|
|
54
|
+
match = from_bytes(data).best()
|
|
55
|
+
detected = (match.encoding if match else "utf-8").lower().replace("_", "-")
|
|
56
|
+
aliases = {
|
|
57
|
+
"cp936": "gbk",
|
|
58
|
+
"ms936": "gbk",
|
|
59
|
+
"gb2312": "gb18030",
|
|
60
|
+
"ascii": "utf-8",
|
|
61
|
+
"932": "shift-jis",
|
|
62
|
+
"sjis": "shift-jis",
|
|
63
|
+
"windows-1252": "cp1252",
|
|
64
|
+
}
|
|
65
|
+
encoding = aliases.get(detected, detected)
|
|
66
|
+
if encoding not in COMMON_ENCODINGS:
|
|
67
|
+
encoding = "utf-8"
|
|
68
|
+
return encoding, False, data
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_valid(data: bytes, encoding: str) -> bool:
|
|
72
|
+
try:
|
|
73
|
+
text = data.decode(encoding, errors="strict")
|
|
74
|
+
return text.encode(encoding, errors="strict") == data
|
|
75
|
+
except UnicodeError:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _looks_like_bomless_utf16(data: bytes, encoding: str) -> bool:
|
|
80
|
+
if len(data) < 16 or len(data) % 2:
|
|
81
|
+
return False
|
|
82
|
+
even_nuls = sum(data[index] == 0 for index in range(0, len(data), 2))
|
|
83
|
+
odd_nuls = sum(data[index] == 0 for index in range(1, len(data), 2))
|
|
84
|
+
half = len(data) // 2
|
|
85
|
+
skewed = (encoding == "utf-16-le" and odd_nuls * 10 >= half * 3 and even_nuls * 20 <= half) or (encoding == "utf-16-be" and even_nuls * 10 >= half * 3 and odd_nuls * 20 <= half)
|
|
86
|
+
if not skewed:
|
|
87
|
+
return False
|
|
88
|
+
try:
|
|
89
|
+
text = data.decode(encoding, errors="strict")
|
|
90
|
+
return "\ufffd" not in text
|
|
91
|
+
except UnicodeError:
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def is_binary(data: bytes, encoding: str, bom: bool) -> bool:
|
|
96
|
+
if not data:
|
|
97
|
+
return False
|
|
98
|
+
if encoding.startswith("utf-16") or encoding.startswith("utf-32"):
|
|
99
|
+
return False
|
|
100
|
+
if b"\x00" in data:
|
|
101
|
+
return True
|
|
102
|
+
controls = sum(byte < 8 or 14 <= byte < 32 for byte in data)
|
|
103
|
+
return controls / len(data) > 0.02
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def newline_of(text: str) -> str:
|
|
107
|
+
match = re.search(r"\r\n|\r|\n", text)
|
|
108
|
+
return {"\r\n": "crlf", "\r": "cr", "\n": "lf"}.get(match.group(0), "lf") if match else "lf"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def to_editor_text(text: str) -> str:
|
|
112
|
+
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def encode_text(content: str, encoding: str, bom: bool, newline: str) -> bytes:
|
|
116
|
+
separator = {"crlf": "\r\n", "cr": "\r", "lf": "\n"}.get(newline, "\n")
|
|
117
|
+
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
|
|
118
|
+
text = normalized.replace("\n", separator)
|
|
119
|
+
raw = text.encode(encoding, errors="strict")
|
|
120
|
+
if bom:
|
|
121
|
+
marker = {
|
|
122
|
+
"utf-8": b"\xef\xbb\xbf",
|
|
123
|
+
"utf-16-le": b"\xff\xfe",
|
|
124
|
+
"utf-16-be": b"\xfe\xff",
|
|
125
|
+
"utf-32-le": b"\xff\xfe\x00\x00",
|
|
126
|
+
"utf-32-be": b"\x00\x00\xfe\xff",
|
|
127
|
+
}.get(encoding, b"")
|
|
128
|
+
raw = marker + raw
|
|
129
|
+
return raw
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def read_file(path: Path) -> dict[str, object]:
|
|
133
|
+
data = path.read_bytes()
|
|
134
|
+
encoding, bom, body = detect(data)
|
|
135
|
+
binary = is_binary(data, encoding, bom)
|
|
136
|
+
if binary:
|
|
137
|
+
return {
|
|
138
|
+
"content": "",
|
|
139
|
+
"encoding": encoding,
|
|
140
|
+
"bom": bom,
|
|
141
|
+
"newline": "lf",
|
|
142
|
+
"size": len(data),
|
|
143
|
+
"sha256": hashlib.sha256(data).hexdigest(),
|
|
144
|
+
"binary": True,
|
|
145
|
+
"extension": path.suffix.lower(),
|
|
146
|
+
}
|
|
147
|
+
text = body.decode(encoding, errors="strict")
|
|
148
|
+
return {
|
|
149
|
+
"content": to_editor_text(text),
|
|
150
|
+
"encoding": encoding,
|
|
151
|
+
"bom": bom,
|
|
152
|
+
"newline": newline_of(text),
|
|
153
|
+
"size": len(data),
|
|
154
|
+
"sha256": hashlib.sha256(data).hexdigest(),
|
|
155
|
+
"binary": binary,
|
|
156
|
+
"extension": path.suffix.lower(),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def atomic_write(path: Path, data: bytes) -> None:
|
|
161
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
with tempfile.NamedTemporaryFile(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", delete=False) as file:
|
|
163
|
+
temporary = Path(file.name)
|
|
164
|
+
file.write(data)
|
|
165
|
+
file.flush()
|
|
166
|
+
import os
|
|
167
|
+
os.fsync(file.fileno())
|
|
168
|
+
try:
|
|
169
|
+
temporary.replace(path)
|
|
170
|
+
finally:
|
|
171
|
+
temporary.unlink(missing_ok=True)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def main(payload: dict[str, object]) -> dict[str, object]:
|
|
175
|
+
action = payload.get("action")
|
|
176
|
+
if action == "read":
|
|
177
|
+
path = Path(str(payload["path"]))
|
|
178
|
+
if path.suffix.lower() not in SUPPORTED_TEXT_EXTENSIONS:
|
|
179
|
+
raise ValueError(f"unsupported file extension: {path.suffix or '<none>'}")
|
|
180
|
+
return read_file(path)
|
|
181
|
+
if action == "encode":
|
|
182
|
+
raw = encode_text(str(payload.get("content", "")), str(payload.get("encoding", "utf-8")), bool(payload.get("bom", False)), str(payload.get("newline", "lf")))
|
|
183
|
+
return {"bytes": base64.b64encode(raw).decode("ascii"), "size": len(raw)}
|
|
184
|
+
if action == "write":
|
|
185
|
+
path = Path(str(payload["path"]))
|
|
186
|
+
if path.suffix.lower() not in SUPPORTED_TEXT_EXTENSIONS:
|
|
187
|
+
raise ValueError(f"unsupported file extension: {path.suffix or '<none>'}")
|
|
188
|
+
current = path.read_bytes()
|
|
189
|
+
expected = str(payload.get("expectedSha256", ""))
|
|
190
|
+
actual = hashlib.sha256(current).hexdigest()
|
|
191
|
+
if expected and expected != actual:
|
|
192
|
+
raise RuntimeError("file changed on disk; reload before saving")
|
|
193
|
+
raw = encode_text(str(payload.get("content", "")), str(payload.get("encoding", "utf-8")), bool(payload.get("bom", False)), str(payload.get("newline", "lf")))
|
|
194
|
+
atomic_write(path, raw)
|
|
195
|
+
return {"ok": True, "size": len(raw), "sha256": hashlib.sha256(raw).hexdigest()}
|
|
196
|
+
raise ValueError(f"unknown action: {action}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
if __name__ == "__main__":
|
|
200
|
+
try:
|
|
201
|
+
request = json.load(sys.stdin)
|
|
202
|
+
# Keep the JSON transport ASCII-only so it is independent of the
|
|
203
|
+
# Windows console code page used by the hosting Python process.
|
|
204
|
+
json.dump(main(request), sys.stdout, ensure_ascii=True)
|
|
205
|
+
except Exception as error:
|
|
206
|
+
print(f"{type(error).__name__}: {error}", file=sys.stderr)
|
|
207
|
+
raise SystemExit(1)
|
package/requirements.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
charset-normalizer>=3.4,<4
|