dsh-vscode-mode 0.5.2 → 0.5.3
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 +25 -5
- package/lib/client.js +556 -31
- package/lib/client.js.map +1 -1
- package/lib/index.js +2 -0
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/compat.ts +1 -1
- package/src/client/editorLimit.ts +31 -0
- package/src/client/index.ts +4 -0
- package/src/client/markdownPreview.ts +24 -0
- package/src/client/md/componentType.ts +28 -0
- package/src/client/md/mdPanel.ts +98 -0
- package/src/client/searchSeed.ts +64 -0
- package/src/client/sidebar/panels/SearchPanel.ts +70 -15
- package/src/client/styles/editor.css +10 -1
- package/src/client/tabActions.ts +54 -0
- package/src/client/ui/EditorView.ts +167 -13
- package/src/client/ui/McpSettings.ts +29 -0
- package/src/client/ui/commandCatalog.ts +5 -0
- package/src/client/ui/horizontalWheel.ts +85 -0
- package/src/client-primitives.d.ts +34 -0
- package/src/fileOpenSettings.ts +3 -0
- package/src/shared/editorLimit.ts +46 -0
- package/src/shared/keybindings.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
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 扩展市场安装),状态持久化到工作区旁车",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
package/src/client/compat.ts
CHANGED
|
@@ -12,7 +12,7 @@ export const SIDEBAR_PLUGIN = 'dsh-better-sidebar'
|
|
|
12
12
|
|
|
13
13
|
/** 设置 scope 的跨组件上下文形状(与官方 settingsScope 绑定的最小面)。 */
|
|
14
14
|
export interface SettingsScopeLike {
|
|
15
|
-
getSnapshot: () => { status?: string; value?: { fileOpenTool?: unknown; keybindings?: Record<string, string>; sidebarMinWidth?: unknown }; writable?: boolean }
|
|
15
|
+
getSnapshot: () => { status?: string; value?: { fileOpenTool?: unknown; keybindings?: Record<string, string>; sidebarMinWidth?: unknown; maxOpenEditors?: unknown }; writable?: boolean }
|
|
16
16
|
subscribe: (listener: () => void) => () => void
|
|
17
17
|
set: (field: string, value: unknown) => Promise<void>
|
|
18
18
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode client — 页签数量上限共享状态。
|
|
3
|
+
* 设置来源:dsh-vscode-mode 命名空间的 maxOpenEditors 字段(通用设置页可调);
|
|
4
|
+
* 客户端 settings 订阅经 editorLimitApply 写入,编辑器经 getMaxOpenEditors 读取,
|
|
5
|
+
* 变更时由 client/index.ts 派发 edrv:max-open-editors 窗口事件通知编辑器复算淘汰。
|
|
6
|
+
* 形态与 sidebarMin.ts 一致(同一设置同步链路,便于统一维护)。
|
|
7
|
+
* 作者 ddj 2026年09月18号
|
|
8
|
+
*/
|
|
9
|
+
import { EDITOR_LIMIT_DEFAULT, normalizeMaxOpenEditors } from '../shared/editorLimit.js'
|
|
10
|
+
|
|
11
|
+
let currentLimit = EDITOR_LIMIT_DEFAULT
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 应用设置值(client/index.ts 设置订阅同步调用)。
|
|
15
|
+
* @author ddj 2026年09月18号
|
|
16
|
+
* @param value 设置文档中的 maxOpenEditors
|
|
17
|
+
* @returns 归一化后的生效值(0 = 不限制)
|
|
18
|
+
*/
|
|
19
|
+
export function editorLimitApply(value: unknown): number {
|
|
20
|
+
currentLimit = normalizeMaxOpenEditors(value)
|
|
21
|
+
return currentLimit
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 读取当前生效的页签上限(编辑器初始/事件回调共用;0 = 不限制)。
|
|
26
|
+
* @author ddj 2026年09月18号
|
|
27
|
+
* @returns 当前上限
|
|
28
|
+
*/
|
|
29
|
+
export function getMaxOpenEditors(): number {
|
|
30
|
+
return currentLimit
|
|
31
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -50,6 +50,7 @@ import { keybindingsApply } from './keybindings.js'
|
|
|
50
50
|
import { createCommandBridge } from './commandBridge.js'
|
|
51
51
|
import { REGISTRY_GLOBAL } from './commandGlobals.js'
|
|
52
52
|
import { sidebarMinApply } from './sidebarMin.js'
|
|
53
|
+
import { editorLimitApply } from './editorLimit.js'
|
|
53
54
|
import { log } from './log.js'
|
|
54
55
|
import { setupLsp, setSession, disposeLsp } from './monaco/lsp/index.js'
|
|
55
56
|
import { disposeSnippets } from './snippets/provider.js'
|
|
@@ -246,6 +247,7 @@ export function apply(ctx: any): void {
|
|
|
246
247
|
}, 'vscode-mode: file opener setting sync')
|
|
247
248
|
// 快捷键配置同步:设置提交后立即刷新键位匹配(编辑器/QuickOpen 按事件时读取)
|
|
248
249
|
// 同一订阅里顺带同步侧边栏最小宽度(sidebarMinWidth)→ 派发 edrv:sidebar-min-width 通知编辑器重夹
|
|
250
|
+
// 以及页签上限(maxOpenEditors)→ 派发 edrv:max-open-editors 通知编辑器复算淘汰
|
|
249
251
|
ctx.effect(() => {
|
|
250
252
|
if (!settings) return undefined
|
|
251
253
|
const sync = (): void => {
|
|
@@ -254,6 +256,8 @@ export function apply(ctx: any): void {
|
|
|
254
256
|
keybindingsApply(snapshot.value?.keybindings)
|
|
255
257
|
const minW = sidebarMinApply(snapshot.value?.sidebarMinWidth)
|
|
256
258
|
window.dispatchEvent(new CustomEvent('edrv:sidebar-min-width', { detail: { value: minW } }))
|
|
259
|
+
const limit = editorLimitApply(snapshot.value?.maxOpenEditors)
|
|
260
|
+
window.dispatchEvent(new CustomEvent('edrv:max-open-editors', { detail: { value: limit } }))
|
|
257
261
|
}
|
|
258
262
|
sync()
|
|
259
263
|
return settings.subscribe(sync)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode client — Markdown 文件判定(编辑区预览用)。
|
|
3
|
+
* 纯函数、无 DOM/React 依赖,便于单测。
|
|
4
|
+
* @author ddj 2026年09月18号
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** 可进入预览的 Markdown 扩展名(小写、无点)。 */
|
|
8
|
+
const MARKDOWN_EXT_SET: Set<string> = new Set(['md', 'markdown'])
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 判断路径是否为可预览的 Markdown 文件(取 basename 扩展名,大小写不敏感)。
|
|
12
|
+
*
|
|
13
|
+
* 为什么不含 `mdx`:官方 MarkdownText 只走 GFM+KaTeX 语法,不解析 JSX;
|
|
14
|
+
* 对 .mdx 做「预览」会把 JSX 当字面文本渲染出来,比文本编辑更容易误导,故排除。
|
|
15
|
+
* @author ddj 2026年09月18号
|
|
16
|
+
* @param path 文件路径(`/` 或 `\` 分隔均可)
|
|
17
|
+
* @returns 是否 Markdown 文件
|
|
18
|
+
*/
|
|
19
|
+
export function isMarkdownPath(path: string): boolean {
|
|
20
|
+
const base = String(path || '').split(/[\\/]/).pop() || ''
|
|
21
|
+
const dot = base.lastIndexOf('.')
|
|
22
|
+
if (dot <= 0) return false
|
|
23
|
+
return MARKDOWN_EXT_SET.has(base.slice(dot + 1).toLowerCase())
|
|
24
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode client — React 元素类型判定(纯函数,无 React/primitives 依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 用途:编辑区 Markdown 预览要判断官方 `MarkdownText` 原语是否可用(旧版 DSH 可能缺该导出)。
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ 为什么不能只判 `typeof value === 'function'`:
|
|
7
|
+
* 官方 `MarkdownText` 是 `React.memo(...)` 的产物(`MemoExoticComponent`),
|
|
8
|
+
* 其 `typeof` 是 **'object'** 而非 'function'。函数判据恒为 false,会让「兼容降级」分支在
|
|
9
|
+
* **所有新版 DSH 上永久生效** —— 表现是预览只显示纯文本降级提示。
|
|
10
|
+
* 该缺陷由 GUI 端到端验证捕获:纯函数单测不覆盖该守卫,手写垫片又把它声明得很宽松,
|
|
11
|
+
* 故 tsc 与既有测试都无法发现。抽出本模块正是为了让它有单测守约。
|
|
12
|
+
*
|
|
13
|
+
* 判定口径与 React 自身一致:函数(函数组件 / 类组件),或带 `$$typeof` 标记的非 null
|
|
14
|
+
* 对象(memo / forwardRef / lazy 等「外部对象类型」)。
|
|
15
|
+
* 作者 ddj 2026年09月18号
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 判断值是否可作为 React 元素类型传给 `React.createElement`。
|
|
20
|
+
* @author ddj 2026年09月18号
|
|
21
|
+
* @param value 候选组件(undefined/null 表示该导出不存在)
|
|
22
|
+
* @returns 是否可作为元素类型使用
|
|
23
|
+
*/
|
|
24
|
+
export function isComponentType(value: unknown): boolean {
|
|
25
|
+
if (typeof value === 'function') return true
|
|
26
|
+
if (typeof value !== 'object' || value === null) return false
|
|
27
|
+
return (value as { $$typeof?: unknown }).$$typeof !== undefined
|
|
28
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode client — 编辑区 Markdown 预览面板。
|
|
3
|
+
*
|
|
4
|
+
* 需求 4:.md 文件支持预览。渲染复用官方 UI 原语 MarkdownText(GFM + KaTeX,走 --dsw-*
|
|
5
|
+
* 令牌自动跟随 DSH 主题与皮肤),不引入任何第三方 Markdown 依赖。
|
|
6
|
+
*
|
|
7
|
+
* 与既有图片 / PDF 面板的形态保持一致:工具条(meta + 切换按钮 + 刷新)+ 内容区。
|
|
8
|
+
* 本面板为**只读**视图(编辑仍在 Monaco 文本态完成),故不接收编辑回调、不参与脏标记。
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ 兼容降级:旧版 DSH 的 primitives 可能没有 MarkdownText。此时渲染只读 <pre> 并给出
|
|
11
|
+
* 提示,绝不抛错白屏(与 pdf.js / Monaco 加载失败的降级口径一致)。
|
|
12
|
+
* 作者 ddj 2026年09月18号
|
|
13
|
+
*/
|
|
14
|
+
import React from 'react'
|
|
15
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
16
|
+
import { MarkdownDelegateProvider, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
17
|
+
import type { MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
18
|
+
import { isComponentType } from './componentType.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 渲染 chrome 常量。必须是模块级稳定引用:MarkdownText 的 memo 以 labels 身份
|
|
22
|
+
* 参与缓存判定,每次渲染新建对象会让渲染缓存在流式场景反复失效。
|
|
23
|
+
*/
|
|
24
|
+
const MD_LABELS: MarkdownLabels = {
|
|
25
|
+
code: { copyLabel: '复制', copiedLabel: '已复制' },
|
|
26
|
+
footnotes: '脚注',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 官方原语声明在 client-primitives.d.ts 的手写垫片里,组件返回值只能声明为 unknown
|
|
31
|
+
* (该虚拟模块不入本地 node_modules,无法在环境声明中引用 react 类型)。
|
|
32
|
+
* 故在此把垫片面收敛成 react 可接受的组件类型,仅本文件承担这层转换;
|
|
33
|
+
* 原语在运行时缺失时由下方 hasMarkdownPrimitive 守卫降级。
|
|
34
|
+
*/
|
|
35
|
+
type MdTextProps = { text: string; streaming?: boolean; labels: MarkdownLabels; variant?: 'body' | 'compact' }
|
|
36
|
+
type MdProviderProps = { children?: ReactNode; openFile?: (path: string, options?: { line?: number }) => void }
|
|
37
|
+
|
|
38
|
+
const MdText = MarkdownText as unknown as ComponentType<MdTextProps>
|
|
39
|
+
const MdProvider = MarkdownDelegateProvider as unknown as ComponentType<MdProviderProps>
|
|
40
|
+
|
|
41
|
+
/** 官方 Markdown 原语是否可用(旧版 DSH 缺该导出时为 false)。 */
|
|
42
|
+
const hasMarkdownPrimitive = isComponentType(MarkdownText)
|
|
43
|
+
|
|
44
|
+
/** 文本行数(meta 展示用;空文本计 0 行)。 */
|
|
45
|
+
function lineCountOf(text: string): number {
|
|
46
|
+
const value = String(text ?? '')
|
|
47
|
+
return value ? value.split(/\r?\n/).length : 0
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 预览面板 props(面板由 EditorView 装配,故用宽松形状避免跨文件类型耦合)。 */
|
|
51
|
+
interface MarkdownPanelProps {
|
|
52
|
+
/** Markdown 原文(取自编辑器已加载内容,无需额外 RPC)。 */
|
|
53
|
+
text?: string
|
|
54
|
+
/** 切回源码编辑(工具条按钮)。 */
|
|
55
|
+
onToggleSource?: () => void
|
|
56
|
+
/** 重新读取文件(工具条刷新按钮)。 */
|
|
57
|
+
onReload?: () => void
|
|
58
|
+
/** 打开 Markdown 内的本地文件链接(可空:不给则链接保持纯文本)。 */
|
|
59
|
+
onOpenFile?: (path: string, options?: { line?: number }) => void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Markdown 预览面板。
|
|
64
|
+
* @author ddj 2026年09月18号
|
|
65
|
+
* @param props 面板输入(原文与三个动作回调)
|
|
66
|
+
* @returns 预览面板元素
|
|
67
|
+
*/
|
|
68
|
+
export function MarkdownPanel(props: MarkdownPanelProps) {
|
|
69
|
+
const text = String(props?.text ?? '')
|
|
70
|
+
const onToggleSource = props?.onToggleSource
|
|
71
|
+
const onReload = props?.onReload
|
|
72
|
+
const onOpenFile = props?.onOpenFile
|
|
73
|
+
const chars = text.length
|
|
74
|
+
const lines = lineCountOf(text)
|
|
75
|
+
|
|
76
|
+
const body = hasMarkdownPrimitive
|
|
77
|
+
? React.createElement(MdProvider, {
|
|
78
|
+
openFile: typeof onOpenFile === 'function' ? onOpenFile : undefined,
|
|
79
|
+
}, React.createElement(MdText, { text, streaming: false, labels: MD_LABELS, variant: 'body' }))
|
|
80
|
+
: React.createElement('div', { className: 'edrv-mdview-fallback' },
|
|
81
|
+
React.createElement('div', null, '当前 DSH 版本不含 Markdown 渲染原语,已降级为纯文本预览。'),
|
|
82
|
+
React.createElement('pre', { className: 'edrv-mdview-pre' }, text))
|
|
83
|
+
|
|
84
|
+
return React.createElement('div', { className: 'edrv-mdview' },
|
|
85
|
+
React.createElement('div', { className: 'edrv-mdview-bar' },
|
|
86
|
+
React.createElement('span', { className: 'edrv-mdview-meta' }, lines + ' 行 · ' + chars + ' 字符'),
|
|
87
|
+
React.createElement('span', { style: { flex: 1 } }),
|
|
88
|
+
React.createElement('button', {
|
|
89
|
+
className: 'edrv-pill edrv-pill-ghost', title: '切回源码编辑',
|
|
90
|
+
onClick: () => onToggleSource?.(),
|
|
91
|
+
}, '以源码打开'),
|
|
92
|
+
React.createElement('button', {
|
|
93
|
+
className: 'edrv-pill edrv-pill-ghost', title: '重新读取文件',
|
|
94
|
+
onClick: () => onReload?.(),
|
|
95
|
+
}, '⟳ 刷新')),
|
|
96
|
+
React.createElement('div', { className: 'edrv-mdview-stage' },
|
|
97
|
+
React.createElement('div', { className: 'edrv-mdview-doc' }, body)))
|
|
98
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode client — 工作区搜索的「选区种子」。
|
|
3
|
+
*
|
|
4
|
+
* 需求:Ctrl+Shift+F 时若编辑器有选中,把选中文本自动填入搜索框。
|
|
5
|
+
* 链路:EditorView 从 Monaco 取选区文本 → seedQueryOf 归一 → setSearchSeed 存入一次性槽
|
|
6
|
+
* → 派发 edrv:search-focus → SearchPanel 消费(挂载时或已挂载时),填入并立即搜索。
|
|
7
|
+
*
|
|
8
|
+
* 为什么用一次性槽而不是事件 detail:
|
|
9
|
+
* 侧边栏原本收起时搜索面板**尚未挂载**,派发瞬间没有监听者;事件 detail 会丢。
|
|
10
|
+
* 槽在 EditorView 与 SearchPanel 之间充当交接点,挂载后由消费方自行取走。
|
|
11
|
+
*
|
|
12
|
+
* 为什么取首行:搜索框是单行 input,ripgrep 按字面连续匹配。多行选区(整段/整函数)
|
|
13
|
+
* 作为单条 query 永远无匹配,故按项目既有口径取首行(与 VS Code 单行选区种子行为一致)。
|
|
14
|
+
*
|
|
15
|
+
* 纯逻辑 + 模块级槽,不依赖 React/DOM,可 node 单测。
|
|
16
|
+
* 作者 ddj 2026年09月18号
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 种子最大长度(超长选区只取前 N 字符,防一次性把巨型 query 送进搜索)。 */
|
|
20
|
+
export const SEED_MAX = 200
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 由选区文本推导搜索种子。
|
|
24
|
+
* @author ddj 2026年09月18号
|
|
25
|
+
* @param text 选区原文(可能含多行、前后空白;null/undefined 视为空)
|
|
26
|
+
* @returns 归一后的搜索词;无效输入返回空串(调用方据此保持原搜索词)
|
|
27
|
+
*/
|
|
28
|
+
export function seedQueryOf(text: unknown): string {
|
|
29
|
+
const raw = String(text ?? '')
|
|
30
|
+
if (!raw) return ''
|
|
31
|
+
const firstLine = raw.split(/\r?\n/)[0] ?? ''
|
|
32
|
+
const trimmed = firstLine.trim()
|
|
33
|
+
if (!trimmed) return ''
|
|
34
|
+
return trimmed.length > SEED_MAX ? trimmed.slice(0, SEED_MAX) : trimmed
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 一次性种子槽(字符串或 null;null 表示无待消费种子)。 */
|
|
38
|
+
let pendingSeed: string | null = null
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 写入待消费种子(空词等价于清除)。
|
|
42
|
+
* @author ddj 2026年09月18号
|
|
43
|
+
* @param text 选区文本(经 seedQueryOf 归一)
|
|
44
|
+
*/
|
|
45
|
+
export function setSearchSeed(text: unknown): void {
|
|
46
|
+
const seed = seedQueryOf(text)
|
|
47
|
+
pendingSeed = seed ? seed : null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 取走待消费种子(取后即清空,保证挂载路径与焦点路径不会重复应用同一个种子)。
|
|
52
|
+
* @author ddj 2026年09月18号
|
|
53
|
+
* @returns 种子搜索词;无待消费种子返回空串
|
|
54
|
+
*/
|
|
55
|
+
export function takeSearchSeed(): string {
|
|
56
|
+
const seed = pendingSeed
|
|
57
|
+
pendingSeed = null
|
|
58
|
+
return seed ?? ''
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 清空待消费种子(测试隔离用)。 */
|
|
62
|
+
export function clearSearchSeed(): void {
|
|
63
|
+
pendingSeed = null
|
|
64
|
+
}
|
|
@@ -12,6 +12,7 @@ import { rpc } from '../../rpc.js'
|
|
|
12
12
|
import type { SidebarCtx } from '../types.js'
|
|
13
13
|
import { CACHE_KEY } from '../../paths.js'
|
|
14
14
|
import { workspaceScopeOf } from '../../state/scopeStore.js'
|
|
15
|
+
import { takeSearchSeed } from '../../searchSeed.js'
|
|
15
16
|
|
|
16
17
|
const DEBOUNCE_MS = 250
|
|
17
18
|
const INCLUDE_PLACEHOLDER = '例如 *.ts, src/**/include'
|
|
@@ -25,6 +26,23 @@ export function splitGlobs(text) {
|
|
|
25
26
|
return String(text ?? '').split(',').map((item) => item.trim()).filter(Boolean)
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* 读取按作用域持久化的搜索条件(损坏/缺失返回 null)。
|
|
31
|
+
* 抽成函数而非内联:恢复 effect 内有两处消费者(填状态、同步 requestRef),
|
|
32
|
+
* 且解析必须只做一次(JSON.parse 重复调用既慢又可能两次结果不一致)。
|
|
33
|
+
* @author ddj 2026年09月18号
|
|
34
|
+
* @param key 作用域缓存键
|
|
35
|
+
* @returns 已解析的搜索条件对象;无/损坏返回 null
|
|
36
|
+
*/
|
|
37
|
+
export function savedSearchOf(key: string) {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(localStorage.getItem(key) || 'null')
|
|
40
|
+
return parsed && typeof parsed === 'object' ? parsed : null
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
/**
|
|
29
47
|
* 命中行文本三段切分(before/命中/after,列为 1-based UTF-16)。
|
|
30
48
|
* @param text 行文本
|
|
@@ -82,20 +100,31 @@ export function SearchPanel(props) {
|
|
|
82
100
|
// 按作用域恢复上次查询、选项与过滤模式(VSCode 记忆搜索词)
|
|
83
101
|
React.useEffect(() => {
|
|
84
102
|
if (!scope) return
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
103
|
+
const saved = savedSearchOf(CACHE_KEY.search + String(scope))
|
|
104
|
+
if (saved) {
|
|
105
|
+
if (typeof saved.query === 'string') setQuery(saved.query)
|
|
106
|
+
if (typeof saved.matchCase === 'boolean') setMatchCase(saved.matchCase)
|
|
107
|
+
if (typeof saved.wholeWord === 'boolean') setWholeWord(saved.wholeWord)
|
|
108
|
+
if (typeof saved.regex === 'boolean') setRegex(saved.regex)
|
|
109
|
+
if (typeof saved.includeText === 'string') setIncludeText(saved.includeText)
|
|
110
|
+
if (typeof saved.excludeText === 'string') setExcludeText(saved.excludeText)
|
|
111
|
+
if (typeof saved.onlyActive === 'boolean') setOnlyActive(saved.onlyActive)
|
|
112
|
+
if (typeof saved.excludeOn === 'boolean') setExcludeOn(saved.excludeOn)
|
|
113
|
+
if (typeof saved.sectionOpen === 'boolean') setSectionOpen(saved.sectionOpen)
|
|
114
|
+
// 显式把恢复后的选项同步进 requestRef:render 期的同步要等下一次渲染才生效,
|
|
115
|
+
// 而紧随其后的「选区种子」effect 会在本次 commit 内立即搜索,读到的会是陈旧选项。
|
|
116
|
+
requestRef.current = {
|
|
117
|
+
matchCase: saved.matchCase === true,
|
|
118
|
+
wholeWord: saved.wholeWord === true,
|
|
119
|
+
regex: saved.regex === true,
|
|
120
|
+
include: saved.onlyActive === true
|
|
121
|
+
? (activePathRef.current ? [activePathRef.current] : [])
|
|
122
|
+
: splitGlobs(typeof saved.includeText === 'string' ? saved.includeText : ''),
|
|
123
|
+
exclude: saved.excludeOn === true
|
|
124
|
+
? splitGlobs(typeof saved.excludeText === 'string' ? saved.excludeText : '')
|
|
125
|
+
: [],
|
|
97
126
|
}
|
|
98
|
-
}
|
|
127
|
+
}
|
|
99
128
|
}, [scope])
|
|
100
129
|
|
|
101
130
|
// 查询/选项/过滤变化 → 持久化
|
|
@@ -191,9 +220,35 @@ export function SearchPanel(props) {
|
|
|
191
220
|
})
|
|
192
221
|
}
|
|
193
222
|
|
|
194
|
-
|
|
223
|
+
/**
|
|
224
|
+
* 取用一次性选区种子(需求 1:Ctrl+Shift+F 时把编辑器选中文本填入搜索框)。
|
|
225
|
+
* 有种子即填入并立即搜索(不再等 250ms 防抖),同时全选便于直接改写;无种子为 no-op。
|
|
226
|
+
* @author ddj 2026年09月18号
|
|
227
|
+
* @returns 是否消费到种子
|
|
228
|
+
*/
|
|
229
|
+
const applySeed = () => {
|
|
230
|
+
const seed = takeSearchSeed()
|
|
231
|
+
if (!seed) return false
|
|
232
|
+
if (timerRef.current) clearTimeout(timerRef.current)
|
|
233
|
+
queryRef.current = seed
|
|
234
|
+
setQuery(seed)
|
|
235
|
+
runSearch(seed)
|
|
236
|
+
inputRef.current?.select?.()
|
|
237
|
+
return true
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 挂载时消费种子:侧栏原本收起 → 派发 edrv:search-focus 时本面板尚未挂载,
|
|
241
|
+
// 事件无人接收,故必须由挂载路径兜底。
|
|
242
|
+
// ⚠️ 声明在「按作用域恢复」effect 之后:React 按声明序执行 effect,恢复值先落地,
|
|
243
|
+
// 种子再覆盖,避免被记忆的旧查询词盖掉用户刚选中的内容。
|
|
244
|
+
React.useEffect(() => { applySeed() }, [])
|
|
245
|
+
|
|
246
|
+
// Ctrl+Shift+F 重复触发:消费种子(有则填入)并聚焦输入框(EditorView 派发 edrv:search-focus)
|
|
195
247
|
React.useEffect(() => {
|
|
196
|
-
const onFocus = () =>
|
|
248
|
+
const onFocus = () => {
|
|
249
|
+
applySeed()
|
|
250
|
+
inputRef.current?.focus?.()
|
|
251
|
+
}
|
|
197
252
|
window.addEventListener('edrv:search-focus', onFocus)
|
|
198
253
|
return () => window.removeEventListener('edrv:search-focus', onFocus)
|
|
199
254
|
}, [])
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
@author ddj 2026-09-10 */
|
|
6
6
|
[data-edrv-view] { box-sizing: border-box; --edrv-tab-h: 34px; }
|
|
7
7
|
|
|
8
|
-
[data-edrv-view] .edrv-tabs { display: flex; align-items: center; gap: 2px; padding: 2px 8px 0; background: var(--dsw-alias-bg-layer-1, transparent); overflow-x: auto; scrollbar-width: thin; flex-shrink: 0; }
|
|
8
|
+
[data-edrv-view] .edrv-tabs { display: flex; align-items: center; gap: 2px; padding: 2px 8px 0; background: var(--dsw-alias-bg-layer-1, transparent); overflow-x: auto; overflow-y: hidden; scrollbar-width: thin; flex-shrink: 0; overscroll-behavior-x: contain; } /* overscroll-behavior-x: 横滚到边界不把滚动链传给页面(滚轮横滚接管更可控)@author ddj 2026年09月18号 */
|
|
9
9
|
[data-edrv-view] .edrv-tab { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 8px 0 12px; border: none; border-radius: 6px 6px 0 0; background: transparent; color: var(--dsw-alias-label-secondary, #aaa); font-size: 12px; cursor: pointer; white-space: nowrap; flex-shrink: 0; }
|
|
10
10
|
[data-edrv-view] .edrv-tab:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,.06)); }
|
|
11
11
|
[data-edrv-view] .edrv-tab.edrv-tab-active { background: var(--dsw-alias-bg-base, #111); color: var(--dsw-alias-label-primary, #eee); box-shadow: inset 0 -2px 0 var(--dsw-alias-brand-primary, #4f8cff); }
|
|
@@ -84,6 +84,15 @@
|
|
|
84
84
|
[data-edrv-view] .edrv-pdf-mode-on { background: var(--dsw-alias-interactive-bg-hover, rgba(15,157,88,.12)); color: var(--dsw-alias-brand-primary, #0f9d58); border-color: var(--dsw-alias-brand-primary, #0f9d58); }
|
|
85
85
|
[data-edrv-view] .edrv-pdf-dirty { color: var(--dsw-alias-state-warn-primary, #d97706); border-color: var(--dsw-alias-state-warn-primary, #d97706); }
|
|
86
86
|
[data-edrv-view] .edrv-pdf-stage { position: absolute; top: 34px; left: 0; right: 0; bottom: 0; overflow: auto; }
|
|
87
|
+
/* Markdown 预览(需求 4):工具条 + 居中阅读栏;正文排版由官方 MarkdownText 自带的 --dsw-* 样式负责 */
|
|
88
|
+
[data-edrv-view] .edrv-mdview { flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
|
89
|
+
[data-edrv-view] .edrv-mdview-bar { display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 10px; flex-shrink: 0; border-bottom: 1px solid var(--dsw-alias-border-l1, #e0e6e8); background: var(--dsw-alias-bg-layer-1, transparent); }
|
|
90
|
+
[data-edrv-view] .edrv-mdview-meta { font-size: 11px; color: var(--dsw-alias-label-tertiary, #9aa5b1); white-space: nowrap; }
|
|
91
|
+
[data-edrv-view] .edrv-mdview-stage { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 14px 20px 28px; }
|
|
92
|
+
[data-edrv-view] .edrv-mdview-doc { max-width: 880px; margin: 0 auto; width: 100%; }
|
|
93
|
+
[data-edrv-view] .edrv-mdview-fallback { font-size: 12px; line-height: 1.7; color: var(--dsw-alias-label-secondary, #52606d); }
|
|
94
|
+
[data-edrv-view] .edrv-mdview-pre { margin: 8px 0 0; padding: 10px 12px; border-radius: 8px; background: var(--dsw-alias-bg-layer-2, #f0f4f4); color: var(--dsw-alias-label-primary, #1f2933); font-family: var(--ds-font-family-code, ui-monospace, monospace); font-size: 11px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
|
|
95
|
+
[data-edrv-view] .edrv-pb-mdbtn { height: 20px; padding: 0 8px; font-size: 11px; flex-shrink: 0; }
|
|
87
96
|
[data-edrv-view] .edrv-pdf-stage .edrv-pdf-viewer { margin: 0 auto; }
|
|
88
97
|
[data-edrv-view] .edrv-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; height: 100%; color: var(--dsw-alias-label-tertiary, #777); font-size: 13px; }
|
|
89
98
|
[data-edrv-view] .edrv-diffbar { position: absolute; left: 0; right: 0; bottom: 10px; margin: 0 auto; z-index: 25; width: fit-content; max-width: calc(100% - 20px); border-radius: 12px; background: var(--dsw-specific-tip, var(--dsw-alias-bg-layer-2, #f0f4f4)); border: 1px solid var(--dsw-alias-border-l1, #e0e6e8); box-shadow: none; overflow: visible; display: flex; flex-direction: column; }
|
package/src/client/tabActions.ts
CHANGED
|
@@ -255,6 +255,60 @@ export function pickActive(tabs: TabLike[], wanted: unknown): string | null {
|
|
|
255
255
|
}
|
|
256
256
|
// --endregion
|
|
257
257
|
|
|
258
|
+
// --region 上限淘汰
|
|
259
|
+
|
|
260
|
+
/** 淘汰判定输入:活动页签、使用序、额外保留集。 */
|
|
261
|
+
export interface EvictOptions {
|
|
262
|
+
/** 当前活动页签路径(**绝不被淘汰**)。 */
|
|
263
|
+
active: string | null
|
|
264
|
+
/** 路径 → 最近使用序号(单调递增;越大越新)。缺记录按最久未用处理。 */
|
|
265
|
+
used: Record<string, number>
|
|
266
|
+
/** 额外保留集(调用方按需保护,如「恢复中刚载入的页签」)。 */
|
|
267
|
+
keep?: ReadonlySet<string>
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 计算超限时应淘汰的页签路径(最久未使用者优先)。
|
|
272
|
+
*
|
|
273
|
+
* 保护规则(不满足则允许溢出而非强关,调用方据此保持「溢出态」:
|
|
274
|
+
* 用户显式固定过、或正在查看的页签,都不该被上限悄悄夺走):
|
|
275
|
+
* - `limit <= 0` → 上限关闭,返回空数组;
|
|
276
|
+
* - 未超限 → 返回空数组;
|
|
277
|
+
* - 固定页签(`pinned === true`)不是候选;
|
|
278
|
+
* - 活动页签不是候选;
|
|
279
|
+
* - `keep` 命中的路径不是候选。
|
|
280
|
+
*
|
|
281
|
+
* 脏页签**仍是候选**:其未保存内容的落盘由调用方在关闭前完成(EditorView 走
|
|
282
|
+
* closeTabs → persistDirty),本函数不感知磁盘,避免把 IO 语义掺进纯判定。
|
|
283
|
+
*
|
|
284
|
+
* @author ddj 2026年09月18号
|
|
285
|
+
* @param tabs 当前页签
|
|
286
|
+
* @param limit 页签上限(0 = 不限制)
|
|
287
|
+
* @param options 活动页签、使用序与额外保留集
|
|
288
|
+
* @returns 待淘汰路径(按最久未用在前);无需淘汰时为 `[]`
|
|
289
|
+
*/
|
|
290
|
+
export function evictPlan(tabs: TabLike[], limit: number, options: EvictOptions): string[] {
|
|
291
|
+
if (!Array.isArray(tabs) || !Number.isFinite(limit) || limit <= 0) return []
|
|
292
|
+
const excess = tabs.length - Math.floor(limit)
|
|
293
|
+
if (excess <= 0) return []
|
|
294
|
+
const active = options?.active ?? null
|
|
295
|
+
const used = options?.used ?? {}
|
|
296
|
+
const keep = options?.keep
|
|
297
|
+
const candidates: Array<{ path: string; at: number; at0: number }> = []
|
|
298
|
+
for (let i = 0; i < tabs.length; i += 1) {
|
|
299
|
+
const tab = tabs[i]
|
|
300
|
+
if (tab.pinned === true) continue
|
|
301
|
+
if (tab.path === active) continue
|
|
302
|
+
if (keep?.has(tab.path) === true) continue
|
|
303
|
+
const order = used[tab.path]
|
|
304
|
+
candidates.push({ path: tab.path, at: Number.isFinite(order) ? order : -1, at0: i })
|
|
305
|
+
}
|
|
306
|
+
// 使用序升序 = 最久未用在前;同序(含全部无记录)按页签原下标,保证确定性
|
|
307
|
+
candidates.sort((a, b) => (a.at === b.at ? a.at0 - b.at0 : a.at - b.at))
|
|
308
|
+
return candidates.slice(0, excess).map((item) => item.path)
|
|
309
|
+
}
|
|
310
|
+
// --endregion
|
|
311
|
+
|
|
258
312
|
// --region 路径推导
|
|
259
313
|
|
|
260
314
|
/** Windows 盘符 / UNC / POSIX 根:视为工作区外的绝对路径。 */
|