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 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)
@@ -0,0 +1 @@
1
+ charset-normalizer>=3.4,<4