Skip to content

Commit 0eb5236

Browse files
committed
wip(app): line selection
1 parent 99e15ca commit 0eb5236

4 files changed

Lines changed: 431 additions & 269 deletions

File tree

packages/app/src/components/prompt-input.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1568,6 +1568,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
15681568
class="h-5 w-5"
15691569
onClick={(e) => {
15701570
e.stopPropagation()
1571+
if (item.commentID) comments.remove(item.path, item.commentID)
15711572
prompt.context.remove(item.key)
15721573
}}
15731574
aria-label={language.t("prompt.context.removeFile")}

packages/app/src/pages/session.tsx

Lines changed: 255 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { useTerminal, type LocalPTY } from "@/context/terminal"
3939
import { useLayout } from "@/context/layout"
4040
import { Terminal } from "@/components/terminal"
4141
import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode"
42+
import { getFilename } from "@opencode-ai/util/path"
4243
import { useDialog } from "@opencode-ai/ui/context/dialog"
4344
import { DialogSelectFile } from "@/components/dialog-select-file"
4445
import { DialogSelectModel } from "@/components/dialog-select-model"
@@ -1866,6 +1867,258 @@ export default function Page() {
18661867
return `L${sel.startLine}-${sel.endLine}`
18671868
})
18681869

1870+
let wrap: HTMLDivElement | undefined
1871+
let textarea: HTMLTextAreaElement | undefined
1872+
1873+
const fileComments = createMemo(() => {
1874+
const p = path()
1875+
if (!p) return []
1876+
return comments.list(p)
1877+
})
1878+
1879+
const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
1880+
1881+
const [openedComment, setOpenedComment] = createSignal<string | null>(null)
1882+
const [commenting, setCommenting] = createSignal<SelectedLineRange | null>(null)
1883+
const [draft, setDraft] = createSignal("")
1884+
const [positions, setPositions] = createSignal<Record<string, number>>({})
1885+
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
1886+
1887+
const commentLabel = (range: SelectedLineRange) => {
1888+
const start = Math.min(range.start, range.end)
1889+
const end = Math.max(range.start, range.end)
1890+
if (start === end) return `line ${start}`
1891+
return `lines ${start}-${end}`
1892+
}
1893+
1894+
const getRoot = () => {
1895+
const el = wrap
1896+
if (!el) return
1897+
1898+
const host = el.querySelector("diffs-container")
1899+
if (!(host instanceof HTMLElement)) return
1900+
1901+
const root = host.shadowRoot
1902+
if (!root) return
1903+
1904+
return root
1905+
}
1906+
1907+
const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
1908+
const line = Math.max(range.start, range.end)
1909+
const node = root.querySelector(`[data-line="${line}"]`)
1910+
if (!(node instanceof HTMLElement)) return
1911+
return node
1912+
}
1913+
1914+
const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
1915+
const wrapperRect = wrapper.getBoundingClientRect()
1916+
const rect = marker.getBoundingClientRect()
1917+
return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
1918+
}
1919+
1920+
const updateComments = () => {
1921+
const el = wrap
1922+
const root = getRoot()
1923+
if (!el || !root) {
1924+
setPositions({})
1925+
setDraftTop(undefined)
1926+
return
1927+
}
1928+
1929+
const next: Record<string, number> = {}
1930+
for (const comment of fileComments()) {
1931+
const marker = findMarker(root, comment.selection)
1932+
if (!marker) continue
1933+
next[comment.id] = markerTop(el, marker)
1934+
}
1935+
1936+
setPositions(next)
1937+
1938+
const range = commenting()
1939+
if (!range) {
1940+
setDraftTop(undefined)
1941+
return
1942+
}
1943+
1944+
const marker = findMarker(root, range)
1945+
if (!marker) {
1946+
setDraftTop(undefined)
1947+
return
1948+
}
1949+
1950+
setDraftTop(markerTop(el, marker))
1951+
}
1952+
1953+
const scheduleComments = () => {
1954+
requestAnimationFrame(updateComments)
1955+
}
1956+
1957+
createEffect(() => {
1958+
fileComments()
1959+
scheduleComments()
1960+
})
1961+
1962+
createEffect(() => {
1963+
commenting()
1964+
scheduleComments()
1965+
})
1966+
1967+
createEffect(() => {
1968+
const range = commenting()
1969+
if (!range) return
1970+
setDraft("")
1971+
requestAnimationFrame(() => textarea?.focus())
1972+
})
1973+
1974+
const renderCode = (source: string, wrapperClass: string) => (
1975+
<div
1976+
ref={(el) => {
1977+
wrap = el
1978+
scheduleComments()
1979+
}}
1980+
class={`relative overflow-hidden ${wrapperClass}`}
1981+
>
1982+
<Dynamic
1983+
component={codeComponent}
1984+
file={{
1985+
name: path() ?? "",
1986+
contents: source,
1987+
cacheKey: cacheKey(),
1988+
}}
1989+
enableLineSelection
1990+
selectedLines={selectedLines()}
1991+
commentedLines={commentedLines()}
1992+
onRendered={() => {
1993+
requestAnimationFrame(restoreScroll)
1994+
requestAnimationFrame(updateSelectionPopover)
1995+
requestAnimationFrame(scheduleComments)
1996+
}}
1997+
onLineSelected={(range: SelectedLineRange | null) => {
1998+
const p = path()
1999+
if (!p) return
2000+
file.setSelectedLines(p, range)
2001+
if (!range) setCommenting(null)
2002+
}}
2003+
onLineSelectionEnd={(range: SelectedLineRange | null) => {
2004+
if (!range) {
2005+
setCommenting(null)
2006+
return
2007+
}
2008+
2009+
setOpenedComment(null)
2010+
setCommenting(range)
2011+
}}
2012+
overflow="scroll"
2013+
class="select-text"
2014+
/>
2015+
<For each={fileComments()}>
2016+
{(comment) => (
2017+
<div
2018+
class="absolute right-6 z-30"
2019+
style={{
2020+
top: `${positions()[comment.id] ?? 0}px`,
2021+
opacity: positions()[comment.id] === undefined ? 0 : 1,
2022+
"pointer-events": positions()[comment.id] === undefined ? "none" : "auto",
2023+
}}
2024+
>
2025+
<button
2026+
type="button"
2027+
class="size-5 rounded-md flex items-center justify-center bg-surface-warning-base border border-border-warning-base text-icon-warning-active shadow-xs hover:bg-surface-warning-weak hover:border-border-warning-hover focus:outline-none focus-visible:shadow-xs-border-focus"
2028+
onMouseEnter={() => {
2029+
const p = path()
2030+
if (!p) return
2031+
file.setSelectedLines(p, comment.selection)
2032+
}}
2033+
onClick={() => {
2034+
const p = path()
2035+
if (!p) return
2036+
setCommenting(null)
2037+
setOpenedComment((current) => (current === comment.id ? null : comment.id))
2038+
file.setSelectedLines(p, comment.selection)
2039+
}}
2040+
>
2041+
<Icon name="speech-bubble" size="small" />
2042+
</button>
2043+
<Show when={openedComment() === comment.id}>
2044+
<div class="absolute top-0 right-[calc(100%+12px)] z-40 min-w-[200px] max-w-[320px] rounded-md bg-surface-raised-stronger-non-alpha border border-border-base shadow-md p-3">
2045+
<div class="flex flex-col gap-1.5">
2046+
<div class="text-12-medium text-text-strong whitespace-nowrap">
2047+
{getFilename(comment.file)}:{commentLabel(comment.selection)}
2048+
</div>
2049+
<div class="text-12-regular text-text-base whitespace-pre-wrap">
2050+
{comment.comment}
2051+
</div>
2052+
</div>
2053+
</div>
2054+
</Show>
2055+
</div>
2056+
)}
2057+
</For>
2058+
<Show when={commenting()}>
2059+
{(range) => (
2060+
<Show when={draftTop() !== undefined}>
2061+
<div class="absolute right-6 z-30" style={{ top: `${draftTop() ?? 0}px` }}>
2062+
<button
2063+
type="button"
2064+
class="size-5 rounded-md flex items-center justify-center bg-surface-warning-base border border-border-warning-base text-icon-warning-active shadow-xs hover:bg-surface-warning-weak hover:border-border-warning-hover focus:outline-none focus-visible:shadow-xs-border-focus"
2065+
onClick={() => textarea?.focus()}
2066+
>
2067+
<Icon name="speech-bubble" size="small" />
2068+
</button>
2069+
<div class="absolute top-0 right-[calc(100%+12px)] z-40 min-w-[200px] max-w-[320px] rounded-md bg-surface-raised-stronger-non-alpha border border-border-base shadow-md p-3">
2070+
<div class="flex flex-col gap-2">
2071+
<div class="text-12-medium text-text-strong">
2072+
Commenting on {getFilename(path() ?? "")}:{commentLabel(range())}
2073+
</div>
2074+
<textarea
2075+
ref={textarea}
2076+
class="w-[320px] max-w-[calc(100vw-48px)] resize-vertical p-2 rounded-sm bg-surface-base border border-border-base text-text-strong text-12-regular leading-5 focus:outline-none focus:shadow-xs-border-focus"
2077+
rows={3}
2078+
placeholder="Add a comment"
2079+
value={draft()}
2080+
onInput={(e) => setDraft(e.currentTarget.value)}
2081+
onKeyDown={(e) => {
2082+
if (e.key !== "Enter") return
2083+
if (e.shiftKey) return
2084+
e.preventDefault()
2085+
const value = draft().trim()
2086+
if (!value) return
2087+
const p = path()
2088+
if (!p) return
2089+
addCommentToContext({ file: p, selection: range(), comment: value })
2090+
setCommenting(null)
2091+
}}
2092+
/>
2093+
<div class="flex justify-end gap-2">
2094+
<Button size="small" variant="ghost" onClick={() => setCommenting(null)}>
2095+
Cancel
2096+
</Button>
2097+
<Button
2098+
size="small"
2099+
variant="secondary"
2100+
disabled={draft().trim().length === 0}
2101+
onClick={() => {
2102+
const value = draft().trim()
2103+
if (!value) return
2104+
const p = path()
2105+
if (!p) return
2106+
addCommentToContext({ file: p, selection: range(), comment: value })
2107+
setCommenting(null)
2108+
}}
2109+
>
2110+
Comment
2111+
</Button>
2112+
</div>
2113+
</div>
2114+
</div>
2115+
</div>
2116+
</Show>
2117+
)}
2118+
</Show>
2119+
</div>
2120+
)
2121+
18692122
const updateSelectionPopover = () => {
18702123
const el = scroll
18712124
if (!el) {
@@ -2107,57 +2360,15 @@ export default function Page() {
21072360
</Match>
21082361
<Match when={state()?.loaded && isSvg()}>
21092362
<div class="flex flex-col gap-4 px-6 py-4">
2110-
<Dynamic
2111-
component={codeComponent}
2112-
file={{
2113-
name: path() ?? "",
2114-
contents: svgContent() ?? "",
2115-
cacheKey: cacheKey(),
2116-
}}
2117-
enableLineSelection
2118-
selectedLines={selectedLines()}
2119-
onRendered={() => {
2120-
requestAnimationFrame(restoreScroll)
2121-
requestAnimationFrame(updateSelectionPopover)
2122-
}}
2123-
onLineSelected={(range: SelectedLineRange | null) => {
2124-
const p = path()
2125-
if (!p) return
2126-
file.setSelectedLines(p, range)
2127-
}}
2128-
overflow="scroll"
2129-
class="select-text"
2130-
/>
2363+
{renderCode(svgContent() ?? "", "")}
21312364
<Show when={svgPreviewUrl()}>
21322365
<div class="flex justify-center pb-40">
21332366
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
21342367
</div>
21352368
</Show>
21362369
</div>
21372370
</Match>
2138-
<Match when={state()?.loaded}>
2139-
<Dynamic
2140-
component={codeComponent}
2141-
file={{
2142-
name: path() ?? "",
2143-
contents: contents(),
2144-
cacheKey: cacheKey(),
2145-
}}
2146-
enableLineSelection
2147-
selectedLines={selectedLines()}
2148-
onRendered={() => {
2149-
requestAnimationFrame(restoreScroll)
2150-
requestAnimationFrame(updateSelectionPopover)
2151-
}}
2152-
onLineSelected={(range: SelectedLineRange | null) => {
2153-
const p = path()
2154-
if (!p) return
2155-
file.setSelectedLines(p, range)
2156-
}}
2157-
overflow="scroll"
2158-
class="select-text pb-40"
2159-
/>
2160-
</Match>
2371+
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
21612372
<Match when={state()?.loading}>
21622373
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
21632374
</Match>

packages/ui/src/components/session-review.css

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,18 @@
7575
overflow: hidden;
7676
}
7777

78-
[data-component="popover-content"] {
79-
position: absolute !important;
80-
}
81-
82-
.session-review-comment-popover-content {
83-
left: auto !important;
84-
right: calc(100% + 12px) !important;
78+
[data-slot="session-review-comment-popover-content"] {
79+
position: absolute;
80+
top: 0;
81+
right: calc(100% + 12px);
82+
z-index: 40;
83+
min-width: 200px;
84+
max-width: min(320px, calc(100vw - 48px));
85+
border-radius: var(--radius-md);
86+
background-color: var(--surface-raised-stronger-non-alpha);
87+
border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
88+
box-shadow: var(--shadow-md);
89+
padding: 12px;
8590
}
8691

8792
[data-slot="session-review-trigger-content"] {

0 commit comments

Comments
 (0)