|
|
@@ -77,6 +77,7 @@ interface SessionReviewTabProps {
|
|
|
comments?: LineComment[]
|
|
|
focusedComment?: { file: string; id: string } | null
|
|
|
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
|
|
|
+ onScrollRef?: (el: HTMLDivElement) => void
|
|
|
classes?: {
|
|
|
root?: string
|
|
|
header?: string
|
|
|
@@ -146,6 +147,7 @@ function SessionReviewTab(props: SessionReviewTabProps) {
|
|
|
<SessionReview
|
|
|
scrollRef={(el) => {
|
|
|
scroll = el
|
|
|
+ props.onScrollRef?.(el)
|
|
|
restoreScroll()
|
|
|
}}
|
|
|
onScroll={handleScroll}
|
|
|
@@ -1015,8 +1017,71 @@ export default function Page() {
|
|
|
|
|
|
const showTabs = createMemo(() => view().reviewPanel.opened())
|
|
|
|
|
|
+ const [fileTreeTab, setFileTreeTab] = createSignal<"changes" | "all">("changes")
|
|
|
+ const [reviewScroll, setReviewScroll] = createSignal<HTMLDivElement | undefined>(undefined)
|
|
|
+ const [pendingDiff, setPendingDiff] = createSignal<string | undefined>(undefined)
|
|
|
+
|
|
|
+ createEffect(() => {
|
|
|
+ if (!layout.fileTree.opened()) return
|
|
|
+ setFileTreeTab("changes")
|
|
|
+ })
|
|
|
+
|
|
|
+ const setFileTreeTabValue = (value: string) => {
|
|
|
+ if (value !== "changes" && value !== "all") return
|
|
|
+ setFileTreeTab(value)
|
|
|
+ }
|
|
|
+
|
|
|
+ const reviewDiffId = (path: string) => {
|
|
|
+ const sum = checksum(path)
|
|
|
+ if (!sum) return
|
|
|
+ return `session-review-diff-${sum}`
|
|
|
+ }
|
|
|
+
|
|
|
+ const scrollToReviewDiff = (path: string, behavior: ScrollBehavior) => {
|
|
|
+ const root = reviewScroll()
|
|
|
+ if (!root) return
|
|
|
+
|
|
|
+ const id = reviewDiffId(path)
|
|
|
+ if (!id) return
|
|
|
+
|
|
|
+ const el = document.getElementById(id)
|
|
|
+ if (!(el instanceof HTMLElement)) return
|
|
|
+ if (!root.contains(el)) return
|
|
|
+
|
|
|
+ const a = el.getBoundingClientRect()
|
|
|
+ const b = root.getBoundingClientRect()
|
|
|
+ const top = a.top - b.top + root.scrollTop
|
|
|
+ root.scrollTo({ top, behavior })
|
|
|
+ }
|
|
|
+
|
|
|
+ const focusReviewDiff = (path: string) => {
|
|
|
+ const current = view().review.open() ?? []
|
|
|
+ if (!current.includes(path)) view().review.setOpen([...current, path])
|
|
|
+ setPendingDiff(path)
|
|
|
+ requestAnimationFrame(() => scrollToReviewDiff(path, "smooth"))
|
|
|
+ }
|
|
|
+
|
|
|
+ createEffect(() => {
|
|
|
+ const pending = pendingDiff()
|
|
|
+ if (!pending) return
|
|
|
+ if (!reviewScroll()) return
|
|
|
+ if (!diffsReady()) return
|
|
|
+
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ scrollToReviewDiff(pending, "smooth")
|
|
|
+ setPendingDiff(undefined)
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
const activeTab = createMemo(() => {
|
|
|
const active = tabs().active()
|
|
|
+ if (layout.fileTree.opened() && fileTreeTab() === "all") {
|
|
|
+ if (active && active !== "review" && active !== "context") return normalizeTab(active)
|
|
|
+
|
|
|
+ const first = openedTabs()[0]
|
|
|
+ if (first) return first
|
|
|
+ return "review"
|
|
|
+ }
|
|
|
if (active) return normalizeTab(active)
|
|
|
if (hasReview()) return "review"
|
|
|
|
|
|
@@ -1033,12 +1098,27 @@ export default function Page() {
|
|
|
tabs().setActive(activeTab())
|
|
|
})
|
|
|
|
|
|
+ createEffect(() => {
|
|
|
+ if (!layout.fileTree.opened()) return
|
|
|
+ if (fileTreeTab() !== "all") return
|
|
|
+
|
|
|
+ const first = openedTabs()[0]
|
|
|
+ if (!first) return
|
|
|
+
|
|
|
+ const active = tabs().active()
|
|
|
+ if (active && active !== "review" && active !== "context") return
|
|
|
+ tabs().setActive(first)
|
|
|
+ })
|
|
|
+
|
|
|
createEffect(() => {
|
|
|
const id = params.id
|
|
|
if (!id) return
|
|
|
if (!hasReview()) return
|
|
|
|
|
|
- const wants = isDesktop() ? view().reviewPanel.opened() && activeTab() === "review" : store.mobileTab === "review"
|
|
|
+ const wants = isDesktop()
|
|
|
+ ? view().reviewPanel.opened() &&
|
|
|
+ (layout.fileTree.opened() ? fileTreeTab() === "changes" : activeTab() === "review")
|
|
|
+ : store.mobileTab === "review"
|
|
|
if (!wants) return
|
|
|
if (diffsReady()) return
|
|
|
|
|
|
@@ -1814,672 +1894,762 @@ export default function Page() {
|
|
|
aria-label={language.t("session.panel.reviewAndFiles")}
|
|
|
class="relative flex-1 min-w-0 h-full border-l border-border-weak-base flex"
|
|
|
>
|
|
|
- <Show when={layout.fileTree.opened()}>
|
|
|
- <div class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
|
|
|
- <div class="h-full bg-background-base border-r border-border-weak-base flex flex-col">
|
|
|
- <div class="hidden h-12 shrink-0 flex items-center px-3 border-b border-border-weak-base text-12-medium text-text-weak">
|
|
|
- Files
|
|
|
- </div>
|
|
|
- <div class="flex-1 min-h-0 overflow-y-auto no-scrollbar p-2">
|
|
|
- <FileTree path="" onFileClick={(node) => openTab(file.tab(node.path))} />
|
|
|
+ <div class="flex-1 min-w-0 h-full">
|
|
|
+ <Show when={layout.fileTree.opened() && fileTreeTab() === "changes"}>
|
|
|
+ <div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
|
|
|
+ <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
|
|
+ <Switch>
|
|
|
+ <Match when={hasReview()}>
|
|
|
+ <Show
|
|
|
+ when={diffsReady()}
|
|
|
+ fallback={
|
|
|
+ <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <SessionReviewTab
|
|
|
+ diffs={diffs}
|
|
|
+ view={view}
|
|
|
+ diffStyle={layout.review.diffStyle()}
|
|
|
+ onDiffStyleChange={layout.review.setDiffStyle}
|
|
|
+ onScrollRef={setReviewScroll}
|
|
|
+ onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
|
|
+ comments={comments.all()}
|
|
|
+ focusedComment={comments.focus()}
|
|
|
+ onFocusedCommentChange={comments.setFocus}
|
|
|
+ onViewFile={(path) => {
|
|
|
+ const value = file.tab(path)
|
|
|
+ tabs().open(value)
|
|
|
+ file.load(path)
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </Show>
|
|
|
+ </Match>
|
|
|
+ <Match when={true}>
|
|
|
+ <div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
|
|
|
+ <Mark class="w-14 opacity-10" />
|
|
|
+ <div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div>
|
|
|
+ </div>
|
|
|
+ </Match>
|
|
|
+ </Switch>
|
|
|
</div>
|
|
|
</div>
|
|
|
- <ResizeHandle
|
|
|
- direction="horizontal"
|
|
|
- size={layout.fileTree.width()}
|
|
|
- min={200}
|
|
|
- max={480}
|
|
|
- collapseThreshold={160}
|
|
|
- onResize={layout.fileTree.resize}
|
|
|
- onCollapse={layout.fileTree.close}
|
|
|
- />
|
|
|
- </div>
|
|
|
- </Show>
|
|
|
- <DragDropProvider
|
|
|
- onDragStart={handleDragStart}
|
|
|
- onDragEnd={handleDragEnd}
|
|
|
- onDragOver={handleDragOver}
|
|
|
- collisionDetector={closestCenter}
|
|
|
- >
|
|
|
- <DragDropSensors />
|
|
|
- <ConstrainDragYAxis />
|
|
|
- <Tabs value={activeTab()} onChange={openTab}>
|
|
|
- <div class="sticky top-0 shrink-0 flex">
|
|
|
- <Tabs.List>
|
|
|
- <Show when={true}>
|
|
|
- <Tabs.Trigger value="review">
|
|
|
- <div class="flex items-center gap-3">
|
|
|
- <Show when={diffs()}>
|
|
|
- <DiffChanges changes={diffs()} variant="bars" />
|
|
|
- </Show>
|
|
|
- <div class="flex items-center gap-1.5">
|
|
|
- <div>{language.t("session.tab.review")}</div>
|
|
|
- <Show when={info()?.summary?.files}>
|
|
|
- <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
|
|
|
- {info()?.summary?.files ?? 0}
|
|
|
+ </Show>
|
|
|
+
|
|
|
+ <Show when={!layout.fileTree.opened() || fileTreeTab() === "all"}>
|
|
|
+ <DragDropProvider
|
|
|
+ onDragStart={handleDragStart}
|
|
|
+ onDragEnd={handleDragEnd}
|
|
|
+ onDragOver={handleDragOver}
|
|
|
+ collisionDetector={closestCenter}
|
|
|
+ >
|
|
|
+ <DragDropSensors />
|
|
|
+ <ConstrainDragYAxis />
|
|
|
+ <Tabs value={activeTab()} onChange={openTab}>
|
|
|
+ <div class="sticky top-0 shrink-0 flex">
|
|
|
+ <Tabs.List>
|
|
|
+ <Show when={!layout.fileTree.opened()}>
|
|
|
+ <Tabs.Trigger value="review">
|
|
|
+ <div class="flex items-center gap-3">
|
|
|
+ <Show when={diffs()}>
|
|
|
+ <DiffChanges changes={diffs()} variant="bars" />
|
|
|
+ </Show>
|
|
|
+ <div class="flex items-center gap-1.5">
|
|
|
+ <div>{language.t("session.tab.review")}</div>
|
|
|
+ <Show when={info()?.summary?.files}>
|
|
|
+ <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
|
|
|
+ {info()?.summary?.files ?? 0}
|
|
|
+ </div>
|
|
|
+ </Show>
|
|
|
</div>
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </Tabs.Trigger>
|
|
|
- </Show>
|
|
|
- <Show when={contextOpen()}>
|
|
|
- <Tabs.Trigger
|
|
|
- value="context"
|
|
|
- closeButton={
|
|
|
- <Tooltip value={language.t("common.closeTab")} placement="bottom">
|
|
|
+ </div>
|
|
|
+ </Tabs.Trigger>
|
|
|
+ </Show>
|
|
|
+ <Show when={!layout.fileTree.opened() && contextOpen()}>
|
|
|
+ <Tabs.Trigger
|
|
|
+ value="context"
|
|
|
+ closeButton={
|
|
|
+ <Tooltip value={language.t("common.closeTab")} placement="bottom">
|
|
|
+ <IconButton
|
|
|
+ icon="close"
|
|
|
+ variant="ghost"
|
|
|
+ onClick={() => tabs().close("context")}
|
|
|
+ aria-label={language.t("common.closeTab")}
|
|
|
+ />
|
|
|
+ </Tooltip>
|
|
|
+ }
|
|
|
+ hideCloseButton
|
|
|
+ onMiddleClick={() => tabs().close("context")}
|
|
|
+ >
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <SessionContextUsage variant="indicator" />
|
|
|
+ <div>{language.t("session.tab.context")}</div>
|
|
|
+ </div>
|
|
|
+ </Tabs.Trigger>
|
|
|
+ </Show>
|
|
|
+ <SortableProvider ids={openedTabs()}>
|
|
|
+ <For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
|
|
|
+ </SortableProvider>
|
|
|
+ <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
|
|
|
+ <TooltipKeybind
|
|
|
+ title={language.t("command.file.open")}
|
|
|
+ keybind={command.keybind("file.open")}
|
|
|
+ class="flex items-center"
|
|
|
+ >
|
|
|
<IconButton
|
|
|
- icon="close"
|
|
|
+ icon="plus-small"
|
|
|
variant="ghost"
|
|
|
- onClick={() => tabs().close("context")}
|
|
|
- aria-label={language.t("common.closeTab")}
|
|
|
+ iconSize="large"
|
|
|
+ onClick={() => dialog.show(() => <DialogSelectFile />)}
|
|
|
+ aria-label={language.t("command.file.open")}
|
|
|
/>
|
|
|
- </Tooltip>
|
|
|
- }
|
|
|
- hideCloseButton
|
|
|
- onMiddleClick={() => tabs().close("context")}
|
|
|
- >
|
|
|
- <div class="flex items-center gap-2">
|
|
|
- <SessionContextUsage variant="indicator" />
|
|
|
- <div>{language.t("session.tab.context")}</div>
|
|
|
+ </TooltipKeybind>
|
|
|
</div>
|
|
|
- </Tabs.Trigger>
|
|
|
- </Show>
|
|
|
- <SortableProvider ids={openedTabs()}>
|
|
|
- <For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
|
|
|
- </SortableProvider>
|
|
|
- <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
|
|
|
- <TooltipKeybind
|
|
|
- title={language.t("command.file.open")}
|
|
|
- keybind={command.keybind("file.open")}
|
|
|
- class="flex items-center"
|
|
|
- >
|
|
|
- <IconButton
|
|
|
- icon="plus-small"
|
|
|
- variant="ghost"
|
|
|
- iconSize="large"
|
|
|
- onClick={() => dialog.show(() => <DialogSelectFile />)}
|
|
|
- aria-label={language.t("command.file.open")}
|
|
|
- />
|
|
|
- </TooltipKeybind>
|
|
|
+ </Tabs.List>
|
|
|
</div>
|
|
|
- </Tabs.List>
|
|
|
- </div>
|
|
|
- <Show when={true}>
|
|
|
- <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
|
|
- <Show when={activeTab() === "review"}>
|
|
|
- <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
|
|
- <Switch>
|
|
|
- <Match when={hasReview()}>
|
|
|
- <Show
|
|
|
- when={diffsReady()}
|
|
|
- fallback={
|
|
|
- <div class="px-6 py-4 text-text-weak">
|
|
|
- {language.t("session.review.loadingChanges")}
|
|
|
+ <Show when={!layout.fileTree.opened()}>
|
|
|
+ <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
|
|
+ <Show when={activeTab() === "review"}>
|
|
|
+ <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
|
|
+ <Switch>
|
|
|
+ <Match when={hasReview()}>
|
|
|
+ <Show
|
|
|
+ when={diffsReady()}
|
|
|
+ fallback={
|
|
|
+ <div class="px-6 py-4 text-text-weak">
|
|
|
+ {language.t("session.review.loadingChanges")}
|
|
|
+ </div>
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <SessionReviewTab
|
|
|
+ diffs={diffs}
|
|
|
+ view={view}
|
|
|
+ diffStyle={layout.review.diffStyle()}
|
|
|
+ onDiffStyleChange={layout.review.setDiffStyle}
|
|
|
+ onScrollRef={setReviewScroll}
|
|
|
+ onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
|
|
+ comments={comments.all()}
|
|
|
+ focusedComment={comments.focus()}
|
|
|
+ onFocusedCommentChange={comments.setFocus}
|
|
|
+ onViewFile={(path) => {
|
|
|
+ const value = file.tab(path)
|
|
|
+ tabs().open(value)
|
|
|
+ file.load(path)
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </Show>
|
|
|
+ </Match>
|
|
|
+ <Match when={true}>
|
|
|
+ <div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
|
|
|
+ <Mark class="w-14 opacity-10" />
|
|
|
+ <div class="text-13-regular text-text-weak max-w-56">
|
|
|
+ No changes in this session yet
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- }
|
|
|
- >
|
|
|
- <SessionReviewTab
|
|
|
- diffs={diffs}
|
|
|
- view={view}
|
|
|
- diffStyle={layout.review.diffStyle()}
|
|
|
- onDiffStyleChange={layout.review.setDiffStyle}
|
|
|
- onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
|
|
- comments={comments.all()}
|
|
|
- focusedComment={comments.focus()}
|
|
|
- onFocusedCommentChange={comments.setFocus}
|
|
|
- onViewFile={(path) => {
|
|
|
- const value = file.tab(path)
|
|
|
- tabs().open(value)
|
|
|
- file.load(path)
|
|
|
- }}
|
|
|
- />
|
|
|
- </Show>
|
|
|
- </Match>
|
|
|
- <Match when={true}>
|
|
|
- <div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
|
|
|
- <Mark class="w-14 opacity-10" />
|
|
|
- <div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div>
|
|
|
- </div>
|
|
|
- </Match>
|
|
|
- </Switch>
|
|
|
- </div>
|
|
|
- </Show>
|
|
|
- </Tabs.Content>
|
|
|
- </Show>
|
|
|
- <Show when={contextOpen()}>
|
|
|
- <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
|
|
- <Show when={activeTab() === "context"}>
|
|
|
- <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
|
|
- <SessionContextTab
|
|
|
- messages={messages}
|
|
|
- visibleUserMessages={visibleUserMessages}
|
|
|
- view={view}
|
|
|
- info={info}
|
|
|
- />
|
|
|
- </div>
|
|
|
+ </Match>
|
|
|
+ </Switch>
|
|
|
+ </div>
|
|
|
+ </Show>
|
|
|
+ </Tabs.Content>
|
|
|
</Show>
|
|
|
- </Tabs.Content>
|
|
|
- </Show>
|
|
|
- <For each={openedTabs()}>
|
|
|
- {(tab) => {
|
|
|
- let scroll: HTMLDivElement | undefined
|
|
|
- let scrollFrame: number | undefined
|
|
|
- let pending: { x: number; y: number } | undefined
|
|
|
- let codeScroll: HTMLElement[] = []
|
|
|
- let focusToken = 0
|
|
|
-
|
|
|
- const path = createMemo(() => file.pathFromTab(tab))
|
|
|
- const state = createMemo(() => {
|
|
|
- const p = path()
|
|
|
- if (!p) return
|
|
|
- return file.get(p)
|
|
|
- })
|
|
|
- const contents = createMemo(() => state()?.content?.content ?? "")
|
|
|
- const cacheKey = createMemo(() => checksum(contents()))
|
|
|
- const isImage = createMemo(() => {
|
|
|
- const c = state()?.content
|
|
|
- return (
|
|
|
- c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
|
|
|
- )
|
|
|
- })
|
|
|
- const isSvg = createMemo(() => {
|
|
|
- const c = state()?.content
|
|
|
- return c?.mimeType === "image/svg+xml"
|
|
|
- })
|
|
|
- const svgContent = createMemo(() => {
|
|
|
- if (!isSvg()) return
|
|
|
- const c = state()?.content
|
|
|
- if (!c) return
|
|
|
- if (c.encoding === "base64") return base64Decode(c.content)
|
|
|
- return c.content
|
|
|
- })
|
|
|
- const svgPreviewUrl = createMemo(() => {
|
|
|
- if (!isSvg()) return
|
|
|
- const c = state()?.content
|
|
|
- if (!c) return
|
|
|
- if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
|
|
|
- return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
|
|
|
- })
|
|
|
- const imageDataUrl = createMemo(() => {
|
|
|
- if (!isImage()) return
|
|
|
- const c = state()?.content
|
|
|
- return `data:${c?.mimeType};base64,${c?.content}`
|
|
|
- })
|
|
|
- const selectedLines = createMemo(() => {
|
|
|
- const p = path()
|
|
|
- if (!p) return null
|
|
|
- if (file.ready()) return file.selectedLines(p) ?? null
|
|
|
- return handoff.files[p] ?? null
|
|
|
- })
|
|
|
-
|
|
|
- let wrap: HTMLDivElement | undefined
|
|
|
-
|
|
|
- const fileComments = createMemo(() => {
|
|
|
- const p = path()
|
|
|
- if (!p) return []
|
|
|
- return comments.list(p)
|
|
|
- })
|
|
|
-
|
|
|
- const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
|
|
|
-
|
|
|
- const [openedComment, setOpenedComment] = createSignal<string | null>(null)
|
|
|
- const [commenting, setCommenting] = createSignal<SelectedLineRange | null>(null)
|
|
|
- const [draft, setDraft] = createSignal("")
|
|
|
- const [positions, setPositions] = createSignal<Record<string, number>>({})
|
|
|
- const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
|
|
|
-
|
|
|
- const empty = {} as Record<string, number>
|
|
|
-
|
|
|
- const commentLabel = (range: SelectedLineRange) => {
|
|
|
- const start = Math.min(range.start, range.end)
|
|
|
- const end = Math.max(range.start, range.end)
|
|
|
- if (start === end) return `line ${start}`
|
|
|
- return `lines ${start}-${end}`
|
|
|
- }
|
|
|
-
|
|
|
- const getRoot = () => {
|
|
|
- const el = wrap
|
|
|
- if (!el) return
|
|
|
-
|
|
|
- const host = el.querySelector("diffs-container")
|
|
|
- if (!(host instanceof HTMLElement)) return
|
|
|
|
|
|
- const root = host.shadowRoot
|
|
|
- if (!root) return
|
|
|
+ <Show when={layout.fileTree.opened() && fileTreeTab() === "all" && openedTabs().length === 0}>
|
|
|
+ <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
|
|
+ <div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
|
|
|
+ <Mark class="w-14 opacity-10" />
|
|
|
+ <div class="text-13-regular text-text-weak max-w-56">Select a file to open</div>
|
|
|
+ </div>
|
|
|
+ </Tabs.Content>
|
|
|
+ </Show>
|
|
|
|
|
|
- return root
|
|
|
- }
|
|
|
+ <Show when={!layout.fileTree.opened() && contextOpen()}>
|
|
|
+ <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
|
|
+ <Show when={activeTab() === "context"}>
|
|
|
+ <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
|
|
+ <SessionContextTab
|
|
|
+ messages={messages}
|
|
|
+ visibleUserMessages={visibleUserMessages}
|
|
|
+ view={view}
|
|
|
+ info={info}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </Show>
|
|
|
+ </Tabs.Content>
|
|
|
+ </Show>
|
|
|
+ <For each={openedTabs()}>
|
|
|
+ {(tab) => {
|
|
|
+ let scroll: HTMLDivElement | undefined
|
|
|
+ let scrollFrame: number | undefined
|
|
|
+ let pending: { x: number; y: number } | undefined
|
|
|
+ let codeScroll: HTMLElement[] = []
|
|
|
+ let focusToken = 0
|
|
|
+
|
|
|
+ const path = createMemo(() => file.pathFromTab(tab))
|
|
|
+ const state = createMemo(() => {
|
|
|
+ const p = path()
|
|
|
+ if (!p) return
|
|
|
+ return file.get(p)
|
|
|
+ })
|
|
|
+ const contents = createMemo(() => state()?.content?.content ?? "")
|
|
|
+ const cacheKey = createMemo(() => checksum(contents()))
|
|
|
+ const isImage = createMemo(() => {
|
|
|
+ const c = state()?.content
|
|
|
+ return (
|
|
|
+ c?.encoding === "base64" &&
|
|
|
+ c?.mimeType?.startsWith("image/") &&
|
|
|
+ c?.mimeType !== "image/svg+xml"
|
|
|
+ )
|
|
|
+ })
|
|
|
+ const isSvg = createMemo(() => {
|
|
|
+ const c = state()?.content
|
|
|
+ return c?.mimeType === "image/svg+xml"
|
|
|
+ })
|
|
|
+ const svgContent = createMemo(() => {
|
|
|
+ if (!isSvg()) return
|
|
|
+ const c = state()?.content
|
|
|
+ if (!c) return
|
|
|
+ if (c.encoding === "base64") return base64Decode(c.content)
|
|
|
+ return c.content
|
|
|
+ })
|
|
|
+ const svgPreviewUrl = createMemo(() => {
|
|
|
+ if (!isSvg()) return
|
|
|
+ const c = state()?.content
|
|
|
+ if (!c) return
|
|
|
+ if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
|
|
|
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
|
|
|
+ })
|
|
|
+ const imageDataUrl = createMemo(() => {
|
|
|
+ if (!isImage()) return
|
|
|
+ const c = state()?.content
|
|
|
+ return `data:${c?.mimeType};base64,${c?.content}`
|
|
|
+ })
|
|
|
+ const selectedLines = createMemo(() => {
|
|
|
+ const p = path()
|
|
|
+ if (!p) return null
|
|
|
+ if (file.ready()) return file.selectedLines(p) ?? null
|
|
|
+ return handoff.files[p] ?? null
|
|
|
+ })
|
|
|
+
|
|
|
+ let wrap: HTMLDivElement | undefined
|
|
|
+
|
|
|
+ const fileComments = createMemo(() => {
|
|
|
+ const p = path()
|
|
|
+ if (!p) return []
|
|
|
+ return comments.list(p)
|
|
|
+ })
|
|
|
+
|
|
|
+ const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
|
|
|
+
|
|
|
+ const [openedComment, setOpenedComment] = createSignal<string | null>(null)
|
|
|
+ const [commenting, setCommenting] = createSignal<SelectedLineRange | null>(null)
|
|
|
+ const [draft, setDraft] = createSignal("")
|
|
|
+ const [positions, setPositions] = createSignal<Record<string, number>>({})
|
|
|
+ const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
|
|
|
+
|
|
|
+ const empty = {} as Record<string, number>
|
|
|
+
|
|
|
+ const commentLabel = (range: SelectedLineRange) => {
|
|
|
+ const start = Math.min(range.start, range.end)
|
|
|
+ const end = Math.max(range.start, range.end)
|
|
|
+ if (start === end) return `line ${start}`
|
|
|
+ return `lines ${start}-${end}`
|
|
|
+ }
|
|
|
|
|
|
- const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
|
|
|
- const line = Math.max(range.start, range.end)
|
|
|
- const node = root.querySelector(`[data-line="${line}"]`)
|
|
|
- if (!(node instanceof HTMLElement)) return
|
|
|
- return node
|
|
|
- }
|
|
|
+ const getRoot = () => {
|
|
|
+ const el = wrap
|
|
|
+ if (!el) return
|
|
|
|
|
|
- const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
|
|
|
- const wrapperRect = wrapper.getBoundingClientRect()
|
|
|
- const rect = marker.getBoundingClientRect()
|
|
|
- return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
|
|
|
- }
|
|
|
+ const host = el.querySelector("diffs-container")
|
|
|
+ if (!(host instanceof HTMLElement)) return
|
|
|
|
|
|
- const equal = (a: Record<string, number>, b: Record<string, number>) => {
|
|
|
- const aKeys = Object.keys(a)
|
|
|
- const bKeys = Object.keys(b)
|
|
|
- if (aKeys.length !== bKeys.length) return false
|
|
|
- for (const key of aKeys) {
|
|
|
- if (a[key] !== b[key]) return false
|
|
|
- }
|
|
|
- return true
|
|
|
- }
|
|
|
+ const root = host.shadowRoot
|
|
|
+ if (!root) return
|
|
|
|
|
|
- const updateComments = () => {
|
|
|
- const el = wrap
|
|
|
- const root = getRoot()
|
|
|
- if (!el || !root) {
|
|
|
- setPositions((prev) => (Object.keys(prev).length === 0 ? prev : empty))
|
|
|
- setDraftTop((prev) => (prev === undefined ? prev : undefined))
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- const next: Record<string, number> = {}
|
|
|
- for (const comment of fileComments()) {
|
|
|
- const marker = findMarker(root, comment.selection)
|
|
|
- if (!marker) continue
|
|
|
- next[comment.id] = markerTop(el, marker)
|
|
|
- }
|
|
|
-
|
|
|
- setPositions((prev) => (equal(prev, next) ? prev : next))
|
|
|
-
|
|
|
- const range = commenting()
|
|
|
- if (!range) {
|
|
|
- setDraftTop(undefined)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- const marker = findMarker(root, range)
|
|
|
- if (!marker) {
|
|
|
- setDraftTop(undefined)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- const nextTop = markerTop(el, marker)
|
|
|
- setDraftTop((prev) => (prev === nextTop ? prev : nextTop))
|
|
|
- }
|
|
|
+ return root
|
|
|
+ }
|
|
|
|
|
|
- let commentFrame: number | undefined
|
|
|
+ const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
|
|
|
+ const line = Math.max(range.start, range.end)
|
|
|
+ const node = root.querySelector(`[data-line="${line}"]`)
|
|
|
+ if (!(node instanceof HTMLElement)) return
|
|
|
+ return node
|
|
|
+ }
|
|
|
|
|
|
- const scheduleComments = () => {
|
|
|
- if (commentFrame !== undefined) return
|
|
|
- commentFrame = requestAnimationFrame(() => {
|
|
|
- commentFrame = undefined
|
|
|
- updateComments()
|
|
|
- })
|
|
|
- }
|
|
|
+ const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
|
|
|
+ const wrapperRect = wrapper.getBoundingClientRect()
|
|
|
+ const rect = marker.getBoundingClientRect()
|
|
|
+ return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
|
|
|
+ }
|
|
|
|
|
|
- createEffect(() => {
|
|
|
- fileComments()
|
|
|
- scheduleComments()
|
|
|
- })
|
|
|
-
|
|
|
- createEffect(() => {
|
|
|
- commenting()
|
|
|
- scheduleComments()
|
|
|
- })
|
|
|
-
|
|
|
- createEffect(() => {
|
|
|
- const range = commenting()
|
|
|
- if (!range) return
|
|
|
- setDraft("")
|
|
|
- })
|
|
|
-
|
|
|
- createEffect(() => {
|
|
|
- const focus = comments.focus()
|
|
|
- const p = path()
|
|
|
- if (!focus || !p) return
|
|
|
- if (focus.file !== p) return
|
|
|
- if (activeTab() !== tab) return
|
|
|
-
|
|
|
- const target = fileComments().find((comment) => comment.id === focus.id)
|
|
|
- if (!target) return
|
|
|
-
|
|
|
- focusToken++
|
|
|
- const token = focusToken
|
|
|
-
|
|
|
- setOpenedComment(target.id)
|
|
|
- setCommenting(null)
|
|
|
- file.setSelectedLines(p, target.selection)
|
|
|
-
|
|
|
- const scrollTo = (attempt: number) => {
|
|
|
- if (token !== focusToken) return
|
|
|
-
|
|
|
- const root = scroll
|
|
|
- if (!root) {
|
|
|
- if (attempt >= 120) return
|
|
|
- requestAnimationFrame(() => scrollTo(attempt + 1))
|
|
|
- return
|
|
|
+ const equal = (a: Record<string, number>, b: Record<string, number>) => {
|
|
|
+ const aKeys = Object.keys(a)
|
|
|
+ const bKeys = Object.keys(b)
|
|
|
+ if (aKeys.length !== bKeys.length) return false
|
|
|
+ for (const key of aKeys) {
|
|
|
+ if (a[key] !== b[key]) return false
|
|
|
+ }
|
|
|
+ return true
|
|
|
}
|
|
|
|
|
|
- const anchor = root.querySelector(`[data-comment-id="${target.id}"]`)
|
|
|
- const ready =
|
|
|
- anchor instanceof HTMLElement &&
|
|
|
- anchor.style.pointerEvents !== "none" &&
|
|
|
- anchor.style.opacity !== "0"
|
|
|
-
|
|
|
- const shadow = getRoot()
|
|
|
- const marker = shadow ? findMarker(shadow, target.selection) : undefined
|
|
|
- const node = (ready ? anchor : (marker ?? wrap)) as HTMLElement | undefined
|
|
|
- if (!node) {
|
|
|
- if (attempt >= 120) return
|
|
|
- requestAnimationFrame(() => scrollTo(attempt + 1))
|
|
|
- return
|
|
|
+ const updateComments = () => {
|
|
|
+ const el = wrap
|
|
|
+ const root = getRoot()
|
|
|
+ if (!el || !root) {
|
|
|
+ setPositions((prev) => (Object.keys(prev).length === 0 ? prev : empty))
|
|
|
+ setDraftTop((prev) => (prev === undefined ? prev : undefined))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const next: Record<string, number> = {}
|
|
|
+ for (const comment of fileComments()) {
|
|
|
+ const marker = findMarker(root, comment.selection)
|
|
|
+ if (!marker) continue
|
|
|
+ next[comment.id] = markerTop(el, marker)
|
|
|
+ }
|
|
|
+
|
|
|
+ setPositions((prev) => (equal(prev, next) ? prev : next))
|
|
|
+
|
|
|
+ const range = commenting()
|
|
|
+ if (!range) {
|
|
|
+ setDraftTop(undefined)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const marker = findMarker(root, range)
|
|
|
+ if (!marker) {
|
|
|
+ setDraftTop(undefined)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const nextTop = markerTop(el, marker)
|
|
|
+ setDraftTop((prev) => (prev === nextTop ? prev : nextTop))
|
|
|
}
|
|
|
|
|
|
- const rootRect = root.getBoundingClientRect()
|
|
|
- const targetRect = node.getBoundingClientRect()
|
|
|
- const offset = targetRect.top - rootRect.top
|
|
|
- const next = root.scrollTop + offset - rootRect.height / 2 + targetRect.height / 2
|
|
|
- root.scrollTop = Math.max(0, next)
|
|
|
+ let commentFrame: number | undefined
|
|
|
|
|
|
- if (ready || marker) return
|
|
|
- if (attempt >= 120) return
|
|
|
- requestAnimationFrame(() => scrollTo(attempt + 1))
|
|
|
- }
|
|
|
+ const scheduleComments = () => {
|
|
|
+ if (commentFrame !== undefined) return
|
|
|
+ commentFrame = requestAnimationFrame(() => {
|
|
|
+ commentFrame = undefined
|
|
|
+ updateComments()
|
|
|
+ })
|
|
|
+ }
|
|
|
|
|
|
- requestAnimationFrame(() => scrollTo(0))
|
|
|
- requestAnimationFrame(() => comments.clearFocus())
|
|
|
- })
|
|
|
+ createEffect(() => {
|
|
|
+ fileComments()
|
|
|
+ scheduleComments()
|
|
|
+ })
|
|
|
|
|
|
- const renderCode = (source: string, wrapperClass: string) => (
|
|
|
- <div
|
|
|
- ref={(el) => {
|
|
|
- wrap = el
|
|
|
+ createEffect(() => {
|
|
|
+ commenting()
|
|
|
scheduleComments()
|
|
|
- }}
|
|
|
- class={`relative overflow-hidden ${wrapperClass}`}
|
|
|
- >
|
|
|
- <Dynamic
|
|
|
- component={codeComponent}
|
|
|
- file={{
|
|
|
- name: path() ?? "",
|
|
|
- contents: source,
|
|
|
- cacheKey: cacheKey(),
|
|
|
- }}
|
|
|
- enableLineSelection
|
|
|
- selectedLines={selectedLines()}
|
|
|
- commentedLines={commentedLines()}
|
|
|
- onRendered={() => {
|
|
|
- requestAnimationFrame(restoreScroll)
|
|
|
- requestAnimationFrame(scheduleComments)
|
|
|
- }}
|
|
|
- onLineSelected={(range: SelectedLineRange | null) => {
|
|
|
- const p = path()
|
|
|
- if (!p) return
|
|
|
- file.setSelectedLines(p, range)
|
|
|
- if (!range) setCommenting(null)
|
|
|
- }}
|
|
|
- onLineSelectionEnd={(range: SelectedLineRange | null) => {
|
|
|
- if (!range) {
|
|
|
- setCommenting(null)
|
|
|
+ })
|
|
|
+
|
|
|
+ createEffect(() => {
|
|
|
+ const range = commenting()
|
|
|
+ if (!range) return
|
|
|
+ setDraft("")
|
|
|
+ })
|
|
|
+
|
|
|
+ createEffect(() => {
|
|
|
+ const focus = comments.focus()
|
|
|
+ const p = path()
|
|
|
+ if (!focus || !p) return
|
|
|
+ if (focus.file !== p) return
|
|
|
+ if (activeTab() !== tab) return
|
|
|
+
|
|
|
+ const target = fileComments().find((comment) => comment.id === focus.id)
|
|
|
+ if (!target) return
|
|
|
+
|
|
|
+ focusToken++
|
|
|
+ const token = focusToken
|
|
|
+
|
|
|
+ setOpenedComment(target.id)
|
|
|
+ setCommenting(null)
|
|
|
+ file.setSelectedLines(p, target.selection)
|
|
|
+
|
|
|
+ const scrollTo = (attempt: number) => {
|
|
|
+ if (token !== focusToken) return
|
|
|
+
|
|
|
+ const root = scroll
|
|
|
+ if (!root) {
|
|
|
+ if (attempt >= 120) return
|
|
|
+ requestAnimationFrame(() => scrollTo(attempt + 1))
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- setOpenedComment(null)
|
|
|
- setCommenting(range)
|
|
|
- }}
|
|
|
- overflow="scroll"
|
|
|
- class="select-text"
|
|
|
- />
|
|
|
- <For each={fileComments()}>
|
|
|
- {(comment) => (
|
|
|
- <LineCommentView
|
|
|
- id={comment.id}
|
|
|
- top={positions()[comment.id]}
|
|
|
- open={openedComment() === comment.id}
|
|
|
- onMouseEnter={() => {
|
|
|
- const p = path()
|
|
|
- if (!p) return
|
|
|
- file.setSelectedLines(p, comment.selection)
|
|
|
+ const anchor = root.querySelector(`[data-comment-id="${target.id}"]`)
|
|
|
+ const ready =
|
|
|
+ anchor instanceof HTMLElement &&
|
|
|
+ anchor.style.pointerEvents !== "none" &&
|
|
|
+ anchor.style.opacity !== "0"
|
|
|
+
|
|
|
+ const shadow = getRoot()
|
|
|
+ const marker = shadow ? findMarker(shadow, target.selection) : undefined
|
|
|
+ const node = (ready ? anchor : (marker ?? wrap)) as HTMLElement | undefined
|
|
|
+ if (!node) {
|
|
|
+ if (attempt >= 120) return
|
|
|
+ requestAnimationFrame(() => scrollTo(attempt + 1))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const rootRect = root.getBoundingClientRect()
|
|
|
+ const targetRect = node.getBoundingClientRect()
|
|
|
+ const offset = targetRect.top - rootRect.top
|
|
|
+ const next = root.scrollTop + offset - rootRect.height / 2 + targetRect.height / 2
|
|
|
+ root.scrollTop = Math.max(0, next)
|
|
|
+
|
|
|
+ if (ready || marker) return
|
|
|
+ if (attempt >= 120) return
|
|
|
+ requestAnimationFrame(() => scrollTo(attempt + 1))
|
|
|
+ }
|
|
|
+
|
|
|
+ requestAnimationFrame(() => scrollTo(0))
|
|
|
+ requestAnimationFrame(() => comments.clearFocus())
|
|
|
+ })
|
|
|
+
|
|
|
+ const renderCode = (source: string, wrapperClass: string) => (
|
|
|
+ <div
|
|
|
+ ref={(el) => {
|
|
|
+ wrap = el
|
|
|
+ scheduleComments()
|
|
|
+ }}
|
|
|
+ class={`relative overflow-hidden ${wrapperClass}`}
|
|
|
+ >
|
|
|
+ <Dynamic
|
|
|
+ component={codeComponent}
|
|
|
+ file={{
|
|
|
+ name: path() ?? "",
|
|
|
+ contents: source,
|
|
|
+ cacheKey: cacheKey(),
|
|
|
+ }}
|
|
|
+ enableLineSelection
|
|
|
+ selectedLines={selectedLines()}
|
|
|
+ commentedLines={commentedLines()}
|
|
|
+ onRendered={() => {
|
|
|
+ requestAnimationFrame(restoreScroll)
|
|
|
+ requestAnimationFrame(scheduleComments)
|
|
|
}}
|
|
|
- onClick={() => {
|
|
|
+ onLineSelected={(range: SelectedLineRange | null) => {
|
|
|
const p = path()
|
|
|
if (!p) return
|
|
|
- setCommenting(null)
|
|
|
- setOpenedComment((current) => (current === comment.id ? null : comment.id))
|
|
|
- file.setSelectedLines(p, comment.selection)
|
|
|
+ file.setSelectedLines(p, range)
|
|
|
+ if (!range) setCommenting(null)
|
|
|
}}
|
|
|
- comment={comment.comment}
|
|
|
- selection={commentLabel(comment.selection)}
|
|
|
- />
|
|
|
- )}
|
|
|
- </For>
|
|
|
- <Show when={commenting()}>
|
|
|
- {(range) => (
|
|
|
- <Show when={draftTop() !== undefined}>
|
|
|
- <LineCommentEditor
|
|
|
- top={draftTop()}
|
|
|
- value={draft()}
|
|
|
- selection={commentLabel(range())}
|
|
|
- onInput={setDraft}
|
|
|
- onCancel={() => setCommenting(null)}
|
|
|
- onSubmit={(comment) => {
|
|
|
- const p = path()
|
|
|
- if (!p) return
|
|
|
- addCommentToContext({
|
|
|
- file: p,
|
|
|
- selection: range(),
|
|
|
- comment,
|
|
|
- origin: "file",
|
|
|
- })
|
|
|
+ onLineSelectionEnd={(range: SelectedLineRange | null) => {
|
|
|
+ if (!range) {
|
|
|
setCommenting(null)
|
|
|
- }}
|
|
|
- onPopoverFocusOut={(e) => {
|
|
|
- const target = e.relatedTarget as Node | null
|
|
|
- if (target && e.currentTarget.contains(target)) return
|
|
|
- // Delay to allow click handlers to fire first
|
|
|
- setTimeout(() => {
|
|
|
- if (!document.activeElement || !e.currentTarget.contains(document.activeElement)) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ setOpenedComment(null)
|
|
|
+ setCommenting(range)
|
|
|
+ }}
|
|
|
+ overflow="scroll"
|
|
|
+ class="select-text"
|
|
|
+ />
|
|
|
+ <For each={fileComments()}>
|
|
|
+ {(comment) => (
|
|
|
+ <LineCommentView
|
|
|
+ id={comment.id}
|
|
|
+ top={positions()[comment.id]}
|
|
|
+ open={openedComment() === comment.id}
|
|
|
+ onMouseEnter={() => {
|
|
|
+ const p = path()
|
|
|
+ if (!p) return
|
|
|
+ file.setSelectedLines(p, comment.selection)
|
|
|
+ }}
|
|
|
+ onClick={() => {
|
|
|
+ const p = path()
|
|
|
+ if (!p) return
|
|
|
+ setCommenting(null)
|
|
|
+ setOpenedComment((current) => (current === comment.id ? null : comment.id))
|
|
|
+ file.setSelectedLines(p, comment.selection)
|
|
|
+ }}
|
|
|
+ comment={comment.comment}
|
|
|
+ selection={commentLabel(comment.selection)}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </For>
|
|
|
+ <Show when={commenting()}>
|
|
|
+ {(range) => (
|
|
|
+ <Show when={draftTop() !== undefined}>
|
|
|
+ <LineCommentEditor
|
|
|
+ top={draftTop()}
|
|
|
+ value={draft()}
|
|
|
+ selection={commentLabel(range())}
|
|
|
+ onInput={setDraft}
|
|
|
+ onCancel={() => setCommenting(null)}
|
|
|
+ onSubmit={(comment) => {
|
|
|
+ const p = path()
|
|
|
+ if (!p) return
|
|
|
+ addCommentToContext({
|
|
|
+ file: p,
|
|
|
+ selection: range(),
|
|
|
+ comment,
|
|
|
+ origin: "file",
|
|
|
+ })
|
|
|
setCommenting(null)
|
|
|
- }
|
|
|
- }, 0)
|
|
|
- }}
|
|
|
- />
|
|
|
+ }}
|
|
|
+ onPopoverFocusOut={(e) => {
|
|
|
+ const target = e.relatedTarget as Node | null
|
|
|
+ if (target && e.currentTarget.contains(target)) return
|
|
|
+ // Delay to allow click handlers to fire first
|
|
|
+ setTimeout(() => {
|
|
|
+ if (
|
|
|
+ !document.activeElement ||
|
|
|
+ !e.currentTarget.contains(document.activeElement)
|
|
|
+ ) {
|
|
|
+ setCommenting(null)
|
|
|
+ }
|
|
|
+ }, 0)
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </Show>
|
|
|
+ )}
|
|
|
</Show>
|
|
|
- )}
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- )
|
|
|
-
|
|
|
- const getCodeScroll = () => {
|
|
|
- const el = scroll
|
|
|
- if (!el) return []
|
|
|
-
|
|
|
- const host = el.querySelector("diffs-container")
|
|
|
- if (!(host instanceof HTMLElement)) return []
|
|
|
-
|
|
|
- const root = host.shadowRoot
|
|
|
- if (!root) return []
|
|
|
-
|
|
|
- return Array.from(root.querySelectorAll("[data-code]")).filter(
|
|
|
- (node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
|
|
|
- )
|
|
|
- }
|
|
|
+ </div>
|
|
|
+ )
|
|
|
|
|
|
- const queueScrollUpdate = (next: { x: number; y: number }) => {
|
|
|
- pending = next
|
|
|
- if (scrollFrame !== undefined) return
|
|
|
+ const getCodeScroll = () => {
|
|
|
+ const el = scroll
|
|
|
+ if (!el) return []
|
|
|
|
|
|
- scrollFrame = requestAnimationFrame(() => {
|
|
|
- scrollFrame = undefined
|
|
|
+ const host = el.querySelector("diffs-container")
|
|
|
+ if (!(host instanceof HTMLElement)) return []
|
|
|
|
|
|
- const next = pending
|
|
|
- pending = undefined
|
|
|
- if (!next) return
|
|
|
+ const root = host.shadowRoot
|
|
|
+ if (!root) return []
|
|
|
|
|
|
- view().setScroll(tab, next)
|
|
|
- })
|
|
|
- }
|
|
|
+ return Array.from(root.querySelectorAll("[data-code]")).filter(
|
|
|
+ (node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
|
|
|
+ )
|
|
|
+ }
|
|
|
|
|
|
- const handleCodeScroll = (event: Event) => {
|
|
|
- const el = scroll
|
|
|
- if (!el) return
|
|
|
+ const queueScrollUpdate = (next: { x: number; y: number }) => {
|
|
|
+ pending = next
|
|
|
+ if (scrollFrame !== undefined) return
|
|
|
|
|
|
- const target = event.currentTarget
|
|
|
- if (!(target instanceof HTMLElement)) return
|
|
|
+ scrollFrame = requestAnimationFrame(() => {
|
|
|
+ scrollFrame = undefined
|
|
|
|
|
|
- queueScrollUpdate({
|
|
|
- x: target.scrollLeft,
|
|
|
- y: el.scrollTop,
|
|
|
- })
|
|
|
- }
|
|
|
+ const next = pending
|
|
|
+ pending = undefined
|
|
|
+ if (!next) return
|
|
|
|
|
|
- const syncCodeScroll = () => {
|
|
|
- const next = getCodeScroll()
|
|
|
- if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
|
|
|
+ view().setScroll(tab, next)
|
|
|
+ })
|
|
|
+ }
|
|
|
|
|
|
- for (const item of codeScroll) {
|
|
|
- item.removeEventListener("scroll", handleCodeScroll)
|
|
|
- }
|
|
|
+ const handleCodeScroll = (event: Event) => {
|
|
|
+ const el = scroll
|
|
|
+ if (!el) return
|
|
|
|
|
|
- codeScroll = next
|
|
|
+ const target = event.currentTarget
|
|
|
+ if (!(target instanceof HTMLElement)) return
|
|
|
|
|
|
- for (const item of codeScroll) {
|
|
|
- item.addEventListener("scroll", handleCodeScroll)
|
|
|
- }
|
|
|
- }
|
|
|
+ queueScrollUpdate({
|
|
|
+ x: target.scrollLeft,
|
|
|
+ y: el.scrollTop,
|
|
|
+ })
|
|
|
+ }
|
|
|
|
|
|
- const restoreScroll = () => {
|
|
|
- const el = scroll
|
|
|
- if (!el) return
|
|
|
+ const syncCodeScroll = () => {
|
|
|
+ const next = getCodeScroll()
|
|
|
+ if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
|
|
|
|
|
|
- const s = view()?.scroll(tab)
|
|
|
- if (!s) return
|
|
|
+ for (const item of codeScroll) {
|
|
|
+ item.removeEventListener("scroll", handleCodeScroll)
|
|
|
+ }
|
|
|
|
|
|
- syncCodeScroll()
|
|
|
+ codeScroll = next
|
|
|
|
|
|
- if (codeScroll.length > 0) {
|
|
|
- for (const item of codeScroll) {
|
|
|
- if (item.scrollLeft !== s.x) item.scrollLeft = s.x
|
|
|
+ for (const item of codeScroll) {
|
|
|
+ item.addEventListener("scroll", handleCodeScroll)
|
|
|
+ }
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- if (el.scrollTop !== s.y) el.scrollTop = s.y
|
|
|
+ const restoreScroll = () => {
|
|
|
+ const el = scroll
|
|
|
+ if (!el) return
|
|
|
|
|
|
- if (codeScroll.length > 0) return
|
|
|
+ const s = view()?.scroll(tab)
|
|
|
+ if (!s) return
|
|
|
|
|
|
- if (el.scrollLeft !== s.x) el.scrollLeft = s.x
|
|
|
- }
|
|
|
+ syncCodeScroll()
|
|
|
|
|
|
- const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
|
|
- if (codeScroll.length === 0) syncCodeScroll()
|
|
|
+ if (codeScroll.length > 0) {
|
|
|
+ for (const item of codeScroll) {
|
|
|
+ if (item.scrollLeft !== s.x) item.scrollLeft = s.x
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- queueScrollUpdate({
|
|
|
- x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
|
|
|
- y: event.currentTarget.scrollTop,
|
|
|
- })
|
|
|
- }
|
|
|
+ if (el.scrollTop !== s.y) el.scrollTop = s.y
|
|
|
|
|
|
- createEffect(
|
|
|
- on(
|
|
|
- () => state()?.loaded,
|
|
|
- (loaded) => {
|
|
|
- if (!loaded) return
|
|
|
- requestAnimationFrame(restoreScroll)
|
|
|
- },
|
|
|
- { defer: true },
|
|
|
- ),
|
|
|
- )
|
|
|
+ if (codeScroll.length > 0) return
|
|
|
|
|
|
- createEffect(
|
|
|
- on(
|
|
|
- () => file.ready(),
|
|
|
- (ready) => {
|
|
|
- if (!ready) return
|
|
|
- requestAnimationFrame(restoreScroll)
|
|
|
- },
|
|
|
- { defer: true },
|
|
|
- ),
|
|
|
- )
|
|
|
-
|
|
|
- createEffect(
|
|
|
- on(
|
|
|
- () => tabs().active() === tab,
|
|
|
- (active) => {
|
|
|
- if (!active) return
|
|
|
- if (!state()?.loaded) return
|
|
|
- requestAnimationFrame(restoreScroll)
|
|
|
- },
|
|
|
- ),
|
|
|
- )
|
|
|
+ if (el.scrollLeft !== s.x) el.scrollLeft = s.x
|
|
|
+ }
|
|
|
|
|
|
- onCleanup(() => {
|
|
|
- if (commentFrame !== undefined) cancelAnimationFrame(commentFrame)
|
|
|
- for (const item of codeScroll) {
|
|
|
- item.removeEventListener("scroll", handleCodeScroll)
|
|
|
- }
|
|
|
+ const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
|
|
+ if (codeScroll.length === 0) syncCodeScroll()
|
|
|
|
|
|
- if (scrollFrame === undefined) return
|
|
|
- cancelAnimationFrame(scrollFrame)
|
|
|
- })
|
|
|
+ queueScrollUpdate({
|
|
|
+ x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
|
|
|
+ y: event.currentTarget.scrollTop,
|
|
|
+ })
|
|
|
+ }
|
|
|
|
|
|
- return (
|
|
|
- <Tabs.Content
|
|
|
- value={tab}
|
|
|
- class="mt-3 relative"
|
|
|
- ref={(el: HTMLDivElement) => {
|
|
|
- scroll = el
|
|
|
- restoreScroll()
|
|
|
- }}
|
|
|
- onScroll={handleScroll}
|
|
|
- >
|
|
|
- <Switch>
|
|
|
- <Match when={state()?.loaded && isImage()}>
|
|
|
- <div class="px-6 py-4 pb-40">
|
|
|
- <img
|
|
|
- src={imageDataUrl()}
|
|
|
- alt={path()}
|
|
|
- class="max-w-full"
|
|
|
- onLoad={() => requestAnimationFrame(restoreScroll)}
|
|
|
- />
|
|
|
- </div>
|
|
|
- </Match>
|
|
|
- <Match when={state()?.loaded && isSvg()}>
|
|
|
- <div class="flex flex-col gap-4 px-6 py-4">
|
|
|
- {renderCode(svgContent() ?? "", "")}
|
|
|
- <Show when={svgPreviewUrl()}>
|
|
|
- <div class="flex justify-center pb-40">
|
|
|
- <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
|
|
|
+ createEffect(
|
|
|
+ on(
|
|
|
+ () => state()?.loaded,
|
|
|
+ (loaded) => {
|
|
|
+ if (!loaded) return
|
|
|
+ requestAnimationFrame(restoreScroll)
|
|
|
+ },
|
|
|
+ { defer: true },
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ createEffect(
|
|
|
+ on(
|
|
|
+ () => file.ready(),
|
|
|
+ (ready) => {
|
|
|
+ if (!ready) return
|
|
|
+ requestAnimationFrame(restoreScroll)
|
|
|
+ },
|
|
|
+ { defer: true },
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ createEffect(
|
|
|
+ on(
|
|
|
+ () => tabs().active() === tab,
|
|
|
+ (active) => {
|
|
|
+ if (!active) return
|
|
|
+ if (!state()?.loaded) return
|
|
|
+ requestAnimationFrame(restoreScroll)
|
|
|
+ },
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ onCleanup(() => {
|
|
|
+ if (commentFrame !== undefined) cancelAnimationFrame(commentFrame)
|
|
|
+ for (const item of codeScroll) {
|
|
|
+ item.removeEventListener("scroll", handleCodeScroll)
|
|
|
+ }
|
|
|
+
|
|
|
+ if (scrollFrame === undefined) return
|
|
|
+ cancelAnimationFrame(scrollFrame)
|
|
|
+ })
|
|
|
+
|
|
|
+ return (
|
|
|
+ <Tabs.Content
|
|
|
+ value={tab}
|
|
|
+ class="mt-3 relative"
|
|
|
+ ref={(el: HTMLDivElement) => {
|
|
|
+ scroll = el
|
|
|
+ restoreScroll()
|
|
|
+ }}
|
|
|
+ onScroll={handleScroll}
|
|
|
+ >
|
|
|
+ <Switch>
|
|
|
+ <Match when={state()?.loaded && isImage()}>
|
|
|
+ <div class="px-6 py-4 pb-40">
|
|
|
+ <img
|
|
|
+ src={imageDataUrl()}
|
|
|
+ alt={path()}
|
|
|
+ class="max-w-full"
|
|
|
+ onLoad={() => requestAnimationFrame(restoreScroll)}
|
|
|
+ />
|
|
|
</div>
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- </Match>
|
|
|
- <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
|
|
|
- <Match when={state()?.loading}>
|
|
|
- <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
|
|
|
- </Match>
|
|
|
- <Match when={state()?.error}>
|
|
|
- {(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
|
|
|
- </Match>
|
|
|
- </Switch>
|
|
|
- </Tabs.Content>
|
|
|
- )
|
|
|
- }}
|
|
|
- </For>
|
|
|
- </Tabs>
|
|
|
- <DragOverlay>
|
|
|
- <Show when={store.activeDraggable}>
|
|
|
- {(tab) => {
|
|
|
- const path = createMemo(() => file.pathFromTab(tab()))
|
|
|
- return (
|
|
|
- <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
|
|
|
- <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
|
|
|
- </div>
|
|
|
- )
|
|
|
- }}
|
|
|
- </Show>
|
|
|
- </DragOverlay>
|
|
|
- </DragDropProvider>
|
|
|
+ </Match>
|
|
|
+ <Match when={state()?.loaded && isSvg()}>
|
|
|
+ <div class="flex flex-col gap-4 px-6 py-4">
|
|
|
+ {renderCode(svgContent() ?? "", "")}
|
|
|
+ <Show when={svgPreviewUrl()}>
|
|
|
+ <div class="flex justify-center pb-40">
|
|
|
+ <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
|
|
|
+ </div>
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
+ </Match>
|
|
|
+ <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
|
|
|
+ <Match when={state()?.loading}>
|
|
|
+ <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
|
|
|
+ </Match>
|
|
|
+ <Match when={state()?.error}>
|
|
|
+ {(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
|
|
|
+ </Match>
|
|
|
+ </Switch>
|
|
|
+ </Tabs.Content>
|
|
|
+ )
|
|
|
+ }}
|
|
|
+ </For>
|
|
|
+ </Tabs>
|
|
|
+ <DragOverlay>
|
|
|
+ <Show when={store.activeDraggable}>
|
|
|
+ {(tab) => {
|
|
|
+ const path = createMemo(() => file.pathFromTab(tab()))
|
|
|
+ return (
|
|
|
+ <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
|
|
|
+ <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ }}
|
|
|
+ </Show>
|
|
|
+ </DragOverlay>
|
|
|
+ </DragDropProvider>
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Show when={layout.fileTree.opened()}>
|
|
|
+ <div class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
|
|
|
+ <div class="h-full border-l border-border-weak-base flex flex-col overflow-hidden">
|
|
|
+ <Tabs value={fileTreeTab()} onChange={setFileTreeTabValue} class="h-full">
|
|
|
+ <Tabs.List class="h-auto">
|
|
|
+ <Tabs.Trigger value="changes" class="w-1/2" classes={{ button: "w-full" }}>
|
|
|
+ Changes
|
|
|
+ </Tabs.Trigger>
|
|
|
+ <Tabs.Trigger value="all" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
|
|
|
+ All files
|
|
|
+ </Tabs.Trigger>
|
|
|
+ </Tabs.List>
|
|
|
+ <Tabs.Content value="changes" class="bg-background-base p-2">
|
|
|
+ <Switch>
|
|
|
+ <Match when={hasReview()}>
|
|
|
+ <Show
|
|
|
+ when={diffsReady()}
|
|
|
+ fallback={<div class="px-2 py-2 text-12-regular text-text-weak">Loading...</div>}
|
|
|
+ >
|
|
|
+ <FileTree
|
|
|
+ path=""
|
|
|
+ allowed={diffs().map((d) => d.file)}
|
|
|
+ onFileClick={(node) => focusReviewDiff(node.path)}
|
|
|
+ />
|
|
|
+ </Show>
|
|
|
+ </Match>
|
|
|
+ <Match when={true}>
|
|
|
+ <div class="px-2 py-2 text-12-regular text-text-weak">No changes</div>
|
|
|
+ </Match>
|
|
|
+ </Switch>
|
|
|
+ </Tabs.Content>
|
|
|
+ <Tabs.Content value="all" class="bg-background-base p-2">
|
|
|
+ <FileTree path="" onFileClick={(node) => openTab(file.tab(node.path))} />
|
|
|
+ </Tabs.Content>
|
|
|
+ </Tabs>
|
|
|
+ </div>
|
|
|
+ <ResizeHandle
|
|
|
+ direction="horizontal"
|
|
|
+ edge="start"
|
|
|
+ size={layout.fileTree.width()}
|
|
|
+ min={200}
|
|
|
+ max={480}
|
|
|
+ collapseThreshold={160}
|
|
|
+ onResize={layout.fileTree.resize}
|
|
|
+ onCollapse={layout.fileTree.close}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </Show>
|
|
|
</aside>
|
|
|
</Show>
|
|
|
</div>
|