dsh-vscode-mode 0.20.3 → 0.20.5
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/README.md +6 -4
- package/lib/client.js +352 -232
- package/lib/client.js.map +1 -1
- package/lib/index.js +124 -23
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/reviewBar.ts +41 -0
- package/src/client/sessionError.ts +6 -3
- package/src/client/styles/editor.css +3 -0
- package/src/client/ui/DiffBox.ts +11 -108
- package/src/client/ui/EditorView.ts +11 -1
- package/src/client/ui/OfficialDiffBar.tsx +23 -18
- package/src/client/ui/PerfSettings.ts +1 -1
- package/src/client/ui/SidecarDiffBar.tsx +154 -0
- package/src/client/ui/reviewAllRow.tsx +48 -0
- package/src/registry.ts +98 -0
- package/src/reviewRpc.ts +25 -9
- package/src/rpc.ts +20 -18
- package/src/store.ts +13 -0
package/src/registry.ts
CHANGED
|
@@ -6,10 +6,29 @@
|
|
|
6
6
|
import type { DiffRecord } from './shared/types.js'
|
|
7
7
|
import type { Ctx, Session } from './store.js'
|
|
8
8
|
import { cwdOfSession, loadBucket } from './store.js'
|
|
9
|
+
import { SESSION_MISSING_ERROR, SESSION_NO_WORKSPACE_ERROR } from './shared/rpc.js'
|
|
9
10
|
|
|
10
11
|
/** cwd → 记录桶(内存缓存;随会话销毁清理)。 */
|
|
11
12
|
export type Registry = Map<string, Map<string, DiffRecord>>
|
|
12
13
|
|
|
14
|
+
/** 会话作用域:本进程 live 会话可带 session;跨实例冷读只有已确认的 cwd。 */
|
|
15
|
+
export interface SessionScope {
|
|
16
|
+
/** 本进程 live 会话;冷读持久化会话时为 undefined。 */
|
|
17
|
+
session?: Session
|
|
18
|
+
/** 已确认的绝对工作区路径。 */
|
|
19
|
+
cwd: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** sessionQuery 会话记录的最小读取面:只取 header.cwd,不依赖 DSH 类型包。 */
|
|
23
|
+
interface ColdSessionRecord {
|
|
24
|
+
header?: { id?: string; cwd?: string }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** sessionId → cwd 冷读缓存:header.cwd 建会话后不可变,故只增不改。 */
|
|
28
|
+
const coldCwdCache = new Map<string, string>()
|
|
29
|
+
/** 冷读缓存上限:超出后整表清空,避免长跑实例无限累积。 */
|
|
30
|
+
const COLD_CWD_CACHE_MAX = 2048
|
|
31
|
+
|
|
13
32
|
/** 按 sessionId 取会话;缺省时仅当唯一活跃会话才返回。 */
|
|
14
33
|
export function sessionOf(ctx: Ctx, sessionId?: string): Session | undefined {
|
|
15
34
|
const sessions = ctx.get('sessions')
|
|
@@ -46,3 +65,82 @@ export function bucketOf(registry: Registry, ctx: Ctx, cwd: string): Promise<Map
|
|
|
46
65
|
return map
|
|
47
66
|
})
|
|
48
67
|
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 解析会话作用域:优先本进程 live 会话;未命中时冷读持久化会话头取 cwd。
|
|
71
|
+
* 仅供只读 RPC 使用——桌面端与 Web 端各有独立宿主,会话只在创建端 live,
|
|
72
|
+
* 但审查数据按 cwd 落盘、跨实例可读;写类 RPC 必须另行要求 live 会话。
|
|
73
|
+
* @author ddj 2026年10月28号
|
|
74
|
+
* @param ctx DSH 上下文(可选探测 sessions 与 sessionQuery)
|
|
75
|
+
* @param sessionId 目标会话 id(冷读必需;缺省时只认唯一活跃会话)
|
|
76
|
+
* @returns 会话作用域,或与 requireSession 同源的错误文案
|
|
77
|
+
*/
|
|
78
|
+
export async function resolveScope(ctx: Ctx, sessionId: string | undefined): Promise<SessionScope | { err: string }> {
|
|
79
|
+
const session = sessionOf(ctx, sessionId)
|
|
80
|
+
if (session) {
|
|
81
|
+
const cwd = cwdOfSession(ctx, session)
|
|
82
|
+
return cwd ? { session, cwd } : { err: SESSION_NO_WORKSPACE_ERROR }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const cwd = await coldCwdOf(ctx, sessionId)
|
|
86
|
+
return cwd ? { cwd } : { err: SESSION_MISSING_ERROR }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 冷读持久化会话头的 cwd:走 DSH sessionQuery 的持久化视图,不读事件日志、不碰写句柄。
|
|
91
|
+
* @author ddj 2026年10月28号
|
|
92
|
+
* @param ctx DSH 上下文
|
|
93
|
+
* @param sessionId 目标会话 id
|
|
94
|
+
* @returns 绝对路径;服务缺失、查无此会话或读取失败时 null(失败语义等同 live 未命中)
|
|
95
|
+
*/
|
|
96
|
+
export async function coldCwdOf(ctx: Ctx, sessionId: string | undefined): Promise<string | null> {
|
|
97
|
+
if (!sessionId) return null
|
|
98
|
+
const cached = coldCwdCache.get(sessionId)
|
|
99
|
+
if (cached) return cached
|
|
100
|
+
|
|
101
|
+
const query = ctx?.get?.('sessionQuery')
|
|
102
|
+
if (!query) return null
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const record = await findColdRecord(query, sessionId)
|
|
106
|
+
const cwd = record?.header?.cwd
|
|
107
|
+
if (typeof cwd !== 'string' || !cwd) return null
|
|
108
|
+
rememberColdCwd(sessionId, cwd)
|
|
109
|
+
return cwd
|
|
110
|
+
} catch {
|
|
111
|
+
// 冷读故障不改语义:仍按「会话不存在」上报,避免把存储异常说成权限问题
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 在 sessionQuery 的持久化会话视图里定位一条记录(优先精确过滤,其次整表列举)。
|
|
118
|
+
* 两种能力都缺失时返回 null,调用方回落原有「会话不存在」。
|
|
119
|
+
* @author ddj 2026年10月28号
|
|
120
|
+
* @param query ctx.sessionQuery 服务(不同 DSH 版本能力不同)
|
|
121
|
+
* @param sessionId 目标会话 id
|
|
122
|
+
* @returns 会话记录;无此会话或能力缺失时 null
|
|
123
|
+
*/
|
|
124
|
+
async function findColdRecord(query: Ctx, sessionId: string): Promise<ColdSessionRecord | null> {
|
|
125
|
+
const filterable = typeof query.filterSessions === 'function'
|
|
126
|
+
const listable = typeof query.listSessions === 'function'
|
|
127
|
+
if (!filterable && !listable) return null
|
|
128
|
+
|
|
129
|
+
const records = filterable
|
|
130
|
+
? await query.filterSessions([{ kind: 'id', values: [sessionId] }])
|
|
131
|
+
: await query.listSessions()
|
|
132
|
+
if (!Array.isArray(records)) return null
|
|
133
|
+
|
|
134
|
+
return records.find((item: ColdSessionRecord) => item?.header?.id === sessionId) ?? null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 记录冷读结果(header.cwd 不可变,可长期复用)。
|
|
139
|
+
* @author ddj 2026年10月28号
|
|
140
|
+
* @param sessionId 目标会话 id
|
|
141
|
+
* @param cwd 已确认的绝对工作区路径
|
|
142
|
+
*/
|
|
143
|
+
function rememberColdCwd(sessionId: string, cwd: string): void {
|
|
144
|
+
if (coldCwdCache.size >= COLD_CWD_CACHE_MAX) coldCwdCache.clear()
|
|
145
|
+
coldCwdCache.set(sessionId, cwd)
|
|
146
|
+
}
|
package/src/reviewRpc.ts
CHANGED
|
@@ -2,19 +2,34 @@
|
|
|
2
2
|
import type { RpcHandlerMap } from './shared/rpc.js'
|
|
3
3
|
import { decideReview, reviewDetail, reviewPage } from './reviewDb.js'
|
|
4
4
|
import { undoReview } from './reviewUndo.js'
|
|
5
|
-
import { bucketOf,
|
|
5
|
+
import { bucketOf, resolveScope, sessionOf } from './registry.js'
|
|
6
6
|
import type { Registry } from './registry.js'
|
|
7
7
|
import type { Ctx } from './store.js'
|
|
8
8
|
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* @public
|
|
11
|
+
* @author ddj 2026年10月26号 / 2026年10月28号
|
|
12
|
+
* @description: 审查 RPC 处理器。工作区一律由 host 解析,绝不采信调用方 cwd:
|
|
13
|
+
* 读路径优先 live 会话、其次冷读持久化会话头(跨实例只读);写路径仍须 live 会话。
|
|
14
|
+
* @param ctx Host 上下文
|
|
15
|
+
* @param registry 工作区记录桶缓存
|
|
16
|
+
* @returns 审查相关处理器表
|
|
17
|
+
*/
|
|
10
18
|
export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandlerMap> {
|
|
11
|
-
/** @private @author ddj 2026年10月
|
|
19
|
+
/** @private @author ddj 2026年10月28号 @param sessionId 请求作用域 @returns 已迁移工作区(允许跨实例冷读) */
|
|
12
20
|
async function workspace(sessionId: string): Promise<string> {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
const scope = await resolveScope(ctx, sessionId)
|
|
22
|
+
if ('err' in scope) throw new Error('会话不存在或没有工作区')
|
|
23
|
+
await bucketOf(registry, ctx, scope.cwd)
|
|
24
|
+
return scope.cwd
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @private @author ddj 2026年10月28号 @param sessionId 请求作用域 @returns 本端 live 会话的工作区;冷读作用域一律拒绝写入 */
|
|
28
|
+
async function liveWorkspace(sessionId: string): Promise<string> {
|
|
29
|
+
const scope = await resolveScope(ctx, sessionId)
|
|
30
|
+
if ('err' in scope || !scope.session) throw new Error('会话不存在或没有工作区')
|
|
31
|
+
await bucketOf(registry, ctx, scope.cwd)
|
|
32
|
+
return scope.cwd
|
|
18
33
|
}
|
|
19
34
|
return {
|
|
20
35
|
'edrv.review.list': async (args) => {
|
|
@@ -27,7 +42,8 @@ export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandler
|
|
|
27
42
|
return detail ? { ok: true, detail } : { ok: false, error: '官方审查记录不存在' }
|
|
28
43
|
},
|
|
29
44
|
'edrv.review.decide': async (args) => {
|
|
30
|
-
|
|
45
|
+
// 写类操作要求 live 会话:避免两端并发写同一工作区审查库,回滚也依赖本端会话内容
|
|
46
|
+
const cwd = await liveWorkspace(args.sessionId)
|
|
31
47
|
try {
|
|
32
48
|
const result = args.decision === 'rejected'
|
|
33
49
|
? await undoReview(ctx, sessionOf(ctx, args.sessionId), cwd, args)
|
package/src/rpc.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
autoArchiveStale,
|
|
18
18
|
policyOf,
|
|
19
19
|
resolveTarget,
|
|
20
|
+
resolveTargetIn,
|
|
20
21
|
saveBucket,
|
|
21
22
|
} from './store.js'
|
|
22
23
|
import { cp, mkdir, readFile, rename, rm, stat, writeFile, readdir } from 'node:fs/promises'
|
|
@@ -25,8 +26,8 @@ import { archiveEntryFor, markDecision, recordResolved, reconstructOriginal } fr
|
|
|
25
26
|
import { clearRevertState, listArchives, pageArchives, readArchives, revertState, setRevertState, startReverts } from './archiveDb.js'
|
|
26
27
|
import { hasOfficial } from './reviewDb.js'
|
|
27
28
|
import { log } from './log.js'
|
|
28
|
-
import type { Registry } from './registry.js'
|
|
29
|
-
import { bucketOf, cwdOfSession, sessionOf } from './registry.js'
|
|
29
|
+
import type { Registry, SessionScope } from './registry.js'
|
|
30
|
+
import { bucketOf, cwdOfSession, resolveScope, sessionOf } from './registry.js'
|
|
30
31
|
import type { SearchOrchestrator } from './search/orchestrator.js'
|
|
31
32
|
import { newSearcher } from './search/orchestrator.js'
|
|
32
33
|
import type { ContentSearcher } from './search/content.js'
|
|
@@ -166,13 +167,13 @@ interface ReadTargetOk {
|
|
|
166
167
|
*/
|
|
167
168
|
async function readTargetOf(
|
|
168
169
|
ctx: Ctx,
|
|
169
|
-
sc:
|
|
170
|
+
sc: SessionScope,
|
|
170
171
|
path: string,
|
|
171
172
|
): Promise<ReadTargetOk | { err: string; resolvedPath?: string; code?: 'file-too-large' }> {
|
|
172
173
|
const fs = ctx.get('fs')
|
|
173
174
|
if (!fs) return { err: '缺少 fs' }
|
|
174
175
|
try {
|
|
175
|
-
const target = await
|
|
176
|
+
const target = await resolveTargetIn(ctx, sc.cwd, path)
|
|
176
177
|
debugRecord(ctx, sc.cwd, '[DEBUG path.resolve] input=' + String(path ?? '') + ' resolved=' + fs.processPath(target), 'debug')
|
|
177
178
|
const info = await fs.stat(target)
|
|
178
179
|
if (!info || info.type !== 'file') {
|
|
@@ -596,7 +597,7 @@ export function buildHandlers(
|
|
|
596
597
|
let staleMs = 0
|
|
597
598
|
let staleChecked = false
|
|
598
599
|
// #endregion
|
|
599
|
-
const sc = await
|
|
600
|
+
const sc = await resolveScope(ctx, args.sessionId)
|
|
600
601
|
if ('err' in sc) return { ok: false, error: sc.err }
|
|
601
602
|
const bucket = await bucketOf(registry, ctx, sc.cwd)
|
|
602
603
|
// #region debug log
|
|
@@ -606,7 +607,8 @@ export function buildHandlers(
|
|
|
606
607
|
// 全量轮询时自动清理 stale 幽灵差异(批量决策后的即时刷新可 skipStale 跳过);
|
|
607
608
|
// 三个组件各自 5s 全量 list,stale 检查节流到 STALE_CHECK_MIN_MS 一次,
|
|
608
609
|
// 避免每轮都对全部记录读文件 + 算指纹(keepall 期间多组件同时刷新的主要 host 开销)。
|
|
609
|
-
|
|
610
|
+
// 跨实例冷读没有 live 会话:stale 清理要写侧车与归档,一律跳过(只读语义)。
|
|
611
|
+
if (sc.session && !want && args.skipStale !== true) {
|
|
610
612
|
const now = Date.now()
|
|
611
613
|
if (now - (staleCheckedAt.get(sc.cwd) ?? 0) > STALE_CHECK_MIN_MS) {
|
|
612
614
|
staleCheckedAt.set(sc.cwd, now)
|
|
@@ -645,7 +647,7 @@ export function buildHandlers(
|
|
|
645
647
|
* @author ddj 2026年09月28号
|
|
646
648
|
*/
|
|
647
649
|
'edrv.uiState.get': async (args) => {
|
|
648
|
-
const sc = await
|
|
650
|
+
const sc = await resolveScope(ctx, args.sessionId)
|
|
649
651
|
if ('err' in sc) return { ok: false, error: sc.err }
|
|
650
652
|
const keys = await readUiState(uiStateFile(sc.cwd))
|
|
651
653
|
const globals = await readUiState(uiGlobalsFile())
|
|
@@ -657,7 +659,7 @@ export function buildHandlers(
|
|
|
657
659
|
* @author ddj 2026年09月28号 / 2026年09月29号
|
|
658
660
|
*/
|
|
659
661
|
'edrv.uiState.set': async (args) => {
|
|
660
|
-
const sc = await
|
|
662
|
+
const sc = await resolveScope(ctx, args.sessionId)
|
|
661
663
|
if ('err' in sc) return { ok: false, error: sc.err }
|
|
662
664
|
const tasks: Array<Promise<{ ok: true } | { ok: false; error: string }>> = []
|
|
663
665
|
if (args.keys && Object.keys(args.keys).length) tasks.push(writeUiState(uiStateFile(sc.cwd), args.keys))
|
|
@@ -700,10 +702,10 @@ export function buildHandlers(
|
|
|
700
702
|
},
|
|
701
703
|
/** @author ddj 2026年10月22号 @param args Read-only request. @returns Bounded official range read, or unsupported on older providers; never bypasses Host IO. */
|
|
702
704
|
'edrv.textPage': async (args) => {
|
|
703
|
-
const sc = await
|
|
705
|
+
const sc = await resolveScope(ctx, args.sessionId)
|
|
704
706
|
if ('err' in sc) return { ok: false, error: sc.err, reason: 'read-failed' }
|
|
705
707
|
try {
|
|
706
|
-
const target = await
|
|
708
|
+
const target = await resolveTargetIn(ctx, sc.cwd, args.path)
|
|
707
709
|
const fs = ctx.get('fs')
|
|
708
710
|
if (typeof fs?.readByteRange !== 'function' || typeof fs.stat !== 'function') return readTextPage(undefined, args)
|
|
709
711
|
// Keys are opaque identities, never decoded into physical paths. Both legacy string and official FsTarget are supported.
|
|
@@ -741,7 +743,7 @@ export function buildHandlers(
|
|
|
741
743
|
return { ok: false, error: '读取片段文件失败:' + String(error), resolvedPath: snippetTarget }
|
|
742
744
|
}
|
|
743
745
|
}
|
|
744
|
-
const sc = await
|
|
746
|
+
const sc = await resolveScope(ctx, args.sessionId)
|
|
745
747
|
if ('err' in sc) return { ok: false, error: sc.err }
|
|
746
748
|
const prep = await readTargetOf(ctx, sc, args.path)
|
|
747
749
|
if ('err' in prep) return targetErrOf(prep)
|
|
@@ -763,7 +765,7 @@ export function buildHandlers(
|
|
|
763
765
|
// 二进制直读通道(图片/PDF 预览):与 edrv.read base64 分支共用 readTargetOf 前置,
|
|
764
766
|
// 返回 Uint8Array 信封;routes 接线后以 octet-stream + x-edrv-* 头直出,
|
|
765
767
|
// 未接线时信封被 JSON 化,由 client 探测到后自动回退 base64 路径(见 shared/rpc readBinaryPreview)。
|
|
766
|
-
const sc = await
|
|
768
|
+
const sc = await resolveScope(ctx, args.sessionId)
|
|
767
769
|
if ('err' in sc) return { ok: false, error: sc.err }
|
|
768
770
|
const prep = await readTargetOf(ctx, sc, args.path)
|
|
769
771
|
if ('err' in prep) return targetErrOf(prep)
|
|
@@ -802,12 +804,12 @@ export function buildHandlers(
|
|
|
802
804
|
'edrv.original': async (args) => {
|
|
803
805
|
// 重建"本批次修改前"内容:DiffEditor 原始侧。仅反解 pending 块;
|
|
804
806
|
// 全部反解失败时回退到最早记录的整体 before(若存在)。
|
|
805
|
-
const sc = await
|
|
807
|
+
const sc = await resolveScope(ctx, args.sessionId)
|
|
806
808
|
if ('err' in sc) return { ok: false, error: sc.err }
|
|
807
809
|
const fs = ctx.get('fs')
|
|
808
810
|
if (!fs) return { ok: false, error: '缺少 fs' }
|
|
809
811
|
try {
|
|
810
|
-
const target = await
|
|
812
|
+
const target = await resolveTargetIn(ctx, sc.cwd, args.path)
|
|
811
813
|
debugRecord(ctx, sc.cwd, '[DEBUG path.resolve] input=' + String(args.path ?? '') + ' resolved=' + fs.processPath(target), 'debug')
|
|
812
814
|
const info = await fs.stat(target)
|
|
813
815
|
if (!info || info.type !== 'file') return { ok: false, error: '文件不存在' }
|
|
@@ -898,7 +900,7 @@ export function buildHandlers(
|
|
|
898
900
|
}
|
|
899
901
|
},
|
|
900
902
|
'edrv.archiveList': async (args) => {
|
|
901
|
-
const sc = await
|
|
903
|
+
const sc = await resolveScope(ctx, args.sessionId)
|
|
902
904
|
if ('err' in sc) return { ok: false, error: sc.err }
|
|
903
905
|
try {
|
|
904
906
|
const ws = findRemoteWs(sc.cwd)
|
|
@@ -911,7 +913,7 @@ export function buildHandlers(
|
|
|
911
913
|
}
|
|
912
914
|
},
|
|
913
915
|
'edrv.archivePage': async (args) => {
|
|
914
|
-
const sc = await
|
|
916
|
+
const sc = await resolveScope(ctx, args.sessionId)
|
|
915
917
|
if ('err' in sc) return { ok: false, error: sc.err }
|
|
916
918
|
try {
|
|
917
919
|
const ws = findRemoteWs(sc.cwd)
|
|
@@ -924,7 +926,7 @@ export function buildHandlers(
|
|
|
924
926
|
}
|
|
925
927
|
},
|
|
926
928
|
'edrv.archiveRead': async (args) => {
|
|
927
|
-
const sc = await
|
|
929
|
+
const sc = await resolveScope(ctx, args.sessionId)
|
|
928
930
|
if ('err' in sc) return { ok: false, error: sc.err }
|
|
929
931
|
try {
|
|
930
932
|
if (args.batch !== undefined && (!args.path || (args.batch !== null && !Number.isInteger(args.batch)))) return { ok: false, error: '归档批次参数无效' }
|
|
@@ -1064,7 +1066,7 @@ export function buildHandlers(
|
|
|
1064
1066
|
'edrv.listDir': async (args) => {
|
|
1065
1067
|
// 目录树(侧边栏文件管理用):树索引命中直出(内存,0 IO);失效/force 时
|
|
1066
1068
|
// 走快路径(resolve + listDirCheap + putIndex);同路径在途请求去重复用。
|
|
1067
|
-
const sc = await
|
|
1069
|
+
const sc = await resolveScope(ctx, args.sessionId)
|
|
1068
1070
|
if ('err' in sc) return { ok: false, error: sc.err }
|
|
1069
1071
|
const rel = normalizeRel(args.path)
|
|
1070
1072
|
if (rel === null) return { ok: false, error: '路径不合法' }
|
package/src/store.ts
CHANGED
|
@@ -65,6 +65,19 @@ export async function resolveTarget(ctx: Ctx, session: Session | undefined, path
|
|
|
65
65
|
return fs.resolve(path, cwd ? { cwd } : {})
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* 按已知工作区目录解析目标路径:跨实例只读路径在无 live 会话时使用。
|
|
70
|
+
* @author ddj 2026年10月28号
|
|
71
|
+
* @param ctx DSH 上下文
|
|
72
|
+
* @param cwd 工作区绝对路径(live 投影或持久化 header)
|
|
73
|
+
* @param path 客户端请求路径
|
|
74
|
+
* @returns 解析后的目标
|
|
75
|
+
*/
|
|
76
|
+
export async function resolveTargetIn(ctx: Ctx, cwd: string, path: string): Promise<string> {
|
|
77
|
+
const fs = ctx.get('fs')
|
|
78
|
+
return fs.resolve(path, cwd ? { cwd } : {})
|
|
79
|
+
}
|
|
80
|
+
|
|
68
81
|
/** 读取工作区主 sidecar 文本(缺失/失败 → null)。 */
|
|
69
82
|
async function readSidecarText(ctx: Ctx, cwd: string | null): Promise<string | null> {
|
|
70
83
|
const fs = ctx.get('fs')
|