@neurocode-ai/session-ui 1.18.8
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 +71 -0
- package/src/components/apply-patch-file.test.ts +43 -0
- package/src/components/apply-patch-file.ts +78 -0
- package/src/components/basic-tool.css +358 -0
- package/src/components/basic-tool.stories.tsx +133 -0
- package/src/components/basic-tool.tsx +343 -0
- package/src/components/dock-prompt.stories.tsx +62 -0
- package/src/components/dock-prompt.tsx +23 -0
- package/src/components/file-media.tsx +291 -0
- package/src/components/file-search.tsx +72 -0
- package/src/components/file-ssr.tsx +197 -0
- package/src/components/file.css +46 -0
- package/src/components/file.tsx +1202 -0
- package/src/components/line-comment-annotations.tsx +637 -0
- package/src/components/line-comment-styles.ts +292 -0
- package/src/components/line-comment.stories.tsx +115 -0
- package/src/components/line-comment.tsx +439 -0
- package/src/components/markdown-cache.tsx +78 -0
- package/src/components/markdown-code-state.test.ts +32 -0
- package/src/components/markdown-code-state.ts +22 -0
- package/src/components/markdown-inline-code-kind.test.ts +46 -0
- package/src/components/markdown-inline-code-kind.ts +1915 -0
- package/src/components/markdown-preload.test.ts +18 -0
- package/src/components/markdown-shiki.worker.ts +104 -0
- package/src/components/markdown-stream.test.ts +194 -0
- package/src/components/markdown-stream.ts +110 -0
- package/src/components/markdown-worker-protocol.test.ts +81 -0
- package/src/components/markdown-worker-protocol.ts +48 -0
- package/src/components/markdown-worker-queue.test.ts +49 -0
- package/src/components/markdown-worker-queue.ts +64 -0
- package/src/components/markdown-worker-transport.test.ts +56 -0
- package/src/components/markdown-worker-transport.ts +41 -0
- package/src/components/markdown-worker.ts +122 -0
- package/src/components/markdown.css +377 -0
- package/src/components/markdown.stories.tsx +53 -0
- package/src/components/markdown.tsx +678 -0
- package/src/components/message-file.test.ts +63 -0
- package/src/components/message-file.ts +34 -0
- package/src/components/message-nav.css +127 -0
- package/src/components/message-nav.stories.tsx +7 -0
- package/src/components/message-nav.tsx +102 -0
- package/src/components/message-part-text.ts +3 -0
- package/src/components/message-part.css +1569 -0
- package/src/components/message-part.stories.tsx +7 -0
- package/src/components/message-part.test.ts +28 -0
- package/src/components/message-part.tsx +2662 -0
- package/src/components/session-diff.test.ts +135 -0
- package/src/components/session-diff.ts +145 -0
- package/src/components/session-retry.tsx +74 -0
- package/src/components/session-review.css +247 -0
- package/src/components/session-review.stories.tsx +7 -0
- package/src/components/session-review.tsx +657 -0
- package/src/components/session-turn.css +241 -0
- package/src/components/session-turn.stories.tsx +7 -0
- package/src/components/session-turn.tsx +543 -0
- package/src/components/shell-submessage-motion.stories.tsx +346 -0
- package/src/components/shell-submessage.css +23 -0
- package/src/components/timeline-playground.stories.tsx +2090 -0
- package/src/components/tool-count-label.css +57 -0
- package/src/components/tool-count-label.tsx +58 -0
- package/src/components/tool-count-summary.css +102 -0
- package/src/components/tool-count-summary.stories.tsx +238 -0
- package/src/components/tool-count-summary.tsx +52 -0
- package/src/components/tool-error-card.css +91 -0
- package/src/components/tool-error-card.stories.tsx +92 -0
- package/src/components/tool-error-card.tsx +157 -0
- package/src/components/tool-status-title.css +89 -0
- package/src/components/tool-status-title.tsx +133 -0
- package/src/context/data.tsx +64 -0
- package/src/context/index.ts +1 -0
- package/src/pierre/comment-hover.ts +83 -0
- package/src/pierre/commented-lines.ts +91 -0
- package/src/pierre/diff-selection.ts +71 -0
- package/src/pierre/file-find.ts +485 -0
- package/src/pierre/file-runtime.ts +114 -0
- package/src/pierre/file-selection.ts +85 -0
- package/src/pierre/index.ts +186 -0
- package/src/pierre/media.ts +110 -0
- package/src/pierre/selection-bridge.ts +132 -0
- package/src/pierre/virtualizer.ts +100 -0
- package/src/pierre/worker.ts +52 -0
- package/src/styles/index.css +14 -0
- package/src/v2/components/attachment-card-v2.css +70 -0
- package/src/v2/components/attachment-card-v2.tsx +33 -0
- package/src/v2/components/basic-tool-v2.css +163 -0
- package/src/v2/components/basic-tool-v2.stories.tsx +137 -0
- package/src/v2/components/basic-tool-v2.tsx +139 -0
- package/src/v2/components/comment-card-v2.tsx +64 -0
- package/src/v2/components/line-comment-annotations-v2.tsx +220 -0
- package/src/v2/components/prompt-input/attachments.ts +266 -0
- package/src/v2/components/prompt-input/index.tsx +706 -0
- package/src/v2/components/prompt-input/interaction.ts +482 -0
- package/src/v2/components/prompt-input/machine.test.ts +164 -0
- package/src/v2/components/prompt-input/machine.ts +261 -0
- package/src/v2/components/prompt-input/prompt-input.stories.tsx +221 -0
- package/src/v2/components/prompt-input/store.test.ts +116 -0
- package/src/v2/components/prompt-input/store.ts +152 -0
- package/src/v2/components/prompt-input/types.ts +106 -0
- package/src/v2/components/session-file-panel-v2.tsx +43 -0
- package/src/v2/components/session-progress-indicator-v2.css +875 -0
- package/src/v2/components/session-progress-indicator-v2.stories.tsx +66 -0
- package/src/v2/components/session-progress-indicator-v2.tsx +32 -0
- package/src/v2/components/session-review-empty-changes-v2.tsx +17 -0
- package/src/v2/components/session-review-empty-no-git-v2.tsx +28 -0
- package/src/v2/components/session-review-file-preview-v2-virtualize.test.ts +13 -0
- package/src/v2/components/session-review-file-preview-v2-virtualize.ts +5 -0
- package/src/v2/components/session-review-file-preview-v2.tsx +290 -0
- package/src/v2/components/session-review-v2.css +458 -0
- package/src/v2/components/session-review-v2.tsx +340 -0
- package/src/v2/components/tool-error-card-v2.css +200 -0
- package/src/v2/components/tool-error-card-v2.stories.tsx +91 -0
- package/src/v2/components/tool-error-card-v2.tsx +166 -0
- package/sst-env.d.ts +10 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,1202 @@
|
|
|
1
|
+
import { sampledChecksum } from "@neurocode-ai/core/util/encode"
|
|
2
|
+
import {
|
|
3
|
+
areFilesEqual,
|
|
4
|
+
areOptionsEqual,
|
|
5
|
+
DEFAULT_VIRTUAL_FILE_METRICS,
|
|
6
|
+
type DiffLineAnnotation,
|
|
7
|
+
type FileContents,
|
|
8
|
+
type FileDiffMetadata,
|
|
9
|
+
File as PierreFile,
|
|
10
|
+
type FileDiffOptions,
|
|
11
|
+
FileDiff,
|
|
12
|
+
type FileOptions,
|
|
13
|
+
type LineAnnotation,
|
|
14
|
+
type SelectedLineRange,
|
|
15
|
+
type VirtualFileMetrics,
|
|
16
|
+
VirtualizedFile,
|
|
17
|
+
VirtualizedFileDiff,
|
|
18
|
+
Virtualizer,
|
|
19
|
+
} from "@pierre/diffs"
|
|
20
|
+
import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
|
21
|
+
import { createMediaQuery } from "@solid-primitives/media"
|
|
22
|
+
import { makeEventListener } from "@solid-primitives/event-listener"
|
|
23
|
+
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
|
|
24
|
+
import { createDefaultOptions, styleVariables } from "../pierre"
|
|
25
|
+
import { markCommentedDiffLines, markCommentedFileLines } from "../pierre/commented-lines"
|
|
26
|
+
import { fixDiffSelection, findDiffSide, type DiffSelectionSide } from "../pierre/diff-selection"
|
|
27
|
+
import { createFileFind } from "../pierre/file-find"
|
|
28
|
+
import {
|
|
29
|
+
applyViewerScheme,
|
|
30
|
+
clearReadyWatcher,
|
|
31
|
+
createReadyWatcher,
|
|
32
|
+
getViewerHost,
|
|
33
|
+
getViewerRoot,
|
|
34
|
+
notifyShadowReady,
|
|
35
|
+
observeViewerScheme,
|
|
36
|
+
} from "../pierre/file-runtime"
|
|
37
|
+
import {
|
|
38
|
+
findCodeSelectionSide,
|
|
39
|
+
findDiffLineNumber,
|
|
40
|
+
findElement,
|
|
41
|
+
findFileLineNumber,
|
|
42
|
+
readShadowLineSelection,
|
|
43
|
+
} from "../pierre/file-selection"
|
|
44
|
+
import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge"
|
|
45
|
+
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
|
|
46
|
+
import { getWorkerPool } from "../pierre/worker"
|
|
47
|
+
import { FileMedia, type FileMediaOptions } from "./file-media"
|
|
48
|
+
import { FileSearchBar } from "./file-search"
|
|
49
|
+
|
|
50
|
+
const VIRTUALIZE_BYTES = 500_000
|
|
51
|
+
|
|
52
|
+
const codeMetrics = {
|
|
53
|
+
...DEFAULT_VIRTUAL_FILE_METRICS,
|
|
54
|
+
lineHeight: 24,
|
|
55
|
+
spacing: 0,
|
|
56
|
+
} satisfies Partial<VirtualFileMetrics>
|
|
57
|
+
|
|
58
|
+
type SharedProps<T> = {
|
|
59
|
+
annotations?: LineAnnotation<T>[] | DiffLineAnnotation<T>[]
|
|
60
|
+
selectedLines?: SelectedLineRange | null
|
|
61
|
+
commentedLines?: SelectedLineRange[]
|
|
62
|
+
onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
|
|
63
|
+
onRendered?: () => void
|
|
64
|
+
class?: string
|
|
65
|
+
classList?: ComponentProps<"div">["classList"]
|
|
66
|
+
media?: FileMediaOptions
|
|
67
|
+
search?: FileSearchControl
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type FileSearchHandle = {
|
|
71
|
+
focus: () => void
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type FileSearchControl = {
|
|
75
|
+
register: (handle: FileSearchHandle | null) => void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type TextFileProps<T = {}> = FileOptions<T> &
|
|
79
|
+
SharedProps<T> & {
|
|
80
|
+
mode: "text"
|
|
81
|
+
file: FileContents
|
|
82
|
+
annotations?: LineAnnotation<T>[]
|
|
83
|
+
preloadedDiff?: PreloadMultiFileDiffResult<T>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
|
|
87
|
+
|
|
88
|
+
type DiffBaseProps<T> = FileDiffOptions<T> &
|
|
89
|
+
SharedProps<T> & {
|
|
90
|
+
mode: "diff"
|
|
91
|
+
annotations?: DiffLineAnnotation<T>[]
|
|
92
|
+
preloadedDiff?: DiffPreload<T>
|
|
93
|
+
virtualize?: boolean
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type DiffPairProps<T> = DiffBaseProps<T> & {
|
|
97
|
+
before: FileContents
|
|
98
|
+
after: FileContents
|
|
99
|
+
fileDiff?: undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type DiffPatchProps<T> = DiffBaseProps<T> & {
|
|
103
|
+
fileDiff: FileDiffMetadata
|
|
104
|
+
before?: undefined
|
|
105
|
+
after?: undefined
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type DiffFileProps<T = {}> = DiffPairProps<T> | DiffPatchProps<T>
|
|
109
|
+
|
|
110
|
+
export type FileProps<T = {}> = TextFileProps<T> | DiffFileProps<T>
|
|
111
|
+
|
|
112
|
+
const sharedKeys = [
|
|
113
|
+
"mode",
|
|
114
|
+
"media",
|
|
115
|
+
"class",
|
|
116
|
+
"classList",
|
|
117
|
+
"annotations",
|
|
118
|
+
"selectedLines",
|
|
119
|
+
"commentedLines",
|
|
120
|
+
"search",
|
|
121
|
+
"onLineSelected",
|
|
122
|
+
"onLineSelectionEnd",
|
|
123
|
+
"onLineNumberSelectionEnd",
|
|
124
|
+
"onRendered",
|
|
125
|
+
"preloadedDiff",
|
|
126
|
+
] as const
|
|
127
|
+
|
|
128
|
+
const textKeys = ["file", ...sharedKeys] as const
|
|
129
|
+
const diffKeys = ["fileDiff", "before", "after", "virtualize", ...sharedKeys] as const
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Shared viewer hook
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
type MouseHit = {
|
|
136
|
+
line: number | undefined
|
|
137
|
+
numberColumn: boolean
|
|
138
|
+
side?: DiffSelectionSide
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
type ViewerConfig = {
|
|
142
|
+
enableLineSelection: () => boolean
|
|
143
|
+
selectedLines: () => SelectedLineRange | null | undefined
|
|
144
|
+
commentedLines: () => SelectedLineRange[]
|
|
145
|
+
onLineSelectionEnd: (range: SelectedLineRange | null) => void
|
|
146
|
+
|
|
147
|
+
// mode-specific callbacks
|
|
148
|
+
lineFromMouseEvent: (event: MouseEvent) => MouseHit
|
|
149
|
+
setSelectedLines: (range: SelectedLineRange | null, preserve?: { root: ShadowRoot; text: Range }) => void
|
|
150
|
+
updateSelection: (preserveTextSelection: boolean) => void
|
|
151
|
+
buildDragSelection: () => SelectedLineRange | undefined
|
|
152
|
+
buildClickSelection: () => SelectedLineRange | undefined
|
|
153
|
+
onDragStart: (hit: MouseHit) => void
|
|
154
|
+
onDragMove: (hit: MouseHit) => void
|
|
155
|
+
onDragReset: () => void
|
|
156
|
+
markCommented: (root: ShadowRoot, ranges: SelectedLineRange[]) => void
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function useFileViewer(config: ViewerConfig) {
|
|
160
|
+
let wrapper!: HTMLDivElement
|
|
161
|
+
let container!: HTMLDivElement
|
|
162
|
+
let overlay!: HTMLDivElement
|
|
163
|
+
let selectionFrame: number | undefined
|
|
164
|
+
let dragFrame: number | undefined
|
|
165
|
+
let dragStart: number | undefined
|
|
166
|
+
let dragEnd: number | undefined
|
|
167
|
+
let dragMoved = false
|
|
168
|
+
let lastSelection: SelectedLineRange | null = null
|
|
169
|
+
let pendingSelectionEnd = false
|
|
170
|
+
|
|
171
|
+
const ready = createReadyWatcher()
|
|
172
|
+
const bridge = createLineNumberSelectionBridge()
|
|
173
|
+
const [rendered, setRendered] = createSignal(0)
|
|
174
|
+
|
|
175
|
+
const getRoot = () => getViewerRoot(container)
|
|
176
|
+
const getHost = () => getViewerHost(container)
|
|
177
|
+
|
|
178
|
+
const find = createFileFind({
|
|
179
|
+
wrapper: () => wrapper,
|
|
180
|
+
overlay: () => overlay,
|
|
181
|
+
getRoot,
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// -- selection scheduling --
|
|
185
|
+
|
|
186
|
+
const scheduleSelectionUpdate = () => {
|
|
187
|
+
if (selectionFrame !== undefined) return
|
|
188
|
+
selectionFrame = requestAnimationFrame(() => {
|
|
189
|
+
selectionFrame = undefined
|
|
190
|
+
const finishing = pendingSelectionEnd
|
|
191
|
+
config.updateSelection(finishing)
|
|
192
|
+
if (!pendingSelectionEnd) return
|
|
193
|
+
pendingSelectionEnd = false
|
|
194
|
+
config.onLineSelectionEnd(lastSelection)
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const scheduleDragUpdate = () => {
|
|
199
|
+
if (dragFrame !== undefined) return
|
|
200
|
+
dragFrame = requestAnimationFrame(() => {
|
|
201
|
+
dragFrame = undefined
|
|
202
|
+
const selected = config.buildDragSelection()
|
|
203
|
+
if (selected) config.setSelectedLines(selected)
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// -- mouse handlers --
|
|
208
|
+
|
|
209
|
+
const handleMouseDown = (event: MouseEvent) => {
|
|
210
|
+
if (!config.enableLineSelection()) return
|
|
211
|
+
if (event.button !== 0) return
|
|
212
|
+
|
|
213
|
+
const hit = config.lineFromMouseEvent(event)
|
|
214
|
+
if (hit.numberColumn) {
|
|
215
|
+
bridge.begin(true, hit.line)
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
if (hit.line === undefined) return
|
|
219
|
+
|
|
220
|
+
bridge.begin(false, hit.line)
|
|
221
|
+
dragStart = hit.line
|
|
222
|
+
dragEnd = hit.line
|
|
223
|
+
dragMoved = false
|
|
224
|
+
config.onDragStart(hit)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const handleMouseMove = (event: MouseEvent) => {
|
|
228
|
+
if (!config.enableLineSelection()) return
|
|
229
|
+
|
|
230
|
+
const hit = config.lineFromMouseEvent(event)
|
|
231
|
+
if (bridge.track(event.buttons, hit.line)) return
|
|
232
|
+
if (dragStart === undefined) return
|
|
233
|
+
|
|
234
|
+
if ((event.buttons & 1) === 0) {
|
|
235
|
+
dragStart = undefined
|
|
236
|
+
dragEnd = undefined
|
|
237
|
+
dragMoved = false
|
|
238
|
+
config.onDragReset()
|
|
239
|
+
bridge.finish()
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (hit.line === undefined) return
|
|
244
|
+
dragEnd = hit.line
|
|
245
|
+
dragMoved = true
|
|
246
|
+
config.onDragMove(hit)
|
|
247
|
+
scheduleDragUpdate()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const handleMouseUp = () => {
|
|
251
|
+
if (!config.enableLineSelection()) return
|
|
252
|
+
if (bridge.finish() === "numbers") return
|
|
253
|
+
if (dragStart === undefined) return
|
|
254
|
+
|
|
255
|
+
if (!dragMoved) {
|
|
256
|
+
pendingSelectionEnd = false
|
|
257
|
+
const selected = config.buildClickSelection()
|
|
258
|
+
if (selected) config.setSelectedLines(selected)
|
|
259
|
+
config.onLineSelectionEnd(lastSelection)
|
|
260
|
+
dragStart = undefined
|
|
261
|
+
dragEnd = undefined
|
|
262
|
+
dragMoved = false
|
|
263
|
+
config.onDragReset()
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
pendingSelectionEnd = true
|
|
268
|
+
scheduleDragUpdate()
|
|
269
|
+
scheduleSelectionUpdate()
|
|
270
|
+
|
|
271
|
+
dragStart = undefined
|
|
272
|
+
dragEnd = undefined
|
|
273
|
+
dragMoved = false
|
|
274
|
+
config.onDragReset()
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const handleSelectionChange = () => {
|
|
278
|
+
if (!config.enableLineSelection()) return
|
|
279
|
+
if (dragStart === undefined) return
|
|
280
|
+
const selection = window.getSelection()
|
|
281
|
+
if (!selection || selection.isCollapsed) return
|
|
282
|
+
scheduleSelectionUpdate()
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// -- shared effects --
|
|
286
|
+
|
|
287
|
+
onMount(() => {
|
|
288
|
+
onCleanup(observeViewerScheme(getHost))
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
createEffect(() => {
|
|
292
|
+
rendered()
|
|
293
|
+
const ranges = config.commentedLines()
|
|
294
|
+
requestAnimationFrame(() => {
|
|
295
|
+
const root = getRoot()
|
|
296
|
+
if (!root) return
|
|
297
|
+
config.markCommented(root, ranges)
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
createEffect(() => {
|
|
302
|
+
config.setSelectedLines(config.selectedLines() ?? null)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
createEffect(() => {
|
|
306
|
+
if (!config.enableLineSelection()) return
|
|
307
|
+
|
|
308
|
+
makeEventListener(container, "mousedown", handleMouseDown)
|
|
309
|
+
makeEventListener(container, "mousemove", handleMouseMove)
|
|
310
|
+
makeEventListener(window, "mouseup", handleMouseUp)
|
|
311
|
+
makeEventListener(document, "selectionchange", handleSelectionChange)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
onCleanup(() => {
|
|
315
|
+
clearReadyWatcher(ready)
|
|
316
|
+
|
|
317
|
+
if (selectionFrame !== undefined) cancelAnimationFrame(selectionFrame)
|
|
318
|
+
if (dragFrame !== undefined) cancelAnimationFrame(dragFrame)
|
|
319
|
+
|
|
320
|
+
selectionFrame = undefined
|
|
321
|
+
dragFrame = undefined
|
|
322
|
+
dragStart = undefined
|
|
323
|
+
dragEnd = undefined
|
|
324
|
+
dragMoved = false
|
|
325
|
+
bridge.reset()
|
|
326
|
+
lastSelection = null
|
|
327
|
+
pendingSelectionEnd = false
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
get wrapper() {
|
|
332
|
+
return wrapper
|
|
333
|
+
},
|
|
334
|
+
set wrapper(v: HTMLDivElement) {
|
|
335
|
+
wrapper = v
|
|
336
|
+
},
|
|
337
|
+
get container() {
|
|
338
|
+
return container
|
|
339
|
+
},
|
|
340
|
+
set container(v: HTMLDivElement) {
|
|
341
|
+
container = v
|
|
342
|
+
},
|
|
343
|
+
get overlay() {
|
|
344
|
+
return overlay
|
|
345
|
+
},
|
|
346
|
+
set overlay(v: HTMLDivElement) {
|
|
347
|
+
overlay = v
|
|
348
|
+
},
|
|
349
|
+
get dragStart() {
|
|
350
|
+
return dragStart
|
|
351
|
+
},
|
|
352
|
+
get dragEnd() {
|
|
353
|
+
return dragEnd
|
|
354
|
+
},
|
|
355
|
+
get lastSelection() {
|
|
356
|
+
return lastSelection
|
|
357
|
+
},
|
|
358
|
+
set lastSelection(v: SelectedLineRange | null) {
|
|
359
|
+
lastSelection = v
|
|
360
|
+
},
|
|
361
|
+
ready,
|
|
362
|
+
bridge,
|
|
363
|
+
rendered,
|
|
364
|
+
setRendered,
|
|
365
|
+
getRoot,
|
|
366
|
+
getHost,
|
|
367
|
+
find,
|
|
368
|
+
scheduleSelectionUpdate,
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
type Viewer = ReturnType<typeof useFileViewer>
|
|
373
|
+
|
|
374
|
+
type ModeAdapter = Omit<ViewerConfig, "enableLineSelection" | "selectedLines" | "commentedLines" | "onLineSelectionEnd">
|
|
375
|
+
|
|
376
|
+
type ModeConfig = {
|
|
377
|
+
enableLineSelection: () => boolean
|
|
378
|
+
selectedLines: () => SelectedLineRange | null | undefined
|
|
379
|
+
commentedLines: () => SelectedLineRange[] | undefined
|
|
380
|
+
onLineSelectionEnd: (range: SelectedLineRange | null) => void
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
type RenderTarget = {
|
|
384
|
+
cleanUp: () => void
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
type AnnotationTarget<A> = {
|
|
388
|
+
setLineAnnotations: (annotations: A[]) => void
|
|
389
|
+
rerender: () => void
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
type VirtualStrategy = {
|
|
393
|
+
get: () => Virtualizer | undefined
|
|
394
|
+
cleanup: () => void
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function useModeViewer(config: ModeConfig, adapter: ModeAdapter) {
|
|
398
|
+
return useFileViewer({
|
|
399
|
+
enableLineSelection: config.enableLineSelection,
|
|
400
|
+
selectedLines: config.selectedLines,
|
|
401
|
+
commentedLines: () => config.commentedLines() ?? [],
|
|
402
|
+
onLineSelectionEnd: config.onLineSelectionEnd,
|
|
403
|
+
...adapter,
|
|
404
|
+
})
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function useSearchHandle(opts: {
|
|
408
|
+
search: () => FileSearchControl | undefined
|
|
409
|
+
find: ReturnType<typeof createFileFind>
|
|
410
|
+
}) {
|
|
411
|
+
createEffect(() => {
|
|
412
|
+
const search = opts.search()
|
|
413
|
+
if (!search) return
|
|
414
|
+
|
|
415
|
+
const handle = {
|
|
416
|
+
focus: () => opts.find.focus(),
|
|
417
|
+
} satisfies FileSearchHandle
|
|
418
|
+
|
|
419
|
+
search.register(handle)
|
|
420
|
+
onCleanup(() => search.register(null))
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function createLineCallbacks(opts: {
|
|
425
|
+
viewer: Viewer
|
|
426
|
+
normalize?: (range: SelectedLineRange | null) => SelectedLineRange | null | undefined
|
|
427
|
+
onLineSelected?: (range: SelectedLineRange | null) => void
|
|
428
|
+
onLineSelectionEnd?: (range: SelectedLineRange | null) => void
|
|
429
|
+
onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
|
|
430
|
+
}) {
|
|
431
|
+
const select = (range: SelectedLineRange | null) => {
|
|
432
|
+
if (!opts.normalize) return range
|
|
433
|
+
const next = opts.normalize(range)
|
|
434
|
+
if (next !== undefined) return next
|
|
435
|
+
return range
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
onLineSelected: (range: SelectedLineRange | null) => {
|
|
440
|
+
const next = select(range)
|
|
441
|
+
opts.viewer.lastSelection = next
|
|
442
|
+
opts.onLineSelected?.(next)
|
|
443
|
+
},
|
|
444
|
+
onLineSelectionEnd: (range: SelectedLineRange | null) => {
|
|
445
|
+
const next = select(range)
|
|
446
|
+
opts.viewer.lastSelection = next
|
|
447
|
+
opts.onLineSelectionEnd?.(next)
|
|
448
|
+
if (!opts.viewer.bridge.consume(next)) return
|
|
449
|
+
requestAnimationFrame(() => opts.onLineNumberSelectionEnd?.(next))
|
|
450
|
+
},
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function useAnnotationRerender<A>(opts: {
|
|
455
|
+
viewer: Viewer
|
|
456
|
+
current: () => AnnotationTarget<A> | undefined
|
|
457
|
+
annotations: () => A[]
|
|
458
|
+
}) {
|
|
459
|
+
const applied = new WeakSet<AnnotationTarget<A>>()
|
|
460
|
+
createEffect(() => {
|
|
461
|
+
opts.viewer.rendered()
|
|
462
|
+
const active = opts.current()
|
|
463
|
+
if (!active) return
|
|
464
|
+
const annotations = opts.annotations()
|
|
465
|
+
// renderViewer always draws with empty annotations, so skip the extra rerender
|
|
466
|
+
// when this instance has nothing applied and nothing to apply.
|
|
467
|
+
if (annotations.length === 0 && !applied.has(active)) return
|
|
468
|
+
if (annotations.length === 0) applied.delete(active)
|
|
469
|
+
else applied.add(active)
|
|
470
|
+
active.setLineAnnotations(annotations)
|
|
471
|
+
active.rerender()
|
|
472
|
+
requestAnimationFrame(() => opts.viewer.find.refresh({ reset: true }))
|
|
473
|
+
})
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function notifyRendered(opts: {
|
|
477
|
+
viewer: Viewer
|
|
478
|
+
isReady: (root: ShadowRoot) => boolean
|
|
479
|
+
settleFrames?: number
|
|
480
|
+
onReady: () => void
|
|
481
|
+
}) {
|
|
482
|
+
notifyShadowReady({
|
|
483
|
+
state: opts.viewer.ready,
|
|
484
|
+
container: opts.viewer.container,
|
|
485
|
+
getRoot: opts.viewer.getRoot,
|
|
486
|
+
isReady: opts.isReady,
|
|
487
|
+
settleFrames: opts.settleFrames,
|
|
488
|
+
onReady: opts.onReady,
|
|
489
|
+
})
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function renderViewer<I extends RenderTarget>(opts: {
|
|
493
|
+
viewer: Viewer
|
|
494
|
+
current: I | undefined
|
|
495
|
+
reset?: boolean
|
|
496
|
+
create: () => I
|
|
497
|
+
update?: (value: I) => void
|
|
498
|
+
assign: (value: I) => void
|
|
499
|
+
draw: (value: I) => void
|
|
500
|
+
onReady: () => void
|
|
501
|
+
}) {
|
|
502
|
+
clearReadyWatcher(opts.viewer.ready)
|
|
503
|
+
const reset = opts.reset === true && opts.current !== undefined
|
|
504
|
+
if (reset) opts.current?.cleanUp()
|
|
505
|
+
const next = reset || !opts.current ? opts.create() : opts.current
|
|
506
|
+
if (reset || !opts.current) {
|
|
507
|
+
opts.viewer.container.innerHTML = ""
|
|
508
|
+
opts.assign(next)
|
|
509
|
+
} else {
|
|
510
|
+
opts.update?.(next)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
opts.draw(next)
|
|
514
|
+
|
|
515
|
+
applyViewerScheme(opts.viewer.getHost())
|
|
516
|
+
opts.viewer.setRendered((value) => value + 1)
|
|
517
|
+
opts.onReady()
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function preserve(viewer: Viewer) {
|
|
521
|
+
const root = scrollParent(viewer.wrapper)
|
|
522
|
+
if (!root) return () => {}
|
|
523
|
+
|
|
524
|
+
const high = viewer.container.getBoundingClientRect().height
|
|
525
|
+
if (!high) return () => {}
|
|
526
|
+
|
|
527
|
+
const top = viewer.wrapper.getBoundingClientRect().top - root.getBoundingClientRect().top
|
|
528
|
+
const prev = viewer.container.style.minHeight
|
|
529
|
+
viewer.container.style.minHeight = `${Math.ceil(high)}px`
|
|
530
|
+
|
|
531
|
+
let done = false
|
|
532
|
+
return () => {
|
|
533
|
+
if (done) return
|
|
534
|
+
done = true
|
|
535
|
+
viewer.container.style.minHeight = prev
|
|
536
|
+
|
|
537
|
+
const next = viewer.wrapper.getBoundingClientRect().top - root.getBoundingClientRect().top
|
|
538
|
+
const delta = next - top
|
|
539
|
+
if (delta) root.scrollTop += delta
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function scrollParent(el: HTMLElement): HTMLElement | undefined {
|
|
544
|
+
let parent = el.parentElement
|
|
545
|
+
while (parent) {
|
|
546
|
+
const style = getComputedStyle(parent)
|
|
547
|
+
if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
|
|
548
|
+
parent = parent.parentElement
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
|
|
553
|
+
let virtualizer: Virtualizer | undefined
|
|
554
|
+
let root: Document | HTMLElement | undefined
|
|
555
|
+
|
|
556
|
+
const release = () => {
|
|
557
|
+
virtualizer?.cleanUp()
|
|
558
|
+
virtualizer = undefined
|
|
559
|
+
root = undefined
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
get: () => {
|
|
564
|
+
if (!enabled()) {
|
|
565
|
+
release()
|
|
566
|
+
return
|
|
567
|
+
}
|
|
568
|
+
if (typeof document === "undefined") return
|
|
569
|
+
|
|
570
|
+
const wrapper = host()
|
|
571
|
+
if (!wrapper) return
|
|
572
|
+
|
|
573
|
+
const next = scrollParent(wrapper) ?? document
|
|
574
|
+
if (virtualizer && root === next) return virtualizer
|
|
575
|
+
|
|
576
|
+
release()
|
|
577
|
+
virtualizer = new Virtualizer()
|
|
578
|
+
root = next
|
|
579
|
+
virtualizer.setup(next, next instanceof Document ? undefined : wrapper)
|
|
580
|
+
return virtualizer
|
|
581
|
+
},
|
|
582
|
+
cleanup: release,
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
|
|
587
|
+
let shared: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
|
|
588
|
+
|
|
589
|
+
const release = () => {
|
|
590
|
+
shared?.release()
|
|
591
|
+
shared = undefined
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return {
|
|
595
|
+
get: () => {
|
|
596
|
+
if (!enabled()) {
|
|
597
|
+
release()
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
if (shared) return shared.virtualizer
|
|
601
|
+
|
|
602
|
+
const container = host()
|
|
603
|
+
if (!container) return
|
|
604
|
+
|
|
605
|
+
const result = acquireVirtualizer(container)
|
|
606
|
+
if (!result) return
|
|
607
|
+
shared = result
|
|
608
|
+
return result.virtualizer
|
|
609
|
+
},
|
|
610
|
+
cleanup: release,
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function parseLine(node: HTMLElement) {
|
|
615
|
+
if (!node.dataset.line) return
|
|
616
|
+
const value = parseInt(node.dataset.line, 10)
|
|
617
|
+
if (Number.isNaN(value)) return
|
|
618
|
+
return value
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function mouseHit(
|
|
622
|
+
event: MouseEvent,
|
|
623
|
+
line: (node: HTMLElement) => number | undefined,
|
|
624
|
+
side?: (node: HTMLElement) => DiffSelectionSide | undefined,
|
|
625
|
+
): MouseHit {
|
|
626
|
+
const path = event.composedPath()
|
|
627
|
+
let numberColumn = false
|
|
628
|
+
let value: number | undefined
|
|
629
|
+
let branch: DiffSelectionSide | undefined
|
|
630
|
+
|
|
631
|
+
for (const item of path) {
|
|
632
|
+
if (!(item instanceof HTMLElement)) continue
|
|
633
|
+
|
|
634
|
+
numberColumn = numberColumn || item.dataset.columnNumber != null
|
|
635
|
+
if (value === undefined) value = line(item)
|
|
636
|
+
if (branch === undefined && side) branch = side(item)
|
|
637
|
+
|
|
638
|
+
if (numberColumn && value !== undefined && (side == null || branch !== undefined)) break
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return {
|
|
642
|
+
line: value,
|
|
643
|
+
numberColumn,
|
|
644
|
+
side: branch,
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function diffMouseSide(node: HTMLElement) {
|
|
649
|
+
const type = node.dataset.lineType
|
|
650
|
+
if (type === "change-deletion") return "deletions" satisfies DiffSelectionSide
|
|
651
|
+
if (type === "change-addition" || type === "change-additions") return "additions" satisfies DiffSelectionSide
|
|
652
|
+
if (node.dataset.code == null) return
|
|
653
|
+
return node.hasAttribute("data-deletions") ? "deletions" : "additions"
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function diffSelectionSide(node: Node | null) {
|
|
657
|
+
const el = findElement(node)
|
|
658
|
+
if (!el) return
|
|
659
|
+
return findDiffSide(el)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ---------------------------------------------------------------------------
|
|
663
|
+
// Shared JSX shell
|
|
664
|
+
// ---------------------------------------------------------------------------
|
|
665
|
+
|
|
666
|
+
function ViewerShell(props: {
|
|
667
|
+
mode: "text" | "diff"
|
|
668
|
+
viewer: ReturnType<typeof useFileViewer>
|
|
669
|
+
class: string | undefined
|
|
670
|
+
classList: ComponentProps<"div">["classList"] | undefined
|
|
671
|
+
}) {
|
|
672
|
+
return (
|
|
673
|
+
<div
|
|
674
|
+
data-component="file"
|
|
675
|
+
data-mode={props.mode}
|
|
676
|
+
style={styleVariables}
|
|
677
|
+
class="relative outline-none"
|
|
678
|
+
classList={{
|
|
679
|
+
...props.classList,
|
|
680
|
+
[props.class ?? ""]: !!props.class,
|
|
681
|
+
}}
|
|
682
|
+
ref={(el) => (props.viewer.wrapper = el)}
|
|
683
|
+
tabIndex={0}
|
|
684
|
+
onPointerDown={props.viewer.find.onPointerDown}
|
|
685
|
+
onFocus={props.viewer.find.onFocus}
|
|
686
|
+
>
|
|
687
|
+
<Show when={props.viewer.find.open()}>
|
|
688
|
+
<FileSearchBar
|
|
689
|
+
pos={props.viewer.find.pos}
|
|
690
|
+
query={props.viewer.find.query}
|
|
691
|
+
count={props.viewer.find.count}
|
|
692
|
+
index={props.viewer.find.index}
|
|
693
|
+
setInput={props.viewer.find.setInput}
|
|
694
|
+
onInput={props.viewer.find.setQuery}
|
|
695
|
+
onKeyDown={props.viewer.find.onInputKeyDown}
|
|
696
|
+
onClose={props.viewer.find.close}
|
|
697
|
+
onPrev={() => props.viewer.find.next(-1)}
|
|
698
|
+
onNext={() => props.viewer.find.next(1)}
|
|
699
|
+
/>
|
|
700
|
+
</Show>
|
|
701
|
+
<div ref={(el) => (props.viewer.container = el)} />
|
|
702
|
+
<div ref={(el) => (props.viewer.overlay = el)} class="pointer-events-none absolute inset-0 z-0" />
|
|
703
|
+
</div>
|
|
704
|
+
)
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ---------------------------------------------------------------------------
|
|
708
|
+
// TextViewer
|
|
709
|
+
// ---------------------------------------------------------------------------
|
|
710
|
+
|
|
711
|
+
function TextViewer<T>(props: TextFileProps<T>) {
|
|
712
|
+
let instance: PierreFile<T> | VirtualizedFile<T> | undefined
|
|
713
|
+
let viewer!: Viewer
|
|
714
|
+
|
|
715
|
+
const [local, others] = splitProps(props, textKeys)
|
|
716
|
+
|
|
717
|
+
const text = () => {
|
|
718
|
+
const value = local.file.contents as unknown
|
|
719
|
+
if (typeof value === "string") return value
|
|
720
|
+
if (Array.isArray(value)) return value.join("\n")
|
|
721
|
+
if (value == null) return ""
|
|
722
|
+
// oxlint-disable-next-line no-base-to-string -- file contents cast to unknown, coercion is intentional
|
|
723
|
+
return String(value)
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const lineCount = () => {
|
|
727
|
+
const value = text()
|
|
728
|
+
const total = value.split("\n").length - (value.endsWith("\n") ? 1 : 0)
|
|
729
|
+
return Math.max(1, total)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const bytes = createMemo(() => {
|
|
733
|
+
const value = local.file.contents as unknown
|
|
734
|
+
if (typeof value === "string") return value.length
|
|
735
|
+
if (Array.isArray(value)) {
|
|
736
|
+
return value.reduce(
|
|
737
|
+
// oxlint-disable-next-line no-base-to-string -- array parts coerced intentionally
|
|
738
|
+
(sum, part) => sum + (typeof part === "string" ? part.length + 1 : String(part).length + 1),
|
|
739
|
+
0,
|
|
740
|
+
)
|
|
741
|
+
}
|
|
742
|
+
if (value == null) return 0
|
|
743
|
+
// oxlint-disable-next-line no-base-to-string -- file contents cast to unknown, coercion is intentional
|
|
744
|
+
return String(value).length
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES)
|
|
748
|
+
|
|
749
|
+
const virtuals = createLocalVirtualStrategy(() => viewer.wrapper, virtual)
|
|
750
|
+
|
|
751
|
+
const lineFromMouseEvent = (event: MouseEvent): MouseHit => mouseHit(event, parseLine)
|
|
752
|
+
|
|
753
|
+
const applySelection = (range: SelectedLineRange | null) => {
|
|
754
|
+
const current = instance
|
|
755
|
+
if (!current) return false
|
|
756
|
+
|
|
757
|
+
if (virtual()) {
|
|
758
|
+
current.setSelectedLines(range)
|
|
759
|
+
return true
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const root = viewer.getRoot()
|
|
763
|
+
if (!root) return false
|
|
764
|
+
|
|
765
|
+
const total = lineCount()
|
|
766
|
+
if (root.querySelectorAll("[data-line]").length < total) return false
|
|
767
|
+
|
|
768
|
+
if (!range) {
|
|
769
|
+
current.setSelectedLines(null)
|
|
770
|
+
return true
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const start = Math.min(range.start, range.end)
|
|
774
|
+
const end = Math.max(range.start, range.end)
|
|
775
|
+
if (start < 1 || end > total) {
|
|
776
|
+
current.setSelectedLines(null)
|
|
777
|
+
return true
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (!root.querySelector(`[data-line="${start}"]`) || !root.querySelector(`[data-line="${end}"]`)) {
|
|
781
|
+
current.setSelectedLines(null)
|
|
782
|
+
return true
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const normalized = (() => {
|
|
786
|
+
if (range.endSide != null) return { start: range.start, end: range.end }
|
|
787
|
+
if (range.side !== "deletions") return range
|
|
788
|
+
if (root.querySelector("[data-deletions]") != null) return range
|
|
789
|
+
return { start: range.start, end: range.end }
|
|
790
|
+
})()
|
|
791
|
+
|
|
792
|
+
current.setSelectedLines(normalized)
|
|
793
|
+
return true
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const setSelectedLines = (range: SelectedLineRange | null) => {
|
|
797
|
+
viewer.lastSelection = range
|
|
798
|
+
applySelection(range)
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const adapter: ModeAdapter = {
|
|
802
|
+
lineFromMouseEvent,
|
|
803
|
+
setSelectedLines,
|
|
804
|
+
updateSelection: (preserveTextSelection) => {
|
|
805
|
+
const root = viewer.getRoot()
|
|
806
|
+
if (!root) return
|
|
807
|
+
|
|
808
|
+
const selected = readShadowLineSelection({
|
|
809
|
+
root,
|
|
810
|
+
lineForNode: findFileLineNumber,
|
|
811
|
+
sideForNode: findCodeSelectionSide,
|
|
812
|
+
preserveTextSelection,
|
|
813
|
+
})
|
|
814
|
+
if (!selected) return
|
|
815
|
+
|
|
816
|
+
setSelectedLines(selected.range)
|
|
817
|
+
if (!preserveTextSelection || !selected.text) return
|
|
818
|
+
restoreShadowTextSelection(root, selected.text)
|
|
819
|
+
},
|
|
820
|
+
buildDragSelection: () => {
|
|
821
|
+
if (viewer.dragStart === undefined || viewer.dragEnd === undefined) return
|
|
822
|
+
return { start: Math.min(viewer.dragStart, viewer.dragEnd), end: Math.max(viewer.dragStart, viewer.dragEnd) }
|
|
823
|
+
},
|
|
824
|
+
buildClickSelection: () => {
|
|
825
|
+
if (viewer.dragStart === undefined) return
|
|
826
|
+
return { start: viewer.dragStart, end: viewer.dragStart }
|
|
827
|
+
},
|
|
828
|
+
onDragStart: () => {},
|
|
829
|
+
onDragMove: () => {},
|
|
830
|
+
onDragReset: () => {},
|
|
831
|
+
markCommented: markCommentedFileLines,
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
viewer = useModeViewer(
|
|
835
|
+
{
|
|
836
|
+
enableLineSelection: () => props.enableLineSelection === true,
|
|
837
|
+
selectedLines: () => local.selectedLines,
|
|
838
|
+
commentedLines: () => local.commentedLines,
|
|
839
|
+
onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
|
|
840
|
+
},
|
|
841
|
+
adapter,
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
const lineCallbacks = createLineCallbacks({
|
|
845
|
+
viewer,
|
|
846
|
+
onLineSelected: (range) => local.onLineSelected?.(range),
|
|
847
|
+
onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
|
|
848
|
+
onLineNumberSelectionEnd: (range) => local.onLineNumberSelectionEnd?.(range),
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
const options = createMemo(() => ({
|
|
852
|
+
...createDefaultOptions<T>("unified"),
|
|
853
|
+
...others,
|
|
854
|
+
...lineCallbacks,
|
|
855
|
+
}))
|
|
856
|
+
|
|
857
|
+
const notify = () => {
|
|
858
|
+
notifyRendered({
|
|
859
|
+
viewer,
|
|
860
|
+
isReady: (root) => {
|
|
861
|
+
if (virtual()) return root.querySelector("[data-line]") != null
|
|
862
|
+
return root.querySelectorAll("[data-line]").length >= lineCount()
|
|
863
|
+
},
|
|
864
|
+
onReady: () => {
|
|
865
|
+
applySelection(viewer.lastSelection)
|
|
866
|
+
viewer.find.refresh({ reset: true })
|
|
867
|
+
local.onRendered?.()
|
|
868
|
+
},
|
|
869
|
+
})
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
useSearchHandle({
|
|
873
|
+
search: () => local.search,
|
|
874
|
+
find: viewer.find,
|
|
875
|
+
})
|
|
876
|
+
|
|
877
|
+
// -- render instance --
|
|
878
|
+
|
|
879
|
+
createEffect(() => {
|
|
880
|
+
const opts = options()
|
|
881
|
+
const workerPool = getWorkerPool("unified")
|
|
882
|
+
const virtualizer = virtuals.get()
|
|
883
|
+
|
|
884
|
+
renderViewer({
|
|
885
|
+
viewer,
|
|
886
|
+
current: instance,
|
|
887
|
+
reset: instance !== undefined,
|
|
888
|
+
create: () =>
|
|
889
|
+
virtualizer
|
|
890
|
+
? new VirtualizedFile<T>(opts, virtualizer, codeMetrics, workerPool)
|
|
891
|
+
: new PierreFile<T>(opts, workerPool),
|
|
892
|
+
assign: (value) => {
|
|
893
|
+
instance = value
|
|
894
|
+
},
|
|
895
|
+
draw: (value) => {
|
|
896
|
+
const contents = text()
|
|
897
|
+
value.render({
|
|
898
|
+
file: typeof local.file.contents === "string" ? local.file : { ...local.file, contents },
|
|
899
|
+
lineAnnotations: [],
|
|
900
|
+
containerWrapper: viewer.container,
|
|
901
|
+
})
|
|
902
|
+
},
|
|
903
|
+
onReady: notify,
|
|
904
|
+
})
|
|
905
|
+
})
|
|
906
|
+
|
|
907
|
+
useAnnotationRerender<LineAnnotation<T>>({
|
|
908
|
+
viewer,
|
|
909
|
+
current: () => instance,
|
|
910
|
+
annotations: () => (local.annotations as LineAnnotation<T>[] | undefined) ?? [],
|
|
911
|
+
})
|
|
912
|
+
|
|
913
|
+
// -- cleanup --
|
|
914
|
+
|
|
915
|
+
onCleanup(() => {
|
|
916
|
+
instance?.cleanUp()
|
|
917
|
+
instance = undefined
|
|
918
|
+
virtuals.cleanup()
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
return <ViewerShell mode="text" viewer={viewer} class={local.class} classList={local.classList} />
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// ---------------------------------------------------------------------------
|
|
925
|
+
// DiffViewer
|
|
926
|
+
// ---------------------------------------------------------------------------
|
|
927
|
+
|
|
928
|
+
function DiffViewer<T>(props: DiffFileProps<T>) {
|
|
929
|
+
let instance: FileDiff<T> | undefined
|
|
930
|
+
let instanceVirtualizer: Virtualizer | undefined
|
|
931
|
+
let instanceWorkerPool: ReturnType<typeof getWorkerPool>
|
|
932
|
+
let instanceVirtualHunkSeparators: FileDiffOptions<T>["hunkSeparators"] | undefined
|
|
933
|
+
let instanceFileDiff: FileDiffMetadata | undefined
|
|
934
|
+
let instanceBefore: FileContents | undefined
|
|
935
|
+
let instanceAfter: FileContents | undefined
|
|
936
|
+
let dragSide: DiffSelectionSide | undefined
|
|
937
|
+
let dragEndSide: DiffSelectionSide | undefined
|
|
938
|
+
let viewer!: Viewer
|
|
939
|
+
|
|
940
|
+
const [local, others] = splitProps(props, diffKeys)
|
|
941
|
+
|
|
942
|
+
const mobile = createMediaQuery("(max-width: 640px)")
|
|
943
|
+
|
|
944
|
+
const lineFromMouseEvent = (event: MouseEvent): MouseHit => mouseHit(event, findDiffLineNumber, diffMouseSide)
|
|
945
|
+
|
|
946
|
+
const setSelectedLines = (range: SelectedLineRange | null, preserve?: { root: ShadowRoot; text: Range }) => {
|
|
947
|
+
const active = instance
|
|
948
|
+
if (!active) return
|
|
949
|
+
|
|
950
|
+
const fixed = fixDiffSelection(viewer.getRoot(), range)
|
|
951
|
+
if (fixed === undefined) {
|
|
952
|
+
viewer.lastSelection = range
|
|
953
|
+
return
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
viewer.lastSelection = fixed
|
|
957
|
+
active.setSelectedLines(fixed)
|
|
958
|
+
restoreShadowTextSelection(preserve?.root, preserve?.text)
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
const adapter: ModeAdapter = {
|
|
962
|
+
lineFromMouseEvent,
|
|
963
|
+
setSelectedLines,
|
|
964
|
+
updateSelection: (preserveTextSelection) => {
|
|
965
|
+
const root = viewer.getRoot()
|
|
966
|
+
if (!root) return
|
|
967
|
+
|
|
968
|
+
const selected = readShadowLineSelection({
|
|
969
|
+
root,
|
|
970
|
+
lineForNode: findDiffLineNumber,
|
|
971
|
+
sideForNode: diffSelectionSide,
|
|
972
|
+
preserveTextSelection,
|
|
973
|
+
})
|
|
974
|
+
if (!selected) return
|
|
975
|
+
|
|
976
|
+
if (selected.text) {
|
|
977
|
+
setSelectedLines(selected.range, { root, text: selected.text })
|
|
978
|
+
return
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
setSelectedLines(selected.range)
|
|
982
|
+
},
|
|
983
|
+
buildDragSelection: () => {
|
|
984
|
+
if (viewer.dragStart === undefined || viewer.dragEnd === undefined) return
|
|
985
|
+
const selected: SelectedLineRange = { start: viewer.dragStart, end: viewer.dragEnd }
|
|
986
|
+
if (dragSide) selected.side = dragSide
|
|
987
|
+
if (dragEndSide && dragSide && dragEndSide !== dragSide) selected.endSide = dragEndSide
|
|
988
|
+
return selected
|
|
989
|
+
},
|
|
990
|
+
buildClickSelection: () => {
|
|
991
|
+
if (viewer.dragStart === undefined) return
|
|
992
|
+
const selected: SelectedLineRange = { start: viewer.dragStart, end: viewer.dragStart }
|
|
993
|
+
if (dragSide) selected.side = dragSide
|
|
994
|
+
return selected
|
|
995
|
+
},
|
|
996
|
+
onDragStart: (hit) => {
|
|
997
|
+
dragSide = hit.side
|
|
998
|
+
dragEndSide = hit.side
|
|
999
|
+
},
|
|
1000
|
+
onDragMove: (hit) => {
|
|
1001
|
+
dragEndSide = hit.side
|
|
1002
|
+
},
|
|
1003
|
+
onDragReset: () => {
|
|
1004
|
+
dragSide = undefined
|
|
1005
|
+
dragEndSide = undefined
|
|
1006
|
+
},
|
|
1007
|
+
markCommented: markCommentedDiffLines,
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
viewer = useModeViewer(
|
|
1011
|
+
{
|
|
1012
|
+
enableLineSelection: () => props.enableLineSelection === true,
|
|
1013
|
+
selectedLines: () => local.selectedLines,
|
|
1014
|
+
commentedLines: () => local.commentedLines,
|
|
1015
|
+
onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
|
|
1016
|
+
},
|
|
1017
|
+
adapter,
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
const virtuals = createSharedVirtualStrategy(
|
|
1021
|
+
() => viewer.container,
|
|
1022
|
+
() => local.virtualize !== false,
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
const large = createMemo(() => {
|
|
1026
|
+
if (local.fileDiff) {
|
|
1027
|
+
const before = local.fileDiff.deletionLines.join("")
|
|
1028
|
+
const after = local.fileDiff.additionLines.join("")
|
|
1029
|
+
return Math.max(before.length, after.length) > 500_000
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const before = typeof local.before?.contents === "string" ? local.before.contents : ""
|
|
1033
|
+
const after = typeof local.after?.contents === "string" ? local.after.contents : ""
|
|
1034
|
+
return Math.max(before.length, after.length) > 500_000
|
|
1035
|
+
})
|
|
1036
|
+
|
|
1037
|
+
const largeOptions = {
|
|
1038
|
+
lineDiffType: "none",
|
|
1039
|
+
maxLineDiffLength: 0,
|
|
1040
|
+
tokenizeMaxLineLength: 1,
|
|
1041
|
+
} satisfies Pick<FileDiffOptions<T>, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
|
|
1042
|
+
|
|
1043
|
+
const lineCallbacks = createLineCallbacks({
|
|
1044
|
+
viewer,
|
|
1045
|
+
normalize: (range) => fixDiffSelection(viewer.getRoot(), range),
|
|
1046
|
+
onLineSelected: (range) => local.onLineSelected?.(range),
|
|
1047
|
+
onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
|
|
1048
|
+
onLineNumberSelectionEnd: (range) => local.onLineNumberSelectionEnd?.(range),
|
|
1049
|
+
})
|
|
1050
|
+
|
|
1051
|
+
const options = createMemo<FileDiffOptions<T>>(() => {
|
|
1052
|
+
const base = {
|
|
1053
|
+
...createDefaultOptions(props.diffStyle),
|
|
1054
|
+
...others,
|
|
1055
|
+
...lineCallbacks,
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const perf = large() ? { ...base, ...largeOptions } : base
|
|
1059
|
+
if (!mobile()) return perf
|
|
1060
|
+
return { ...perf, disableLineNumbers: true }
|
|
1061
|
+
})
|
|
1062
|
+
|
|
1063
|
+
const notify = (done?: VoidFunction) => {
|
|
1064
|
+
notifyRendered({
|
|
1065
|
+
viewer,
|
|
1066
|
+
isReady: (root) => root.querySelector("[data-line]") != null,
|
|
1067
|
+
settleFrames: 1,
|
|
1068
|
+
onReady: () => {
|
|
1069
|
+
done?.()
|
|
1070
|
+
setSelectedLines(viewer.lastSelection)
|
|
1071
|
+
viewer.find.refresh({ reset: true })
|
|
1072
|
+
local.onRendered?.()
|
|
1073
|
+
},
|
|
1074
|
+
})
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
useSearchHandle({
|
|
1078
|
+
search: () => local.search,
|
|
1079
|
+
find: viewer.find,
|
|
1080
|
+
})
|
|
1081
|
+
|
|
1082
|
+
// -- render instance --
|
|
1083
|
+
|
|
1084
|
+
createEffect(() => {
|
|
1085
|
+
const opts = options()
|
|
1086
|
+
const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
|
|
1087
|
+
const virtualizer = virtuals.get()
|
|
1088
|
+
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
|
|
1089
|
+
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
|
|
1090
|
+
const done = preserve(viewer)
|
|
1091
|
+
|
|
1092
|
+
onCleanup(done)
|
|
1093
|
+
|
|
1094
|
+
const cacheKey = (contents: string) => {
|
|
1095
|
+
if (!large()) return sampledChecksum(contents, contents.length)
|
|
1096
|
+
return sampledChecksum(contents)
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
const before = local.before
|
|
1100
|
+
? { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) }
|
|
1101
|
+
: undefined
|
|
1102
|
+
const after = local.after
|
|
1103
|
+
? { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) }
|
|
1104
|
+
: undefined
|
|
1105
|
+
const targetChanged =
|
|
1106
|
+
local.fileDiff !== undefined
|
|
1107
|
+
? instanceFileDiff !== local.fileDiff
|
|
1108
|
+
: instanceFileDiff !== undefined ||
|
|
1109
|
+
before === undefined ||
|
|
1110
|
+
after === undefined ||
|
|
1111
|
+
instanceBefore === undefined ||
|
|
1112
|
+
instanceAfter === undefined ||
|
|
1113
|
+
!areFilesEqual(instanceBefore, before) ||
|
|
1114
|
+
!areFilesEqual(instanceAfter, after)
|
|
1115
|
+
// Pierre beta virtualized instances retain their first diff target and resolve separator metrics at construction.
|
|
1116
|
+
// Plain timeline diffs can retain the instance as content streams; virtualized viewers reset only when that is unsafe.
|
|
1117
|
+
const reset =
|
|
1118
|
+
instance !== undefined &&
|
|
1119
|
+
(instanceVirtualizer !== virtualizer ||
|
|
1120
|
+
instanceWorkerPool !== workerPool ||
|
|
1121
|
+
(virtualizer !== undefined && (instanceVirtualHunkSeparators !== opts.hunkSeparators || targetChanged)))
|
|
1122
|
+
const forceRender = !reset && instance !== undefined && !areOptionsEqual(instance.options, opts)
|
|
1123
|
+
|
|
1124
|
+
renderViewer({
|
|
1125
|
+
viewer,
|
|
1126
|
+
current: instance,
|
|
1127
|
+
reset,
|
|
1128
|
+
create: () =>
|
|
1129
|
+
virtualizer
|
|
1130
|
+
? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
|
|
1131
|
+
: new FileDiff<T>(opts, workerPool),
|
|
1132
|
+
update: (value) => value.setOptions(opts),
|
|
1133
|
+
assign: (value) => {
|
|
1134
|
+
instance = value
|
|
1135
|
+
instanceVirtualizer = virtualizer
|
|
1136
|
+
instanceWorkerPool = workerPool
|
|
1137
|
+
instanceVirtualHunkSeparators = virtualizer ? opts.hunkSeparators : undefined
|
|
1138
|
+
instanceFileDiff = local.fileDiff
|
|
1139
|
+
instanceBefore = before
|
|
1140
|
+
instanceAfter = after
|
|
1141
|
+
},
|
|
1142
|
+
draw: (value) => {
|
|
1143
|
+
if (local.fileDiff) {
|
|
1144
|
+
value.render({
|
|
1145
|
+
fileDiff: local.fileDiff,
|
|
1146
|
+
forceRender,
|
|
1147
|
+
lineAnnotations: [],
|
|
1148
|
+
containerWrapper: viewer.container,
|
|
1149
|
+
})
|
|
1150
|
+
return
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
if (!before || !after) return
|
|
1154
|
+
|
|
1155
|
+
value.render({
|
|
1156
|
+
oldFile: before,
|
|
1157
|
+
newFile: after,
|
|
1158
|
+
forceRender,
|
|
1159
|
+
lineAnnotations: [],
|
|
1160
|
+
containerWrapper: viewer.container,
|
|
1161
|
+
})
|
|
1162
|
+
},
|
|
1163
|
+
onReady: () => notify(done),
|
|
1164
|
+
})
|
|
1165
|
+
})
|
|
1166
|
+
|
|
1167
|
+
useAnnotationRerender<DiffLineAnnotation<T>>({
|
|
1168
|
+
viewer,
|
|
1169
|
+
current: () => instance,
|
|
1170
|
+
annotations: () => (local.annotations as DiffLineAnnotation<T>[] | undefined) ?? [],
|
|
1171
|
+
})
|
|
1172
|
+
|
|
1173
|
+
// -- cleanup --
|
|
1174
|
+
|
|
1175
|
+
onCleanup(() => {
|
|
1176
|
+
instance?.cleanUp()
|
|
1177
|
+
instance = undefined
|
|
1178
|
+
instanceVirtualizer = undefined
|
|
1179
|
+
instanceWorkerPool = undefined
|
|
1180
|
+
instanceVirtualHunkSeparators = undefined
|
|
1181
|
+
instanceFileDiff = undefined
|
|
1182
|
+
instanceBefore = undefined
|
|
1183
|
+
instanceAfter = undefined
|
|
1184
|
+
virtuals.cleanup()
|
|
1185
|
+
dragSide = undefined
|
|
1186
|
+
dragEndSide = undefined
|
|
1187
|
+
})
|
|
1188
|
+
|
|
1189
|
+
return <ViewerShell mode="diff" viewer={viewer} class={local.class} classList={local.classList} />
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// ---------------------------------------------------------------------------
|
|
1193
|
+
// Public API
|
|
1194
|
+
// ---------------------------------------------------------------------------
|
|
1195
|
+
|
|
1196
|
+
export function File<T>(props: FileProps<T>) {
|
|
1197
|
+
if (props.mode === "text") {
|
|
1198
|
+
return <FileMedia media={props.media} fallback={() => TextViewer(props)} />
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
return <FileMedia media={props.media} fallback={() => DiffViewer(props)} />
|
|
1202
|
+
}
|