wip(app): line selection

This commit is contained in:
Adam
2026-01-04 15:40:25 -06:00
parent 2e53697da0
commit 640d1f1ecc
7 changed files with 788 additions and 261 deletions

View File

@@ -15,7 +15,7 @@ import {
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element" import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { useFile, type FileSelection } from "@/context/file" import { selectionFromLines, useFile, type FileSelection } from "@/context/file"
import { import {
ContentPart, ContentPart,
DEFAULT_PROMPT, DEFAULT_PROMPT,
@@ -163,6 +163,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!tab) return if (!tab) return
return files.pathFromTab(tab) return files.pathFromTab(tab)
}) })
const activeFileSelection = createMemo(() => {
const path = activeFile()
if (!path) return
const range = files.selectedLines(path)
if (!range) return
return selectionFromLines(range)
})
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const status = createMemo( const status = createMemo(
() => () =>
@@ -1256,7 +1264,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const activePath = activeFile() const activePath = activeFile()
if (activePath && prompt.context.activeTab()) { if (activePath && prompt.context.activeTab()) {
addContextFile(activePath) addContextFile(activePath, activeFileSelection())
} }
for (const item of prompt.context.items()) { for (const item of prompt.context.items()) {
@@ -1476,22 +1484,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div> </div>
</div> </div>
</Show> </Show>
<Show when={false && (prompt.context.items().length > 0 || !!activeFile())}> <Show when={prompt.context.items().length > 0 || !!activeFile()}>
<div class="flex flex-wrap items-center gap-2 px-3 pt-3"> <div class="flex flex-wrap items-center gap-1.5 px-3 pt-3">
<Show when={prompt.context.activeTab() ? activeFile() : undefined}> <Show when={prompt.context.activeTab() ? activeFile() : undefined}>
{(path) => ( {(path) => (
<div class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base max-w-full"> <div class="flex items-center gap-1.5 px-1.5 py-0.5 rounded-md bg-surface-base border border-border-base max-w-full">
<FileIcon node={{ path: path(), type: "file" }} class="shrink-0 size-4" /> <FileIcon node={{ path: path(), type: "file" }} class="shrink-0 size-3.5" />
<div class="flex items-center text-12-regular min-w-0"> <div class="flex items-center text-11-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(path())}</span> <span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(path())}</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(path())}</span> <span class="text-text-strong whitespace-nowrap">{getFilename(path())}</span>
<Show when={activeFileSelection()}>
{(sel) => (
<span class="text-text-weak whitespace-nowrap ml-1">
{sel().startLine === sel().endLine
? `:${sel().startLine}`
: `:${sel().startLine}-${sel().endLine}`}
</span>
)}
</Show>
<span class="text-text-weak whitespace-nowrap ml-1">{language.t("prompt.context.active")}</span> <span class="text-text-weak whitespace-nowrap ml-1">{language.t("prompt.context.active")}</span>
</div> </div>
<IconButton <IconButton
type="button" type="button"
icon="close" icon="close"
variant="ghost" variant="ghost"
class="h-6 w-6" class="h-5 w-5"
onClick={() => prompt.context.removeActive()} onClick={() => prompt.context.removeActive()}
aria-label={language.t("prompt.context.removeActiveFile")} aria-label={language.t("prompt.context.removeActiveFile")}
/> />
@@ -1501,7 +1518,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={!prompt.context.activeTab() && !!activeFile()}> <Show when={!prompt.context.activeTab() && !!activeFile()}>
<button <button
type="button" type="button"
class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-weak hover:bg-surface-raised-base-hover" class="flex items-center gap-1.5 px-1.5 py-0.5 rounded-md bg-surface-base border border-border-base text-11-regular text-text-weak hover:bg-surface-raised-base-hover"
onClick={() => prompt.context.addActive()} onClick={() => prompt.context.addActive()}
> >
<Icon name="plus-small" size="small" /> <Icon name="plus-small" size="small" />
@@ -1510,9 +1527,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Show> </Show>
<For each={prompt.context.items()}> <For each={prompt.context.items()}>
{(item) => ( {(item) => (
<div class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base max-w-full"> <div class="flex items-center gap-1.5 px-1.5 py-0.5 rounded-md bg-surface-base border border-border-base max-w-full">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" /> <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
<div class="flex items-center text-12-regular min-w-0"> <div class="flex items-center text-11-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(item.path)}</span> <span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(item.path)}</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(item.path)}</span> <span class="text-text-strong whitespace-nowrap">{getFilename(item.path)}</span>
<Show when={item.selection}> <Show when={item.selection}>
@@ -1529,7 +1546,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type="button" type="button"
icon="close" icon="close"
variant="ghost" variant="ghost"
class="h-6 w-6" class="h-5 w-5"
onClick={() => prompt.context.remove(item.key)} onClick={() => prompt.context.remove(item.key)}
aria-label={language.t("prompt.context.removeFile")} aria-label={language.t("prompt.context.removeFile")}
/> />

View File

@@ -282,7 +282,9 @@ export function SessionContextTab(props: SessionContextTabProps) {
} }
}) })
return <Code file={file()} overflow="wrap" class="select-text" /> return (
<Code file={file()} overflow="wrap" class="select-text" onRendered={() => requestAnimationFrame(restoreScroll)} />
)
} }
function RawMessage(msgProps: { message: Message }) { function RawMessage(msgProps: { message: Message }) {
@@ -314,19 +316,13 @@ export function SessionContextTab(props: SessionContextTabProps) {
let frame: number | undefined let frame: number | undefined
let pending: { x: number; y: number } | undefined let pending: { x: number; y: number } | undefined
const restoreScroll = (retries = 0) => { const restoreScroll = () => {
const el = scroll const el = scroll
if (!el) return if (!el) return
const s = props.view()?.scroll("context") const s = props.view()?.scroll("context")
if (!s) return if (!s) return
// Wait for content to be scrollable - content may not have rendered yet
if (el.scrollHeight <= el.clientHeight && retries < 10) {
requestAnimationFrame(() => restoreScroll(retries + 1))
return
}
if (el.scrollTop !== s.y) el.scrollTop = s.y if (el.scrollTop !== s.y) el.scrollTop = s.y
if (el.scrollLeft !== s.x) el.scrollLeft = s.x if (el.scrollLeft !== s.x) el.scrollLeft = s.x
} }

View File

@@ -15,7 +15,7 @@ import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type SelectedLineRange } from "@/context/file" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { PromptInput } from "@/components/prompt-input" import { PromptInput } from "@/components/prompt-input"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
@@ -102,19 +102,13 @@ function SessionReviewTab(props: SessionReviewTabProps) {
.catch(() => undefined) .catch(() => undefined)
} }
const restoreScroll = (retries = 0) => { const restoreScroll = () => {
const el = scroll const el = scroll
if (!el) return if (!el) return
const s = props.view().scroll("review") const s = props.view().scroll("review")
if (!s) return if (!s) return
// Wait for content to be scrollable - content may not have rendered yet
if (el.scrollHeight <= el.clientHeight && retries < 10) {
requestAnimationFrame(() => restoreScroll(retries + 1))
return
}
if (el.scrollTop !== s.y) el.scrollTop = s.y if (el.scrollTop !== s.y) el.scrollTop = s.y
if (el.scrollLeft !== s.x) el.scrollLeft = s.x if (el.scrollLeft !== s.x) el.scrollLeft = s.x
} }
@@ -159,6 +153,7 @@ function SessionReviewTab(props: SessionReviewTabProps) {
restoreScroll() restoreScroll()
}} }}
onScroll={handleScroll} onScroll={handleScroll}
onDiffRendered={() => requestAnimationFrame(restoreScroll)}
open={props.view().review.open()} open={props.view().review.open()}
onOpenChange={props.view().review.setOpen} onOpenChange={props.view().review.setOpen}
classes={{ classes={{
@@ -192,7 +187,6 @@ export default function Page() {
const prompt = usePrompt() const prompt = usePrompt()
const permission = usePermission() const permission = usePermission()
const [pendingMessage, setPendingMessage] = createSignal<string | undefined>(undefined) const [pendingMessage, setPendingMessage] = createSignal<string | undefined>(undefined)
const [pendingHash, setPendingHash] = createSignal<string | undefined>(undefined)
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey())) const tabs = createMemo(() => layout.tabs(sessionKey()))
const view = createMemo(() => layout.view(sessionKey())) const view = createMemo(() => layout.view(sessionKey()))
@@ -494,38 +488,73 @@ export default function Page() {
setStore("expanded", id, status().type !== "idle") setStore("expanded", id, status().type !== "idle")
}) })
const addSelectionToContext = (path: string, selection: FileSelection) => {
prompt.context.add({ type: "file", path, selection })
}
command.register(() => [ command.register(() => [
{ {
id: "session.new", id: "session.new",
title: language.t("command.session.new"), title: "New session",
category: language.t("command.category.session"), category: "Session",
keybind: "mod+shift+s", keybind: "mod+shift+s",
slash: "new", slash: "new",
onSelect: () => navigate(`/${params.dir}/session`), onSelect: () => navigate(`/${params.dir}/session`),
}, },
{ {
id: "file.open", id: "file.open",
title: language.t("command.file.open"), title: "Open file",
description: language.t("command.file.open.description"), description: "Search files and commands",
category: language.t("command.category.file"), category: "File",
keybind: "mod+p", keybind: "mod+p",
slash: "open", slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile />), onSelect: () => dialog.show(() => <DialogSelectFile />),
}, },
{
id: "context.addSelection",
title: "Add selection to context",
description: "Add selected lines from the current file",
category: "Context",
keybind: "mod+shift+l",
disabled: (() => {
const active = tabs().active()
if (!active) return true
const path = file.pathFromTab(active)
if (!path) return true
return file.selectedLines(path) == null
})(),
onSelect: () => {
const active = tabs().active()
if (!active) return
const path = file.pathFromTab(active)
if (!path) return
const range = file.selectedLines(path)
if (!range) {
showToast({
title: "No line selection",
description: "Select a line range in a file tab first.",
})
return
}
addSelectionToContext(path, selectionFromLines(range))
},
},
{ {
id: "terminal.toggle", id: "terminal.toggle",
title: language.t("command.terminal.toggle"), title: "Toggle terminal",
description: "", description: "",
category: language.t("command.category.view"), category: "View",
keybind: "ctrl+`", keybind: "ctrl+`",
slash: "terminal", slash: "terminal",
onSelect: () => view().terminal.toggle(), onSelect: () => view().terminal.toggle(),
}, },
{ {
id: "review.toggle", id: "review.toggle",
title: language.t("command.review.toggle"), title: "Toggle review",
description: "", description: "",
category: language.t("command.category.view"), category: "View",
keybind: "mod+shift+r", keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(), onSelect: () => view().reviewPanel.toggle(),
}, },
@@ -542,9 +571,9 @@ export default function Page() {
}, },
{ {
id: "steps.toggle", id: "steps.toggle",
title: language.t("command.steps.toggle"), title: "Toggle steps",
description: language.t("command.steps.toggle.description"), description: "Show or hide steps for the current message",
category: language.t("command.category.view"), category: "View",
keybind: "mod+e", keybind: "mod+e",
slash: "steps", slash: "steps",
disabled: !params.id, disabled: !params.id,
@@ -556,62 +585,62 @@ export default function Page() {
}, },
{ {
id: "message.previous", id: "message.previous",
title: language.t("command.message.previous"), title: "Previous message",
description: language.t("command.message.previous.description"), description: "Go to the previous user message",
category: language.t("command.category.session"), category: "Session",
keybind: "mod+arrowup", keybind: "mod+arrowup",
disabled: !params.id, disabled: !params.id,
onSelect: () => navigateMessageByOffset(-1), onSelect: () => navigateMessageByOffset(-1),
}, },
{ {
id: "message.next", id: "message.next",
title: language.t("command.message.next"), title: "Next message",
description: language.t("command.message.next.description"), description: "Go to the next user message",
category: language.t("command.category.session"), category: "Session",
keybind: "mod+arrowdown", keybind: "mod+arrowdown",
disabled: !params.id, disabled: !params.id,
onSelect: () => navigateMessageByOffset(1), onSelect: () => navigateMessageByOffset(1),
}, },
{ {
id: "model.choose", id: "model.choose",
title: language.t("command.model.choose"), title: "Choose model",
description: language.t("command.model.choose.description"), description: "Select a different model",
category: language.t("command.category.model"), category: "Model",
keybind: "mod+'", keybind: "mod+'",
slash: "model", slash: "model",
onSelect: () => dialog.show(() => <DialogSelectModel />), onSelect: () => dialog.show(() => <DialogSelectModel />),
}, },
{ {
id: "mcp.toggle", id: "mcp.toggle",
title: language.t("command.mcp.toggle"), title: "Toggle MCPs",
description: language.t("command.mcp.toggle.description"), description: "Toggle MCPs",
category: language.t("command.category.mcp"), category: "MCP",
keybind: "mod+;", keybind: "mod+;",
slash: "mcp", slash: "mcp",
onSelect: () => dialog.show(() => <DialogSelectMcp />), onSelect: () => dialog.show(() => <DialogSelectMcp />),
}, },
{ {
id: "agent.cycle", id: "agent.cycle",
title: language.t("command.agent.cycle"), title: "Cycle agent",
description: language.t("command.agent.cycle.description"), description: "Switch to the next agent",
category: language.t("command.category.agent"), category: "Agent",
keybind: "mod+.", keybind: "mod+.",
slash: "agent", slash: "agent",
onSelect: () => local.agent.move(1), onSelect: () => local.agent.move(1),
}, },
{ {
id: "agent.cycle.reverse", id: "agent.cycle.reverse",
title: language.t("command.agent.cycle.reverse"), title: "Cycle agent backwards",
description: language.t("command.agent.cycle.reverse.description"), description: "Switch to the previous agent",
category: language.t("command.category.agent"), category: "Agent",
keybind: "shift+mod+.", keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1), onSelect: () => local.agent.move(-1),
}, },
{ {
id: "model.variant.cycle", id: "model.variant.cycle",
title: language.t("command.model.variant.cycle"), title: "Cycle thinking effort",
description: language.t("command.model.variant.cycle.description"), description: "Switch to the next effort level",
category: language.t("command.category.model"), category: "Model",
keybind: "shift+mod+d", keybind: "shift+mod+d",
onSelect: () => { onSelect: () => {
local.model.variant.cycle() local.model.variant.cycle()
@@ -621,31 +650,30 @@ export default function Page() {
id: "permissions.autoaccept", id: "permissions.autoaccept",
title: title:
params.id && permission.isAutoAccepting(params.id, sdk.directory) params.id && permission.isAutoAccepting(params.id, sdk.directory)
? language.t("command.permissions.autoaccept.disable") ? "Stop auto-accepting edits"
: language.t("command.permissions.autoaccept.enable"), : "Auto-accept edits",
category: language.t("command.category.permissions"), category: "Permissions",
keybind: "mod+shift+a", keybind: "mod+shift+a",
disabled: !params.id || !permission.permissionsEnabled(), disabled: !params.id || !permission.permissionsEnabled(),
onSelect: () => { onSelect: () => {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return if (!sessionID) return
permission.toggleAutoAccept(sessionID, sdk.directory) permission.toggleAutoAccept(sessionID, sdk.directory)
const enabled = permission.isAutoAccepting(sessionID, sdk.directory)
showToast({ showToast({
title: enabled title: permission.isAutoAccepting(sessionID, sdk.directory)
? language.t("toast.permissions.autoaccept.on.title") ? "Auto-accepting edits"
: language.t("toast.permissions.autoaccept.off.title"), : "Stopped auto-accepting edits",
description: enabled description: permission.isAutoAccepting(sessionID, sdk.directory)
? language.t("toast.permissions.autoaccept.on.description") ? "Edit and write permissions will be automatically approved"
: language.t("toast.permissions.autoaccept.off.description"), : "Edit and write permissions will require approval",
}) })
}, },
}, },
{ {
id: "session.undo", id: "session.undo",
title: language.t("command.session.undo"), title: "Undo",
description: language.t("command.session.undo.description"), description: "Undo the last message",
category: language.t("command.category.session"), category: "Session",
slash: "undo", slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0, disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => { onSelect: async () => {
@@ -662,10 +690,7 @@ export default function Page() {
// Restore the prompt from the reverted message // Restore the prompt from the reverted message
const parts = sync.data.part[message.id] const parts = sync.data.part[message.id]
if (parts) { if (parts) {
const restored = extractPromptFromParts(parts, { const restored = extractPromptFromParts(parts, { directory: sdk.directory })
directory: sdk.directory,
attachmentName: language.t("common.attachment"),
})
prompt.set(restored) prompt.set(restored)
} }
// Navigate to the message before the reverted one (which will be the new last visible message) // Navigate to the message before the reverted one (which will be the new last visible message)
@@ -675,9 +700,9 @@ export default function Page() {
}, },
{ {
id: "session.redo", id: "session.redo",
title: language.t("command.session.redo"), title: "Redo",
description: language.t("command.session.redo.description"), description: "Redo the last undone message",
category: language.t("command.category.session"), category: "Session",
slash: "redo", slash: "redo",
disabled: !params.id || !info()?.revert?.messageID, disabled: !params.id || !info()?.revert?.messageID,
onSelect: async () => { onSelect: async () => {
@@ -704,9 +729,9 @@ export default function Page() {
}, },
{ {
id: "session.compact", id: "session.compact",
title: language.t("command.session.compact"), title: "Compact session",
description: language.t("command.session.compact.description"), description: "Summarize the session to reduce context size",
category: language.t("command.category.session"), category: "Session",
slash: "compact", slash: "compact",
disabled: !params.id || visibleUserMessages().length === 0, disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => { onSelect: async () => {
@@ -715,8 +740,8 @@ export default function Page() {
const model = local.model.current() const model = local.model.current()
if (!model) { if (!model) {
showToast({ showToast({
title: language.t("toast.model.none.title"), title: "No model selected",
description: language.t("toast.model.none.description"), description: "Connect a provider to summarize this session",
}) })
return return
} }
@@ -729,9 +754,9 @@ export default function Page() {
}, },
{ {
id: "session.fork", id: "session.fork",
title: language.t("command.session.fork"), title: "Fork from message",
description: language.t("command.session.fork.description"), description: "Create a new session from a previous message",
category: language.t("command.category.session"), category: "Session",
slash: "fork", slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0, disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />), onSelect: () => dialog.show(() => <DialogFork />),
@@ -740,9 +765,9 @@ export default function Page() {
? [ ? [
{ {
id: "session.share", id: "session.share",
title: language.t("command.session.share"), title: "Share session",
description: language.t("command.session.share.description"), description: "Share this session and copy the URL to clipboard",
category: language.t("command.category.session"), category: "Session",
slash: "share", slash: "share",
disabled: !params.id || !!info()?.share?.url, disabled: !params.id || !!info()?.share?.url,
onSelect: async () => { onSelect: async () => {
@@ -752,22 +777,22 @@ export default function Page() {
.then((res) => { .then((res) => {
navigator.clipboard.writeText(res.data!.share!.url).catch(() => navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
showToast({ showToast({
title: language.t("toast.session.share.copyFailed.title"), title: "Failed to copy URL to clipboard",
variant: "error", variant: "error",
}), }),
) )
}) })
.then(() => .then(() =>
showToast({ showToast({
title: language.t("toast.session.share.success.title"), title: "Session shared",
description: language.t("toast.session.share.success.description"), description: "Share URL copied to clipboard!",
variant: "success", variant: "success",
}), }),
) )
.catch(() => .catch(() =>
showToast({ showToast({
title: language.t("toast.session.share.failed.title"), title: "Failed to share session",
description: language.t("toast.session.share.failed.description"), description: "An error occurred while sharing the session",
variant: "error", variant: "error",
}), }),
) )
@@ -775,9 +800,9 @@ export default function Page() {
}, },
{ {
id: "session.unshare", id: "session.unshare",
title: language.t("command.session.unshare"), title: "Unshare session",
description: language.t("command.session.unshare.description"), description: "Stop sharing this session",
category: language.t("command.category.session"), category: "Session",
slash: "unshare", slash: "unshare",
disabled: !params.id || !info()?.share?.url, disabled: !params.id || !info()?.share?.url,
onSelect: async () => { onSelect: async () => {
@@ -786,15 +811,15 @@ export default function Page() {
.unshare({ sessionID: params.id }) .unshare({ sessionID: params.id })
.then(() => .then(() =>
showToast({ showToast({
title: language.t("toast.session.unshare.success.title"), title: "Session unshared",
description: language.t("toast.session.unshare.success.description"), description: "Session unshared successfully!",
variant: "success", variant: "success",
}), }),
) )
.catch(() => .catch(() =>
showToast({ showToast({
title: language.t("toast.session.unshare.failed.title"), title: "Failed to unshare session",
description: language.t("toast.session.unshare.failed.description"), description: "An error occurred while unsharing the session",
variant: "error", variant: "error",
}), }),
) )
@@ -1093,39 +1118,63 @@ export default function Page() {
const a = el.getBoundingClientRect() const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect() const b = root.getBoundingClientRect()
const offset = (info()?.title ? 40 : 0) + 12 const top = a.top - b.top + root.scrollTop
const top = a.top - b.top + root.scrollTop - offset root.scrollTo({ top, behavior })
root.scrollTo({ top: top > 0 ? top : 0, behavior })
return true return true
} }
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
// Navigating to a specific message should always pause auto-follow.
autoScroll.pause()
setActiveMessage(message) setActiveMessage(message)
updateHash(message.id)
const msgs = visibleUserMessages() const msgs = visibleUserMessages()
const index = msgs.findIndex((m) => m.id === message.id) const index = msgs.findIndex((m) => m.id === message.id)
if (index !== -1 && index < store.turnStart) { if (index !== -1 && index < store.turnStart) {
setStore("turnStart", index) setStore("turnStart", index)
scheduleTurnBackfill() scheduleTurnBackfill()
requestAnimationFrame(() => {
const el = document.getElementById(anchor(message.id))
if (!el) {
requestAnimationFrame(() => {
const next = document.getElementById(anchor(message.id))
if (!next) return
scrollToElement(next, behavior)
})
return
}
scrollToElement(el, behavior)
})
updateHash(message.id)
return
} }
const id = anchor(message.id) const el = document.getElementById(anchor(message.id))
const attempt = (tries: number) => { if (!el) {
const el = document.getElementById(id) updateHash(message.id)
if (el && scrollToElement(el, behavior)) return requestAnimationFrame(() => {
if (tries >= 8) return const next = document.getElementById(anchor(message.id))
requestAnimationFrame(() => attempt(tries + 1)) if (!next) return
if (!scrollToElement(next, behavior)) return
})
return
} }
attempt(0) if (scrollToElement(el, behavior)) {
updateHash(message.id)
return
}
requestAnimationFrame(() => {
const next = document.getElementById(anchor(message.id))
if (!next) return
if (!scrollToElement(next, behavior)) return
})
updateHash(message.id)
} }
const applyHash = (behavior: ScrollBehavior) => { const applyHash = (behavior: ScrollBehavior) => {
const hash = window.location.hash.slice(1) const hash = window.location.hash.slice(1)
if (!hash) { if (!hash) {
setPendingHash(undefined)
autoScroll.forceScrollToBottom() autoScroll.forceScrollToBottom()
return return
} }
@@ -1134,25 +1183,21 @@ export default function Page() {
if (match) { if (match) {
const msg = visibleUserMessages().find((m) => m.id === match[1]) const msg = visibleUserMessages().find((m) => m.id === match[1])
if (msg) { if (msg) {
setPendingHash(undefined)
scrollToMessage(msg, behavior) scrollToMessage(msg, behavior)
return return
} }
// If we have a message hash but the message isn't loaded/rendered yet, // If we have a message hash but the message isn't loaded/rendered yet,
// don't fall back to "bottom". We'll retry once messages arrive. // don't fall back to "bottom". We'll retry once messages arrive.
setPendingHash(match[1])
return return
} }
const target = document.getElementById(hash) const target = document.getElementById(hash)
if (target) { if (target) {
setPendingHash(undefined)
scrollToElement(target, behavior) scrollToElement(target, behavior)
return return
} }
setPendingHash(undefined)
autoScroll.forceScrollToBottom() autoScroll.forceScrollToBottom()
} }
@@ -1210,14 +1255,20 @@ export default function Page() {
visibleUserMessages().length visibleUserMessages().length
store.turnStart store.turnStart
const targetId = pendingMessage() ?? pendingHash() const targetId =
pendingMessage() ??
(() => {
const hash = window.location.hash.slice(1)
const match = hash.match(/^message-(.+)$/)
if (!match) return undefined
return match[1]
})()
if (!targetId) return if (!targetId) return
if (store.messageId === targetId) return if (store.messageId === targetId) return
const msg = visibleUserMessages().find((m) => m.id === targetId) const msg = visibleUserMessages().find((m) => m.id === targetId)
if (!msg) return if (!msg) return
if (pendingMessage() === targetId) setPendingMessage(undefined) if (pendingMessage() === targetId) setPendingMessage(undefined)
if (pendingHash() === targetId) setPendingHash(undefined)
requestAnimationFrame(() => scrollToMessage(msg, "auto")) requestAnimationFrame(() => scrollToMessage(msg, "auto"))
}) })
@@ -1305,7 +1356,7 @@ export default function Page() {
classes={{ button: "w-full" }} classes={{ button: "w-full" }}
onClick={() => setStore("mobileTab", "session")} onClick={() => setStore("mobileTab", "session")}
> >
{language.t("session.tab.session")} Session
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger <Tabs.Trigger
value="review" value="review"
@@ -1314,10 +1365,8 @@ export default function Page() {
onClick={() => setStore("mobileTab", "review")} onClick={() => setStore("mobileTab", "review")}
> >
<Switch> <Switch>
<Match when={hasReview()}> <Match when={hasReview()}>{reviewCount()} Files Changed</Match>
{language.t("session.review.filesChanged", { count: reviewCount() })} <Match when={true}>Review</Match>
</Match>
<Match when={true}>{language.t("session.tab.review")}</Match>
</Switch> </Switch>
</Tabs.Trigger> </Tabs.Trigger>
</Tabs.List> </Tabs.List>
@@ -1347,11 +1396,7 @@ export default function Page() {
<Match when={hasReview()}> <Match when={hasReview()}>
<Show <Show
when={diffsReady()} when={diffsReady()}
fallback={ fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>}
<div class="px-4 py-4 text-text-weak">
{language.t("session.review.loadingChanges")}
</div>
}
> >
<SessionReviewTab <SessionReviewTab
diffs={diffs} diffs={diffs}
@@ -1373,9 +1418,7 @@ export default function Page() {
<Match when={true}> <Match when={true}>
<div class="h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6"> <div class="h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" /> <Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56"> <div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div>
{language.t("session.review.empty")}
</div>
</div> </div>
</Match> </Match>
</Switch> </Switch>
@@ -1413,8 +1456,9 @@ export default function Page() {
if (!hasScrollGesture()) return if (!hasScrollGesture()) return
markScrollGesture(e.target) markScrollGesture(e.target)
autoScroll.handleScroll() autoScroll.handleScroll()
if (isDesktop() && autoScroll.userScrolled()) scheduleScrollSpy(e.currentTarget) if (isDesktop()) scheduleScrollSpy(e.currentTarget)
}} }}
onClick={autoScroll.handleInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller" class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
style={{ "--session-title-height": info()?.title ? "40px" : "0px" }} style={{ "--session-title-height": info()?.title ? "40px" : "0px" }}
> >
@@ -1452,7 +1496,7 @@ export default function Page() {
class="text-12-medium opacity-50" class="text-12-medium opacity-50"
onClick={() => setStore("turnStart", 0)} onClick={() => setStore("turnStart", 0)}
> >
{language.t("session.messages.renderEarlier")} Render earlier messages
</Button> </Button>
</div> </div>
</Show> </Show>
@@ -1470,9 +1514,7 @@ export default function Page() {
sync.session.history.loadMore(id) sync.session.history.loadMore(id)
}} }}
> >
{historyLoading() {historyLoading() ? "Loading earlier messages..." : "Load earlier messages"}
? language.t("session.messages.loadingEarlier")
: language.t("session.messages.loadEarlier")}
</Button> </Button>
</div> </div>
</Show> </Show>
@@ -1556,7 +1598,7 @@ export default function Page() {
when={prompt.ready()} when={prompt.ready()}
fallback={ fallback={
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"> <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
{handoff.prompt || language.t("prompt.loading")} {handoff.prompt || "Loading prompt..."}
</div> </div>
} }
> >
@@ -1608,7 +1650,7 @@ export default function Page() {
<DiffChanges changes={diffs()} variant="bars" /> <DiffChanges changes={diffs()} variant="bars" />
</Show> </Show>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div> <div>Review</div>
<Show when={info()?.summary?.files}> <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"> <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} {info()?.summary?.files ?? 0}
@@ -1636,7 +1678,7 @@ export default function Page() {
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" /> <SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div> <div>Context</div>
</div> </div>
</Tabs.Trigger> </Tabs.Trigger>
</Show> </Show>
@@ -1645,7 +1687,7 @@ export default function Page() {
</SortableProvider> </SortableProvider>
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3"> <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
<TooltipKeybind <TooltipKeybind
title={language.t("command.file.open")} title="Open file"
keybind={command.keybind("file.open")} keybind={command.keybind("file.open")}
class="flex items-center" class="flex items-center"
> >
@@ -1668,11 +1710,7 @@ export default function Page() {
<Match when={hasReview()}> <Match when={hasReview()}>
<Show <Show
when={diffsReady()} when={diffsReady()}
fallback={ fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>}
<div class="px-6 py-4 text-text-weak">
{language.t("session.review.loadingChanges")}
</div>
}
> >
<SessionReviewTab <SessionReviewTab
diffs={diffs} diffs={diffs}
@@ -1690,9 +1728,7 @@ export default function Page() {
<Match when={true}> <Match when={true}>
<div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6"> <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" /> <Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56"> <div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div>
{language.t("session.review.empty")}
</div>
</div> </div>
</Match> </Match>
</Switch> </Switch>
@@ -1719,6 +1755,9 @@ export default function Page() {
let scroll: HTMLDivElement | undefined let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined let scrollFrame: number | undefined
let pending: { x: number; y: number } | undefined let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = []
const [selectionPopoverTop, setSelectionPopoverTop] = createSignal<number | undefined>()
const path = createMemo(() => file.pathFromTab(tab)) const path = createMemo(() => file.pathFromTab(tab))
const state = createMemo(() => { const state = createMemo(() => {
@@ -1775,28 +1814,78 @@ export default function Page() {
return `L${sel.startLine}-${sel.endLine}` return `L${sel.startLine}-${sel.endLine}`
}) })
const restoreScroll = (retries = 0) => { const updateSelectionPopover = () => {
const el = scroll const el = scroll
if (!el) return if (!el) {
setSelectionPopoverTop(undefined)
const s = view()?.scroll(tab)
if (!s) return
// Wait for content to be scrollable - content may not have rendered yet
if (el.scrollHeight <= el.clientHeight && retries < 10) {
requestAnimationFrame(() => restoreScroll(retries + 1))
return return
} }
if (el.scrollTop !== s.y) el.scrollTop = s.y const sel = selection()
if (el.scrollLeft !== s.x) el.scrollLeft = s.x if (!sel) {
setSelectionPopoverTop(undefined)
return
} }
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { const host = el.querySelector("diffs-container")
pending = { if (!(host instanceof HTMLElement)) {
x: event.currentTarget.scrollLeft, setSelectionPopoverTop(undefined)
y: event.currentTarget.scrollTop, return
} }
const root = host.shadowRoot
if (!root) {
setSelectionPopoverTop(undefined)
return
}
const marker =
(root.querySelector(
'[data-selected-line="last"], [data-selected-line="single"]',
) as HTMLElement | null) ?? (root.querySelector("[data-selected-line]") as HTMLElement | null)
if (!marker) {
setSelectionPopoverTop(undefined)
return
}
const containerRect = el.getBoundingClientRect()
const markerRect = marker.getBoundingClientRect()
setSelectionPopoverTop(markerRect.bottom - containerRect.top + el.scrollTop + 8)
}
createEffect(
on(
selection,
(sel) => {
if (!sel) {
setSelectionPopoverTop(undefined)
return
}
requestAnimationFrame(updateSelectionPopover)
},
{ defer: true },
),
)
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,
)
}
const queueScrollUpdate = (next: { x: number; y: number }) => {
pending = next
if (scrollFrame !== undefined) return if (scrollFrame !== undefined) return
scrollFrame = requestAnimationFrame(() => { scrollFrame = requestAnimationFrame(() => {
@@ -1810,6 +1899,65 @@ export default function Page() {
}) })
} }
const handleCodeScroll = (event: Event) => {
const el = scroll
if (!el) return
const target = event.currentTarget
if (!(target instanceof HTMLElement)) return
queueScrollUpdate({
x: target.scrollLeft,
y: el.scrollTop,
})
}
const syncCodeScroll = () => {
const next = getCodeScroll()
if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
for (const item of codeScroll) {
item.removeEventListener("scroll", handleCodeScroll)
}
codeScroll = next
for (const item of codeScroll) {
item.addEventListener("scroll", handleCodeScroll)
}
}
const restoreScroll = () => {
const el = scroll
if (!el) return
const s = view()?.scroll(tab)
if (!s) return
syncCodeScroll()
if (codeScroll.length > 0) {
for (const item of codeScroll) {
if (item.scrollLeft !== s.x) item.scrollLeft = s.x
}
}
if (el.scrollTop !== s.y) el.scrollTop = s.y
if (codeScroll.length > 0) return
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (codeScroll.length === 0) syncCodeScroll()
queueScrollUpdate({
x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
})
}
createEffect( createEffect(
on( on(
() => state()?.loaded, () => state()?.loaded,
@@ -1844,6 +1992,10 @@ export default function Page() {
) )
onCleanup(() => { onCleanup(() => {
for (const item of codeScroll) {
item.removeEventListener("scroll", handleCodeScroll)
}
if (scrollFrame === undefined) return if (scrollFrame === undefined) return
cancelAnimationFrame(scrollFrame) cancelAnimationFrame(scrollFrame)
}) })
@@ -1851,38 +2003,53 @@ export default function Page() {
return ( return (
<Tabs.Content <Tabs.Content
value={tab} value={tab}
class="mt-3" class="mt-3 relative"
ref={(el: HTMLDivElement) => { ref={(el: HTMLDivElement) => {
scroll = el scroll = el
restoreScroll() restoreScroll()
updateSelectionPopover()
}} }}
onScroll={handleScroll} onScroll={handleScroll}
> >
<Show when={activeTab() === tab}> <Show when={activeTab() === tab}>
<Show when={selection()}> <Show when={selectionPopoverTop() !== undefined && selection()}>
{(sel) => ( {(sel) => (
<div class="hidden sticky top-0 z-10 px-6 py-2 _flex justify-end bg-background-base border-b border-border-weak-base"> <div class="absolute z-20 right-6" style={{ top: `${selectionPopoverTop() ?? 0}px` }}>
<TooltipKeybind
placement="bottom"
title="Add selection to context"
keybind={command.keybind("context.addSelection")}
>
<button <button
type="button" type="button"
class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-strong hover:bg-surface-raised-base-hover" class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-raised-stronger-non-alpha border border-border-base text-12-regular text-text-strong hover:bg-surface-raised-base-hover"
onClick={() => { onClick={() => {
const p = path() const p = path()
if (!p) return if (!p) return
prompt.context.add({ type: "file", path: p, selection: sel() }) addSelectionToContext(p, sel())
}} }}
> >
<Icon name="plus-small" size="small" /> <Icon name="plus-small" size="small" />
<span> <span>
{language.t("session.context.addToContext", { selection: selectionLabel() ?? "" })} {language.t("session.context.addToContext", {
selection: selectionLabel() ?? "",
})}
</span> </span>
</button> </button>
</TooltipKeybind>
</div> </div>
)} )}
</Show> </Show>
</Show>
<Switch> <Switch>
<Match when={state()?.loaded && isImage()}> <Match when={state()?.loaded && isImage()}>
<div class="px-6 py-4 pb-40"> <div class="px-6 py-4 pb-40">
<img src={imageDataUrl()} alt={path()} class="max-w-full" /> <img
src={imageDataUrl()}
alt={path()}
class="max-w-full"
onLoad={() => requestAnimationFrame(restoreScroll)}
/>
</div> </div>
</Match> </Match>
<Match when={state()?.loaded && isSvg()}> <Match when={state()?.loaded && isSvg()}>
@@ -1896,6 +2063,10 @@ export default function Page() {
}} }}
enableLineSelection enableLineSelection
selectedLines={selectedLines()} selectedLines={selectedLines()}
onRendered={() => {
requestAnimationFrame(restoreScroll)
requestAnimationFrame(updateSelectionPopover)
}}
onLineSelected={(range: SelectedLineRange | null) => { onLineSelected={(range: SelectedLineRange | null) => {
const p = path() const p = path()
if (!p) return if (!p) return
@@ -1921,6 +2092,10 @@ export default function Page() {
}} }}
enableLineSelection enableLineSelection
selectedLines={selectedLines()} selectedLines={selectedLines()}
onRendered={() => {
requestAnimationFrame(restoreScroll)
requestAnimationFrame(updateSelectionPopover)
}}
onLineSelected={(range: SelectedLineRange | null) => { onLineSelected={(range: SelectedLineRange | null) => {
const p = path() const p = path()
if (!p) return if (!p) return
@@ -1937,7 +2112,6 @@ export default function Page() {
{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>} {(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
</Match> </Match>
</Switch> </Switch>
</Show>
</Tabs.Content> </Tabs.Content>
) )
}} }}
@@ -1990,11 +2164,9 @@ export default function Page() {
)} )}
</For> </For>
<div class="flex-1" /> <div class="flex-1" />
<div class="text-text-weak pr-2">{language.t("common.loading")}...</div> <div class="text-text-weak pr-2">Loading...</div>
</div>
<div class="flex-1 flex items-center justify-center text-text-weak">
{language.t("terminal.loading")}
</div> </div>
<div class="flex-1 flex items-center justify-center text-text-weak">Loading terminal...</div>
</div> </div>
} }
> >

View File

@@ -9,6 +9,7 @@ export type CodeProps<T = {}> = FileOptions<T> & {
file: FileContents file: FileContents
annotations?: LineAnnotation<T>[] annotations?: LineAnnotation<T>[]
selectedLines?: SelectedLineRange | null selectedLines?: SelectedLineRange | null
onRendered?: () => void
class?: string class?: string
classList?: ComponentProps<"div">["classList"] classList?: ComponentProps<"div">["classList"]
} }
@@ -45,8 +46,32 @@ function findSide(node: Node | null): SelectionSide | undefined {
export function Code<T>(props: CodeProps<T>) { export function Code<T>(props: CodeProps<T>) {
let container!: HTMLDivElement let container!: HTMLDivElement
let observer: MutationObserver | undefined
let renderToken = 0
let selectionFrame: number | undefined
let dragFrame: number | undefined
let dragStart: number | undefined
let dragEnd: number | undefined
let dragMoved = false
const [local, others] = splitProps(props, ["file", "class", "classList", "annotations", "selectedLines"]) const [local, others] = splitProps(props, [
"file",
"class",
"classList",
"annotations",
"selectedLines",
"onRendered",
])
const handleLineClick: FileOptions<T>["onLineClick"] = (info) => {
props.onLineClick?.(info)
if (props.enableLineSelection !== true) return
if (info.numberColumn) return
if (!local.selectedLines) return
file().setSelectedLines(null)
}
const file = createMemo( const file = createMemo(
() => () =>
@@ -54,6 +79,7 @@ export function Code<T>(props: CodeProps<T>) {
{ {
...createDefaultOptions<T>("unified"), ...createDefaultOptions<T>("unified"),
...others, ...others,
onLineClick: props.enableLineSelection === true || props.onLineClick ? handleLineClick : undefined,
}, },
getWorkerPool("unified"), getWorkerPool("unified"),
), ),
@@ -69,37 +95,218 @@ export function Code<T>(props: CodeProps<T>) {
return root return root
} }
const handleMouseUp = () => { const notifyRendered = () => {
if (props.enableLineSelection !== true) return if (!local.onRendered) return
observer?.disconnect()
observer = undefined
renderToken++
const token = renderToken
const lines = (() => {
const text = local.file.contents
const total = text.split("\n").length - (text.endsWith("\n") ? 1 : 0)
return Math.max(1, total)
})()
const isReady = (root: ShadowRoot) => root.querySelectorAll("[data-line]").length >= lines
const notify = () => {
if (token !== renderToken) return
observer?.disconnect()
observer = undefined
requestAnimationFrame(() => {
if (token !== renderToken) return
local.onRendered?.()
})
}
const root = getRoot()
if (root && isReady(root)) {
notify()
return
}
if (typeof MutationObserver === "undefined") return
const observeRoot = (root: ShadowRoot) => {
if (isReady(root)) {
notify()
return
}
observer?.disconnect()
observer = new MutationObserver(() => {
if (token !== renderToken) return
if (!isReady(root)) return
notify()
})
observer.observe(root, { childList: true, subtree: true })
}
if (root) {
observeRoot(root)
return
}
observer = new MutationObserver(() => {
if (token !== renderToken) return
const root = getRoot() const root = getRoot()
if (!root) return if (!root) return
const selection = window.getSelection() observeRoot(root)
})
observer.observe(container, { childList: true, subtree: true })
}
const updateSelection = () => {
const root = getRoot()
if (!root) return
const selection =
(root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
if (!selection || selection.isCollapsed) return if (!selection || selection.isCollapsed) return
const anchor = selection.anchorNode const domRange =
const focus = selection.focusNode (
if (!anchor || !focus) return selection as unknown as {
if (!root.contains(anchor) || !root.contains(focus)) return getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[]
}
).getComposedRanges?.({ shadowRoots: [root] })?.[0] ??
(selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined)
const start = findLineNumber(anchor) const startNode = domRange?.startContainer ?? selection.anchorNode
const end = findLineNumber(focus) const endNode = domRange?.endContainer ?? selection.focusNode
if (!startNode || !endNode) return
if (!root.contains(startNode) || !root.contains(endNode)) return
const start = findLineNumber(startNode)
const end = findLineNumber(endNode)
if (start === undefined || end === undefined) return if (start === undefined || end === undefined) return
const startSide = findSide(anchor) const startSide = findSide(startNode)
const endSide = findSide(focus) const endSide = findSide(endNode)
const side = startSide ?? endSide const side = startSide ?? endSide
const range: SelectedLineRange = { const selected: SelectedLineRange = {
start, start,
end, end,
} }
if (side) range.side = side if (side) selected.side = side
if (endSide && side && endSide !== side) range.endSide = endSide if (endSide && side && endSide !== side) selected.endSide = endSide
file().setSelectedLines(range) file().setSelectedLines(selected)
}
const scheduleSelectionUpdate = () => {
if (selectionFrame !== undefined) return
selectionFrame = requestAnimationFrame(() => {
selectionFrame = undefined
updateSelection()
})
}
const updateDragSelection = () => {
if (dragStart === undefined || dragEnd === undefined) return
const start = Math.min(dragStart, dragEnd)
const end = Math.max(dragStart, dragEnd)
file().setSelectedLines({ start, end })
}
const scheduleDragUpdate = () => {
if (dragFrame !== undefined) return
dragFrame = requestAnimationFrame(() => {
dragFrame = undefined
updateDragSelection()
})
}
const lineFromMouseEvent = (event: MouseEvent) => {
const path = event.composedPath()
let numberColumn = false
let line: number | undefined
for (const item of path) {
if (!(item instanceof HTMLElement)) continue
numberColumn = numberColumn || item.dataset.columnNumber != null
if (line === undefined && item.dataset.line) {
const parsed = parseInt(item.dataset.line, 10)
if (!Number.isNaN(parsed)) line = parsed
}
if (numberColumn && line !== undefined) break
}
return { line, numberColumn }
}
const handleMouseDown = (event: MouseEvent) => {
if (props.enableLineSelection !== true) return
if (event.button !== 0) return
const { line, numberColumn } = lineFromMouseEvent(event)
if (numberColumn) return
if (line === undefined) return
dragStart = line
dragEnd = line
dragMoved = false
}
const handleMouseMove = (event: MouseEvent) => {
if (props.enableLineSelection !== true) return
if (dragStart === undefined) return
if ((event.buttons & 1) === 0) {
dragStart = undefined
dragEnd = undefined
dragMoved = false
return
}
const { line } = lineFromMouseEvent(event)
if (line === undefined) return
dragEnd = line
dragMoved = true
scheduleDragUpdate()
}
const handleMouseUp = () => {
if (props.enableLineSelection !== true) return
if (dragStart !== undefined) {
if (dragMoved) scheduleDragUpdate()
dragStart = undefined
dragEnd = undefined
dragMoved = false
}
scheduleSelectionUpdate()
}
const handleSelectionChange = () => {
if (props.enableLineSelection !== true) return
const selection = window.getSelection()
if (!selection || selection.isCollapsed) return
scheduleSelectionUpdate()
} }
createEffect(() => { createEffect(() => {
@@ -111,12 +318,17 @@ export function Code<T>(props: CodeProps<T>) {
}) })
createEffect(() => { createEffect(() => {
observer?.disconnect()
observer = undefined
container.innerHTML = "" container.innerHTML = ""
file().render({ file().render({
file: local.file, file: local.file,
lineAnnotations: local.annotations, lineAnnotations: local.annotations,
containerWrapper: container, containerWrapper: container,
}) })
notifyRendered()
}) })
createEffect(() => { createEffect(() => {
@@ -126,13 +338,37 @@ export function Code<T>(props: CodeProps<T>) {
createEffect(() => { createEffect(() => {
if (props.enableLineSelection !== true) return if (props.enableLineSelection !== true) return
container.addEventListener("mouseup", handleMouseUp) container.addEventListener("mousedown", handleMouseDown)
container.addEventListener("mousemove", handleMouseMove)
window.addEventListener("mouseup", handleMouseUp)
document.addEventListener("selectionchange", handleSelectionChange)
onCleanup(() => { onCleanup(() => {
container.removeEventListener("mouseup", handleMouseUp) container.removeEventListener("mousedown", handleMouseDown)
container.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp)
document.removeEventListener("selectionchange", handleSelectionChange)
}) })
}) })
onCleanup(() => {
observer?.disconnect()
if (selectionFrame !== undefined) {
cancelAnimationFrame(selectionFrame)
selectionFrame = undefined
}
if (dragFrame !== undefined) {
cancelAnimationFrame(dragFrame)
dragFrame = undefined
}
dragStart = undefined
dragEnd = undefined
dragMoved = false
})
return ( return (
<div <div
data-component="code" data-component="code"

View File

@@ -7,7 +7,10 @@ import { getWorkerPool } from "../pierre/worker"
export function Diff<T>(props: DiffProps<T>) { export function Diff<T>(props: DiffProps<T>) {
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations"]) let observer: MutationObserver | undefined
let renderToken = 0
const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations", "onRendered"])
const mobile = createMediaQuery("(max-width: 640px)") const mobile = createMediaQuery("(max-width: 640px)")
@@ -25,6 +28,95 @@ export function Diff<T>(props: DiffProps<T>) {
let instance: FileDiff<T> | undefined let instance: FileDiff<T> | undefined
const getRoot = () => {
const host = container.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return
const root = host.shadowRoot
if (!root) return
return root
}
const notifyRendered = () => {
if (!local.onRendered) return
observer?.disconnect()
observer = undefined
renderToken++
const token = renderToken
let settle = 0
const isReady = (root: ShadowRoot) => root.querySelector("[data-line]") != null
const notify = () => {
if (token !== renderToken) return
observer?.disconnect()
observer = undefined
requestAnimationFrame(() => {
if (token !== renderToken) return
local.onRendered?.()
})
}
const schedule = () => {
settle++
const current = settle
requestAnimationFrame(() => {
if (token !== renderToken) return
if (current !== settle) return
requestAnimationFrame(() => {
if (token !== renderToken) return
if (current !== settle) return
notify()
})
})
}
const observeRoot = (root: ShadowRoot) => {
observer?.disconnect()
observer = new MutationObserver(() => {
if (token !== renderToken) return
if (!isReady(root)) return
schedule()
})
observer.observe(root, { childList: true, subtree: true })
if (!isReady(root)) return
schedule()
}
const root = getRoot()
if (typeof MutationObserver === "undefined") {
if (!root || !isReady(root)) return
local.onRendered()
return
}
if (root) {
observeRoot(root)
return
}
observer = new MutationObserver(() => {
if (token !== renderToken) return
const root = getRoot()
if (!root) return
observeRoot(root)
})
observer.observe(container, { childList: true, subtree: true })
}
createEffect(() => { createEffect(() => {
const opts = options() const opts = options()
const workerPool = getWorkerPool(props.diffStyle) const workerPool = getWorkerPool(props.diffStyle)
@@ -50,9 +142,12 @@ export function Diff<T>(props: DiffProps<T>) {
lineAnnotations: annotations, lineAnnotations: annotations,
containerWrapper: container, containerWrapper: container,
}) })
notifyRendered()
}) })
onCleanup(() => { onCleanup(() => {
observer?.disconnect()
instance?.cleanUp() instance?.cleanUp()
}) })

View File

@@ -22,6 +22,7 @@ export interface SessionReviewProps {
split?: boolean split?: boolean
diffStyle?: SessionReviewDiffStyle diffStyle?: SessionReviewDiffStyle
onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
onDiffRendered?: () => void
open?: string[] open?: string[]
onOpenChange?: (open: string[]) => void onOpenChange?: (open: string[]) => void
scrollRef?: (el: HTMLDivElement) => void scrollRef?: (el: HTMLDivElement) => void
@@ -346,6 +347,7 @@ export const SessionReview = (props: SessionReviewProps) => {
component={diffComponent} component={diffComponent}
preloadedDiff={diff.preloaded} preloadedDiff={diff.preloaded}
diffStyle={diffStyle()} diffStyle={diffStyle()}
onRendered={props.onDiffRendered}
before={{ before={{
name: diff.file!, name: diff.file!,
contents: beforeText(), contents: beforeText(),

View File

@@ -5,6 +5,7 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
before: FileContents before: FileContents
after: FileContents after: FileContents
annotations?: DiffLineAnnotation<T>[] annotations?: DiffLineAnnotation<T>[]
onRendered?: () => void
class?: string class?: string
classList?: ComponentProps<"div">["classList"] classList?: ComponentProps<"div">["classList"]
} }
@@ -18,9 +19,9 @@ const unsafeCSS = `
--diffs-bg-separator: var(--diffs-bg-separator-override, light-dark( color-mix(in lab, var(--diffs-bg) 96%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-mixer)))); --diffs-bg-separator: var(--diffs-bg-separator-override, light-dark( color-mix(in lab, var(--diffs-bg) 96%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-mixer))));
--diffs-fg: light-dark(var(--diffs-light), var(--diffs-dark)); --diffs-fg: light-dark(var(--diffs-light), var(--diffs-dark));
--diffs-fg-number: var(--diffs-fg-number-override, light-dark(color-mix(in lab, var(--diffs-fg) 65%, var(--diffs-bg)), color-mix(in lab, var(--diffs-fg) 65%, var(--diffs-bg)))); --diffs-fg-number: var(--diffs-fg-number-override, light-dark(color-mix(in lab, var(--diffs-fg) 65%, var(--diffs-bg)), color-mix(in lab, var(--diffs-fg) 65%, var(--diffs-bg))));
--diffs-deletion-base: var(--diffs-deletion-color-override, light-dark(var(--diffs-light-deletion-color, var(--diffs-deletion-color, rgb(255, 0, 0))), var(--diffs-dark-deletion-color, var(--diffs-deletion-color, rgb(255, 0, 0))))); --diffs-deletion-base: var(--syntax-diff-delete);
--diffs-addition-base: var(--diffs-addition-color-override, light-dark(var(--diffs-light-addition-color, var(--diffs-addition-color, rgb(0, 255, 0))), var(--diffs-dark-addition-color, var(--diffs-addition-color, rgb(0, 255, 0))))); --diffs-addition-base: var(--syntax-diff-add);
--diffs-modified-base: var(--diffs-modified-color-override, light-dark(var(--diffs-light-modified-color, var(--diffs-modified-color, rgb(0, 0, 255))), var(--diffs-dark-modified-color, var(--diffs-modified-color, rgb(0, 0, 255))))); --diffs-modified-base: var(--syntax-diff-unknown);
--diffs-bg-deletion: var(--diffs-bg-deletion-override, light-dark( color-mix(in lab, var(--diffs-bg) 98%, var(--diffs-deletion-base)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-deletion-base)))); --diffs-bg-deletion: var(--diffs-bg-deletion-override, light-dark( color-mix(in lab, var(--diffs-bg) 98%, var(--diffs-deletion-base)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-deletion-base))));
--diffs-bg-deletion-number: var(--diffs-bg-deletion-number-override, light-dark( color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-deletion-base)), color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-deletion-base)))); --diffs-bg-deletion-number: var(--diffs-bg-deletion-number-override, light-dark( color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-deletion-base)), color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-deletion-base))));
--diffs-bg-deletion-hover: var(--diffs-bg-deletion-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 80%, var(--diffs-deletion-base)), color-mix(in lab, var(--diffs-bg) 75%, var(--diffs-deletion-base)))); --diffs-bg-deletion-hover: var(--diffs-bg-deletion-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 80%, var(--diffs-deletion-base)), color-mix(in lab, var(--diffs-bg) 75%, var(--diffs-deletion-base))));
@@ -29,10 +30,15 @@ const unsafeCSS = `
--diffs-bg-addition-number: var(--diffs-bg-addition-number-override, light-dark( color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-addition-base)), color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-addition-base)))); --diffs-bg-addition-number: var(--diffs-bg-addition-number-override, light-dark( color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-addition-base)), color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-addition-base))));
--diffs-bg-addition-hover: var(--diffs-bg-addition-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 80%, var(--diffs-addition-base)), color-mix(in lab, var(--diffs-bg) 70%, var(--diffs-addition-base)))); --diffs-bg-addition-hover: var(--diffs-bg-addition-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 80%, var(--diffs-addition-base)), color-mix(in lab, var(--diffs-bg) 70%, var(--diffs-addition-base))));
--diffs-bg-addition-emphasis: var(--diffs-bg-addition-emphasis-override, light-dark(rgb(from var(--diffs-addition-base) r g b / 0.07), rgb(from var(--diffs-addition-base) r g b / 0.1))); --diffs-bg-addition-emphasis: var(--diffs-bg-addition-emphasis-override, light-dark(rgb(from var(--diffs-addition-base) r g b / 0.07), rgb(from var(--diffs-addition-base) r g b / 0.1)));
--diffs-selection-base: var(--diffs-modified-base); --diffs-selection-base: var(--text-interactive-base);
--diffs-selection-number-fg: light-dark( color-mix(in lab, var(--diffs-selection-base) 65%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-selection-base) 75%, var(--diffs-mixer))); --diffs-selection-number-fg: light-dark( color-mix(in lab, var(--diffs-selection-base) 65%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-selection-base) 75%, var(--diffs-mixer)));
--diffs-bg-selection: var(--diffs-bg-selection-override, light-dark( color-mix(in lab, var(--diffs-bg) 82%, var(--diffs-selection-base)), color-mix(in lab, var(--diffs-bg) 75%, var(--diffs-selection-base)))); --diffs-bg-selection: var(--diffs-bg-selection-override, rgb(from var(--diffs-selection-base) r g b / 0.18));
--diffs-bg-selection-number: var(--diffs-bg-selection-number-override, light-dark( color-mix(in lab, var(--diffs-bg) 75%, var(--diffs-selection-base)), color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-selection-base)))); --diffs-bg-selection-number: var(--diffs-bg-selection-number-override, rgb(from var(--diffs-selection-base) r g b / 0.22));
--diffs-bg-selection-text: rgb(from var(--diffs-selection-base) r g b / 0.12);
}
[data-diffs] ::selection {
background-color: var(--diffs-bg-selection-text);
} }
[data-diffs-header], [data-diffs-header],
@@ -57,6 +63,9 @@ const unsafeCSS = `
[data-separator-content] { [data-separator-content] {
height: 24px !important; height: 24px !important;
} }
[data-column-number] {
background-color: var(--background-stronger);
}
[data-code] { [data-code] {
overflow-x: auto !important; overflow-x: auto !important;
} }