@yuhufe/wtool-vdiff 0.0.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.
@@ -0,0 +1,149 @@
1
+ import type { WtoolDiffViewerProps } from '@/types'
2
+ import { parseHunks } from './patch2Pair'
3
+
4
+ const LINE_HEIGHT = 18
5
+
6
+ interface CommonParams {
7
+ minLine: number
8
+ maxLine: number
9
+ unchangedVisiable: boolean
10
+ unchangedCtxLineNum: number
11
+ }
12
+
13
+ /**
14
+ * 在线可见行计数器,按顺序逐个喂入变更区间(1-based inclusive),流式累计可见行数。
15
+ * 区间必须按 start 升序喂入,每次 feed 后可读取 visible 判断是否达到 maxLine。
16
+ * @param totalLines 文件总行数,用于 clamp context 窗口上界及判断尾部是否有折叠 widget
17
+ * @param ctx 每个变更块上下保留的 context 行数,对应 Monaco contextLineCount
18
+ * @param maxLine 可见行上限,达到后 maxReached 为 true,调用方应立即停止 feed
19
+ */
20
+ function makeVisibleLineCounter(totalLines: number, ctx: number, maxLine: number) {
21
+ let visible = 0 // 已 commit 的确定可见行数(不含当前 pending 块)
22
+ let pendingStart = -1 // 当前待合并窗口的起始行(-1 表示无 pending)
23
+ let pendingEnd = -1 // 当前待合并窗口的结束行
24
+
25
+ const commitPending = () => {
26
+ if (pendingStart === -1) return
27
+ if (pendingStart > 1) visible += 1
28
+ visible += pendingEnd - pendingStart + 1
29
+ pendingStart = -1
30
+ pendingEnd = -1
31
+ }
32
+
33
+ return {
34
+ feed(s: number, e: number) {
35
+ const winStart = Math.max(1, s - ctx)
36
+ const winEnd = Math.min(totalLines, e + ctx)
37
+ if (pendingEnd === -1 || winStart > pendingEnd + 1) {
38
+ commitPending() // 新窗口与 pending 有间隙:先提交 pending,再开新窗口
39
+ pendingStart = winStart
40
+ pendingEnd = winEnd
41
+ } else {
42
+ pendingEnd = Math.max(pendingEnd, winEnd) // 新窗口与 pending 重叠:合并,延伸末端
43
+ }
44
+ },
45
+ flush() {
46
+ commitPending()
47
+ if (visible > 0 && pendingEnd !== totalLines) visible += 1
48
+ return Math.min(visible, maxLine)
49
+ },
50
+ get visible() {
51
+ return visible
52
+ },
53
+ get maxReached() {
54
+ return visible >= maxLine
55
+ },
56
+ }
57
+ }
58
+
59
+ const autoHeightPatch = function ({
60
+ patch,
61
+ minLine,
62
+ maxLine,
63
+ unchangedCtxLineNum,
64
+ }: {
65
+ patch: string
66
+ } & CommonParams): number {
67
+ const { hunks } = parseHunks(patch)
68
+ if (hunks.length === 0) return minLine
69
+
70
+ 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)
76
+
77
+ for (const h of hunks) {
78
+ const hunkEnd = Math.max(h.origStart + h.origCount - 1, h.modStart + h.modCount - 1)
79
+ counter.feed(h.origStart, hunkEnd)
80
+ if (counter.maxReached) return maxLine
81
+ }
82
+
83
+ return Math.max(minLine, counter.flush())
84
+ }
85
+
86
+ const autoHeightPair = function ({
87
+ pair,
88
+ minLine,
89
+ maxLine,
90
+ unchangedVisiable,
91
+ unchangedCtxLineNum,
92
+ }: {
93
+ pair: WtoolDiffViewerProps['diffPair']
94
+ } & CommonParams): number {
95
+ if (!pair || pair.length < 2) return minLine
96
+
97
+ const origLines = pair[0].content.split('\n')
98
+ const modLines = pair[1].content.split('\n')
99
+ const totalLines = Math.max(origLines.length, modLines.length)
100
+
101
+ if (unchangedVisiable) {
102
+ return Math.max(minLine, Math.min(totalLines, maxLine))
103
+ }
104
+
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
+ }
116
+ }
117
+
118
+ return Math.max(minLine, counter.flush())
119
+ }
120
+
121
+ const height2Num = (heightStr: string): number => {
122
+ if (heightStr.endsWith('vh')) {
123
+ const vh = parseFloat(heightStr)
124
+ return Math.round((vh / 100) * document.documentElement.clientHeight)
125
+ }
126
+ return Math.round(parseFloat(heightStr))
127
+ }
128
+
129
+ export const autoHeight = function ({
130
+ patch,
131
+ pair,
132
+ maxHeight,
133
+ minHeight,
134
+ unchangedVisiable,
135
+ unchangedCtxLineNum,
136
+ }: {
137
+ patch?: string
138
+ pair?: WtoolDiffViewerProps['diffPair']
139
+ maxHeight: string
140
+ minHeight: string
141
+ unchangedVisiable: boolean
142
+ unchangedCtxLineNum: number
143
+ }): number {
144
+ const [minLine, maxLine] = [minHeight, maxHeight].map(str => Math.floor(height2Num(str) / LINE_HEIGHT))
145
+ const commonParams: CommonParams = { minLine, maxLine, unchangedVisiable, unchangedCtxLineNum }
146
+
147
+ if (patch) return autoHeightPatch({ patch, ...commonParams }) * LINE_HEIGHT
148
+ return autoHeightPair({ pair, ...commonParams }) * LINE_HEIGHT
149
+ }
@@ -0,0 +1,121 @@
1
+ export interface FilePair {
2
+ filename: string
3
+ content: string
4
+ }
5
+
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
+ /**
57
+ * 将 unified diff patch 转换为 [original, modified] 文件对。
58
+ *
59
+ * Monaco diff editor 需要两个完整文件内容,而 patch 只记录变更片段。
60
+ * 未改动的行(hunk 之外)用空行填充,使两侧行号与 hunk 声明的起始位置对齐,
61
+ * 从而让 Monaco 能在正确的行号位置渲染增删差异。
62
+ */
63
+ export const patch2Pair = function (patch: string): FilePair[] {
64
+ if (!patch) {
65
+ return [
66
+ { filename: '', content: '' },
67
+ { filename: '', content: '' },
68
+ ]
69
+ }
70
+
71
+ const { origFilename, modFilename, hunks } = parseHunks(patch)
72
+
73
+ const origLines: string[] = []
74
+ const modLines: string[] = []
75
+
76
+ // 追踪两侧各自已写到的行号(1-based)
77
+ let origCursor = 1
78
+ let modCursor = 1
79
+
80
+ for (const hunk of hunks) {
81
+ // hunk 之前未覆盖的行用空行填充,使光标推进到 hunk 起始位置
82
+ while (origCursor < hunk.origStart) {
83
+ origLines.push('')
84
+ origCursor++
85
+ }
86
+ while (modCursor < hunk.modStart) {
87
+ modLines.push('')
88
+ modCursor++
89
+ }
90
+
91
+ for (const line of hunk.lines) {
92
+ const prefix = line[0]
93
+ const content = line.slice(1)
94
+
95
+ if (prefix === ' ') {
96
+ // context 行:两侧都保留原文
97
+ origLines.push(content)
98
+ modLines.push(content)
99
+ origCursor++
100
+ modCursor++
101
+ } else if (prefix === '-') {
102
+ // 删除行:original 保留,modified 用空行占位
103
+ origLines.push(content)
104
+ modLines.push('')
105
+ origCursor++
106
+ modCursor++
107
+ } else if (prefix === '+') {
108
+ // 新增行:modified 保留,original 用空行占位
109
+ origLines.push('')
110
+ modLines.push(content)
111
+ origCursor++
112
+ modCursor++
113
+ }
114
+ }
115
+ }
116
+
117
+ return [
118
+ { filename: origFilename, content: origLines.join('\n') },
119
+ { filename: modFilename, content: modLines.join('\n') },
120
+ ]
121
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './DiffViewer'
2
+ export { default as loader } from '@monaco-editor/loader'
package/src/types.ts ADDED
@@ -0,0 +1,20 @@
1
+ import type * as Monaco from 'monaco-editor'
2
+
3
+ export type DiffEditorOptions = Monaco.editor.IStandaloneDiffEditorConstructionOptions
4
+ export type ModelOptions = Monaco.editor.ITextModelUpdateOptions
5
+
6
+ export interface WtoolDiffViewerProps {
7
+ diffPair?: { filename: string; content: string }[]
8
+ diffPatch?: string
9
+ language?: string
10
+ options?: DiffEditorOptions
11
+ modelOptions?: ModelOptions
12
+ viewerStyle?: WtoolDiffViewerStyle
13
+ }
14
+
15
+ export interface WtoolDiffViewerStyle {
16
+ width?: string
17
+ height?: string
18
+ minHeight?: string
19
+ maxHeight?: string
20
+ }
@@ -0,0 +1,7 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module '*.vue' {
4
+ import type { DefineComponent } from 'vue'
5
+ const component: DefineComponent<object, object, unknown>
6
+ export default component
7
+ }