@yuhufe/wtool-vdiff 0.0.6 → 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.6",
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,8 +1,10 @@
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
5
6
  const GAP_HEIGHT = 24
7
+ const heightMap = new Map<string, Partial<Record<'visible' | 'hidden', number>>>()
6
8
 
7
9
  interface CommonParams {
8
10
  minPx: number
@@ -11,6 +13,54 @@ interface CommonParams {
11
13
  unchangedCtxLineNum: number
12
14
  }
13
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
+
14
64
  /**
15
65
  * 在线可见行计数器,按顺序逐个喂入变更区间(1-based inclusive),流式累计可见行数。
16
66
  * 区间必须按 start 升序喂入,每次 feed 后可读取 maxReached 判断是否达到像素上限。
@@ -19,10 +69,10 @@ interface CommonParams {
19
69
  * @param maxPx 像素上限,达到后 maxReached 为 true,调用方应立即停止 feed
20
70
  */
21
71
  function makeVisibleLineCounter(totalLines: number, ctx: number, maxPx: number) {
22
- let visible = 0 // 已 commit 的确定可见行数(不含当前 pending 块)
23
- let gaps = 0 // gap widget 数量(每个折叠区域占 GAP_HEIGHT,非 LINE_HEIGHT)
72
+ let visible = 0 // 已 commit 的确定可见行数(不含当前 pending 块)
73
+ let gaps = 0 // gap widget 数量(每个折叠区域占 GAP_HEIGHT,非 LINE_HEIGHT)
24
74
  let pendingStart = -1 // 当前待合并窗口的起始行(-1 表示无 pending)
25
- let pendingEnd = -1 // 当前待合并窗口的结束行
75
+ let pendingEnd = -1 // 当前待合并窗口的结束行
26
76
 
27
77
  const usedPx = () => visible * LINE_HEIGHT + gaps * GAP_HEIGHT
28
78
 
@@ -39,7 +89,7 @@ function makeVisibleLineCounter(totalLines: number, ctx: number, maxPx: number)
39
89
  const winStart = Math.max(1, s - ctx)
40
90
  const winEnd = Math.min(totalLines, e + ctx)
41
91
  if (pendingEnd === -1 || winStart > pendingEnd + 1) {
42
- commitPending() // 新窗口与 pending 有间隙:先提交 pending,再开新窗口
92
+ commitPending() // 新窗口与 pending 有间隙:先提交 pending,再开新窗口
43
93
  pendingStart = winStart
44
94
  pendingEnd = winEnd
45
95
  } else {
@@ -72,14 +122,11 @@ const autoHeightPatch = function ({
72
122
  }: {
73
123
  patch: string
74
124
  } & CommonParams): number {
75
- const { hunks } = parseHunks(patch)
125
+ const hunks = parsePatchHunks(patch)
76
126
  if (hunks.length === 0) return minPx
77
127
 
78
128
  const lastHunk = hunks[hunks.length - 1]
79
- const totalLines = Math.max(
80
- lastHunk.origStart + lastHunk.origCount - 1,
81
- lastHunk.modStart + lastHunk.modCount - 1,
82
- )
129
+ const totalLines = Math.max(lastHunk.origStart + lastHunk.origCount - 1, lastHunk.modStart + lastHunk.modCount - 1)
83
130
  const counter = makeVisibleLineCounter(totalLines, unchangedCtxLineNum, maxPx)
84
131
 
85
132
  for (const h of hunks) {
@@ -102,27 +149,30 @@ const autoHeightPair = function ({
102
149
  } & CommonParams): number {
103
150
  if (!pair || pair.length < 2) return minPx
104
151
 
105
- const origLines = pair[0].content?.split('\n') ?? []
106
- 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')
107
156
  const totalLines = Math.max(origLines.length, modLines.length)
108
157
 
109
158
  if (unchangedVisiable) {
110
159
  return Math.max(minPx, Math.min(totalLines * LINE_HEIGHT, maxPx))
111
160
  }
112
161
 
113
- // 剥前缀相同行
114
- let lo = 0
115
- while (lo < origLines.length && lo < modLines.length && origLines[lo] === modLines[lo]) lo++
162
+ const { blocks, totalLines: diffTotalLines } = getChangedLineInfo(origContent, modContent)
163
+ if (blocks.length === 0) return minPx
116
164
 
117
- // 剥后缀相同行
118
- let origHi = origLines.length, modHi = modLines.length
119
- while (origHi > lo && modHi > lo && origLines[origHi - 1] === modLines[modHi - 1]) { origHi--; modHi-- }
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
169
+ }
120
170
 
121
- if (origHi <= lo && modHi <= lo) return minPx
171
+ let result = counter.flush()
172
+ result = Math.max(minPx, result)
173
+ result = Math.min(maxPx, result)
122
174
 
123
- const counter = makeVisibleLineCounter(totalLines, unchangedCtxLineNum, maxPx)
124
- counter.feed(lo + 1, Math.max(origHi, modHi))
125
- return Math.max(minPx, counter.flush())
175
+ return result
126
176
  }
127
177
 
128
178
  const height2Num = (heightStr: string): number => {
@@ -134,6 +184,7 @@ const height2Num = (heightStr: string): number => {
134
184
  }
135
185
 
136
186
  export const autoHeight = function ({
187
+ id,
137
188
  patch,
138
189
  pair,
139
190
  maxHeight,
@@ -141,6 +192,7 @@ export const autoHeight = function ({
141
192
  unchangedVisiable,
142
193
  unchangedCtxLineNum,
143
194
  }: {
195
+ id: string
144
196
  patch?: string
145
197
  pair?: WtoolDiffViewerProps['diffPair']
146
198
  maxHeight: string
@@ -148,9 +200,19 @@ export const autoHeight = function ({
148
200
  unchangedVisiable: boolean
149
201
  unchangedCtxLineNum: number
150
202
  }): number {
203
+ const cacheKey = unchangedVisiable ? 'visible' : 'hidden'
204
+ const cache = heightMap.get(id) || {}
205
+ const cachedHeight = cache?.[cacheKey]
206
+ if (cachedHeight !== undefined) return cachedHeight
207
+
151
208
  const [minPx, maxPx] = [minHeight, maxHeight].map(height2Num)
152
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
+ })
153
216
 
154
- if (patch) return autoHeightPatch({ patch, ...commonParams })
155
- return autoHeightPair({ pair, ...commonParams })
217
+ return height
156
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
+ }