dsh-vscode-mode 0.23.4 → 0.24.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.23.4",
3
+ "version": "0.24.0",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ SVN 集成(侧栏变更面板/日志弹窗/并排差异)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车,界面状态(页签/光标/侧栏/编辑 Tab/导航历史)镜像到 DSH home 跨重启恢复",
5
5
  "icon": "assets/icon.svg",
6
6
  "keywords": [
@@ -0,0 +1,84 @@
1
+ /**
2
+ * dsh-vscode-mode client — 加载失败诊断(把「加载失败」拆成可定位的四类原因)。
3
+ *
4
+ * 为什么需要:宿主对非 loopback/bind/trustedHosts 的 Host 一律回 403(Host 围栏),
5
+ * 认证失效回 401,路由或资源缺失回 404,另有纯网络失败。此前 Monaco/pdf.js 的失败面板
6
+ * 一律提示「检查资源是否完整」,远程访问时会把 403 误报成资源缺失,误导排查方向。
7
+ *
8
+ * 文案只输出 pathname 与状态码,不含 origin、查询串、cookie 或任何凭据。
9
+ * @author ddj 2026年11月02号
10
+ */
11
+
12
+ /** 状态码 0:请求未完成(网络错误 / 被拦截 / 离线)。 */
13
+ const NETWORK_FAILED = 0
14
+
15
+ /**
16
+ * 探测资源地址的真实响应状态。
17
+ * @author ddj 2026年11月02号
18
+ * @param url 已解析的绝对或根路径地址
19
+ * @returns HTTP 状态码;请求未完成时返回 0
20
+ */
21
+ export async function probeStatus(url: string): Promise<number> {
22
+ try {
23
+ const res = await fetch(url, { method: 'HEAD', credentials: 'same-origin' })
24
+ return res.status
25
+ } catch (e) {
26
+ return NETWORK_FAILED
27
+ }
28
+ }
29
+
30
+ /**
31
+ * 取地址的 pathname(只用于诊断文案,避免暴露 origin 与查询串)。
32
+ * @author ddj 2026年11月02号
33
+ * @param url 绝对或根路径地址
34
+ * @returns pathname;无法解析时原样返回
35
+ */
36
+ function pathOf(url: string): string {
37
+ try {
38
+ return new URL(url, 'http://localhost').pathname
39
+ } catch (e) {
40
+ return url
41
+ }
42
+ }
43
+
44
+ /**
45
+ * 状态码 → 可操作的原因说明(四类互不相同)。
46
+ * @author ddj 2026年11月02号
47
+ * @param status HTTP 状态码,0 表示请求未完成
48
+ * @returns 原因说明
49
+ */
50
+ export function statusHint(status: number): string {
51
+ if (status === NETWORK_FAILED) return '请求未到达宿主(网络不可达、被浏览器或代理拦截、或宿主已停止)'
52
+ if (status === 403) return '宿主拒绝了当前页面(Host/Origin 校验未通过;远程访问需经远程访问插件的通道)'
53
+ if (status === 401) return '宿主登录态失效,请重新打开已登录的应用页面'
54
+ if (status === 404) return '路由或资源不存在(插件资源缺失或插件版本与宿主不匹配)'
55
+ return '宿主返回了非预期状态'
56
+ }
57
+
58
+ /**
59
+ * 组装加载失败文案:标签 + 资源路径 + 状态 + 原因。
60
+ * @author ddj 2026年11月02号
61
+ * @param label 失败对象(如 'Monaco loader 加载失败')
62
+ * @param url 加载地址
63
+ * @param status 探测到的状态码(0=网络错误)
64
+ * @returns 用户可读的诊断文案
65
+ */
66
+ export function loadFailureText(label: string, url: string, status: number): string {
67
+ const outcome = status === NETWORK_FAILED ? '请求未完成' : '返回 HTTP ' + status
68
+ return label + ':' + pathOf(url) + ' ' + outcome + ';' + statusHint(status)
69
+ }
70
+
71
+ /**
72
+ * Monaco 失败面板提示:按错误内容给出对应排查方向(不再一律提示「检查资源是否完整」)。
73
+ * @author ddj 2026年11月02号
74
+ * @param message 编辑器记录的失败原因
75
+ * @returns 排查建议
76
+ */
77
+ export function monacoHint(message: string): string {
78
+ const text = String(message ?? '')
79
+ if (/403/.test(text)) return '宿主拒绝了本次请求:远程访问需经远程访问插件的通道(本页已自动接入);本机访问请用 127.0.0.1 或 localhost 打开。'
80
+ if (/401/.test(text)) return '宿主登录态失效:请重新打开已登录的应用页面后再试。'
81
+ if (/404/.test(text)) return 'Monaco 静态资源未找到:插件包 assets/vendor/monaco 可能不完整,或插件版本与宿主不匹配。'
82
+ if (/请求未完成|Failed to fetch|NetworkError/i.test(text)) return '请求未能到达宿主:请检查网络、代理与宿主进程状态。'
83
+ return '请展开下方详情,并在浏览器控制台确认失败请求的地址与状态码。'
84
+ }
@@ -6,9 +6,10 @@
6
6
  */
7
7
  import { applyOfficial, registerThemes } from './theme.js'
8
8
 
9
- import { appUrl } from '../../shared/appUrl.js'
9
+ import { transportUrl } from '../../shared/remoteChannel.js'
10
+ import { loadFailureText, probeStatus } from '../loadFailure.js'
10
11
 
11
- export const MONACO_BASE = appUrl('/edrv/vendor/monaco/vs')
12
+ export const MONACO_BASE = transportUrl('/edrv/vendor/monaco/vs')
12
13
  let monacoPromise = null
13
14
  let monacoStage = { phase: 'idle', progress: 0, message: '准备加载 Monaco…' }
14
15
  const stageListeners = new Set()
@@ -154,10 +155,12 @@ export function loadMonaco(onProgress) {
154
155
  s.src = MONACO_BASE + '/loader.js'
155
156
  s.dataset.edrvMonacoLoader = '1'
156
157
  s.onload = boot
157
- s.onerror = (event) => {
158
- // 保留首次失败真实原因(网络/HTTP 层事件),不让后续 TypeError 覆盖
159
- const hint = event && event.type ? '(' + event.type + ')' : ''
160
- fail(new Error('Monaco loader 加载失败' + hint))
158
+ s.onerror = async () => {
159
+ // 保留首次失败真实原因(网络/HTTP 层),不让后续 TypeError 覆盖;
160
+ // 探测真实状态码,把 403(Host 围栏)/401/404/网络错误区分开,避免一律提示「资源不完整」
161
+ const loaderUrl = MONACO_BASE + '/loader.js'
162
+ const status = await probeStatus(loaderUrl)
163
+ fail(new Error(loadFailureText('Monaco loader 加载失败', loaderUrl, status)))
161
164
  }
162
165
  hideNodeGlobals()
163
166
  document.head.appendChild(s)
@@ -7,10 +7,11 @@
7
7
  * 作者 ddj 2026-09-22
8
8
  */
9
9
 
10
- import { appUrl } from '../../shared/appUrl.js'
10
+ import { transportUrl } from '../../shared/remoteChannel.js'
11
+ import { loadFailureText, probeStatus } from '../loadFailure.js'
11
12
 
12
- /** vendor 根,保留官方桌面协议及反向代理前缀。 */
13
- export const PDFJS_BASE = appUrl('/edrv/vendor/pdfjs')
13
+ /** vendor 根,保留官方桌面协议、反向代理前缀与远程访问通道前缀。 */
14
+ export const PDFJS_BASE = transportUrl('/edrv/vendor/pdfjs')
14
15
  /** pdf.js Worker 地址(GlobalWorkerOptions.workerSrc)。 */
15
16
  export const PDFJS_WORKER_SRC = PDFJS_BASE + '/build/pdf.worker.mjs'
16
17
  /** CJK 字符映射目录(中文 PDF 渲染必需,尾斜杠固定)。 */
@@ -76,7 +77,12 @@ export function loadPdfjs() {
76
77
  'document.querySelector("script[data-edrv-pdfjs-loader]")?.dispatchEvent(new Event("edrv-pdfjs-ready"));'
77
78
  s.dataset.edrvPdfjsLoader = '1'
78
79
  s.onload = boot
79
- s.onerror = () => fail(new Error('pdf.js loader 脚本加载失败(检查 assets/vendor/pdfjs 完整 / /edrv/vendor 路由可达)'))
80
+ s.onerror = async () => {
81
+ // 探测真实状态码:403(Host 围栏)/401/404/网络错误各自给不同排查方向
82
+ const moduleUrl = PDFJS_BASE + '/build/pdf.mjs'
83
+ const status = await probeStatus(moduleUrl)
84
+ fail(new Error(loadFailureText('pdf.js 模块加载失败', moduleUrl, status)))
85
+ }
80
86
  document.head.appendChild(s)
81
87
  })
82
88
  }
package/src/client/rpc.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * 作者 ddj 2026-08-20 / 2026-09-17
6
6
  */
7
7
  import { svnNotice } from './svnNotice.js'
8
- import { appUrl } from '../shared/appUrl.js'
8
+ import { channelPrefix, transportUrl } from '../shared/remoteChannel.js'
9
9
  import { RPC_PATH } from '../shared/rpc.js'
10
10
  import type { RpcMethod, RpcRequestMap, RpcResult } from '../shared/rpc.js'
11
11
  import type { LoggerLevel } from '../shared/logger.js'
@@ -55,21 +55,14 @@ export async function rpc<M extends RpcMethod>(method: M, args: RpcRequestMap[M]
55
55
 
56
56
  /** @private @author ddj 2026年10月10号 @param method Typed method. @param args Request. @param signal Fetch lifetime. @returns Validated authenticated business response. @throws Transport/format errors. */
57
57
  async function rpcFetch<M extends RpcMethod>(method: M, args: RpcRequestMap[M], signal?: AbortSignal): Promise<RpcResult<M>> {
58
- const res = await fetch(appUrl(RPC_PATH), {
58
+ const res = await fetch(transportUrl(RPC_PATH), {
59
59
  method: 'POST',
60
60
  credentials: 'same-origin',
61
61
  headers: { 'content-type': 'application/json' },
62
62
  body: JSON.stringify({ method, args }),
63
63
  signal,
64
64
  })
65
- if (!res.ok) {
66
- const message = res.status === 401 ? 'DSH 登录已失效,请重新打开已登录的应用页面'
67
- : res.status === 403 ? 'DSH 拒绝了当前页面的访问'
68
- : res.status === 413 ? '请求超过支持的文件大小'
69
- : res.status === 503 ? 'DSH 认证服务尚未就绪'
70
- : 'DSH 请求失败(HTTP ' + res.status + ')'
71
- throw new Error(message)
72
- }
65
+ if (!res.ok) throw new Error(await failureMessage(res))
73
66
  if (!/^application\/json(?:\s*;|\s*$)/i.test(res.headers.get('content-type') ?? '')) {
74
67
  throw new Error('DSH 返回了非 JSON 响应,请检查应用地址和代理配置')
75
68
  }
@@ -80,6 +73,57 @@ async function rpcFetch<M extends RpcMethod>(method: M, args: RpcRequestMap[M],
80
73
  return value as RpcResult<M>
81
74
  }
82
75
 
76
+ /**
77
+ * 非 2xx 响应 → 可读失败原因:403 再区分「远程设备未配对」与「Host 围栏拒绝」。
78
+ * @author ddj 2026年11月02号
79
+ * @param res 失败响应
80
+ * @returns 用户可读原因
81
+ */
82
+ async function failureMessage(res: Response): Promise<string> {
83
+ if (res.status === 403) {
84
+ if (await unpairedDenied(res)) return '远程访问设备尚未配对:请在远程访问面板完成配对后重试(HTTP 403)'
85
+ return 'DSH 拒绝了当前页面的访问(HTTP 403,Host 校验未通过)' + channelNote()
86
+ }
87
+ if (res.status === 401) return 'DSH 登录已失效,请重新打开已登录的应用页面'
88
+ if (res.status === 404) return '插件路由不存在(HTTP 404),请确认 dsh-vscode-mode 与宿主版本匹配'
89
+ if (res.status === 413) return '请求超过支持的文件大小'
90
+ if (res.status === 503) return 'DSH 认证服务尚未就绪'
91
+ return 'DSH 请求失败(HTTP ' + res.status + ')'
92
+ }
93
+
94
+ /**
95
+ * 403 是否为远程访问通道的「未配对」拒绝(远程插件以 `{result:{ok:false,error:{code}}}` 回)。
96
+ * @author ddj 2026年11月02号
97
+ * @param res 403 响应
98
+ * @returns 是否为未配对拒绝
99
+ */
100
+ async function unpairedDenied(res: Response): Promise<boolean> {
101
+ try {
102
+ const body = (await res.clone().json()) as { result?: { error?: { code?: unknown } }; error?: { code?: unknown } } | null
103
+ const code = body?.result?.error?.code ?? body?.error?.code
104
+ return code === 'unpaired'
105
+ } catch (e) {
106
+ return false
107
+ }
108
+ }
109
+
110
+ /**
111
+ * 网络来源页面却未接入远程通道时的补充提示(本机页面不追加)。
112
+ * @author ddj 2026年11月02号
113
+ * @returns 提示后缀;无需提示时为空串
114
+ */
115
+ function channelNote(): string {
116
+ if (channelPrefix() !== '') return ''
117
+ try {
118
+ if (typeof location === 'undefined') return ''
119
+ const host = location.hostname
120
+ if (host === 'localhost' || host === '::1' || host === '[::1]' || /^127(\.\d{1,3}){3}$/.test(host)) return ''
121
+ return ';本页未接入远程访问通道(远程访问插件未安装或未完成配对)'
122
+ } catch (e) {
123
+ return ''
124
+ }
125
+ }
126
+
83
127
  /**
84
128
  * 诊断日志:开关关闭时直接返回(零 RPC 零落盘);开启时走 host edrv.debug 落盘。
85
129
  * @author ddj 2026年08月26号 / 2026年09月17号
@@ -44,7 +44,11 @@ function pendingRowsOf(page: ReviewPage | null): { rows: DiffFile[]; covered: Se
44
44
  for (const item of page?.items ?? []) {
45
45
  if (!item.id.startsWith(PENDING_PREFIX)) continue
46
46
  const path = item.currentPath || item.path
47
+ // 待审条目的 path 取自写入时的原始写法(agent 常用工作区相对路径),官方导航用的却是绝对身份:
48
+ // 只按 path 建覆盖集时,同一文件的官方汇总行不会被压掉,差异栏把 3 个文件算成 6 条。
49
+ // 身份(id 去掉待审前缀)与官方 identity 同源,必须一起入集。
47
50
  covered.add(diffPathKey(path))
51
+ covered.add(diffPathKey(item.id.slice(PENDING_PREFIX.length)))
48
52
  rows.push({
49
53
  path,
50
54
  label: item.display || path,
@@ -5,7 +5,7 @@
5
5
  * 作者 ddj 2026-08-20
6
6
  */
7
7
  import React from 'react'
8
- import { appUrl } from '../../shared/appUrl.js'
8
+ import { transportUrl } from '../../shared/remoteChannel.js'
9
9
  import { openEditorView, emitShowLauncher } from '../events.js'
10
10
  import { diffSummary } from '../state/diffSummary.js'
11
11
  import { subscribeReview } from '../state/reviewFeed.js'
@@ -44,6 +44,6 @@ export function DiffBadge(props) {
44
44
  title: '差异审查:' + n + ' 个待处理文件;点击查看总览与历史',
45
45
  style: { position: 'relative', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, padding: 0, background: 'transparent', border: 'none', borderRadius: 6, cursor: 'pointer' },
46
46
  },
47
- React.createElement('img', { src: appUrl('/edrv/assets/compare-select.png'), alt: '差异', style: { width: 22, height: 22, display: 'block' } }),
47
+ React.createElement('img', { src: transportUrl('/edrv/assets/compare-select.png'), alt: '差异', style: { width: 22, height: 22, display: 'block' } }),
48
48
  React.createElement('span', { style: { position: 'absolute', top: 1, right: 0, minWidth: 15, height: 15, padding: '0 3px', borderRadius: 8, background: 'var(--dsw-alias-state-warn-primary,#d97706)', color: '#fff', fontSize: 10, lineHeight: '15px', textAlign: 'center', fontWeight: 700, boxSizing: 'border-box' } }, String(n)))
49
49
  }
@@ -46,6 +46,7 @@ import { base64ToBytes, bytesToBase64, isPdfPath } from '../pdfPreview.js'
46
46
  import { inNativePath, tryNativeOpen } from '../nativeOpenStore.js'
47
47
  import { clearPendingNav, putPendingNav, readPendingNav } from '../pendingNav.js'
48
48
  import { readBinaryPreview } from '../../shared/rpc.js'
49
+ import { monacoHint } from '../loadFailure.js'
49
50
  import {
50
51
  clearBaseline,
51
52
  clearReadVersion,
@@ -4747,7 +4748,7 @@ export function EditorView(props) {
4747
4748
  } else if (monacoErr) {
4748
4749
  body = React.createElement('div', { className: 'edrv-empty' },
4749
4750
  React.createElement('div', null, 'Monaco 编辑器加载失败:' + String(monacoErr)),
4750
- React.createElement('div', { style: { fontSize: 11 } }, '请确认插件包 assets/vendor/monaco 完整(/edrv/vendor 路由可达)'),
4751
+ React.createElement('div', { style: { fontSize: 11 } }, monacoHint(String(monacoErr))),
4751
4752
  React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: () => { setMonacoErr(null); setLoadStage({ progress: 0, message: '准备重试 Monaco…' }) } }, '重试'))
4752
4753
  } else if (!active) {
4753
4754
  body = React.createElement('div', { className: 'edrv-empty' },
@@ -206,8 +206,15 @@ export function capabilityOf(entry: PendingEntry, flags: PendingFlags): { keep:
206
206
  * @author ddj 2026年10月29号
207
207
  * @description: 供详情接口复用的差异构造。
208
208
  * @param entry 待审条目
209
+ * @param baselineEmpty 基线置空:文件由本次会话新建,旧基线只是会话自己的中间产物
209
210
  * @returns 官方差异
210
211
  */
211
- export function diffOf(entry: PendingEntry): OfficialDiff {
212
- return textDiff(entry.path, entry.path, entry.oldText, entry.newText)
212
+ export function diffOf(entry: PendingEntry, baselineEmpty = false): OfficialDiff {
213
+ if (!baselineEmpty) return textDiff(entry.path, entry.path, entry.oldText, entry.newText)
214
+ // 空基线在按行切分时会留下一个空行,差异里会多出一条「删空行」;新增文件应当只有新增行,
215
+ // 与官方整文件新增块(oldLines=0、全是 + 行)保持同一形状。
216
+ if (entry.newText === '') return textDiff(entry.path, entry.path, '', '')
217
+ const diff = textDiff(entry.path, entry.path, '', entry.newText)
218
+ if (diff.kind !== 'text') return diff
219
+ return { ...diff, hunks: diff.hunks.map((hunk) => ({ ...hunk, oldLines: 0, lines: hunk.lines.filter((line) => line !== '-') })) }
213
220
  }
@@ -25,10 +25,11 @@ import {
25
25
  * @author ddj 2026年10月29号
26
26
  * @description: 统计一次差异的增删行数;非文本差异统一记 0/0。
27
27
  * @param entry 待审条目
28
+ * @param baselineEmpty 基线置空(新建文件按全量新增计)
28
29
  * @returns 增删行数
29
30
  */
30
- function countsOf(entry: PendingEntry): { added: number; deleted: number } {
31
- const diff = diffOf(entry)
31
+ function countsOf(entry: PendingEntry, baselineEmpty = false): { added: number; deleted: number } {
32
+ const diff = diffOf(entry, baselineEmpty)
32
33
  if (diff.kind !== 'text') return { added: 0, deleted: 0 }
33
34
  let added = 0
34
35
  let deleted = 0
@@ -93,11 +94,12 @@ export function foldWrite(stored: StoredEntry | undefined, write: PendingWrite):
93
94
  * @author ddj 2026年10月29号
94
95
  * @description: 条目转列表投影,已解决条目仍保留但不可再决定。
95
96
  * @param stored 条目与标记
97
+ * @param baselineEmpty 基线置空(新建文件按全量新增计)
96
98
  * @returns 列表项
97
99
  */
98
- export function itemOf(stored: StoredEntry): PendingItem {
100
+ export function itemOf(stored: StoredEntry, baselineEmpty = false): PendingItem {
99
101
  const { entry, flags } = stored
100
- const counts = countsOf(entry)
102
+ const counts = countsOf(entry, baselineEmpty)
101
103
  const item: PendingItem = {
102
104
  id: entry.id,
103
105
  path: entry.path,
@@ -123,12 +125,13 @@ export function itemOf(stored: StoredEntry): PendingItem {
123
125
  * @description: 读一页待审条目,按更新时间升序;会话过滤走血缘集合。
124
126
  * @param cwd 工作区
125
127
  * @param sessionId 当前会话
128
+ * @param wholeAdd 本次会话新建的文件身份集合,命中时基线置空
126
129
  * @returns 列表
127
130
  */
128
- export function loadPage(cwd: string, sessionId?: string): PendingPage {
131
+ export function loadPage(cwd: string, sessionId?: string, wholeAdd?: ReadonlySet<string>): PendingPage {
129
132
  const all = loadEntries(cwd)
130
133
  const visible = sessionId ? all.filter((stored) => stored.entry.sessionIds.includes(sessionId)) : all
131
- const items = visible.map(itemOf)
134
+ const items = visible.map((stored) => itemOf(stored, wholeAdd?.has(stored.entry.id) === true))
132
135
  const gaps: string[] = []
133
136
  for (const stored of visible) {
134
137
  if (stored.flags.oversized) gaps.push(`${stored.entry.path}: 文本超出上限,仅保留标记`)
@@ -141,14 +144,15 @@ export function loadPage(cwd: string, sessionId?: string): PendingPage {
141
144
  * @description: 读单条详情,包含可交给现有面板渲染的差异与块决定。
142
145
  * @param cwd 工作区
143
146
  * @param id 条目身份
147
+ * @param baselineEmpty 基线置空(新建文件按全量新增渲染)
144
148
  * @returns 详情,条目不存在时 null
145
149
  */
146
- export function loadDetail(cwd: string, id: string): PendingDetail | null {
150
+ export function loadDetail(cwd: string, id: string, baselineEmpty = false): PendingDetail | null {
147
151
  const stored = loadEntries(cwd).find((item) => item.entry.id === id)
148
152
  if (!stored) return null
149
- const diff = diffOf(stored.entry)
153
+ const diff = diffOf(stored.entry, baselineEmpty)
150
154
  return {
151
- item: itemOf(stored),
155
+ item: itemOf(stored, baselineEmpty),
152
156
  diff,
153
157
  blocks: blockStatesOf(stored.entry),
154
158
  capability: capabilityOf(stored.entry, stored.flags),
@@ -171,7 +171,13 @@ export function pendingCovers(cwd: string, path: string): boolean {
171
171
  * @returns 合并后的页
172
172
  */
173
173
  export function mergedPage(official: ReviewPage, pending: PendingPage, cwd?: string): ReviewPage {
174
- const covered = new Set(pending.items.map((item) => pathKey(item.path)))
174
+ const covered = new Set<string>()
175
+ for (const item of pending.items) {
176
+ // 待审条目的 path 是写入时的原始写法(常为工作区相对路径),官方条目用绝对身份:
177
+ // 只按 path 建覆盖集时同一文件会同时留下待审行与官方行,差异栏把一份改动算成两条。
178
+ covered.add(pathKey(item.path))
179
+ covered.add(pathKey(item.id))
180
+ }
175
181
  const kept = official.items.filter((item) => !covered.has(pathKey(item.currentPath)) && !covered.has(pathKey(item.path)))
176
182
  const items = [...pending.items.map((item) => pendingItemOf(item, cwd)), ...kept]
177
183
  return {
package/src/pendingRpc.ts CHANGED
@@ -8,6 +8,7 @@ import { readText } from './pending/capture.js'
8
8
  import { pendingRawId, trackPendingCover } from './pendingCompat.js'
9
9
  import { loadEntries, saveEntry } from './pending/persist.js'
10
10
  import { applyDecide, importFromVcs, itemOf, loadDetail, loadPage, refreshed, removeEntry, undoDecide } from './pending/store.js'
11
+ import { addedFiles } from './reviewDb.js'
11
12
  import type { ShellLike } from './pending/vcs.js'
12
13
  import type { Ctx } from './store.js'
13
14
  import { policyOf } from './store.js'
@@ -82,14 +83,16 @@ function readHandlers(kit: ScopeKit): Partial<RpcHandlerMap> {
82
83
  return {
83
84
  'edrv.pending.list': async (args: PendingRequest) => {
84
85
  const cwd = await kit.read(args.sessionId)
85
- const page = loadPage(cwd, args.sessionId)
86
+ // 与官方入口同口径:本会话新建的文件基线置空,列表里的增删行数才等于整文件行数
87
+ const page = loadPage(cwd, args.sessionId, addedFiles(cwd, args.sessionId))
86
88
  // 列表即登记接管关系;旧写入口守卫之后只读内存集合,不再产生数据库读
87
89
  for (const item of page.items) trackPendingCover(cwd, item.path, true)
88
90
  return { ok: true, page }
89
91
  },
90
92
  'edrv.pending.detail': async (args: PendingRequest & { id: string }) => {
91
93
  const cwd = await kit.read(args.sessionId)
92
- const detail = loadDetail(cwd, pendingRawId(args.id))
94
+ const rawId = pendingRawId(args.id)
95
+ const detail = loadDetail(cwd, rawId, addedFiles(cwd, args.sessionId).has(rawId))
93
96
  return detail ? { ok: true, detail } : { ok: false, error: '待审条目不存在' }
94
97
  },
95
98
  }
package/src/reviewDb.ts CHANGED
@@ -205,6 +205,31 @@ export function reviewPage(cwd: string, cursor = 0, history = false, path?: stri
205
205
 
206
206
  }
207
207
 
208
+ /**
209
+ * @public
210
+ * @author ddj 2026年10月31号
211
+ * @description: 该会话里由官方记录判为「整文件新增」的文件身份集合。待审条目命中时基线要置空:
212
+ * 文件是本会话新建的,累计基线只是会话自己的中间产物,拿它做旧侧会把新增文件渲染成一堆修改块。
213
+ * @param cwd 迁移后的工作区
214
+ * @param sessionId 会话
215
+ * @returns 整文件新增的文件身份集合
216
+ */
217
+ export function addedFiles(cwd: string, sessionId: string): Set<string> {
218
+ try {
219
+ const db = dbFor(cwd)
220
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE name='review_files' AND type='table'").get()) return new Set()
221
+ // 先按会话收窄再取每个文件最早的那条官方记录:首个块是整文件新增(before=false)才说明文件
222
+ // 由本次会话创建,后续修改块不能反过来把新建成改写成「有旧基线的修改」。
223
+ const rows = db.prepare(`SELECT identity FROM (SELECT identity,
224
+ json_extract(diff,'$.before') AS before, ROW_NUMBER() OVER (PARTITION BY identity ORDER BY turn, seq) AS rank
225
+ FROM review_files WHERE session_id=?) WHERE rank=1 AND before=0`).all(sessionId) as Row[]
226
+ return new Set(rows.map((row) => String(row.identity)))
227
+ } catch {
228
+ // 判定失败只退回旧的累计基线口径,不影响列表与详情可用
229
+ return new Set()
230
+ }
231
+ }
232
+
208
233
  /**
209
234
  * @public
210
235
  * @author ddj 2026年10月31号
package/src/reviewRpc.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import type { RpcHandlerMap } from './shared/rpc.js'
3
3
  import type { ReviewDetail, ReviewItem, ReviewPage } from './shared/review.js'
4
4
  import type { PendingPage } from './shared/pendingReview.js'
5
- import { decideReview, reviewDetail, reviewPage } from './reviewDb.js'
5
+ import { addedFiles, decideReview, reviewDetail, reviewPage } from './reviewDb.js'
6
6
  import { undoReview } from './reviewUndo.js'
7
7
  import { applyDecide, loadDetail, loadPage } from './pending/store.js'
8
8
  import { PENDING_PREFIX, mergedPage, pendingDetailOf, pendingItemOf, pendingSamePath } from './pendingCompat.js'
@@ -153,7 +153,9 @@ export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandler
153
153
  */
154
154
  function pendingPageOf(cwd: string, sessionId: string): PendingPage {
155
155
  try {
156
- const page = loadPage(cwd, sessionId)
156
+ // 本会话新建的文件由官方首个块判为整文件新增,待审基线必须跟着置空:
157
+ // 否则同一次新建会同时存在「官方说全加 516 行」与「待审说改 8 处」两套相反口径。
158
+ const page = loadPage(cwd, sessionId, addedFiles(cwd, sessionId))
157
159
  // 已结算(老文本与新文本一致)的投影没有净变化可审:留着会顶掉同路径的官方待审记录,
158
160
  // 让差异栏选中一条 Keep 不可用的条目,表现为「按钮亮着却点不动」。
159
161
  return page.items.some((item) => item.settled === true)
@@ -192,7 +194,9 @@ export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandler
192
194
  const scope = await scopeOf(args.sessionId)
193
195
  const cwd = scope.cwd
194
196
  if (args.id.startsWith(PENDING_PREFIX)) {
195
- const detail = loadDetail(cwd, args.id.slice(PENDING_PREFIX.length))
197
+ const entryId = args.id.slice(PENDING_PREFIX.length)
198
+ // 详情侧与列表侧必须同一口径:整文件新增的文件用空基线渲染,差异块才是全绿一整块
199
+ const detail = loadDetail(cwd, entryId, addedFiles(cwd, args.sessionId).has(entryId))
196
200
  if (!detail) return { ok: false, error: '待审条目不存在' }
197
201
 
198
202
  const mapped = pendingDetailOf(detail, new Date(detail.item.updatedAt).toISOString(), cwd)
@@ -0,0 +1,59 @@
1
+ /**
2
+ * 远程访问通道适配:插件内部路径 → 远程传输可达路径。
3
+ *
4
+ * 背景:宿主 connection 插件对 Host 不是 loopback / bind 地址 / trustedHosts 的请求一律回 403
5
+ * (Host 围栏先于任何 cookie 校验),因此 /edrv/rpc 与 /edrv/vendor/** 在局域网 IP 或隧道域名
6
+ * 页面上必然被拒。第三方远程访问插件(@linxin666/dsh-remote-web-ui)提供 /remote/<内部路径>
7
+ * 的 catch-all 代理:校验配对设备后回环重发到本进程,本插件自身的准入照旧执行。
8
+ *
9
+ * 该插件在 <head> 首部内联一段 classic script(解析期同步执行,早于任何 module script),并在
10
+ * window 上安装 seat 对象作为「本页已接入通道」的唯一标记。本模块据此选前缀:
11
+ * 有标记 ⇒ '/remote' + 原路径;无标记 ⇒ 原路径(本机、桌面 dsh-app:、未装该插件均不受影响)。
12
+ * 只决定 URL 前缀,不读取、不写入、不转发任何 cookie 或凭据。
13
+ *
14
+ * Purity rule: no node/react imports(与 appUrl.ts 一致)。
15
+ * @author ddj 2026年11月02号
16
+ */
17
+ import { appUrl } from './appUrl.js'
18
+
19
+ /** 远程访问插件在解析期安装的通道 seat 全局名(其 REMOTE_CHANNEL_BOOT_GLOBAL)。 */
20
+ export const CHANNEL_SEAT = '__DSH_REMOTE_CHANNEL_BOOT__'
21
+
22
+ /** 远程访问插件接管的前缀:/remote/<内部路径> 由服务端回环转发。 */
23
+ export const CHANNEL_PREFIX = '/remote'
24
+
25
+ /**
26
+ * 当前页面的通道前缀。
27
+ * @author ddj 2026年11月02号
28
+ * @returns 本页已接入远程通道时为 '/remote',否则空串(保持既有本地路径)。
29
+ */
30
+ export function channelPrefix(): string {
31
+ if (typeof window === 'undefined') return ''
32
+ try {
33
+ const seat = (window as unknown as Record<string, unknown>)[CHANNEL_SEAT]
34
+ return seat && typeof seat === 'object' ? CHANNEL_PREFIX : ''
35
+ } catch (e) {
36
+ return ''
37
+ }
38
+ }
39
+
40
+ /**
41
+ * 给插件内部路径加通道前缀。
42
+ * @author ddj 2026年11月02号
43
+ * @param path 以 '/' 开头的插件内部路径(合法性由 {@link transportUrl} 内的 appUrl 兜底校验)
44
+ * @returns 带通道前缀的路径;本机页面原样返回
45
+ */
46
+ export function transportPath(path: string): string {
47
+ return channelPrefix() + path
48
+ }
49
+
50
+ /**
51
+ * 把插件内部路径解析为当前页面可请求的地址(继承通道前缀、反向代理前缀与桌面协议)。
52
+ * @author ddj 2026年11月02号
53
+ * @param path 以 '/' 开头的插件内部路径
54
+ * @param base 测试用显式 document base;缺省同 appUrl(document.baseURI → location.href)
55
+ * @returns 应用地址;无浏览器环境时返回原根路径
56
+ */
57
+ export function transportUrl(path: string, base?: string): string {
58
+ return appUrl(transportPath(path), base)
59
+ }
package/src/shared/rpc.ts CHANGED
@@ -24,7 +24,7 @@ import type { AiConfigPatch, AiConfigView, AiDirectoryView, AiInlineRequest, AiI
24
24
  import type { SvnAction, SvnAiPlan, SvnChangeEntry, SvnConflictArtifact, SvnDiffRevResult, SvnIgnoreItem, SvnLogEntry, SvnRemoteOutdatedEntry, SvnStatusPayload, SvnSumEntry, SvnUpdatePoll, SvnUpdateResult, SvnUpdateState } from './svn.js'
25
25
  import type { DapAction, DapAdapterInfo, DapBreakpointAck, DapBreakpointInput, DapConfigSnippet, DapConfigSource, DapDebugConfig, DapFrameView, DapPhase, DapPollResult, DapProcessInfo, DapScopeView, DapVariableView } from './dap.js'
26
26
  import { DAP_ACTIONS } from './dap.js'
27
- import { appUrl } from './appUrl.js'
27
+ import { transportUrl } from './remoteChannel.js'
28
28
  import type { LoggerLevel } from './logger.js'
29
29
  import type { SvnViewRequest, SvnViewPayload } from './svnExplorer.js'
30
30
  import type { DecideReview, ReviewDetail, ReviewPage, ReviewRequest } from './review.js'
@@ -153,7 +153,7 @@ export async function readBinaryPreview(
153
153
  */
154
154
  async function tryBinaryRead(args: RpcRequestMap['edrv.readBinary']): Promise<PreviewRead | null> {
155
155
  try {
156
- const res = await fetch(appUrl(RPC_PATH), {
156
+ const res = await fetch(transportUrl(RPC_PATH), {
157
157
  method: 'POST',
158
158
  headers: { 'content-type': 'application/json' },
159
159
  body: JSON.stringify({ method: 'edrv.readBinary', args }),