@young1lin/dsh-ui-gitworkbench 0.1.0
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/AGENTS.md +70 -0
- package/LICENSE +21 -0
- package/README.md +395 -0
- package/README_EN.md +74 -0
- package/cordis.patch.yml +7 -0
- package/lib/atomic-json.js +48 -0
- package/lib/client.js +15297 -0
- package/lib/commit-cache.js +68 -0
- package/lib/git-log.js +79 -0
- package/lib/git-ops.js +409 -0
- package/lib/index.js +1143 -0
- package/lib/style-store.js +123 -0
- package/lib/worktree.js +112 -0
- package/package.json +86 -0
- package/scripts/install.ps1 +240 -0
- package/scripts/install.sh +231 -0
- package/src/atomic-json.ts +55 -0
- package/src/client/GitWorkbenchPanel.module.css +1512 -0
- package/src/client/GitWorkbenchPanel.tsx +3446 -0
- package/src/client/commit-graph.ts +140 -0
- package/src/client/diff-model.ts +193 -0
- package/src/client/highlight.ts +257 -0
- package/src/client/index.ts +198 -0
- package/src/client/locales.ts +270 -0
- package/src/client/op-feedback.ts +65 -0
- package/src/client/stage-tree.ts +178 -0
- package/src/client/themes.ts +181 -0
- package/src/client/worktree-view.ts +193 -0
- package/src/commit-cache.ts +69 -0
- package/src/git-log.ts +92 -0
- package/src/git-ops.ts +490 -0
- package/src/index.ts +1172 -0
- package/src/style-store.ts +144 -0
- package/src/types/dsh-client-shim.d.ts +100 -0
- package/src/types/dsh-shim.d.ts +77 -0
- package/src/worktree.ts +142 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lane assignment for the commit graph — the branch/merge diagram down the left
|
|
3
|
+
* edge of the history list, the way IDEA, gitk and GitHub's network view draw it.
|
|
4
|
+
*
|
|
5
|
+
* A LANE is a vertical line. Each one is "waiting for" a commit hash: the hash
|
|
6
|
+
* of the commit that will terminate it further down the list. Walking the log
|
|
7
|
+
* newest-first, every commit takes over the lane(s) waiting for it, then hands
|
|
8
|
+
* that lane to its FIRST parent and opens a lane for each additional one. That
|
|
9
|
+
* single rule is what makes mainline history read as one straight line and the
|
|
10
|
+
* merged branch the one that curves away — first-parent order is the meaning of
|
|
11
|
+
* `%p`, not an arbitrary choice.
|
|
12
|
+
*
|
|
13
|
+
* Everything here is pure. The renderer that consumes it draws paths and has no
|
|
14
|
+
* decisions of its own, so the graph's whole behaviour is testable without a DOM.
|
|
15
|
+
*
|
|
16
|
+
* The list is PAGED, so the layout is always over a prefix of history. A lane
|
|
17
|
+
* still waiting for an unfetched parent simply leaves the bottom edge, which is
|
|
18
|
+
* exactly what should be drawn: the line continues because the history does.
|
|
19
|
+
*
|
|
20
|
+
* @module
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The only thing the layout needs from a commit. */
|
|
24
|
+
export interface GraphInput {
|
|
25
|
+
readonly hash: string
|
|
26
|
+
/** Parent hashes in git's order — first parent first. */
|
|
27
|
+
readonly parents: readonly string[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One row's geometry, in lane indices. The renderer turns these into paths. */
|
|
31
|
+
export interface GraphRow {
|
|
32
|
+
readonly hash: string
|
|
33
|
+
/** Lane the commit's dot sits in. */
|
|
34
|
+
readonly lane: number
|
|
35
|
+
/**
|
|
36
|
+
* Lanes arriving from the rows above and ending at this commit. Contains
|
|
37
|
+
* {@link lane} when this commit continues the line it sits on, and additional
|
|
38
|
+
* entries when branches converge here. Empty for a tip.
|
|
39
|
+
*/
|
|
40
|
+
readonly into: readonly number[]
|
|
41
|
+
/**
|
|
42
|
+
* Lanes leaving toward the rows below. Contains {@link lane} unless this is a
|
|
43
|
+
* root commit, plus one entry per additional parent.
|
|
44
|
+
*/
|
|
45
|
+
readonly outOf: readonly number[]
|
|
46
|
+
/** Lanes crossing this row untouched — drawn as an unbroken vertical line. */
|
|
47
|
+
readonly through: readonly number[]
|
|
48
|
+
/** Lanes in use at this row; every index above is `< width`. */
|
|
49
|
+
readonly width: number
|
|
50
|
+
readonly isMerge: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface Graph {
|
|
54
|
+
readonly rows: readonly GraphRow[]
|
|
55
|
+
/** The widest row — how much horizontal space the column reserves. */
|
|
56
|
+
readonly width: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Assign lanes to a page of commits.
|
|
61
|
+
* @param commits - newest-first, as `git log` orders them.
|
|
62
|
+
* @returns per-row geometry plus the column width to reserve.
|
|
63
|
+
*/
|
|
64
|
+
export function layoutGraph(commits: readonly GraphInput[]): Graph {
|
|
65
|
+
/** `lanes[i]` is the hash lane `i` is waiting for; null means the lane is free. */
|
|
66
|
+
const lanes: (string | null)[] = []
|
|
67
|
+
const rows: GraphRow[] = []
|
|
68
|
+
let width = 0
|
|
69
|
+
|
|
70
|
+
const freeSlot = (): number => {
|
|
71
|
+
const reused = lanes.indexOf(null)
|
|
72
|
+
if (reused !== -1) return reused
|
|
73
|
+
lanes.push(null)
|
|
74
|
+
return lanes.length - 1
|
|
75
|
+
}
|
|
76
|
+
const occupiedWidth = (state: readonly (string | null)[]): number => {
|
|
77
|
+
for (let i = state.length - 1; i >= 0; i -= 1) if (state[i] !== null) return i + 1
|
|
78
|
+
return 0
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const commit of commits) {
|
|
82
|
+
const before = [...lanes]
|
|
83
|
+
|
|
84
|
+
// Every lane waiting for this commit ends here. The leftmost becomes the
|
|
85
|
+
// commit's own lane; picking the leftmost is what keeps the graph compact
|
|
86
|
+
// and holds the mainline against the left edge.
|
|
87
|
+
const into: number[] = []
|
|
88
|
+
for (let i = 0; i < lanes.length; i += 1) if (lanes[i] === commit.hash) into.push(i)
|
|
89
|
+
|
|
90
|
+
const lane = into.length > 0 ? into[0]! : freeSlot()
|
|
91
|
+
for (const other of into) if (other !== lane) lanes[other] = null
|
|
92
|
+
|
|
93
|
+
// Hand the lane to the first parent; open one per additional parent. A
|
|
94
|
+
// parent some other lane is already waiting for reuses that lane instead of
|
|
95
|
+
// opening a second line to the same commit.
|
|
96
|
+
const outOf: number[] = []
|
|
97
|
+
const seen = new Set<string>()
|
|
98
|
+
lanes[lane] = null
|
|
99
|
+
for (const parent of commit.parents) {
|
|
100
|
+
if (seen.has(parent)) continue
|
|
101
|
+
seen.add(parent)
|
|
102
|
+
if (outOf.length === 0) {
|
|
103
|
+
lanes[lane] = parent
|
|
104
|
+
outOf.push(lane)
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
let target = lanes.indexOf(parent)
|
|
108
|
+
if (target === -1) {
|
|
109
|
+
target = freeSlot()
|
|
110
|
+
lanes[target] = parent
|
|
111
|
+
}
|
|
112
|
+
outOf.push(target)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A lane passes through when it was busy before, is still busy after, and
|
|
116
|
+
// its expectation did not change — i.e. this commit had nothing to do with it.
|
|
117
|
+
const through: number[] = []
|
|
118
|
+
for (let i = 0; i < before.length; i += 1) {
|
|
119
|
+
if (i === lane) continue
|
|
120
|
+
if (before[i] !== null && before[i] === lanes[i]) through.push(i)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Trailing free lanes cost width without carrying a line.
|
|
124
|
+
while (lanes.length > 0 && lanes[lanes.length - 1] === null) lanes.pop()
|
|
125
|
+
|
|
126
|
+
const rowWidth = Math.max(occupiedWidth(before), occupiedWidth(lanes), lane + 1)
|
|
127
|
+
width = Math.max(width, rowWidth)
|
|
128
|
+
rows.push({
|
|
129
|
+
hash: commit.hash,
|
|
130
|
+
lane,
|
|
131
|
+
into,
|
|
132
|
+
outOf,
|
|
133
|
+
through,
|
|
134
|
+
width: rowWidth,
|
|
135
|
+
isMerge: seen.size > 1,
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { rows, width }
|
|
140
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure diff-model: unified-diff rows and word-level ranges. Kept free of
|
|
3
|
+
* React/CSS so `tests/diff-regression.test.ts` can load it without pulling the
|
|
4
|
+
* panel (CSS modules + the client shim) into vitest.
|
|
5
|
+
*
|
|
6
|
+
* Colour is not this module's job — `highlight.ts` runs Shiki against a real
|
|
7
|
+
* TextMate theme. What stays here is the part Shiki does not do: reading the
|
|
8
|
+
* diff itself, and working out which words on a line actually changed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface Row {
|
|
12
|
+
readonly kind: 'add' | 'del' | 'context' | 'hunk'
|
|
13
|
+
readonly text: string
|
|
14
|
+
readonly oldL: number
|
|
15
|
+
readonly newL: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RowWithRanges extends Row {
|
|
19
|
+
ranges?: Array<readonly [number, number]>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse a unified diff segment into typed rows, tracking line numbers.
|
|
24
|
+
* @param segment - one file's `diff --git` text (headers are skipped).
|
|
25
|
+
*/
|
|
26
|
+
export function parseRows(segment: string): Row[] {
|
|
27
|
+
const rows: Row[] = []
|
|
28
|
+
const lines = segment.split('\n')
|
|
29
|
+
let oldL = 0
|
|
30
|
+
let newL = 0
|
|
31
|
+
for (const line of lines) {
|
|
32
|
+
if (line.startsWith('diff --git') || line.startsWith('index ') || line.startsWith('old mode')
|
|
33
|
+
|| line.startsWith('new mode') || line.startsWith('--- ') || line.startsWith('+++ ')
|
|
34
|
+
|| line.startsWith('new file') || line.startsWith('deleted file') || line.startsWith('similarity')
|
|
35
|
+
|| line.startsWith('rename from') || line.startsWith('rename to') || line.startsWith('\\ No newline')) continue
|
|
36
|
+
if (line.startsWith('@@')) {
|
|
37
|
+
const m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
|
|
38
|
+
if (m) { oldL = Number.parseInt(m[1]!, 10); newL = Number.parseInt(m[2]!, 10) }
|
|
39
|
+
rows.push({ kind: 'hunk', text: line, oldL: 0, newL: 0 })
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
if (line.startsWith('+')) {
|
|
43
|
+
rows.push({ kind: 'add', text: line.slice(1), oldL: 0, newL })
|
|
44
|
+
newL += 1
|
|
45
|
+
} else if (line.startsWith('-')) {
|
|
46
|
+
rows.push({ kind: 'del', text: line.slice(1), oldL, newL: 0 })
|
|
47
|
+
oldL += 1
|
|
48
|
+
} else {
|
|
49
|
+
rows.push({ kind: 'context', text: line.slice(1), oldL, newL })
|
|
50
|
+
oldL += 1; newL += 1
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return rows
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Which line-number columns a unified diff actually uses.
|
|
58
|
+
* A new file is only `+` rows (`@@ -0,0 +1,n`), a deletion is only `-`;
|
|
59
|
+
* those should not keep an empty second gutter. Context or a mix needs both.
|
|
60
|
+
* @param rows - parsed unified-diff rows.
|
|
61
|
+
*/
|
|
62
|
+
export function gutterSides(rows: readonly Row[]): { old: boolean; new: boolean } {
|
|
63
|
+
let old = false
|
|
64
|
+
let neu = false
|
|
65
|
+
for (const row of rows) {
|
|
66
|
+
if (row.kind === 'del') old = true
|
|
67
|
+
else if (row.kind === 'add') neu = true
|
|
68
|
+
else if (row.kind === 'context') { old = true; neu = true }
|
|
69
|
+
if (old && neu) return { old: true, new: true }
|
|
70
|
+
}
|
|
71
|
+
return { old, new: neu }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Pair adjacent +/- runs index-wise and compute word-level changed ranges per line.
|
|
76
|
+
* @param rows - parsed unified-diff rows.
|
|
77
|
+
*/
|
|
78
|
+
export function attachWordRanges(rows: Row[]): RowWithRanges[] {
|
|
79
|
+
const out: RowWithRanges[] = rows.map(row => ({ ...row }))
|
|
80
|
+
let i = 0
|
|
81
|
+
while (i < out.length) {
|
|
82
|
+
if (out[i]!.kind !== 'del' && out[i]!.kind !== 'add') { i += 1; continue }
|
|
83
|
+
let delEnd = i
|
|
84
|
+
while (delEnd < out.length && out[delEnd]!.kind === 'del') delEnd += 1
|
|
85
|
+
let addEnd = delEnd
|
|
86
|
+
while (addEnd < out.length && out[addEnd]!.kind === 'add') addEnd += 1
|
|
87
|
+
const pairs = Math.min(delEnd - i, addEnd - delEnd)
|
|
88
|
+
for (let p = 0; p < pairs; p++) {
|
|
89
|
+
const delRow = out[i + p]!
|
|
90
|
+
const addRow = out[delEnd + p]!
|
|
91
|
+
const { oldRanges, newRanges } = changedRanges(delRow.text, addRow.text)
|
|
92
|
+
delRow.ranges = oldRanges
|
|
93
|
+
addRow.ranges = newRanges
|
|
94
|
+
}
|
|
95
|
+
i = addEnd
|
|
96
|
+
}
|
|
97
|
+
return out
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface Tok { readonly text: string; readonly start: number; readonly isWs: boolean }
|
|
101
|
+
|
|
102
|
+
function tokensWithOffsets(text: string): Tok[] {
|
|
103
|
+
const out: Tok[] = []
|
|
104
|
+
const re = /\s+|\S+/g
|
|
105
|
+
let m: RegExpExecArray | null
|
|
106
|
+
while ((m = re.exec(text)) !== null) out.push({ text: m[0], start: m.index, isWs: m[0].trim().length === 0 })
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Word-level diff of two text lines via LCS over whitespace-preserved tokens.
|
|
111
|
+
* Falls back to whole-line emphasis when the pair is too large. */
|
|
112
|
+
function changedRanges(oldText: string, newText: string): { oldRanges: Array<readonly [number, number]>; newRanges: Array<readonly [number, number]> } {
|
|
113
|
+
if (oldText === newText) return { oldRanges: [], newRanges: [] }
|
|
114
|
+
const a = tokensWithOffsets(oldText)
|
|
115
|
+
const b = tokensWithOffsets(newText)
|
|
116
|
+
if (a.length * b.length > 200_000 || a.length + b.length < 2) {
|
|
117
|
+
return { oldRanges: a.length > 0 ? [[0, oldText.length]] : [], newRanges: b.length > 0 ? [[0, newText.length]] : [] }
|
|
118
|
+
}
|
|
119
|
+
const n = a.length
|
|
120
|
+
const m = b.length
|
|
121
|
+
const dp: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1))
|
|
122
|
+
for (let i = n - 1; i >= 0; i -= 1) {
|
|
123
|
+
for (let j = m - 1; j >= 0; j -= 1) {
|
|
124
|
+
const same = (a[i]!.isWs && b[j]!.isWs) || a[i]!.text === b[j]!.text
|
|
125
|
+
dp[i]![j] = same ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const oldRanges: Array<readonly [number, number]> = []
|
|
129
|
+
const newRanges: Array<readonly [number, number]> = []
|
|
130
|
+
const push = (into: Array<readonly [number, number]>, start: number, end: number): void => {
|
|
131
|
+
const last = into[into.length - 1]
|
|
132
|
+
if (last !== undefined && last[1] === start) into[into.length - 1] = [last[0], end]
|
|
133
|
+
else into.push([start, end])
|
|
134
|
+
}
|
|
135
|
+
let i = 0
|
|
136
|
+
let j = 0
|
|
137
|
+
while (i < n && j < m) {
|
|
138
|
+
const same = (a[i]!.isWs && b[j]!.isWs) || a[i]!.text === b[j]!.text
|
|
139
|
+
if (same) { i += 1; j += 1; continue }
|
|
140
|
+
if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
|
|
141
|
+
push(oldRanges, a[i]!.start, a[i]!.start + a[i]!.text.length)
|
|
142
|
+
i += 1
|
|
143
|
+
} else {
|
|
144
|
+
push(newRanges, b[j]!.start, b[j]!.start + b[j]!.text.length)
|
|
145
|
+
j += 1
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
while (i < n) { push(oldRanges, a[i]!.start, a[i]!.start + a[i]!.text.length); i += 1 }
|
|
149
|
+
while (j < m) { push(newRanges, b[j]!.start, b[j]!.start + b[j]!.text.length); j += 1 }
|
|
150
|
+
return { oldRanges, newRanges }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface PaintedTok {
|
|
154
|
+
readonly text: string
|
|
155
|
+
readonly color?: string
|
|
156
|
+
readonly italic?: boolean
|
|
157
|
+
readonly mark: boolean
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Overlay word-level change ranges onto already-lexed tokens, instead of
|
|
162
|
+
* re-lexing slices (a slice of a comment looks like code).
|
|
163
|
+
* @param tokens - one line's tokens (`text` plus optional `color` from Shiki).
|
|
164
|
+
* @param ranges - changed character ranges on that line.
|
|
165
|
+
*/
|
|
166
|
+
export function overlayRanges(
|
|
167
|
+
tokens: ReadonlyArray<{ readonly text: string; readonly color?: string; readonly italic?: boolean }>,
|
|
168
|
+
ranges: ReadonlyArray<readonly [number, number]>,
|
|
169
|
+
): PaintedTok[] {
|
|
170
|
+
if (tokens.length === 0) return []
|
|
171
|
+
if (ranges.length === 0) return tokens.map(tok => ({ text: tok.text, color: tok.color, italic: tok.italic, mark: false }))
|
|
172
|
+
const out: PaintedTok[] = []
|
|
173
|
+
let offset = 0
|
|
174
|
+
let ri = 0
|
|
175
|
+
for (const tok of tokens) {
|
|
176
|
+
let local = 0
|
|
177
|
+
while (local < tok.text.length) {
|
|
178
|
+
const abs = offset + local
|
|
179
|
+
while (ri < ranges.length && ranges[ri]![1] <= abs) ri += 1
|
|
180
|
+
const range = ranges[ri]
|
|
181
|
+
const marked = range !== undefined && abs >= range[0] && abs < range[1]
|
|
182
|
+
const cut = marked
|
|
183
|
+
? Math.min(tok.text.length, range[1] - offset)
|
|
184
|
+
: range !== undefined && range[0] > abs
|
|
185
|
+
? Math.min(tok.text.length, range[0] - offset)
|
|
186
|
+
: tok.text.length
|
|
187
|
+
if (cut > local) out.push({ text: tok.text.slice(local, cut), color: tok.color, italic: tok.italic, mark: marked })
|
|
188
|
+
local = cut > local ? cut : local + 1
|
|
189
|
+
}
|
|
190
|
+
offset += tok.text.length
|
|
191
|
+
}
|
|
192
|
+
return out
|
|
193
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff highlighter: Shiki core with the JavaScript regex engine (no Oniguruma
|
|
3
|
+
* WASM) and a real TextMate theme per drawer palette. The css-variables theme
|
|
4
|
+
* only has ~8 token slots, so identifiers, types and punctuation all collapsed
|
|
5
|
+
* to the body colour — which is why a homemade four-kind pass looked the same
|
|
6
|
+
* as "only keywords are coloured". Bundled themes emit a hex per scope.
|
|
7
|
+
*
|
|
8
|
+
* Boot grammars: TypeScript, shell, JSON. Everything else the drawer opens
|
|
9
|
+
* loads lazily.
|
|
10
|
+
*/
|
|
11
|
+
import { createHighlighterCoreSync } from 'shiki/core'
|
|
12
|
+
import { createJavaScriptRegexEngine, defaultJavaScriptRegexConstructor } from 'shiki/engine/javascript'
|
|
13
|
+
import langTs from '@shikijs/langs/typescript'
|
|
14
|
+
import langBash from '@shikijs/langs/shellscript'
|
|
15
|
+
import langJson from '@shikijs/langs/json'
|
|
16
|
+
import githubDark from 'shiki/themes/github-dark-default.mjs'
|
|
17
|
+
import githubLight from 'shiki/themes/github-light-default.mjs'
|
|
18
|
+
import darkPlus from 'shiki/themes/dark-plus.mjs'
|
|
19
|
+
import lightPlus from 'shiki/themes/light-plus.mjs'
|
|
20
|
+
import oneDarkPro from 'shiki/themes/one-dark-pro.mjs'
|
|
21
|
+
import oneLight from 'shiki/themes/one-light.mjs'
|
|
22
|
+
import solarizedDark from 'shiki/themes/solarized-dark.mjs'
|
|
23
|
+
import solarizedLight from 'shiki/themes/solarized-light.mjs'
|
|
24
|
+
import nord from 'shiki/themes/nord.mjs'
|
|
25
|
+
import synthwave84 from 'shiki/themes/synthwave-84.mjs'
|
|
26
|
+
import type { HighlighterCore } from 'shiki/core'
|
|
27
|
+
import type { Row } from './diff-model.ts'
|
|
28
|
+
|
|
29
|
+
type LangModule = { default: typeof langTs }
|
|
30
|
+
|
|
31
|
+
const LANGS = [langTs, langBash, langJson]
|
|
32
|
+
|
|
33
|
+
const THEMES = [
|
|
34
|
+
githubDark, githubLight, darkPlus, lightPlus,
|
|
35
|
+
oneDarkPro, oneLight, solarizedDark, solarizedLight,
|
|
36
|
+
nord, synthwave84,
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
const LAZY_GRAMMARS = new Map<string, () => Promise<LangModule>>([
|
|
40
|
+
['python', () => import('@shikijs/langs/python')],
|
|
41
|
+
['css', () => import('@shikijs/langs/css')],
|
|
42
|
+
['markdown', () => import('@shikijs/langs/markdown')],
|
|
43
|
+
['html', () => import('@shikijs/langs/html')],
|
|
44
|
+
['yaml', () => import('@shikijs/langs/yaml')],
|
|
45
|
+
['toml', () => import('@shikijs/langs/toml')],
|
|
46
|
+
['rust', () => import('@shikijs/langs/rust')],
|
|
47
|
+
['go', () => import('@shikijs/langs/go')],
|
|
48
|
+
['java', () => import('@shikijs/langs/java')],
|
|
49
|
+
['c', () => import('@shikijs/langs/c')],
|
|
50
|
+
['cpp', () => import('@shikijs/langs/cpp')],
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
const LANG_ALIASES = new Map<string, string>([
|
|
54
|
+
['typescript', 'typescript'], ['ts', 'typescript'], ['tsx', 'typescript'],
|
|
55
|
+
['javascript', 'typescript'], ['js', 'typescript'], ['jsx', 'typescript'],
|
|
56
|
+
['mjs', 'typescript'], ['cjs', 'typescript'],
|
|
57
|
+
['shellscript', 'shellscript'], ['bash', 'shellscript'], ['sh', 'shellscript'],
|
|
58
|
+
['shell', 'shellscript'], ['zsh', 'shellscript'], ['ps1', 'shellscript'],
|
|
59
|
+
['json', 'json'], ['jsonc', 'json'],
|
|
60
|
+
['py', 'python'], ['python', 'python'],
|
|
61
|
+
['css', 'css'], ['scss', 'css'], ['less', 'css'],
|
|
62
|
+
['md', 'markdown'], ['markdown', 'markdown'],
|
|
63
|
+
['html', 'html'], ['htm', 'html'],
|
|
64
|
+
['yaml', 'yaml'], ['yml', 'yaml'],
|
|
65
|
+
['toml', 'toml'],
|
|
66
|
+
['rs', 'rust'], ['rust', 'rust'],
|
|
67
|
+
['go', 'go'],
|
|
68
|
+
['java', 'java'],
|
|
69
|
+
['c', 'c'],
|
|
70
|
+
['cpp', 'cpp'], ['h', 'c'], ['hpp', 'cpp'],
|
|
71
|
+
])
|
|
72
|
+
|
|
73
|
+
/** Drawer `data-gs-theme` → a loaded Shiki theme name. */
|
|
74
|
+
const PALETTE_THEMES: Record<string, string> = {
|
|
75
|
+
'github-dark': 'github-dark-default',
|
|
76
|
+
'github-light': 'github-light-default',
|
|
77
|
+
'vscode-dark': 'dark-plus',
|
|
78
|
+
'vscode-light': 'light-plus',
|
|
79
|
+
'one-dark': 'one-dark-pro',
|
|
80
|
+
'one-light': 'one-light',
|
|
81
|
+
'solarized-dark': 'solarized-dark',
|
|
82
|
+
'solarized-light': 'solarized-light',
|
|
83
|
+
'nord-dark': 'nord',
|
|
84
|
+
'nord-light': 'github-light-default',
|
|
85
|
+
'cyberpunk-dark': 'synthwave-84',
|
|
86
|
+
'cyberpunk-light': 'synthwave-84',
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const regexEngine = createJavaScriptRegexEngine({
|
|
90
|
+
forgiving: true,
|
|
91
|
+
regexConstructor: pattern => defaultJavaScriptRegexConstructor(pattern, {
|
|
92
|
+
lazyCompileLength: Number.POSITIVE_INFINITY,
|
|
93
|
+
}),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
let singleton: HighlighterCore | undefined
|
|
97
|
+
|
|
98
|
+
function highlighter(): HighlighterCore {
|
|
99
|
+
singleton ??= createHighlighterCoreSync({
|
|
100
|
+
themes: THEMES,
|
|
101
|
+
langs: LANGS,
|
|
102
|
+
engine: regexEngine,
|
|
103
|
+
})
|
|
104
|
+
return singleton
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const requested = new Set<string>()
|
|
108
|
+
const listeners = new Set<() => void>()
|
|
109
|
+
let loadCount = 0
|
|
110
|
+
|
|
111
|
+
/** Subscribe to lazy-grammar loads so a first render can re-highlight. */
|
|
112
|
+
export function subscribeGrammarLoaded(listener: () => void): () => void {
|
|
113
|
+
listeners.add(listener)
|
|
114
|
+
return () => { listeners.delete(listener) }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Snapshot for `useSyncExternalStore`. */
|
|
118
|
+
export function grammarLoadCount(): number {
|
|
119
|
+
return loadCount
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function ensureGrammar(resolved: string): boolean {
|
|
123
|
+
const load = LAZY_GRAMMARS.get(resolved)
|
|
124
|
+
if (load === undefined) return true
|
|
125
|
+
if (highlighter().getLoadedLanguages().includes(resolved)) return true
|
|
126
|
+
if (!requested.has(resolved)) {
|
|
127
|
+
requested.add(resolved)
|
|
128
|
+
void load().then(mod => {
|
|
129
|
+
highlighter().loadLanguageSync(mod.default)
|
|
130
|
+
loadCount += 1
|
|
131
|
+
for (const listener of listeners) listener()
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface HighlightRun {
|
|
138
|
+
readonly text: string
|
|
139
|
+
readonly color: string | undefined
|
|
140
|
+
readonly italic?: boolean
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Language id Shiki accepts for this path, or undefined for plain text.
|
|
145
|
+
* @param path - file path from the unified diff.
|
|
146
|
+
*/
|
|
147
|
+
export function shikiLangOf(path: string): string | undefined {
|
|
148
|
+
const ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase()
|
|
149
|
+
return LANG_ALIASES.get(ext)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Shiki theme loaded for this drawer palette.
|
|
154
|
+
* @param palette - `data-gs-theme` value (`github-dark`, …).
|
|
155
|
+
*/
|
|
156
|
+
export function shikiThemeOf(palette: string): string {
|
|
157
|
+
return PALETTE_THEMES[palette] ?? (palette.endsWith('light') ? 'github-light-default' : 'github-dark-default')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Tokenize a whole file into per-line runs. Undefined until a lazy grammar
|
|
162
|
+
* finishes loading (caller re-renders via {@link subscribeGrammarLoaded}).
|
|
163
|
+
*
|
|
164
|
+
* Diff reconstructions are not real files: a hunk often starts inside
|
|
165
|
+
* `export default {` or an unclosed `/*`, and Shiki then paints the following
|
|
166
|
+
* added statements as object keys or comments — keywords go missing. Comment
|
|
167
|
+
* lines keep the file-level pass (JSDoc ` * `); every other line is re-lexed
|
|
168
|
+
* on its own so `async` / `function` / `const` colour as they would at the
|
|
169
|
+
* top level.
|
|
170
|
+
* @param lines - source lines, no leading +/-.
|
|
171
|
+
* @param lang - from {@link shikiLangOf}.
|
|
172
|
+
* @param theme - from {@link shikiThemeOf}.
|
|
173
|
+
*/
|
|
174
|
+
export function highlightFile(
|
|
175
|
+
lines: readonly string[],
|
|
176
|
+
lang: string | undefined,
|
|
177
|
+
theme = 'github-dark-default',
|
|
178
|
+
): HighlightRun[][] | undefined {
|
|
179
|
+
const fileTok = tokenizeLines(lines, lang, theme)
|
|
180
|
+
if (fileTok === undefined) return undefined
|
|
181
|
+
return lines.map((line, i) => {
|
|
182
|
+
const together = fileTok[i] ?? [{ text: line, color: undefined }]
|
|
183
|
+
if (looksLikeCommentLine(line)) return together
|
|
184
|
+
const solo = tokenizeLines([line], lang, theme)?.[0]
|
|
185
|
+
return solo !== undefined && solo.length > 0 ? solo : together
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function looksLikeCommentLine(text: string): boolean {
|
|
190
|
+
const t = text.trimStart()
|
|
191
|
+
return t.startsWith('//') || t.startsWith('/*') || t.startsWith('*') || t.startsWith('#')
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function tokenizeLines(
|
|
195
|
+
lines: readonly string[],
|
|
196
|
+
lang: string | undefined,
|
|
197
|
+
theme: string,
|
|
198
|
+
): HighlightRun[][] | undefined {
|
|
199
|
+
if (lang === undefined || lines.length === 0) return undefined
|
|
200
|
+
if (!ensureGrammar(lang)) return undefined
|
|
201
|
+
const { tokens } = highlighter().codeToTokens(lines.join('\n'), { lang, theme })
|
|
202
|
+
const last = tokens[tokens.length - 1]
|
|
203
|
+
const rows = tokens.length > 1 && last !== undefined && last.length === 0
|
|
204
|
+
? tokens.slice(0, -1)
|
|
205
|
+
: tokens
|
|
206
|
+
const out: HighlightRun[][] = rows.map(line => line.map(token => ({
|
|
207
|
+
text: token.content,
|
|
208
|
+
color: token.color,
|
|
209
|
+
italic: token.fontStyle !== undefined && (token.fontStyle & 1) !== 0 ? true : undefined,
|
|
210
|
+
})))
|
|
211
|
+
while (out.length < lines.length) out.push([{ text: lines[out.length]!, color: undefined }])
|
|
212
|
+
return out.slice(0, lines.length)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Per-row Shiki runs for a unified diff: deletions from the old side,
|
|
217
|
+
* additions and context from the new side, each side highlighted as one file.
|
|
218
|
+
* @param rows - parsed unified-diff rows.
|
|
219
|
+
* @param lang - from {@link shikiLangOf}.
|
|
220
|
+
* @param theme - from {@link shikiThemeOf}.
|
|
221
|
+
*/
|
|
222
|
+
export function highlightForRows(
|
|
223
|
+
rows: readonly Row[],
|
|
224
|
+
lang: string | undefined,
|
|
225
|
+
theme = 'github-dark-default',
|
|
226
|
+
): HighlightRun[][] {
|
|
227
|
+
const oldLines: string[] = []
|
|
228
|
+
const newLines: string[] = []
|
|
229
|
+
const oldAt: number[] = []
|
|
230
|
+
const newAt: number[] = []
|
|
231
|
+
for (const row of rows) {
|
|
232
|
+
if (row.kind === 'del') {
|
|
233
|
+
oldAt.push(oldLines.length)
|
|
234
|
+
newAt.push(-1)
|
|
235
|
+
oldLines.push(row.text)
|
|
236
|
+
} else if (row.kind === 'add') {
|
|
237
|
+
oldAt.push(-1)
|
|
238
|
+
newAt.push(newLines.length)
|
|
239
|
+
newLines.push(row.text)
|
|
240
|
+
} else if (row.kind === 'context') {
|
|
241
|
+
oldAt.push(oldLines.length)
|
|
242
|
+
newAt.push(newLines.length)
|
|
243
|
+
oldLines.push(row.text)
|
|
244
|
+
newLines.push(row.text)
|
|
245
|
+
} else {
|
|
246
|
+
oldAt.push(-1)
|
|
247
|
+
newAt.push(-1)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const oldTok = highlightFile(oldLines, lang, theme)
|
|
251
|
+
const newTok = highlightFile(newLines, lang, theme)
|
|
252
|
+
return rows.map((row, i) => {
|
|
253
|
+
if (row.kind === 'del') return oldTok?.[oldAt[i]!] ?? [{ text: row.text, color: undefined }]
|
|
254
|
+
if (row.kind === 'add' || row.kind === 'context') return newTok?.[newAt[i]!] ?? [{ text: row.text, color: undefined }]
|
|
255
|
+
return [{ text: row.text, color: undefined }]
|
|
256
|
+
})
|
|
257
|
+
}
|