dsh-vscode-mode 0.5.0 → 0.5.1
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 +8 -2
- package/lib/client.js +518 -71
- package/lib/client.js.map +1 -1
- package/lib/index.js +10 -3
- package/lib/index.js.map +1 -1
- package/package.json +7 -7
- package/src/client/ai/inlineProvider.ts +42 -1
- package/src/client/index.ts +47 -45
- package/src/client/monaco/lsp/index.ts +33 -3
- package/src/client/monaco/lsp/lspClient.ts +3 -1
- package/src/client/monaco/lsp/providers.ts +32 -0
- package/src/client/officialSidebar.ts +67 -3
- package/src/client/sessionScope.ts +183 -0
- package/src/client/snippets/provider.ts +26 -4
- package/src/client/tabActions.ts +30 -5
- package/src/client/ui/AiSettings.ts +8 -0
- package/src/client/ui/EditorView.ts +51 -13
- package/src/compat.ts +1 -1
- package/src/dshVersion.ts +8 -1
- package/src/shared/ai.ts +25 -0
|
@@ -23,6 +23,16 @@ let pending = null
|
|
|
23
23
|
let registered = false
|
|
24
24
|
let disposer = null
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* 片段 provider 注销器的全局挂点名。
|
|
28
|
+
*
|
|
29
|
+
* 为什么挂 window:`window.monaco` 由 loader 注入后**跨插件重载存活**,而模块级
|
|
30
|
+
* registered/disposer 会随 bundle 重新求值复位 —— 只判 registered 会在每次重载后
|
|
31
|
+
* 重复注册补全 provider(同一编辑器出现重复候补)。故注销器落 window 供跨代认领与清理。
|
|
32
|
+
* @author ddj 2026年09月18号
|
|
33
|
+
*/
|
|
34
|
+
const SNIPPET_GLOBAL = '__edrvSnippetsDisposer__'
|
|
35
|
+
|
|
26
36
|
/** 当前会话 id(补全按会话工作区叠加项目片段;EditorView 装配时注入)。 */
|
|
27
37
|
let sessionId = null
|
|
28
38
|
|
|
@@ -102,6 +112,12 @@ export function entriesForLanguage(entries, languageId) {
|
|
|
102
112
|
*/
|
|
103
113
|
export function registerSnippetProvider(monaco) {
|
|
104
114
|
if (registered || !monaco?.languages?.registerCompletionItemProvider) return
|
|
115
|
+
// 跨重载守卫:上一代 bundle 注册的 provider 仍在存活 window.monaco 上(注销器已落 window)
|
|
116
|
+
const host = /* @__PURE__ */ (typeof window === 'undefined' ? undefined : window)
|
|
117
|
+
if (host && host[SNIPPET_GLOBAL]) {
|
|
118
|
+
registered = true
|
|
119
|
+
return
|
|
120
|
+
}
|
|
105
121
|
const snippetKind = monaco.languages.CompletionItemKind?.Snippet
|
|
106
122
|
const asSnippet = monaco.languages.CompletionItemInsertTextRule?.InsertAsSnippet
|
|
107
123
|
// 缺少片段枚举(精简版 Monaco)时不注册:否则候选项会退化为纯文本插入,误导用户
|
|
@@ -135,6 +151,8 @@ export function registerSnippetProvider(monaco) {
|
|
|
135
151
|
}
|
|
136
152
|
},
|
|
137
153
|
})
|
|
154
|
+
// 注销器落 window:跨重载认领(见 SNIPPET_GLOBAL 说明)
|
|
155
|
+
if (host) host[SNIPPET_GLOBAL] = disposer
|
|
138
156
|
}
|
|
139
157
|
|
|
140
158
|
/**
|
|
@@ -147,13 +165,17 @@ export function setupSnippets(monaco) {
|
|
|
147
165
|
}
|
|
148
166
|
|
|
149
167
|
/**
|
|
150
|
-
* 卸载:注销 provider
|
|
151
|
-
*
|
|
168
|
+
* 卸载:注销 provider 并复位状态(插件热重载/卸载时调用)。
|
|
169
|
+
* 兼容上一代 bundle 遗留的 window 注销器(模块级 disposer 为空时仍能清干净)。
|
|
170
|
+
* @author ddj 2026年09月10号 / 2026年09月18号
|
|
152
171
|
*/
|
|
153
172
|
export function disposeSnippets() {
|
|
154
|
-
|
|
155
|
-
|
|
173
|
+
const host = /* @__PURE__ */ (typeof window === 'undefined' ? undefined : window)
|
|
174
|
+
const target = (typeof disposer === 'function' ? disposer : null) ?? (host ? host[SNIPPET_GLOBAL] : null)
|
|
175
|
+
if (target && typeof target.dispose === 'function') {
|
|
176
|
+
try { target.dispose() } catch { /* 已注销 */ }
|
|
156
177
|
}
|
|
178
|
+
if (host) delete host[SNIPPET_GLOBAL]
|
|
157
179
|
disposer = null
|
|
158
180
|
registered = false
|
|
159
181
|
invalidateSnippets()
|
package/src/client/tabActions.ts
CHANGED
|
@@ -184,23 +184,48 @@ function isPinned(tab: TabLike): boolean {
|
|
|
184
184
|
/**
|
|
185
185
|
* 归一化持久化的页签数据:兼容旧版 `string[]`、去重、固定分区。
|
|
186
186
|
* 损坏项(非字符串/非对象/无 path)直接丢弃。
|
|
187
|
-
*
|
|
187
|
+
*
|
|
188
|
+
* G9:传入 cwd 时把每个路径收敛为**页签规范形态**(工作区相对路径),
|
|
189
|
+
* 以迁移历史持久化数据里残留的绝对路径(见 {@link tabPathOf})。
|
|
190
|
+
* @author ddj 2026年09月11号 / 2026年09月18号
|
|
188
191
|
* @param raw localStorage 解析结果(任意形状)
|
|
192
|
+
* @param cwd 会话工作区目录(可选;给出时同步归一化路径形态)
|
|
189
193
|
* @returns 归一化后的页签数组
|
|
190
194
|
*/
|
|
191
|
-
export function normalizeTabs(raw: unknown): TabLike[] {
|
|
195
|
+
export function normalizeTabs(raw: unknown, cwd?: string | null): TabLike[] {
|
|
192
196
|
if (!Array.isArray(raw)) return []
|
|
193
197
|
const seen = new Set<string>()
|
|
194
198
|
const out: TabLike[] = []
|
|
195
199
|
for (const item of raw) {
|
|
196
200
|
const tab = tabOf(item)
|
|
197
|
-
if (!tab
|
|
198
|
-
|
|
199
|
-
|
|
201
|
+
if (!tab) continue
|
|
202
|
+
const path = tabPathOf(tab.path, cwd)
|
|
203
|
+
if (!path || seen.has(path)) continue
|
|
204
|
+
seen.add(path)
|
|
205
|
+
out.push(tab.pinned === true ? { path, pinned: true } : { path })
|
|
200
206
|
}
|
|
201
207
|
return pinnedFirst(out)
|
|
202
208
|
}
|
|
203
209
|
|
|
210
|
+
/**
|
|
211
|
+
* 页签规范形态 = 工作区相对路径(G9)。
|
|
212
|
+
*
|
|
213
|
+
* 为什么需要:差异记录路径取自工具结果 `target.displayPath`(官方恒为绝对拼写),
|
|
214
|
+
* 而资源管理器树给出的是工作区相对路径。两者进页签后,地址栏形态不一致,且
|
|
215
|
+
* `insertTab` 按原串去重 → 同一文件可能出两个页签;绝对路径还会被
|
|
216
|
+
* `isTreeRevealable` 判为不可定位,「在资源管理器视图中显示」对差异入口失效。
|
|
217
|
+
* 统一经 {@link relativeOf} 收敛后,页签/地址栏/去重/持久化/`sameFile` 口径一致。
|
|
218
|
+
*
|
|
219
|
+
* 工作区外文件(全局片段/规则)由 relativeOf 自然回退为原绝对路径,语义不变。
|
|
220
|
+
* @author ddj 2026年09月18号
|
|
221
|
+
* @param path 原始路径(绝对或相对,两种分隔符均可)
|
|
222
|
+
* @param cwd 会话工作区目录(可空;无 cwd 时仅做分隔符归一)
|
|
223
|
+
* @returns 页签规范路径
|
|
224
|
+
*/
|
|
225
|
+
export function tabPathOf(path: string, cwd?: string | null): string {
|
|
226
|
+
return relativeOf(path, cwd)
|
|
227
|
+
}
|
|
228
|
+
|
|
204
229
|
/**
|
|
205
230
|
* 解释单条持久化页签:字符串 = 路径(旧版),对象取 path/pinned。
|
|
206
231
|
* @author ddj 2026年09月11号
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import React from 'react'
|
|
10
10
|
import { rpc } from '../rpc.js'
|
|
11
11
|
import { setAiInlineEnabled } from '../ai/inlineProvider.js'
|
|
12
|
+
import { isDelistedModel } from '../../shared/ai.js'
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* AI 补全设置卡片(设置页「AI 补全」Tab 主体)。
|
|
@@ -79,9 +80,16 @@ export function AiSettings() {
|
|
|
79
80
|
if (!cfg) return React.createElement('div', { className: 'vsm-mcp-empty' }, error || '正在读取 AI 补全配置…')
|
|
80
81
|
|
|
81
82
|
const modelValue = cfg.provider && cfg.model ? cfg.provider + '/' + cfg.model : ''
|
|
83
|
+
// G7:配置指向已从官方默认列表下架的模型(DSH 0.1.6-alpha.2 移除 V4 Flash 系列)。
|
|
84
|
+
// 仅提示,不擅自改写用户已保存的配置(避免覆盖其意图);清空即回到自动路由。
|
|
85
|
+
const delisted = isDelistedModel(cfg.model)
|
|
82
86
|
return React.createElement('section', { className: 'vsm-lsp-card' },
|
|
83
87
|
React.createElement('h3', null, 'AI 自动补全(实验)'),
|
|
84
88
|
React.createElement('p', { className: 'vsm-lsp-note' }, '编辑停顿后由模型生成内联建议(ghost text),Tab 接受,Alt+\\ 手动触发。每次补全是一次模型调用;建议选择非推理模型,且思考强度选「跟随默认」或最低档以获得更快响应。'),
|
|
89
|
+
(delisted
|
|
90
|
+
? React.createElement('div', { className: 'vsm-mcp-error vsm-mcp-banner' },
|
|
91
|
+
'当前模型「' + cfg.model + '」已从 DSH 默认模型列表移除(0.1.6-alpha.2 起)。该模型可能不再可用;将「模型」改回「自动」即可使用第一个可用模型(现有配置不会被自动改动)。')
|
|
92
|
+
: null),
|
|
85
93
|
React.createElement('div', { className: 'vsm-lsp-row' },
|
|
86
94
|
React.createElement('button', { className: cfg.enabled ? 'vsm-primary' : '', disabled: busy, onClick: toggle },
|
|
87
95
|
cfg.enabled ? '已开启(点击关闭)' : '已关闭(点击开启)'),
|
|
@@ -56,7 +56,7 @@ import { SnippetsPicker } from './SnippetsPicker.js'
|
|
|
56
56
|
import { invalidateSnippets, setSnippetsSession, setupSnippets } from '../snippets/provider.js'
|
|
57
57
|
import {
|
|
58
58
|
absoluteOf, ancestorDirsOf, applyClose, baseNameOf, closeAll, closeOthers, closeRight, closeSaved,
|
|
59
|
-
insertTab, isTreeRevealable, normalizeTabs, pickActive, relativeOf, togglePin,
|
|
59
|
+
insertTab, isTreeRevealable, normalizeTabs, pickActive, relativeOf, tabPathOf, togglePin,
|
|
60
60
|
} from '../tabActions.js'
|
|
61
61
|
import { buildTabMenu } from '../tabMenu.js'
|
|
62
62
|
import { ensureSvnChanges, ensureSvnStatus, getSvnChanges, getSvnStatus, refreshSvnChanges, svnAdd, svnChangeMapOf, svnDiffBase, svnEditorActions, svnRevert, svnTortoise, svnUpdate } from '../svnStatus.js'
|
|
@@ -307,6 +307,33 @@ export function EditorView(props) {
|
|
|
307
307
|
if (select) setActive(path)
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
+
/**
|
|
311
|
+
* 页签规范路径(G9):统一收敛为工作区相对路径后再进页签。
|
|
312
|
+
*
|
|
313
|
+
* 差异记录路径取自工具结果 `target.displayPath`(官方恒绝对),而资源管理器树给相对路径;
|
|
314
|
+
* 不归一会导致地址栏形态不一致、同文件出现两个页签(insertTab 按原串去重)、
|
|
315
|
+
* 且绝对路径被 isTreeRevealable 判为不可定位(「在资源管理器视图中显示」失效)。
|
|
316
|
+
* 工作区外文件由 relativeOf 回退原绝对路径,语义不变。
|
|
317
|
+
* @author ddj 2026年09月18号
|
|
318
|
+
* @param path 原始路径(绝对或相对)
|
|
319
|
+
* @returns 页签规范路径
|
|
320
|
+
*/
|
|
321
|
+
const tabPath = (path) => tabPathOf(path, cwd)
|
|
322
|
+
/**
|
|
323
|
+
* 打开文件入口的统一收敛(G9):所有「进页签」路径都经此归一,避免逐点打补丁。
|
|
324
|
+
* @author ddj 2026年09月18号
|
|
325
|
+
* @param path 原始路径
|
|
326
|
+
* @param select 是否设为活动页签
|
|
327
|
+
* @returns 归一后的路径(空值返回 null)
|
|
328
|
+
*/
|
|
329
|
+
const addTabNorm = (path, select) => {
|
|
330
|
+
if (!path) return null
|
|
331
|
+
const normalized = tabPath(path)
|
|
332
|
+
if (!normalized) return null
|
|
333
|
+
addTab(normalized, select)
|
|
334
|
+
return normalized
|
|
335
|
+
}
|
|
336
|
+
|
|
310
337
|
/**
|
|
311
338
|
* 保存当前活动文件的视图状态(光标/滚动/折叠)到工作区作用域缓存。
|
|
312
339
|
* @author ddj 2026年08月28号
|
|
@@ -388,7 +415,7 @@ export function EditorView(props) {
|
|
|
388
415
|
flushSave()
|
|
389
416
|
saveViewState(active)
|
|
390
417
|
navPendingRef.current = entry
|
|
391
|
-
|
|
418
|
+
addTabNorm(entry.path, true)
|
|
392
419
|
setFocusRequest((value) => value + 1) // 目标已是活动文件时也触发恢复
|
|
393
420
|
}
|
|
394
421
|
|
|
@@ -832,20 +859,23 @@ export function EditorView(props) {
|
|
|
832
859
|
const onOpen = (e) => {
|
|
833
860
|
const p = e?.detail?.path
|
|
834
861
|
if (!p) return
|
|
862
|
+
// G9:入口统一归一为工作区相对路径(差异栏/对话链接/LSP 跳转等都经此事件)
|
|
863
|
+
const normalized = tabPath(p)
|
|
864
|
+
if (!normalized) return
|
|
835
865
|
if (e?.detail?.focusDiff === true) {
|
|
836
866
|
recordNav()
|
|
837
|
-
pendingFocusRef.current = { path:
|
|
867
|
+
pendingFocusRef.current = { path: normalized, region: null }
|
|
838
868
|
setFocusRequest((value) => value + 1)
|
|
839
|
-
addTab(
|
|
869
|
+
addTab(normalized, true)
|
|
840
870
|
return
|
|
841
871
|
}
|
|
842
872
|
if (e?.detail?.line != null) {
|
|
843
873
|
// LSP/搜索跳转:打开并定位到行列(endLine/endColumn 为目标区间,供落地高亮)
|
|
844
|
-
openFileAt(
|
|
874
|
+
openFileAt(normalized, e?.detail?.line, e?.detail?.column, e?.detail?.endLine, e?.detail?.endColumn)
|
|
845
875
|
return
|
|
846
876
|
}
|
|
847
877
|
recordNav()
|
|
848
|
-
addTab(
|
|
878
|
+
addTab(normalized, true)
|
|
849
879
|
}
|
|
850
880
|
const onShowLauncher = (event) => {
|
|
851
881
|
const tab = event?.detail?.tab
|
|
@@ -939,10 +969,13 @@ export function EditorView(props) {
|
|
|
939
969
|
?? localStorage.getItem(CACHE_KEY.editorLegacy + String(scope))
|
|
940
970
|
if (raw) {
|
|
941
971
|
const saved = JSON.parse(raw)
|
|
942
|
-
|
|
972
|
+
// G9:迁移历史持久化里的绝对路径页签(旧版差异入口写入),并顺带按新形态去重
|
|
973
|
+
const restored = normalizeTabs(saved?.tabs, cwd)
|
|
943
974
|
if (restored.length) {
|
|
944
975
|
setTabs(restored)
|
|
945
|
-
|
|
976
|
+
// 活动路径同形态归一,否则恢复后匹配不到任何页签(pickActive 回退首个)
|
|
977
|
+
const wanted = typeof saved?.active === 'string' ? tabPathOf(saved.active, cwd) : saved?.active
|
|
978
|
+
setActive(pickActive(restored, wanted))
|
|
946
979
|
}
|
|
947
980
|
}
|
|
948
981
|
} catch (e) { /* 损坏忽略 */ }
|
|
@@ -2495,8 +2528,9 @@ export function EditorView(props) {
|
|
|
2495
2528
|
// 打开文件即离开基线差异审阅态(差异视图是「当前文件」的临时视图)
|
|
2496
2529
|
setSvnDiff(null)
|
|
2497
2530
|
recordNav()
|
|
2498
|
-
|
|
2499
|
-
|
|
2531
|
+
// G9:统一归一为页签规范形态(差异栏/启动器/树/命令栏等入口一致)
|
|
2532
|
+
const normalized = addTabNorm(path, true)
|
|
2533
|
+
if (focusDiff && normalized) pendingFocusRef.current = { path: normalized, region: null }
|
|
2500
2534
|
}
|
|
2501
2535
|
|
|
2502
2536
|
/**
|
|
@@ -2512,8 +2546,10 @@ export function EditorView(props) {
|
|
|
2512
2546
|
const openFileAt = (path, line, column, endLine, endColumn) => {
|
|
2513
2547
|
if (!path) return
|
|
2514
2548
|
recordNav()
|
|
2515
|
-
|
|
2516
|
-
|
|
2549
|
+
// G9:同 openFile,先归一再进页签,保证待跳转路径与 active 同形态
|
|
2550
|
+
const normalized = addTabNorm(path, true)
|
|
2551
|
+
if (!normalized) return
|
|
2552
|
+
pendingFocusRef.current = { path: normalized, region: null, line: line ?? null, column: column ?? 1, endLine: endLine ?? null, endColumn: endColumn ?? null }
|
|
2517
2553
|
setFocusRequest((value) => value + 1)
|
|
2518
2554
|
}
|
|
2519
2555
|
|
|
@@ -2683,7 +2719,9 @@ export function EditorView(props) {
|
|
|
2683
2719
|
}, '保留本地')))
|
|
2684
2720
|
}
|
|
2685
2721
|
|
|
2686
|
-
|
|
2722
|
+
// G9:记录路径为绝对、active 为页签规范形态(相对),必须经 sameFile 归一比较,
|
|
2723
|
+
// 否则当前活动文件会被误列入「其他差异文件」。
|
|
2724
|
+
const otherFiles = sum.pendingFiles.filter((f) => !sameFile(f.path, active))
|
|
2687
2725
|
|
|
2688
2726
|
/**
|
|
2689
2727
|
* 渲染编辑器/文件加载进度面板。
|
package/src/compat.ts
CHANGED
|
@@ -133,7 +133,7 @@ export function detectGuards(ctx: Ctx): CompatAdapter[] {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
/** 已实测覆盖的最高 DSH 版本(适配矩阵上界,超过则提示,见 buildReport)。 */
|
|
136
|
-
const TESTED_DSH_MAX = '0.1.6-alpha.
|
|
136
|
+
const TESTED_DSH_MAX = '0.1.6-alpha.2'
|
|
137
137
|
|
|
138
138
|
/** 版本适配机制状态行:DSH 版本探测 + 设置 section 安装策略。 */
|
|
139
139
|
export function versionAdapters(dshVersion: string): CompatAdapter[] {
|
package/src/dshVersion.ts
CHANGED
|
@@ -82,10 +82,17 @@ export function inDshRange(version: DshVersion | null | undefined, range: DshRan
|
|
|
82
82
|
return true
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* 版本线标签:报告与文档展示版本归属;不可解析返回 '未知'。
|
|
87
|
+
* 逐线自新到旧匹配,先命中先返回(区间无上界,故顺序即优先级)。
|
|
88
|
+
* @author ddj 2026年09月02号 / 2026年09月18号
|
|
89
|
+
* @param input 版本串(如 '0.1.6-alpha.2')
|
|
90
|
+
* @returns 版本线标签
|
|
91
|
+
*/
|
|
86
92
|
export function familyLabel(input: string): string {
|
|
87
93
|
const version = parseDshVersion(input)
|
|
88
94
|
if (!version) return '未知'
|
|
95
|
+
if (inDshRange(version, { from: '0.1.6-alpha.2' })) return '0.1.6-alpha.2 及更新(会话多实例共存 + 回合改动卡片 + Office 侧栏预览 + 侧栏浏览器)'
|
|
89
96
|
if (inDshRange(version, { from: '0.1.6-alpha.1' })) return '0.1.6-alpha 及更新(MCP SDK v2 + Web 侧边栏终端 + 文件链接默认侧栏预览)'
|
|
90
97
|
if (inDshRange(version, { from: '0.1.5-alpha.1' })) return '0.1.5-alpha 及更新(官方右侧 Sidebar 编辑区 + sidebar.panellist)'
|
|
91
98
|
if (inDshRange(version, { from: '0.1.3-alpha.1' })) return '0.1.3-alpha 及更新(对话文件链接=remote.session.openWorkspacePath)'
|
package/src/shared/ai.ts
CHANGED
|
@@ -77,6 +77,31 @@ export interface AiConfigPatch {
|
|
|
77
77
|
effort?: string
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* 已从官方默认模型列表下架的模型标识(依据 DSH 0.1.6-alpha.2 release notes:
|
|
82
|
+
* 「默认模型列表移除 V4 Flash 和 V4 Flash Vision Exp」)。
|
|
83
|
+
*
|
|
84
|
+
* 用途:用户此前保存过这些模型时,配置仍指向它们,而新版模型目录不再列出 →
|
|
85
|
+
* 需给出可读降级提示(而非静默失败)。按模型名小写子串匹配,
|
|
86
|
+
* 因为 provider 前缀与具体版本后缀(如 -exp)在不同部署下写法不一。
|
|
87
|
+
*/
|
|
88
|
+
export const DELISTED_MODEL_MARKERS: readonly string[] = ['v4-flash']
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 配置指向的模型是否已被官方下架(G7)。
|
|
92
|
+
*
|
|
93
|
+
* 语义:仅当确实指定了 model(非空 = 非自动路由)且命中下架标识时返回 true。
|
|
94
|
+
* 空 model 表示「自动取首个可用模型」,不构成问题,故不提示。
|
|
95
|
+
* @author ddj 2026年09月18号
|
|
96
|
+
* @param model 配置中的模型标识(可空)
|
|
97
|
+
* @returns 是否已下架
|
|
98
|
+
*/
|
|
99
|
+
export function isDelistedModel(model: unknown): boolean {
|
|
100
|
+
const text = typeof model === 'string' ? model.trim().toLowerCase() : ''
|
|
101
|
+
if (!text) return false
|
|
102
|
+
return DELISTED_MODEL_MARKERS.some((marker) => text.includes(marker))
|
|
103
|
+
}
|
|
104
|
+
|
|
80
105
|
/**
|
|
81
106
|
* 按 AI_PREFIX_MAX/AI_SUFFIX_MAX 裁剪前后缀(client 侧发请求前调用)。
|
|
82
107
|
* @author ddj
|