可以。下面给你一版修正后的、可直接给产品/开发使用的 VS Code 风格 Preview Editor 产品文档。这版严格区分:
- Preview Editor
- Normal Editor
- Pinned Editor
- Dirty 状态
并且把 Keep Open 和 Pin 分开处理。
Preview Editor 产品设计文档
1. 功能名称
Preview Editor
中文建议统一使用:
预览编辑器
相关概念:
Preview Editor
Normal Editor
Pinned Editor
Active Editor
Editor Group
需要特别说明:
Preview 和 Pinned 不是同一维度的状态。
2. 产品目标
用户在文件树中浏览文件时,如果每次单击文件都创建一个永久 Tab,会很快产生大量不需要长期保留的 Tab。
Preview Editor 的目标是:
区分“浏览文件”和“真正打开文件”。
用户只是浏览:
Single Click
↓
Preview
用户明确要继续使用:
Double Click
Edit
Keep Open
↓
Normal
用户明确希望锁定:
Pin
↓
Pinned
最终形成三类 Tab:
Preview
Normal
Pinned
3. 核心概念
3.1 Preview Editor
Preview 是临时打开状态。
特点:
- 同一个 Editor Group 中最多一个 Preview
- 可以被下一个 Preview 替换
- 默认用于文件树单击、Quick Open 等弱打开行为
- 不应该处于 Dirty 状态
- 不应该处于 Pinned 状态
例如:
用户依次单击:
README.md
package.json
src/index.ts
最终 Tab 不是:
README.md | package.json | index.ts
而是:
index.ts
因为 Preview Tab 被复用。
3.2 Normal Editor
Normal 是普通已保留 Tab。
特点:
- 不会被后续 Preview 替换
- 会参与普通批量关闭操作
- 可以 Dirty
- 可以进一步 Pin
例如:
README.md | editor.ts | package.json
这些都是 Normal。
3.3 Pinned Editor
Pinned 是“锁定/固定”状态。
它的语义是:
在执行批量关闭类操作时受到保护。
所有 Pinned Editor 构成标签栏最左侧的连续区域。新固定的标签移动到该区域末尾;取消固定后移动到未固定区域开头。拖拽只调整标签在所属区域内的顺序,不能让固定与未固定标签交错。恢复旧会话时也按此规则稳定归组,同时保留两组各自的原始顺序。
例如:
A | B | C
↑ pinned
执行:
Close All
如果产品定义 Close All 忽略 pinned,则结果是:
B
Pinned 和 Preview 不能混为一谈。
4. 状态维度
建议不要把 Tab 设计成单一三态枚举,而是分成两个维度。
生命周期维度
Preview
Normal
锁定维度
Unpinned
Pinned
因此合法组合:
Preview + Unpinned
Normal + Unpinned
Normal + Pinned
非法组合:
Preview + Pinned
因为一个会被 Preview 替换的临时 Tab,不应该同时又是锁定 Tab。
5. 推荐数据模型
interface EditorTab {
id: string
resource: URI
groupId: string
isPreview: boolean
isPinned: boolean
dirty: boolean
active: boolean
}
必须维护以下约束:
if (tab.isPreview) {
tab.isPinned = false
}
以及:
if (tab.dirty) {
tab.isPreview = false
}
6. 状态转换
完整状态关系:
Single Click
│
▼
Preview
/ | \
/ | \
Edit Double Keep Open
\ | /
\ | /
Normal
│
Pin
│
▼
Pinned
│
Unpin
│
▼
Normal
注意:
Preview → Normal
和:
Normal → Pinned
是两种完全不同的动作。
7. 用户操作映射
建议定义:
| 用户操作 | 结果 |
|---|---|
| 文件树单击 | Preview |
| 文件树双击 | Normal |
| Quick Open 预览 | Preview |
| Quick Open 明确打开 | Normal |
| 开始编辑 Preview | Normal |
| 双击 Preview Tab | Normal |
| Keep Open | Preview → Normal |
| Pin | Normal → Pinned |
| Unpin | Pinned → Normal |
| Drag Preview Tab | Normal |
| Move Preview Tab | Normal |
8. Keep Open
Keep Open 的含义:
将当前 Preview Editor 转为普通持久 Tab。
状态:
Preview
↓
Keep Open
↓
Normal
它不会:
Normal
↓
Pinned
因此 Keep Open 和 Pin 必须是两个独立命令。
建议命令:
editor.keepOpen
9. Pin
Pin 的含义:
锁定当前普通 Tab,使其在批量关闭操作中受到保护。
状态:
Normal
↓
Pin
↓
Pinned
建议命令:
editor.pin
解除:
editor.unpin
10. 文件树交互
单击
Explorer Single Click
↓
Preview
例如:
A | B | C
↑ preview
再单击 D:
A | B | D
↑ preview
C 被替换。
双击
Explorer Double Click
↓
Normal
例如:
第一次单击:
A | B
↑ Preview
双击后:
A | B
↑ Normal
之后再单击 C:
A | B | C
↑ Preview
B 不会被替换。
11. 双击 Tab
Preview Tab 双击:
Preview
↓
Normal
本质上就是:
Keep Open
不应该自动变为 Pinned。
12. 编辑 Preview
如果用户在 Preview 中开始输入:
Preview
↓
dirty
应立即执行:
Preview → Normal
然后:
Normal + Dirty
因此系统应维护:
dirty => !isPreview
禁止出现:
Preview + Dirty
13. Preview 替换规则
当前:
A | B | C
↑ Preview
用户 Preview D:
A | B | D
↑ Preview
要求:
- C 被替换
- D 使用 C 原来的 Tab index
- 不新增 Tab
- 不影响 A、B
14. Preview Slot
建议 Editor Group 明确维护:
interface EditorGroup {
editors: EditorTab[]
activeEditorId?: string
previewEditorId?: string
}
一个 Editor Group 最多:
1 个 Preview
15. 为什么 Preview 属于 Editor Group
假设有分屏:
Group A Group B
A | B C | D
Preview Preview
两个 Group 可以各有自己的 Preview。
因此应该是:
group.previewEditorId
而不是:
workspace.previewEditorId
16. 已存在文件再次 Preview
例如:
A | B | C
↑ Normal
用户单击 B。
此时:
activate B
不能重新创建一个 Preview B。
也不能:
Normal → Preview
规则:
已经是 Normal / Pinned 的 Editor,弱打开操作不能让它降级。
17. Pinned 文件再次打开
例如:
A | B | C
↑ Pinned
用户单击 B:
activate B
B 保持:
Pinned
18. Preview 文件再次单击
当前:
A | B
↑ Preview
再次点击 B:
仅 activate
不创建新实例。
19. Close 行为
这里必须严格区分。
Close 当前 Tab
用户明确关闭当前 Tab:
Close
建议:
- Preview:直接关闭
- Normal:直接关闭
- Pinned:第一次关闭仅提示保护状态;在 5 秒提示期内再次按关闭快捷键或点击关闭按钮后关闭
Pin 仍主要保护批量操作。显式关闭增加一次轻量确认,避免误关重要标签,同时不弹出阻塞式对话框;关闭快捷键和标签关闭按钮共享同一次确认状态。Force Close All 保持一次生效。
20. Close All
建议产品定义:
Close All
只关闭:
Preview
Normal
保留:
Pinned
例如:
A | B | C | D
↑ pinned
↑ pinned
执行 Close All:
B | D
21. Close Others
当前:
A | B | C | D
↑ pinned
↑ current
↑ pinned
执行:
Close Others
结果:
B | C | D
因为:
- 当前 C 保留
- Pinned B、D 保留
- 其他关闭
22. Close to the Right
建议默认:
关闭右侧所有未 pinned Tab。
例如:
A | B | C | D | E
↑ current
↑ pinned
执行:
Close to the Right
结果:
A | B | C | D
E 被关闭,D 保留。
23. Force Close All
如果确实需要彻底关闭所有 Tab,可以提供单独命令:
Close All Editors
或:
Force Close All
这个命令可以包含 Pinned。
但建议它不要和普通:
Close All
混在一起。
24. Pinned 的 UI 表现
Preview:
- 文件名斜体
Normal:
- 普通文件名
Pinned:
- 普通文件名
- 可增加 Pin 图标
例如:
README.md | editor.ts 📌 | package.json
是否显示 Pin 图标可根据 UI 密度决定。
25. Tab 状态矩阵
| 状态 | 标题 | Dirty | 可被 Preview 替换 | 批量关闭 |
|---|---|---|---|---|
| Preview | Italic | 否 | 是 | 是 |
| Normal | Normal | 可 | 否 | 是 |
| Pinned | Normal + Pin | 可 | 否 | 否 |
26. Dirty 与 Pin 是独立状态
例如:
Pinned + Dirty
是合法的。
Normal + Dirty
也是合法的。
只有:
Preview + Dirty
非法。
27. 推荐数据模型升级
如果未来状态更多,可以明确分为:
interface EditorTab {
id: string
resource: URI
openMode:
| 'preview'
| 'normal'
pinned: boolean
dirty: boolean
active: boolean
}
这样比:
state: 'preview' | 'normal' | 'pinned'
更准确。
因为 Pinned 并不是与 Preview/Normal 完全同级的一维状态。
28. Editor Open Intent
建议所有模块只表达打开意图。
type EditorOpenIntent =
| 'preview'
| 'normal'
| 'preserve'
例如:
editorService.open(uri, {
intent: 'preview'
})
或:
editorService.open(uri, {
intent: 'normal'
})
不要让 Explorer 直接操作:
isPreview
29. 各入口默认 Open Intent
建议:
| 来源 | Intent |
|---|---|
| Explorer single click | preview |
| Explorer double click | normal |
| Quick Open browse | preview |
| Quick Open confirm | normal / configurable |
| Open File | normal |
| Open Recent | normal |
| Restore Workspace | preserve |
| Go to Definition | preview / configurable |
| Search Result single click | preview |
| Search Result double click | normal |
30. EditorService
建议统一入口:
editorService.open(resource, {
intent: 'preview'
})
Preview 转 Normal:
editorService.keepOpen(editor)
Pin:
editorService.pin(editor)
Unpin:
editorService.unpin(editor)
31. Keep Open 实现
function keepOpen(editor: EditorTab) {
if (!editor.isPreview) return
editor.isPreview = false
const group = getGroup(editor.groupId)
if (group.previewEditorId === editor.id) {
group.previewEditorId = undefined
}
}
32. Pin 实现
function pin(editor: EditorTab) {
if (editor.isPreview) {
keepOpen(editor)
}
editor.isPinned = true
editor.group.moveToEndOfPinnedRegion(editor)
}
这里建议:
如果用户直接对 Preview 执行 Pin:
Preview
↓
Normal
↓
Pinned
也就是 Pin 可以隐式先 Keep Open。
33. Unpin
function unpin(editor: EditorTab) {
editor.isPinned = false
editor.group.moveToStartOfUnpinnedRegion(editor)
}
结果:
Pinned → Normal
不会:
Pinned → Preview
34. Preview 打开算法
function openPreview(resource: URI) {
const group = getActiveGroup()
const existing = group.findByResource(resource)
if (existing) {
group.activate(existing)
return existing
}
const preview = group.previewEditor
if (preview) {
return group.replaceEditor(preview, resource, {
isPreview: true
})
}
return group.openEditor(resource, {
isPreview: true
})
}
35. Normal 打开算法
function openNormal(resource: URI) {
const group = getActiveGroup()
const existing = group.findByResource(resource)
if (existing) {
if (existing.isPreview) {
keepOpen(existing)
}
group.activate(existing)
return existing
}
return group.openEditor(resource, {
isPreview: false
})
}
36. Preview Slot 保持位置
例如:
A | Preview C | B
Preview D:
A | Preview D | B
不要变成:
A | B | Preview D
否则 Tab 会一直跳动。
37. Drag Preview Tab
推荐:
Drag Preview
↓
Keep Open
↓
Normal
原因:
用户主动拖动 Tab,已经表达了明确保留意图。
不自动 Pin。
38. Move Editor
执行:
Move Left
Move Right
Move to Group
如果当前是 Preview:
Preview → Normal
不应该自动:
Normal → Pinned
39. Quick Open
Quick Open 可以分两种动作。
浏览候选:
ArrowDown
↓
Preview
明确选择:
Enter
这里产品可以有两种策略。
VS Code 风格偏浏览
Enter → Preview
更明确的产品语义
Enter → Normal
建议你根据产品定位决定。
如果是 Markdown / 文件浏览型产品,我更推荐:
Arrow navigation → Preview
Enter → Normal
用户认知更明确。
40. Quick Open 取消
用户:
Ctrl+P
↓
浏览多个 Preview
↓
Esc
推荐:
恢复打开 Quick Open 之前的:
active editor
以及必要的:
preview state
避免最后一个浏览结果被意外留下。
41. Navigation History
Preview 被替换,不应该意味着历史被删除。
例如:
A
↓
Preview B
↓
Preview C
↓
Preview D
Alt+Left:
D → C → B → A
因此:
Editor Tabs
和:
Navigation History
应该是两套独立模型。
42. View State
即使 Preview 已经被替换,也建议保存:
cursor
selection
scrollTop
folding
例如:
interface EditorViewState {
resource: URI
cursor?: Position
selection?: Selection
scrollTop?: number
}
历史返回时可以恢复。
43. Split Editor
每个 Group 独立维护:
previewEditorId
例如:
Group A Group B
A | Preview B C | Preview D
A 中打开 Preview E:
A | Preview E C | Preview D
Group B 不受影响。
44. Context Keys
建议和快捷键系统打通:
activeEditorIsPreview
activeEditorIsPinned
activeEditorIsDirty
editorGroupHasPreview
例如:
{
"command": "editor.keepOpen",
"when": "activeEditorIsPreview"
}
{
"command": "editor.unpin",
"when": "activeEditorIsPinned"
}
45. Command 设计
建议至少:
editor.keepOpen
editor.pin
editor.unpin
editor.close
editor.closeOthers
editor.closeToRight
editor.closeAll
editor.forceCloseAll
46. Tab 右键菜单
Preview:
Keep Open
Pin
Close
Close Others
Close to the Right
Close All
如果用户直接 Pin:
Preview → Normal → Pinned
Normal:
Pin
Close
Close Others
Close to the Right
Close All
Pinned:
Unpin
Close
Close Others
Close to the Right
Close All
47. 设置项
建议:
workbench.editor.enablePreview
默认:
true
再细分:
workbench.editor.enablePreviewFromExplorer
workbench.editor.enablePreviewFromQuickOpen
workbench.editor.enablePreviewFromSearch
默认都可以是:
true
48. Preview Disabled
如果:
enablePreview = false
则:
Explorer Single Click
直接:
Normal
不会创建 Preview。
Pinned 机制不受影响。
49. 推荐架构
Explorer
Quick Open
Search
Outline
References
│
│ open intent
▼
EditorService
│
├── openPreview()
├── openNormal()
├── keepOpen()
├── pin()
└── unpin()
│
▼
EditorGroupService
│
├── editors[]
├── activeEditorId
└── previewEditorId
50. 模块拆分
建议:
editor/
├── editor-service.ts
├── editor-group-service.ts
├── editor-tab-model.ts
├── editor-input.ts
│
├── preview-controller.ts
├── pin-controller.ts
│
├── editor-history-service.ts
├── editor-view-state-service.ts
│
└── editor-settings.ts
Preview 和 Pin 建议逻辑上分开:
preview-controller
pin-controller
避免以后重新混在一起。
51. 关键 Invariant
建议直接写进开发规范。
Invariant 1
一个 Editor Group 最多:
1 Preview Editor
Invariant 2
Preview => !Pinned
Invariant 3
Dirty => !Preview
Invariant 4
Normal / Pinned Editor 不会因为弱打开降级成 Preview。
Invariant 5
Preview replacement 保持原 Tab index。
Invariant 6
Pin 不意味着不能手动 Close。
Pin 主要保护:
批量关闭
Invariant 7
Pinned Editor 必须连续排列在 Editor Group 最左侧;任何固定、取消固定、拖拽或会话恢复操作都不能让 Pinned 与 Unpinned 交错。
52. 状态真值表
| Preview | Pinned | Dirty | 合法 |
|---|---|---|---|
| 1 | 0 | 0 | ✅ |
| 1 | 0 | 1 | ❌ |
| 1 | 1 | 0 | ❌ |
| 1 | 1 | 1 | ❌ |
| 0 | 0 | 0 | ✅ |
| 0 | 0 | 1 | ✅ |
| 0 | 1 | 0 | ✅ |
| 0 | 1 | 1 | ✅ |
开发时非常适合直接写成 assertion。
53. MVP
P0
建议先做:
- Explorer 单击 → Preview
- Double Click → Normal
- 一个 Group 一个 Preview
- Preview replacement
- Preview Tab 斜体
- Edit → Preview 自动转 Normal
- Double-click Tab → Normal
- Keep Open
- Preview slot 保持位置
- Preview setting
P1
增加:
- Pin / Unpin
- Close All 跳过 Pinned
- Close Others 跳过 Pinned
- Close to Right 跳过 Pinned
- Pinned UI
- Context Key
- Shortcut
- Split Group Preview
P2
再增加:
- Quick Open Preview
- Search Preview
- Go to Definition Preview
- Navigation History
- View State restore
- Workspace restore
- Extension API
54. 最终推荐模型
开发层最终只需要牢记:
Preview
=
临时打开生命周期
Normal
=
普通持久打开生命周期
Pinned
=
批量关闭保护状态
不要设计成:
Preview → Pinned
正确模型是:
Preview
↓ Keep Open / Edit / Double Click
Normal
↓ Pin
Pinned
以及:
Pinned
↓ Unpin
Normal
如果从数据建模角度表达,则最准确的是:
type OpenMode =
| 'preview'
| 'normal'
interface EditorTab {
openMode: OpenMode
pinned: boolean
dirty: boolean
}
这比把 preview / normal / pinned 做成一个单独 enum 更符合 VS Code 这类编辑器的真实产品语义,也更适合后续继续扩展 Editor Group、Close 行为、Workspace Restore 和快捷键系统。
