From 640d1f1ecc7a2b46fb2bafed760c7348c70579a8 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 4 Jan 2026 15:40:25 -0600 Subject: wip(app): line selection --- packages/app/src/components/prompt-input.tsx | 43 +- .../src/components/session/session-context-tab.tsx | 12 +- packages/app/src/pages/session.tsx | 598 +++++++++++++-------- 3 files changed, 419 insertions(+), 234 deletions(-) (limited to 'packages/app/src') diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 44a1db253..0d6a7641a 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -15,7 +15,7 @@ import { import { createStore, produce } from "solid-js/store" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" -import { useFile, type FileSelection } from "@/context/file" +import { selectionFromLines, useFile, type FileSelection } from "@/context/file" import { ContentPart, DEFAULT_PROMPT, @@ -163,6 +163,14 @@ export const PromptInput: Component = (props) => { if (!tab) return 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 status = createMemo( () => @@ -1256,7 +1264,7 @@ export const PromptInput: Component = (props) => { const activePath = activeFile() if (activePath && prompt.context.activeTab()) { - addContextFile(activePath) + addContextFile(activePath, activeFileSelection()) } for (const item of prompt.context.items()) { @@ -1476,22 +1484,31 @@ export const PromptInput: Component = (props) => { - 0 || !!activeFile())}> -
+ 0 || !!activeFile()}> +
{(path) => ( -
- -
+
+ +
{getDirectory(path())} {getFilename(path())} + + {(sel) => ( + + {sel().startLine === sel().endLine + ? `:${sel().startLine}` + : `:${sel().startLine}-${sel().endLine}`} + + )} + {language.t("prompt.context.active")}
prompt.context.removeActive()} aria-label={language.t("prompt.context.removeActiveFile")} /> @@ -1501,7 +1518,7 @@ export const PromptInput: Component = (props) => {
@@ -1470,9 +1514,7 @@ export default function Page() { sync.session.history.loadMore(id) }} > - {historyLoading() - ? language.t("session.messages.loadingEarlier") - : language.t("session.messages.loadEarlier")} + {historyLoading() ? "Loading earlier messages..." : "Load earlier messages"}
@@ -1556,7 +1598,7 @@ export default function Page() { when={prompt.ready()} fallback={
- {handoff.prompt || language.t("prompt.loading")} + {handoff.prompt || "Loading prompt..."}
} > @@ -1608,7 +1650,7 @@ export default function Page() {
-
{language.t("session.tab.review")}
+
Review
{info()?.summary?.files ?? 0} @@ -1636,7 +1678,7 @@ export default function Page() { >
-
{language.t("session.tab.context")}
+
Context
@@ -1645,7 +1687,7 @@ export default function Page() {
@@ -1668,11 +1710,7 @@ export default function Page() { - {language.t("session.review.loadingChanges")} -
- } + fallback={
Loading changes...
} >
-
- {language.t("session.review.empty")} -
+
No changes in this session yet
@@ -1719,6 +1755,9 @@ export default function Page() { let scroll: HTMLDivElement | undefined let scrollFrame: number | undefined let pending: { x: number; y: number } | undefined + let codeScroll: HTMLElement[] = [] + + const [selectionPopoverTop, setSelectionPopoverTop] = createSignal() const path = createMemo(() => file.pathFromTab(tab)) const state = createMemo(() => { @@ -1775,28 +1814,78 @@ export default function Page() { return `L${sel.startLine}-${sel.endLine}` }) - const restoreScroll = (retries = 0) => { + const updateSelectionPopover = () => { const el = scroll - if (!el) return + if (!el) { + setSelectionPopoverTop(undefined) + return + } - const s = view()?.scroll(tab) - if (!s) return + const sel = selection() + if (!sel) { + setSelectionPopoverTop(undefined) + return + } - // Wait for content to be scrollable - content may not have rendered yet - if (el.scrollHeight <= el.clientHeight && retries < 10) { - requestAnimationFrame(() => restoreScroll(retries + 1)) + const host = el.querySelector("diffs-container") + if (!(host instanceof HTMLElement)) { + setSelectionPopoverTop(undefined) return } - if (el.scrollTop !== s.y) el.scrollTop = s.y - if (el.scrollLeft !== s.x) el.scrollLeft = s.x - } + const root = host.shadowRoot + if (!root) { + setSelectionPopoverTop(undefined) + return + } - const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { - pending = { - x: event.currentTarget.scrollLeft, - y: event.currentTarget.scrollTop, + 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 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( on( () => state()?.loaded, @@ -1844,6 +1992,10 @@ export default function Page() { ) onCleanup(() => { + for (const item of codeScroll) { + item.removeEventListener("scroll", handleCodeScroll) + } + if (scrollFrame === undefined) return cancelAnimationFrame(scrollFrame) }) @@ -1851,93 +2003,115 @@ export default function Page() { return ( { scroll = el restoreScroll() + updateSelectionPopover() }} onScroll={handleScroll} > - + {(sel) => ( - )} - - -
- {path()} -
-
- -
- { - const p = path() - if (!p) return - file.setSelectedLines(p, range) - }} - overflow="scroll" - class="select-text" - /> - -
- {path()} -
-
-
-
- +
+ + +
+ {path()} requestAnimationFrame(restoreScroll)} + /> +
+
+ +
{ + requestAnimationFrame(restoreScroll) + requestAnimationFrame(updateSelectionPopover) + }} onLineSelected={(range: SelectedLineRange | null) => { const p = path() if (!p) return file.setSelectedLines(p, range) }} overflow="scroll" - class="select-text pb-40" + class="select-text" /> - - -
{language.t("common.loading")}...
-
- - {(err) =>
{err()}
} -
- - + +
+ {path()} +
+
+
+
+ + { + requestAnimationFrame(restoreScroll) + requestAnimationFrame(updateSelectionPopover) + }} + onLineSelected={(range: SelectedLineRange | null) => { + const p = path() + if (!p) return + file.setSelectedLines(p, range) + }} + overflow="scroll" + class="select-text pb-40" + /> + + +
{language.t("common.loading")}...
+
+ + {(err) =>
{err()}
} +
+
) }} @@ -1990,11 +2164,9 @@ export default function Page() { )}
-
{language.t("common.loading")}...
-
-
- {language.t("terminal.loading")} +
Loading...
+
Loading terminal...
} > -- cgit v1.2.3