@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,657 @@
|
|
|
1
|
+
import { Accordion } from "@neurocode-ai/ui/accordion"
|
|
2
|
+
import { Button } from "@neurocode-ai/ui/button"
|
|
3
|
+
import { DropdownMenu } from "@neurocode-ai/ui/dropdown-menu"
|
|
4
|
+
import { RadioGroup } from "@neurocode-ai/ui/radio-group"
|
|
5
|
+
import { DiffChanges } from "@neurocode-ai/ui/diff-changes"
|
|
6
|
+
import { FileIcon } from "@neurocode-ai/ui/file-icon"
|
|
7
|
+
import { Icon } from "@neurocode-ai/ui/icon"
|
|
8
|
+
import { IconButton } from "@neurocode-ai/ui/icon-button"
|
|
9
|
+
import { StickyAccordionHeader } from "@neurocode-ai/ui/sticky-accordion-header"
|
|
10
|
+
import { Tooltip } from "@neurocode-ai/ui/tooltip"
|
|
11
|
+
import { ScrollView } from "@neurocode-ai/ui/scroll-view"
|
|
12
|
+
import { useFileComponent } from "@neurocode-ai/ui/context/file"
|
|
13
|
+
import { useI18n } from "@neurocode-ai/ui/context/i18n"
|
|
14
|
+
import { getDirectory, getFilename } from "@neurocode-ai/core/util/path"
|
|
15
|
+
import { checksum } from "@neurocode-ai/core/util/encode"
|
|
16
|
+
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
|
|
17
|
+
import { createStore } from "solid-js/store"
|
|
18
|
+
import { type FileContent, type SnapshotFileDiff, type VcsFileDiff } from "@neurocode-ai/sdk/v2"
|
|
19
|
+
import type { FileDiffInfo } from "@neurocode-ai/client/promise"
|
|
20
|
+
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
|
21
|
+
import { type SelectedLineRange } from "@pierre/diffs"
|
|
22
|
+
import { Dynamic } from "solid-js/web"
|
|
23
|
+
import { mediaKindFromPath } from "../pierre/media"
|
|
24
|
+
import { cloneSelectedLineRange, previewSelectedLines } from "../pierre/selection-bridge"
|
|
25
|
+
import { createLineCommentController } from "./line-comment-annotations"
|
|
26
|
+
import type { LineCommentEditorProps } from "./line-comment"
|
|
27
|
+
import { normalize, text, type ViewDiff } from "./session-diff"
|
|
28
|
+
|
|
29
|
+
const MAX_DIFF_CHANGED_LINES = 500
|
|
30
|
+
const REVIEW_MOUNT_MARGIN = 300
|
|
31
|
+
|
|
32
|
+
export type SessionReviewDiffStyle = "unified" | "split"
|
|
33
|
+
|
|
34
|
+
export type SessionReviewComment = {
|
|
35
|
+
id: string
|
|
36
|
+
file: string
|
|
37
|
+
selection: SelectedLineRange
|
|
38
|
+
comment: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type SessionReviewLineComment = {
|
|
42
|
+
file: string
|
|
43
|
+
selection: SelectedLineRange
|
|
44
|
+
comment: string
|
|
45
|
+
preview?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type SessionReviewCommentUpdate = SessionReviewLineComment & {
|
|
49
|
+
id: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type SessionReviewCommentDelete = {
|
|
53
|
+
id: string
|
|
54
|
+
file: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type SessionReviewCommentActions = {
|
|
58
|
+
moreLabel: string
|
|
59
|
+
editLabel: string
|
|
60
|
+
deleteLabel: string
|
|
61
|
+
saveLabel: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type SessionReviewFocus = { file: string; id: string }
|
|
65
|
+
|
|
66
|
+
type RawReviewDiff = (SnapshotFileDiff | FileDiffInfo | VcsFileDiff) & {
|
|
67
|
+
preloaded?: PreloadMultiFileDiffResult<any>
|
|
68
|
+
}
|
|
69
|
+
type ReviewDiff = ((SnapshotFileDiff & { file: string }) | FileDiffInfo | VcsFileDiff) & {
|
|
70
|
+
preloaded?: PreloadMultiFileDiffResult<any>
|
|
71
|
+
}
|
|
72
|
+
type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult<any> }
|
|
73
|
+
|
|
74
|
+
function diff(value: unknown): value is ReviewDiff {
|
|
75
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false
|
|
76
|
+
if (!("file" in value) || typeof value.file !== "string") return false
|
|
77
|
+
if (!("additions" in value) || typeof value.additions !== "number") return false
|
|
78
|
+
if (!("deletions" in value) || typeof value.deletions !== "number") return false
|
|
79
|
+
if ("patch" in value && value.patch !== undefined && typeof value.patch !== "string") return false
|
|
80
|
+
if ("before" in value && value.before !== undefined && typeof value.before !== "string") return false
|
|
81
|
+
if ("after" in value && value.after !== undefined && typeof value.after !== "string") return false
|
|
82
|
+
if (!("status" in value) || value.status === undefined) return true
|
|
83
|
+
return value.status === "added" || value.status === "deleted" || value.status === "modified"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function list(value: unknown): ReviewDiff[] {
|
|
87
|
+
if (Array.isArray(value) && value.every(diff)) return value
|
|
88
|
+
if (Array.isArray(value)) return value.filter(diff)
|
|
89
|
+
if (diff(value)) return [value]
|
|
90
|
+
if (!value || typeof value !== "object") return []
|
|
91
|
+
return Object.values(value).filter(diff)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface SessionReviewProps {
|
|
95
|
+
title?: JSX.Element
|
|
96
|
+
empty?: JSX.Element
|
|
97
|
+
split?: boolean
|
|
98
|
+
diffStyle?: SessionReviewDiffStyle
|
|
99
|
+
onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
|
|
100
|
+
onDiffRendered?: VoidFunction
|
|
101
|
+
onLineComment?: (comment: SessionReviewLineComment) => void
|
|
102
|
+
onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
|
|
103
|
+
onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
|
|
104
|
+
lineCommentActions?: SessionReviewCommentActions
|
|
105
|
+
comments?: SessionReviewComment[]
|
|
106
|
+
focusedComment?: SessionReviewFocus | null
|
|
107
|
+
onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void
|
|
108
|
+
focusedFile?: string
|
|
109
|
+
open?: string[]
|
|
110
|
+
onOpenChange?: (open: string[]) => void
|
|
111
|
+
scrollRef?: (el: HTMLDivElement) => void
|
|
112
|
+
onScroll?: JSX.EventHandlerUnion<HTMLDivElement, Event>
|
|
113
|
+
class?: string
|
|
114
|
+
classList?: Record<string, boolean | undefined>
|
|
115
|
+
classes?: { root?: string; header?: string; container?: string }
|
|
116
|
+
actions?: JSX.Element
|
|
117
|
+
diffs: RawReviewDiff[]
|
|
118
|
+
onViewFile?: (file: string) => void
|
|
119
|
+
readFile?: (path: string) => Promise<FileContent | undefined>
|
|
120
|
+
lineCommentMention?: LineCommentEditorProps["mention"]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function ReviewCommentMenu(props: {
|
|
124
|
+
labels: SessionReviewCommentActions
|
|
125
|
+
onEdit: VoidFunction
|
|
126
|
+
onDelete: VoidFunction
|
|
127
|
+
}) {
|
|
128
|
+
return (
|
|
129
|
+
<div onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
|
|
130
|
+
<DropdownMenu gutter={4} placement="bottom-end">
|
|
131
|
+
<DropdownMenu.Trigger
|
|
132
|
+
as={IconButton}
|
|
133
|
+
icon="dot-grid"
|
|
134
|
+
variant="ghost"
|
|
135
|
+
size="small"
|
|
136
|
+
class="size-6 rounded-md"
|
|
137
|
+
aria-label={props.labels.moreLabel}
|
|
138
|
+
/>
|
|
139
|
+
<DropdownMenu.Portal>
|
|
140
|
+
<DropdownMenu.Content>
|
|
141
|
+
<DropdownMenu.Item onSelect={props.onEdit}>
|
|
142
|
+
<DropdownMenu.ItemLabel>{props.labels.editLabel}</DropdownMenu.ItemLabel>
|
|
143
|
+
</DropdownMenu.Item>
|
|
144
|
+
<DropdownMenu.Item onSelect={props.onDelete}>
|
|
145
|
+
<DropdownMenu.ItemLabel>{props.labels.deleteLabel}</DropdownMenu.ItemLabel>
|
|
146
|
+
</DropdownMenu.Item>
|
|
147
|
+
</DropdownMenu.Content>
|
|
148
|
+
</DropdownMenu.Portal>
|
|
149
|
+
</DropdownMenu>
|
|
150
|
+
</div>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function diffId(file: string): string | undefined {
|
|
155
|
+
const sum = checksum(file)
|
|
156
|
+
if (!sum) return
|
|
157
|
+
return `session-review-diff-${sum}`
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
type SessionReviewSelection = {
|
|
161
|
+
file: string
|
|
162
|
+
range: SelectedLineRange
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const SessionReview = (props: SessionReviewProps) => {
|
|
166
|
+
let scroll: HTMLDivElement | undefined
|
|
167
|
+
let focusToken = 0
|
|
168
|
+
let frame: number | undefined
|
|
169
|
+
const i18n = useI18n()
|
|
170
|
+
const fileComponent = useFileComponent()
|
|
171
|
+
const anchors = new Map<string, HTMLElement>()
|
|
172
|
+
const nodes = new Map<string, HTMLDivElement>()
|
|
173
|
+
const [store, setStore] = createStore({
|
|
174
|
+
open: [] as string[],
|
|
175
|
+
visible: {} as Record<string, boolean>,
|
|
176
|
+
force: {} as Record<string, boolean>,
|
|
177
|
+
selection: null as SessionReviewSelection | null,
|
|
178
|
+
commenting: null as SessionReviewSelection | null,
|
|
179
|
+
opened: null as SessionReviewFocus | null,
|
|
180
|
+
})
|
|
181
|
+
const selection = () => store.selection
|
|
182
|
+
const commenting = () => store.commenting
|
|
183
|
+
const opened = () => store.opened
|
|
184
|
+
|
|
185
|
+
const open = () => props.open ?? store.open
|
|
186
|
+
const itemsMap = createMemo(() =>
|
|
187
|
+
Object.fromEntries(list(props.diffs).map((diff) => [diff.file, { ...normalize(diff), preloaded: diff.preloaded }])),
|
|
188
|
+
)
|
|
189
|
+
const files = createMemo(() => props.diffs.map((diff) => diff.file!))
|
|
190
|
+
const grouped = createMemo(() => {
|
|
191
|
+
const next = new Map<string, SessionReviewComment[]>()
|
|
192
|
+
for (const comment of props.comments ?? []) {
|
|
193
|
+
const list = next.get(comment.file)
|
|
194
|
+
if (list) {
|
|
195
|
+
list.push(comment)
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
next.set(comment.file, [comment])
|
|
199
|
+
}
|
|
200
|
+
return next
|
|
201
|
+
})
|
|
202
|
+
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
|
|
203
|
+
const hasDiffs = () => files().length > 0
|
|
204
|
+
|
|
205
|
+
const syncVisible = () => {
|
|
206
|
+
frame = undefined
|
|
207
|
+
if (!scroll) return
|
|
208
|
+
|
|
209
|
+
const root = scroll.getBoundingClientRect()
|
|
210
|
+
const top = root.top - REVIEW_MOUNT_MARGIN
|
|
211
|
+
const bottom = root.bottom + REVIEW_MOUNT_MARGIN
|
|
212
|
+
const openSet = new Set(open())
|
|
213
|
+
const next: Record<string, boolean> = {}
|
|
214
|
+
|
|
215
|
+
for (const [file, el] of nodes) {
|
|
216
|
+
if (!openSet.has(file)) continue
|
|
217
|
+
const rect = el.getBoundingClientRect()
|
|
218
|
+
if (rect.bottom < top || rect.top > bottom) continue
|
|
219
|
+
next[file] = true
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const prev = untrack(() => store.visible)
|
|
223
|
+
const prevKeys = Object.keys(prev)
|
|
224
|
+
const nextKeys = Object.keys(next)
|
|
225
|
+
if (prevKeys.length === nextKeys.length && nextKeys.every((file) => prev[file])) return
|
|
226
|
+
setStore("visible", next)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const queue = () => {
|
|
230
|
+
if (frame !== undefined) return
|
|
231
|
+
frame = requestAnimationFrame(syncVisible)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const pinned = (file: string) =>
|
|
235
|
+
props.focusedComment?.file === file ||
|
|
236
|
+
props.focusedFile === file ||
|
|
237
|
+
selection()?.file === file ||
|
|
238
|
+
commenting()?.file === file ||
|
|
239
|
+
opened()?.file === file
|
|
240
|
+
|
|
241
|
+
const handleScroll: JSX.EventHandler<HTMLDivElement, Event> = (event) => {
|
|
242
|
+
queue()
|
|
243
|
+
const next = props.onScroll
|
|
244
|
+
if (!next) return
|
|
245
|
+
if (Array.isArray(next)) {
|
|
246
|
+
const [fn, data] = next as [(data: unknown, event: Event) => void, unknown]
|
|
247
|
+
fn(data, event)
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
;(next as JSX.EventHandler<HTMLDivElement, Event>)(event)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
onCleanup(() => {
|
|
254
|
+
if (frame === undefined) return
|
|
255
|
+
cancelAnimationFrame(frame)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
createEffect(() => {
|
|
259
|
+
props.open
|
|
260
|
+
files()
|
|
261
|
+
queue()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
const handleChange = (next: string[]) => {
|
|
265
|
+
props.onOpenChange?.(next)
|
|
266
|
+
if (props.open === undefined) setStore("open", next)
|
|
267
|
+
queue()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const handleExpandOrCollapseAll = () => {
|
|
271
|
+
const next = open().length > 0 ? [] : files()
|
|
272
|
+
handleChange(next)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const openFileLabel = () => i18n.t("ui.sessionReview.openFile")
|
|
276
|
+
|
|
277
|
+
const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
|
|
278
|
+
|
|
279
|
+
const selectionPreview = (diff: ViewDiff, range: SelectedLineRange) => {
|
|
280
|
+
const side = selectionSide(range)
|
|
281
|
+
const contents = text(diff, side)
|
|
282
|
+
if (contents.length === 0) return undefined
|
|
283
|
+
|
|
284
|
+
return previewSelectedLines(contents, range)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
createEffect(() => {
|
|
288
|
+
const focus = props.focusedComment
|
|
289
|
+
if (!focus) return
|
|
290
|
+
|
|
291
|
+
untrack(() => {
|
|
292
|
+
focusToken++
|
|
293
|
+
const token = focusToken
|
|
294
|
+
|
|
295
|
+
setStore("opened", focus)
|
|
296
|
+
|
|
297
|
+
const comment = (props.comments ?? []).find((c) => c.file === focus.file && c.id === focus.id)
|
|
298
|
+
if (comment) setStore("selection", { file: comment.file, range: cloneSelectedLineRange(comment.selection) })
|
|
299
|
+
|
|
300
|
+
const current = open()
|
|
301
|
+
if (!current.includes(focus.file)) {
|
|
302
|
+
handleChange([...current, focus.file])
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const scrollTo = (attempt: number) => {
|
|
306
|
+
if (token !== focusToken) return
|
|
307
|
+
|
|
308
|
+
const root = scroll
|
|
309
|
+
if (!root) return
|
|
310
|
+
|
|
311
|
+
const wrapper = anchors.get(focus.file)
|
|
312
|
+
const anchor = wrapper?.querySelector(`[data-comment-id="${focus.id}"]`)
|
|
313
|
+
const ready =
|
|
314
|
+
anchor instanceof HTMLElement && anchor.style.pointerEvents !== "none" && anchor.style.opacity !== "0"
|
|
315
|
+
|
|
316
|
+
const target = ready ? anchor : wrapper
|
|
317
|
+
if (!target) {
|
|
318
|
+
if (attempt >= 120) return
|
|
319
|
+
requestAnimationFrame(() => scrollTo(attempt + 1))
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const rootRect = root.getBoundingClientRect()
|
|
324
|
+
const targetRect = target.getBoundingClientRect()
|
|
325
|
+
const offset = targetRect.top - rootRect.top
|
|
326
|
+
const next = root.scrollTop + offset - rootRect.height / 2 + targetRect.height / 2
|
|
327
|
+
root.scrollTop = Math.max(0, next)
|
|
328
|
+
|
|
329
|
+
if (ready) return
|
|
330
|
+
if (attempt >= 120) return
|
|
331
|
+
requestAnimationFrame(() => scrollTo(attempt + 1))
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
requestAnimationFrame(() => scrollTo(0))
|
|
335
|
+
|
|
336
|
+
requestAnimationFrame(() => props.onFocusedCommentChange?.(null))
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
return (
|
|
341
|
+
<div data-component="session-review" class={props.class} classList={props.classList}>
|
|
342
|
+
<div data-slot="session-review-header" class={props.classes?.header}>
|
|
343
|
+
<div data-slot="session-review-title">
|
|
344
|
+
{props.title === undefined ? i18n.t("ui.sessionReview.title") : props.title}
|
|
345
|
+
</div>
|
|
346
|
+
<div data-slot="session-review-actions">
|
|
347
|
+
<Show when={hasDiffs() && props.onDiffStyleChange}>
|
|
348
|
+
<RadioGroup
|
|
349
|
+
options={["unified", "split"] as const}
|
|
350
|
+
current={diffStyle()}
|
|
351
|
+
size="small"
|
|
352
|
+
value={(style) => style}
|
|
353
|
+
label={(style) =>
|
|
354
|
+
i18n.t(style === "unified" ? "ui.sessionReview.diffStyle.unified" : "ui.sessionReview.diffStyle.split")
|
|
355
|
+
}
|
|
356
|
+
onSelect={(style) => style && props.onDiffStyleChange?.(style)}
|
|
357
|
+
/>
|
|
358
|
+
</Show>
|
|
359
|
+
<Show when={hasDiffs()}>
|
|
360
|
+
<Button
|
|
361
|
+
size="small"
|
|
362
|
+
icon="chevron-grabber-vertical"
|
|
363
|
+
class="w-[106px] justify-start"
|
|
364
|
+
onClick={handleExpandOrCollapseAll}
|
|
365
|
+
>
|
|
366
|
+
<Switch>
|
|
367
|
+
<Match when={open().length > 0}>{i18n.t("ui.sessionReview.collapseAll")}</Match>
|
|
368
|
+
<Match when={true}>{i18n.t("ui.sessionReview.expandAll")}</Match>
|
|
369
|
+
</Switch>
|
|
370
|
+
</Button>
|
|
371
|
+
</Show>
|
|
372
|
+
{props.actions}
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
|
|
376
|
+
<ScrollView
|
|
377
|
+
data-slot="session-review-scroll"
|
|
378
|
+
viewportRef={(el) => {
|
|
379
|
+
scroll = el
|
|
380
|
+
props.scrollRef?.(el)
|
|
381
|
+
queue()
|
|
382
|
+
}}
|
|
383
|
+
onScroll={handleScroll}
|
|
384
|
+
classList={{
|
|
385
|
+
[props.classes?.root ?? ""]: !!props.classes?.root,
|
|
386
|
+
}}
|
|
387
|
+
>
|
|
388
|
+
<div data-slot="session-review-container" class={props.classes?.container}>
|
|
389
|
+
<Show when={hasDiffs()} fallback={props.empty}>
|
|
390
|
+
<div data-slot="session-review-list" class="pb-6">
|
|
391
|
+
<Accordion multiple value={open()} onChange={handleChange}>
|
|
392
|
+
<For each={files()}>
|
|
393
|
+
{(file) => {
|
|
394
|
+
const diff = () => itemsMap()[file]
|
|
395
|
+
|
|
396
|
+
// binary files have empty diffs that we can't render
|
|
397
|
+
const diffCanRender = () => diff().additions !== 0 || diff().deletions !== 0
|
|
398
|
+
|
|
399
|
+
const expanded = createMemo(() => open().includes(file))
|
|
400
|
+
const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file)))
|
|
401
|
+
const force = () => !!store.force[file]
|
|
402
|
+
|
|
403
|
+
const comments = createMemo(() => grouped().get(file) ?? [])
|
|
404
|
+
const commentedLines = createMemo(() => comments().map((c) => c.selection))
|
|
405
|
+
|
|
406
|
+
const beforeText = () => text(diff(), "deletions")
|
|
407
|
+
const afterText = () => text(diff(), "additions")
|
|
408
|
+
const changedLines = () => diff().additions + diff().deletions
|
|
409
|
+
const mediaKind = createMemo(() => mediaKindFromPath(file))
|
|
410
|
+
|
|
411
|
+
const tooLarge = createMemo(() => {
|
|
412
|
+
if (!expanded()) return false
|
|
413
|
+
if (force()) return false
|
|
414
|
+
if (mediaKind()) return false
|
|
415
|
+
return changedLines() > MAX_DIFF_CHANGED_LINES
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
const isAdded = () =>
|
|
419
|
+
diff().status === "added" || (beforeText().length === 0 && afterText().length > 0)
|
|
420
|
+
const isDeleted = () =>
|
|
421
|
+
diff().status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
|
|
422
|
+
|
|
423
|
+
const selectedLines = createMemo(() => {
|
|
424
|
+
const current = selection()
|
|
425
|
+
if (!current || current.file !== file) return null
|
|
426
|
+
return current.range
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
const draftRange = createMemo(() => {
|
|
430
|
+
const current = commenting()
|
|
431
|
+
if (!current || current.file !== file) return null
|
|
432
|
+
return current.range
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
const commentsUi = createLineCommentController<SessionReviewComment>({
|
|
436
|
+
comments,
|
|
437
|
+
label: i18n.t("ui.lineComment.submit"),
|
|
438
|
+
draftKey: () => file,
|
|
439
|
+
mention: props.lineCommentMention,
|
|
440
|
+
state: {
|
|
441
|
+
opened: () => {
|
|
442
|
+
const current = opened()
|
|
443
|
+
if (!current || current.file !== file) return null
|
|
444
|
+
return current.id
|
|
445
|
+
},
|
|
446
|
+
setOpened: (id) => setStore("opened", id ? { file, id } : null),
|
|
447
|
+
selected: selectedLines,
|
|
448
|
+
setSelected: (range) => setStore("selection", range ? { file, range } : null),
|
|
449
|
+
commenting: draftRange,
|
|
450
|
+
setCommenting: (range) => setStore("commenting", range ? { file, range } : null),
|
|
451
|
+
},
|
|
452
|
+
getSide: selectionSide,
|
|
453
|
+
clearSelectionOnSelectionEndNull: false,
|
|
454
|
+
onSubmit: ({ comment, selection }) => {
|
|
455
|
+
props.onLineComment?.({
|
|
456
|
+
file,
|
|
457
|
+
selection,
|
|
458
|
+
comment,
|
|
459
|
+
preview: selectionPreview(diff(), selection),
|
|
460
|
+
})
|
|
461
|
+
},
|
|
462
|
+
onUpdate: ({ id, comment, selection }) => {
|
|
463
|
+
props.onLineCommentUpdate?.({
|
|
464
|
+
id,
|
|
465
|
+
file,
|
|
466
|
+
selection,
|
|
467
|
+
comment,
|
|
468
|
+
preview: selectionPreview(diff(), selection),
|
|
469
|
+
})
|
|
470
|
+
},
|
|
471
|
+
onDelete: (comment) => {
|
|
472
|
+
props.onLineCommentDelete?.({
|
|
473
|
+
id: comment.id,
|
|
474
|
+
file,
|
|
475
|
+
})
|
|
476
|
+
},
|
|
477
|
+
editSubmitLabel: props.lineCommentActions?.saveLabel,
|
|
478
|
+
renderCommentActions: props.lineCommentActions
|
|
479
|
+
? (comment, controls) => (
|
|
480
|
+
<ReviewCommentMenu
|
|
481
|
+
labels={props.lineCommentActions!}
|
|
482
|
+
onEdit={controls.edit}
|
|
483
|
+
onDelete={controls.remove}
|
|
484
|
+
/>
|
|
485
|
+
)
|
|
486
|
+
: undefined,
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
onCleanup(() => {
|
|
490
|
+
anchors.delete(file)
|
|
491
|
+
nodes.delete(file)
|
|
492
|
+
queue()
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
const handleLineSelected = (range: SelectedLineRange | null) => {
|
|
496
|
+
if (!props.onLineComment) return
|
|
497
|
+
commentsUi.onLineSelected(range)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
|
|
501
|
+
if (!props.onLineComment) return
|
|
502
|
+
commentsUi.onLineSelectionEnd(range)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return (
|
|
506
|
+
<Accordion.Item
|
|
507
|
+
value={diffCanRender() ? file : null!}
|
|
508
|
+
id={diffId(file)}
|
|
509
|
+
data-file={file}
|
|
510
|
+
data-slot="session-review-accordion-item"
|
|
511
|
+
data-selected={props.focusedFile === file ? "" : undefined}
|
|
512
|
+
>
|
|
513
|
+
<StickyAccordionHeader>
|
|
514
|
+
<Accordion.Trigger disabled={!diffCanRender()} class="cursor-default">
|
|
515
|
+
<div data-slot="session-review-trigger-content">
|
|
516
|
+
<div data-slot="session-review-file-info">
|
|
517
|
+
<FileIcon node={{ path: file, type: "file" }} />
|
|
518
|
+
<div data-slot="session-review-file-name-container">
|
|
519
|
+
<Show when={file.includes("/")}>
|
|
520
|
+
<span data-slot="session-review-directory">{`\u202A${getDirectory(file)}\u202C`}</span>
|
|
521
|
+
</Show>
|
|
522
|
+
<span data-slot="session-review-filename">{getFilename(file)}</span>
|
|
523
|
+
<Show when={props.onViewFile && diffCanRender()}>
|
|
524
|
+
<Tooltip value={openFileLabel()} placement="top" gutter={4}>
|
|
525
|
+
<button
|
|
526
|
+
data-slot="session-review-view-button"
|
|
527
|
+
type="button"
|
|
528
|
+
aria-label={openFileLabel()}
|
|
529
|
+
onClick={(e) => {
|
|
530
|
+
e.stopPropagation()
|
|
531
|
+
props.onViewFile?.(file)
|
|
532
|
+
}}
|
|
533
|
+
>
|
|
534
|
+
<Icon name="open-file" size="small" />
|
|
535
|
+
</button>
|
|
536
|
+
</Tooltip>
|
|
537
|
+
</Show>
|
|
538
|
+
</div>
|
|
539
|
+
</div>
|
|
540
|
+
<div data-slot="session-review-trigger-actions">
|
|
541
|
+
<Switch>
|
|
542
|
+
<Match when={isAdded()}>
|
|
543
|
+
<div data-slot="session-review-change-group" data-type="added">
|
|
544
|
+
<span data-slot="session-review-change" data-type="added">
|
|
545
|
+
{i18n.t("ui.sessionReview.change.added")}
|
|
546
|
+
</span>
|
|
547
|
+
<DiffChanges changes={diff()} />
|
|
548
|
+
</div>
|
|
549
|
+
</Match>
|
|
550
|
+
<Match when={isDeleted()}>
|
|
551
|
+
<span data-slot="session-review-change" data-type="removed">
|
|
552
|
+
{i18n.t("ui.sessionReview.change.removed")}
|
|
553
|
+
</span>
|
|
554
|
+
</Match>
|
|
555
|
+
<Match when={!!mediaKind()}>
|
|
556
|
+
<span data-slot="session-review-change" data-type="modified">
|
|
557
|
+
{i18n.t("ui.sessionReview.change.modified")}
|
|
558
|
+
</span>
|
|
559
|
+
</Match>
|
|
560
|
+
<Match when={true}>
|
|
561
|
+
<DiffChanges changes={diff()} />
|
|
562
|
+
</Match>
|
|
563
|
+
</Switch>
|
|
564
|
+
<Show when={diffCanRender()}>
|
|
565
|
+
<span data-slot="session-review-diff-chevron">
|
|
566
|
+
<Icon name="chevron-down" size="small" />
|
|
567
|
+
</span>
|
|
568
|
+
</Show>
|
|
569
|
+
</div>
|
|
570
|
+
</div>
|
|
571
|
+
</Accordion.Trigger>
|
|
572
|
+
</StickyAccordionHeader>
|
|
573
|
+
<Accordion.Content data-slot="session-review-accordion-content">
|
|
574
|
+
<div
|
|
575
|
+
data-slot="session-review-diff-wrapper"
|
|
576
|
+
ref={(el) => {
|
|
577
|
+
anchors.set(file, el)
|
|
578
|
+
nodes.set(file, el)
|
|
579
|
+
queue()
|
|
580
|
+
}}
|
|
581
|
+
>
|
|
582
|
+
<Show when={expanded()}>
|
|
583
|
+
<Switch>
|
|
584
|
+
<Match when={!mounted() && !tooLarge()}>
|
|
585
|
+
<div
|
|
586
|
+
data-slot="session-review-diff-placeholder"
|
|
587
|
+
class="rounded-lg border border-border-weak-base bg-background-stronger/40"
|
|
588
|
+
style={{ height: "160px" }}
|
|
589
|
+
/>
|
|
590
|
+
</Match>
|
|
591
|
+
<Match when={tooLarge()}>
|
|
592
|
+
<div data-slot="session-review-large-diff">
|
|
593
|
+
<div data-slot="session-review-large-diff-title">
|
|
594
|
+
{i18n.t("ui.sessionReview.largeDiff.title")}
|
|
595
|
+
</div>
|
|
596
|
+
<div data-slot="session-review-large-diff-meta">
|
|
597
|
+
{i18n.t("ui.sessionReview.largeDiff.meta", {
|
|
598
|
+
limit: MAX_DIFF_CHANGED_LINES.toLocaleString(),
|
|
599
|
+
current: changedLines().toLocaleString(),
|
|
600
|
+
})}
|
|
601
|
+
</div>
|
|
602
|
+
<div data-slot="session-review-large-diff-actions">
|
|
603
|
+
<Button
|
|
604
|
+
size="normal"
|
|
605
|
+
variant="secondary"
|
|
606
|
+
onClick={() => setStore("force", file, true)}
|
|
607
|
+
>
|
|
608
|
+
{i18n.t("ui.sessionReview.largeDiff.renderAnyway")}
|
|
609
|
+
</Button>
|
|
610
|
+
</div>
|
|
611
|
+
</div>
|
|
612
|
+
</Match>
|
|
613
|
+
<Match when={true}>
|
|
614
|
+
<Dynamic
|
|
615
|
+
component={fileComponent}
|
|
616
|
+
mode="diff"
|
|
617
|
+
fileDiff={diff().fileDiff}
|
|
618
|
+
preloadedDiff={diff().preloaded}
|
|
619
|
+
diffStyle={diffStyle()}
|
|
620
|
+
onRendered={() => {
|
|
621
|
+
props.onDiffRendered?.()
|
|
622
|
+
}}
|
|
623
|
+
enableLineSelection={props.onLineComment != null}
|
|
624
|
+
enableGutterUtility={props.onLineComment != null}
|
|
625
|
+
onLineSelected={handleLineSelected}
|
|
626
|
+
onLineSelectionEnd={handleLineSelectionEnd}
|
|
627
|
+
annotations={commentsUi.annotations()}
|
|
628
|
+
renderAnnotation={commentsUi.renderAnnotation}
|
|
629
|
+
renderGutterUtility={
|
|
630
|
+
props.onLineComment ? commentsUi.renderGutterUtility : undefined
|
|
631
|
+
}
|
|
632
|
+
selectedLines={selectedLines()}
|
|
633
|
+
commentedLines={commentedLines()}
|
|
634
|
+
media={{
|
|
635
|
+
mode: "auto",
|
|
636
|
+
path: file,
|
|
637
|
+
deleted: diff().status === "deleted",
|
|
638
|
+
readFile: diff().status === "deleted" ? undefined : props.readFile,
|
|
639
|
+
}}
|
|
640
|
+
/>
|
|
641
|
+
</Match>
|
|
642
|
+
</Switch>
|
|
643
|
+
</Show>
|
|
644
|
+
</div>
|
|
645
|
+
</Accordion.Content>
|
|
646
|
+
</Accordion.Item>
|
|
647
|
+
)
|
|
648
|
+
}}
|
|
649
|
+
</For>
|
|
650
|
+
</Accordion>
|
|
651
|
+
</div>
|
|
652
|
+
</Show>
|
|
653
|
+
</div>
|
|
654
|
+
</ScrollView>
|
|
655
|
+
</div>
|
|
656
|
+
)
|
|
657
|
+
}
|