@@ -39,6 +39,7 @@ import { useTerminal, type LocalPTY } from "@/context/terminal"
3939import { useLayout } from "@/context/layout"
4040import { Terminal } from "@/components/terminal"
4141import { checksum , base64Encode , base64Decode } from "@opencode-ai/util/encode"
42+ import { getFilename } from "@opencode-ai/util/path"
4243import { useDialog } from "@opencode-ai/ui/context/dialog"
4344import { DialogSelectFile } from "@/components/dialog-select-file"
4445import { 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 >
0 commit comments