@yuhufe/wtool-vdiff 0.0.5 → 0.0.7

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@yuhufe/wtool-vdiff",
3
3
  "private": false,
4
- "version": "0.0.5",
4
+ "version": "0.0.7",
5
5
  "type": "module",
6
6
  "description": "Monaco diff viewer as Vue 3 Web Component",
7
7
  "keywords": [
@@ -35,13 +35,15 @@
35
35
  "vue": "^3.5.0"
36
36
  },
37
37
  "dependencies": {
38
+ "@yuhufe/web-ui": "workspace:*",
39
+ "@yuhufe/web-common": "workspace:*",
38
40
  "@monaco-editor/loader": "^1.6.1",
39
- "less": "4.6.4",
41
+ "diff": "^9.0.0",
40
42
  "monaco-editor": "^0.53.0"
41
43
  },
42
44
  "devDependencies": {
43
45
  "@vitejs/plugin-vue": "^5.2.0",
44
- "@yuhufe/web-ui": "workspace:*",
46
+ "less": "4.6.4",
45
47
  "typescript": "^5.9.3",
46
48
  "vite": "^7.3.1",
47
49
  "vite-plugin-css-injected-by-js": "5.0.1",
@@ -0,0 +1,47 @@
1
+ <template>
2
+ <div class="diff-files-wrap">
3
+ <Toolbar class="toolbar" />
4
+ <div class="content-wrap">
5
+ <FileExplore :diffFiles="diffFiles" />
6
+ <div class="filelist-viewer-wrap"></div>
7
+ </div>
8
+ </div>
9
+ </template>
10
+
11
+ <script setup lang="ts">
12
+ import { ref, computed, nextTick } from 'vue'
13
+
14
+ import { loadingDirective } from '@yuhufe/web-ui'
15
+ import type { DiffEditorOptions, WtoolDiffViewerProps, ModelOptions } from '../types'
16
+
17
+ import FileExplore from './FileExplore/FileExplore.vue'
18
+
19
+ const vLoading = loadingDirective
20
+ const loading = ref(true)
21
+
22
+ const props = withDefaults(
23
+ defineProps<{
24
+ diffFiles: WtoolDiffViewerProps[]
25
+ }>(),
26
+ {
27
+ diffFiles: () => [],
28
+ }
29
+ )
30
+ </script>
31
+
32
+ <style scoped>
33
+ .diff-files-wrap {
34
+ width: 100%;
35
+ height: 100%;
36
+ display: flex;
37
+ flex-direction: column;
38
+
39
+ .toolbar {
40
+ flex-shrink: 0;
41
+ }
42
+ .content-wrap {
43
+ flex: 1;
44
+ overflow: hidden;
45
+ }
46
+ }
47
+ </style>
@@ -0,0 +1,23 @@
1
+ <template>
2
+ <div class="file-explore-wrap"></div>
3
+ </template>
4
+
5
+ <script setup lang="ts">
6
+ import { ref, computed, nextTick, shallowRef } from 'vue'
7
+
8
+ import { loadingDirective } from '@yuhufe/web-ui'
9
+ import type { FileTree, WtoolDiffViewerProps, ModelOptions } from '@/types'
10
+
11
+ const props = withDefaults(
12
+ defineProps<{
13
+ diffFiles: WtoolDiffViewerProps[]
14
+ }>(),
15
+ {
16
+ diffFiles: () => [],
17
+ }
18
+ )
19
+
20
+ const filesData = shallowRef<FileTree[]>([])
21
+ </script>
22
+
23
+ <style scoped></style>
@@ -4,6 +4,7 @@
4
4
  <div class="content-wrap" v-show="!viewed" v-loading="loading">
5
5
  <MonacoDiffViewer
6
6
  class="monaco-container"
7
+ :style="{ opacity: loading ? 0 : 1 }"
7
8
  :originalCode="originalCode"
8
9
  :modifiedCode="modifiedCode"
9
10
  :language="language"
@@ -16,21 +17,24 @@
16
17
  </template>
17
18
 
18
19
  <script setup lang="ts">
19
- import { ref, computed } from 'vue'
20
+ import { ref, computed, nextTick } from 'vue'
20
21
 
21
22
  import { loadingDirective } from '@yuhufe/web-ui'
23
+ import { v4 } from '@yuhufe/web-common'
22
24
  import type { DiffEditorOptions, WtoolDiffViewerProps, ModelOptions } from '../types'
23
25
  import MonacoDiffViewer from './MonacoDiffViewer.vue'
24
26
  import TopBar from './TopBar.vue'
25
27
  import { useDiffViewer } from './useDiffView'
26
28
  import { patch2Pair } from './utils/patch2Pair'
27
29
  import { autoHeight } from './utils/autoHeight'
30
+ import { HEIGHT_TOP_BAR } from './const'
28
31
 
29
32
  const vLoading = loadingDirective
30
33
  const loading = ref(true)
34
+ const autoHeightId = v4()
31
35
 
32
36
  const _renderStart = performance.now()
33
- const onMonacoRenderComplete = () => {
37
+ const onMonacoRenderComplete = async () => {
34
38
  const cost = performance.now() - _renderStart
35
39
  console.log(`[DiffViewer] 渲染耗时: ${cost.toFixed(2)} ms`)
36
40
  loading.value = false
@@ -77,6 +81,7 @@ const mergedOptions = computed(() => {
77
81
  hideUnchangedRegions = {
78
82
  enabled: true,
79
83
  contextLineCount: 3,
84
+ minimumLineCount: 1,
80
85
  ...hideUnchangedRegions,
81
86
  }
82
87
  }
@@ -104,6 +109,7 @@ const viewerHeight = computed(() => {
104
109
  }
105
110
 
106
111
  const height = autoHeight({
112
+ id: autoHeightId,
107
113
  patch: props.diffPatch,
108
114
  pair: props.diffPair,
109
115
  ...heightRange,
@@ -111,7 +117,8 @@ const viewerHeight = computed(() => {
111
117
  unchangedCtxLineNum: mergedOptions.value.hideUnchangedRegions.contextLineCount!,
112
118
  })
113
119
 
114
- return `${height}px`
120
+ // 当前高度为代码高度,需要加上 topBar 高度才是完整高度
121
+ return `${height + HEIGHT_TOP_BAR}px`
115
122
  })
116
123
  const viewerStyle = computed(() => {
117
124
  return {
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="top-bar-wrap">
2
+ <div class="top-bar-wrap" :style="{ height: `${HEIGHT_TOP_BAR}px` }">
3
3
  <div class="title-area">
4
4
  <div class="filename">{{ filenameDisplay }}</div>
5
5
  <span :class="['diff-type-tag', diffType]">{{ diffTypeLabel }}</span>
@@ -24,6 +24,7 @@
24
24
  <script setup lang="ts">
25
25
  import { computed, ref, onMounted } from 'vue'
26
26
  import { useDiffViewer } from './useDiffView'
27
+ import { HEIGHT_TOP_BAR } from './const'
27
28
 
28
29
  const { funcs, registerFunc } = useDiffViewer()
29
30
 
@@ -100,7 +101,6 @@ registerFunc({
100
101
  <style scoped>
101
102
  .top-bar-wrap {
102
103
  box-sizing: border-box;
103
- height: 32px;
104
104
 
105
105
  display: flex;
106
106
  overflow: hidden;
@@ -0,0 +1 @@
1
+ export const HEIGHT_TOP_BAR = 32
@@ -1,30 +1,84 @@
1
+ import { diffLines } from 'diff'
1
2
  import type { WtoolDiffViewerProps } from '@/types'
2
- import { parseHunks } from './patch2Pair'
3
+ import { parsePatchHunks } from '@/utils/patch'
3
4
 
4
5
  const LINE_HEIGHT = 18
6
+ const GAP_HEIGHT = 24
7
+ const heightMap = new Map<string, Partial<Record<'visible' | 'hidden', number>>>()
5
8
 
6
9
  interface CommonParams {
7
- minLine: number
8
- maxLine: number
10
+ minPx: number
11
+ maxPx: number
9
12
  unchangedVisiable: boolean
10
13
  unchangedCtxLineNum: number
11
14
  }
12
15
 
16
+ interface ChangedLineBlock {
17
+ start: number
18
+ end: number
19
+ }
20
+
21
+ interface ChangedLineInfo {
22
+ blocks: ChangedLineBlock[]
23
+ totalLines: number
24
+ }
25
+
26
+ function getChangedLineInfo(origContent: string, modContent: string): ChangedLineInfo {
27
+ const changes = diffLines(origContent, modContent)
28
+ const blocks: ChangedLineBlock[] = []
29
+ let row = 1
30
+ let removed = 0
31
+ let added = 0
32
+
33
+ const flush = () => {
34
+ const len = Math.max(removed, added)
35
+ if (len > 0) {
36
+ blocks.push({ start: row, end: row + len - 1 })
37
+ row += len
38
+ removed = 0
39
+ added = 0
40
+ }
41
+ }
42
+
43
+ for (const part of changes) {
44
+ const count = part.count ?? 0
45
+
46
+ if (part.removed) {
47
+ removed += count
48
+ } else if (part.added) {
49
+ added += count
50
+ } else {
51
+ flush()
52
+ row += count
53
+ }
54
+ }
55
+
56
+ flush()
57
+
58
+ return {
59
+ blocks,
60
+ totalLines: Math.max(0, row - 1),
61
+ }
62
+ }
63
+
13
64
  /**
14
65
  * 在线可见行计数器,按顺序逐个喂入变更区间(1-based inclusive),流式累计可见行数。
15
- * 区间必须按 start 升序喂入,每次 feed 后可读取 visible 判断是否达到 maxLine。
66
+ * 区间必须按 start 升序喂入,每次 feed 后可读取 maxReached 判断是否达到像素上限。
16
67
  * @param totalLines 文件总行数,用于 clamp context 窗口上界及判断尾部是否有折叠 widget
17
68
  * @param ctx 每个变更块上下保留的 context 行数,对应 Monaco contextLineCount
18
- * @param maxLine 可见行上限,达到后 maxReached 为 true,调用方应立即停止 feed
69
+ * @param maxPx 像素上限,达到后 maxReached 为 true,调用方应立即停止 feed
19
70
  */
20
- function makeVisibleLineCounter(totalLines: number, ctx: number, maxLine: number) {
21
- let visible = 0 // 已 commit 的确定可见行数(不含当前 pending 块)
71
+ function makeVisibleLineCounter(totalLines: number, ctx: number, maxPx: number) {
72
+ let visible = 0 // 已 commit 的确定可见行数(不含当前 pending 块)
73
+ let gaps = 0 // gap widget 数量(每个折叠区域占 GAP_HEIGHT,非 LINE_HEIGHT)
22
74
  let pendingStart = -1 // 当前待合并窗口的起始行(-1 表示无 pending)
23
- let pendingEnd = -1 // 当前待合并窗口的结束行
75
+ let pendingEnd = -1 // 当前待合并窗口的结束行
76
+
77
+ const usedPx = () => visible * LINE_HEIGHT + gaps * GAP_HEIGHT
24
78
 
25
79
  const commitPending = () => {
26
80
  if (pendingStart === -1) return
27
- if (pendingStart > 1) visible += 1
81
+ if (pendingStart > 1) gaps += 1
28
82
  visible += pendingEnd - pendingStart + 1
29
83
  pendingStart = -1
30
84
  pendingEnd = -1
@@ -35,7 +89,7 @@ function makeVisibleLineCounter(totalLines: number, ctx: number, maxLine: number
35
89
  const winStart = Math.max(1, s - ctx)
36
90
  const winEnd = Math.min(totalLines, e + ctx)
37
91
  if (pendingEnd === -1 || winStart > pendingEnd + 1) {
38
- commitPending() // 新窗口与 pending 有间隙:先提交 pending,再开新窗口
92
+ commitPending() // 新窗口与 pending 有间隙:先提交 pending,再开新窗口
39
93
  pendingStart = winStart
40
94
  pendingEnd = winEnd
41
95
  } else {
@@ -43,79 +97,82 @@ function makeVisibleLineCounter(totalLines: number, ctx: number, maxLine: number
43
97
  }
44
98
  },
45
99
  flush() {
100
+ const lastEnd = pendingEnd
46
101
  commitPending()
47
- if (visible > 0 && pendingEnd !== totalLines) visible += 1
48
- return Math.min(visible, maxLine)
102
+ if (visible > 0 && lastEnd !== totalLines) gaps += 1
103
+ return usedPx()
104
+ },
105
+ get usedPx() {
106
+ return usedPx()
49
107
  },
50
- get visible() {
51
- return visible
108
+ get gaps() {
109
+ return gaps
52
110
  },
53
111
  get maxReached() {
54
- return visible >= maxLine
112
+ return usedPx() >= maxPx
55
113
  },
56
114
  }
57
115
  }
58
116
 
59
117
  const autoHeightPatch = function ({
60
118
  patch,
61
- minLine,
62
- maxLine,
119
+ minPx,
120
+ maxPx,
63
121
  unchangedCtxLineNum,
64
122
  }: {
65
123
  patch: string
66
124
  } & CommonParams): number {
67
- const { hunks } = parseHunks(patch)
68
- if (hunks.length === 0) return minLine
125
+ const hunks = parsePatchHunks(patch)
126
+ if (hunks.length === 0) return minPx
69
127
 
70
128
  const lastHunk = hunks[hunks.length - 1]
71
- const totalLines = Math.max(
72
- lastHunk.origStart + lastHunk.origCount - 1,
73
- lastHunk.modStart + lastHunk.modCount - 1,
74
- )
75
- const counter = makeVisibleLineCounter(totalLines, unchangedCtxLineNum, maxLine)
129
+ const totalLines = Math.max(lastHunk.origStart + lastHunk.origCount - 1, lastHunk.modStart + lastHunk.modCount - 1)
130
+ const counter = makeVisibleLineCounter(totalLines, unchangedCtxLineNum, maxPx)
76
131
 
77
132
  for (const h of hunks) {
78
133
  const hunkEnd = Math.max(h.origStart + h.origCount - 1, h.modStart + h.modCount - 1)
79
134
  counter.feed(h.origStart, hunkEnd)
80
- if (counter.maxReached) return maxLine
135
+ if (counter.maxReached) return maxPx
81
136
  }
82
137
 
83
- return Math.max(minLine, counter.flush())
138
+ return Math.max(minPx, counter.flush())
84
139
  }
85
140
 
86
141
  const autoHeightPair = function ({
87
142
  pair,
88
- minLine,
89
- maxLine,
143
+ minPx,
144
+ maxPx,
90
145
  unchangedVisiable,
91
146
  unchangedCtxLineNum,
92
147
  }: {
93
148
  pair: WtoolDiffViewerProps['diffPair']
94
149
  } & CommonParams): number {
95
- if (!pair || pair.length < 2) return minLine
150
+ if (!pair || pair.length < 2) return minPx
96
151
 
97
- const origLines = pair[0].content?.split('\n') ?? []
98
- const modLines = pair[1].content?.split('\n') ?? []
152
+ const origContent = pair[0].content ?? ''
153
+ const modContent = pair[1].content ?? ''
154
+ const origLines = origContent.split('\n')
155
+ const modLines = modContent.split('\n')
99
156
  const totalLines = Math.max(origLines.length, modLines.length)
100
157
 
101
158
  if (unchangedVisiable) {
102
- return Math.max(minLine, Math.min(totalLines, maxLine))
159
+ return Math.max(minPx, Math.min(totalLines * LINE_HEIGHT, maxPx))
103
160
  }
104
161
 
105
- const counter = makeVisibleLineCounter(totalLines, unchangedCtxLineNum, maxLine)
106
- let i = 0
107
- while (i < totalLines) {
108
- if ((origLines[i] ?? '') !== (modLines[i] ?? '')) {
109
- const start = i + 1
110
- while (i < totalLines && (origLines[i] ?? '') !== (modLines[i] ?? '')) i++
111
- counter.feed(start, i)
112
- if (counter.maxReached) return maxLine
113
- } else {
114
- i++
115
- }
162
+ const { blocks, totalLines: diffTotalLines } = getChangedLineInfo(origContent, modContent)
163
+ if (blocks.length === 0) return minPx
164
+
165
+ const counter = makeVisibleLineCounter(diffTotalLines, unchangedCtxLineNum, maxPx)
166
+ for (const block of blocks) {
167
+ counter.feed(block.start, block.end)
168
+ if (counter.maxReached) return maxPx
116
169
  }
117
170
 
118
- return Math.max(minLine, counter.flush())
171
+ let result = counter.flush()
172
+ result = Math.max(minPx, result)
173
+ result = Math.min(maxPx, result)
174
+
175
+ return result
119
176
  }
120
177
 
121
178
  const height2Num = (heightStr: string): number => {
@@ -127,6 +184,7 @@ const height2Num = (heightStr: string): number => {
127
184
  }
128
185
 
129
186
  export const autoHeight = function ({
187
+ id,
130
188
  patch,
131
189
  pair,
132
190
  maxHeight,
@@ -134,6 +192,7 @@ export const autoHeight = function ({
134
192
  unchangedVisiable,
135
193
  unchangedCtxLineNum,
136
194
  }: {
195
+ id: string
137
196
  patch?: string
138
197
  pair?: WtoolDiffViewerProps['diffPair']
139
198
  maxHeight: string
@@ -141,9 +200,19 @@ export const autoHeight = function ({
141
200
  unchangedVisiable: boolean
142
201
  unchangedCtxLineNum: number
143
202
  }): number {
144
- const [minLine, maxLine] = [minHeight, maxHeight].map(str => Math.floor(height2Num(str) / LINE_HEIGHT))
145
- const commonParams: CommonParams = { minLine, maxLine, unchangedVisiable, unchangedCtxLineNum }
203
+ const cacheKey = unchangedVisiable ? 'visible' : 'hidden'
204
+ const cache = heightMap.get(id) || {}
205
+ const cachedHeight = cache?.[cacheKey]
206
+ if (cachedHeight !== undefined) return cachedHeight
207
+
208
+ const [minPx, maxPx] = [minHeight, maxHeight].map(height2Num)
209
+ const commonParams: CommonParams = { minPx, maxPx, unchangedVisiable, unchangedCtxLineNum }
210
+ const height = patch ? autoHeightPatch({ patch, ...commonParams }) : autoHeightPair({ pair, ...commonParams })
211
+
212
+ heightMap.set(id, {
213
+ ...cache,
214
+ [cacheKey]: height,
215
+ })
146
216
 
147
- if (patch) return autoHeightPatch({ patch, ...commonParams }) * LINE_HEIGHT
148
- return autoHeightPair({ pair, ...commonParams }) * LINE_HEIGHT
217
+ return height
149
218
  }
@@ -1,58 +1,10 @@
1
+ import { parsePatchFilenames, parsePatchHunks } from '@/utils/patch'
2
+
1
3
  export interface FilePair {
2
4
  filename: string
3
5
  content: string
4
6
  }
5
7
 
6
- export interface Hunk {
7
- origStart: number // original 起始行号(1-based)
8
- origCount: number // original 行数
9
- modStart: number // modified 起始行号(1-based)
10
- modCount: number // modified 行数
11
- lines: string[] // hunk 原始行(含前缀字符)
12
- }
13
-
14
- /**
15
- * 解析 unified diff patch,提取文件名与所有 hunk
16
- */
17
- export function parseHunks(patch: string): { origFilename: string; modFilename: string; hunks: Hunk[] } {
18
- const lines = patch.split('\n')
19
-
20
- let origFilename = ''
21
- let modFilename = ''
22
- const hunks: Hunk[] = []
23
- let currentHunk: Hunk | null = null
24
-
25
- for (const line of lines) {
26
- if (line.startsWith('--- ')) {
27
- origFilename = line.slice(4).trim()
28
- continue
29
- }
30
- if (line.startsWith('+++ ')) {
31
- modFilename = line.slice(4).trim()
32
- continue
33
- }
34
- // @@ -origStart,origCount +modStart,modCount @@
35
- const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
36
- if (hunkMatch) {
37
- currentHunk = {
38
- origStart: parseInt(hunkMatch[1], 10),
39
- origCount: hunkMatch[2] !== undefined ? parseInt(hunkMatch[2], 10) : 1,
40
- modStart: parseInt(hunkMatch[3], 10),
41
- modCount: hunkMatch[4] !== undefined ? parseInt(hunkMatch[4], 10) : 1,
42
- lines: [],
43
- }
44
- hunks.push(currentHunk)
45
- continue
46
- }
47
- if (currentHunk) {
48
- if (line.startsWith('\\')) continue // ""
49
- currentHunk.lines.push(line)
50
- }
51
- }
52
-
53
- return { origFilename, modFilename, hunks }
54
- }
55
-
56
8
  /**
57
9
  * 将 unified diff patch 转换为 [original, modified] 文件对。
58
10
  *
@@ -71,7 +23,8 @@ export const patch2Pair = function (patch: string): FilePair[] {
71
23
  ]
72
24
  }
73
25
 
74
- const { origFilename, modFilename, hunks } = parseHunks(patch)
26
+ const { origFilename, modFilename } = parsePatchFilenames(patch)
27
+ const hunks = parsePatchHunks(patch)
75
28
 
76
29
  const origLines: string[] = []
77
30
  const modLines: string[] = []
package/src/types.ts CHANGED
@@ -18,3 +18,10 @@ export interface WtoolDiffViewerStyle {
18
18
  minHeight?: string
19
19
  maxHeight?: string
20
20
  }
21
+
22
+ export interface FileTree {
23
+ isDir?: boolean
24
+ name: string
25
+ type?: string
26
+ children?: FileTree[]
27
+ }
@@ -0,0 +1,74 @@
1
+ export interface Hunk {
2
+ origStart: number // original 起始行号(1-based)
3
+ origCount: number // original 行数
4
+ modStart: number // modified 起始行号(1-based)
5
+ modCount: number // modified 行数
6
+ lines: string[] // hunk 原始行(含前缀字符)
7
+ }
8
+
9
+ export interface PatchFilenames {
10
+ origFilename: string
11
+ modFilename: string
12
+ }
13
+
14
+ /**
15
+ * 从 unified diff patch 解析 --- / +++ 行中的文件名。
16
+ * 文件名位于 patch 头部,遇到首个 @@ hunk 或已解析到两侧文件名后即停止扫描。
17
+ */
18
+ export function parsePatchFilenames(patch: string): PatchFilenames {
19
+ let origFilename = ''
20
+ let modFilename = ''
21
+
22
+ let start = 0
23
+ while (start < patch.length) {
24
+ const nl = patch.indexOf('\n', start)
25
+ const line = nl === -1 ? patch.slice(start) : patch.slice(start, nl)
26
+ start = nl === -1 ? patch.length : nl + 1
27
+
28
+ if (line.startsWith('--- ')) {
29
+ origFilename = line.slice(4).trim()
30
+ if (origFilename && modFilename) break
31
+ continue
32
+ }
33
+ if (line.startsWith('+++ ')) {
34
+ modFilename = line.slice(4).trim()
35
+ if (origFilename && modFilename) break
36
+ continue
37
+ }
38
+ if (line.startsWith('@@')) break
39
+ }
40
+
41
+ return { origFilename, modFilename }
42
+ }
43
+
44
+ /**
45
+ * 从 unified diff patch 解析所有 hunk(@@ 块)
46
+ */
47
+ export function parsePatchHunks(patch: string): Hunk[] {
48
+ const lines = patch.split('\n')
49
+
50
+ const hunks: Hunk[] = []
51
+ let currentHunk: Hunk | null = null
52
+
53
+ for (const line of lines) {
54
+ // @@ -origStart,origCount +modStart,modCount @@
55
+ const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
56
+ if (hunkMatch) {
57
+ currentHunk = {
58
+ origStart: parseInt(hunkMatch[1], 10),
59
+ origCount: hunkMatch[2] !== undefined ? parseInt(hunkMatch[2], 10) : 1,
60
+ modStart: parseInt(hunkMatch[3], 10),
61
+ modCount: hunkMatch[4] !== undefined ? parseInt(hunkMatch[4], 10) : 1,
62
+ lines: [],
63
+ }
64
+ hunks.push(currentHunk)
65
+ continue
66
+ }
67
+ if (currentHunk) {
68
+ if (line.startsWith('\\')) continue // ""
69
+ currentHunk.lines.push(line)
70
+ }
71
+ }
72
+
73
+ return hunks
74
+ }