Compare commits

..

4 Commits

Author SHA1 Message Date
Aiden Cline
e6a49ed85c rm messages 2026-02-20 14:02:02 -06:00
Aiden Cline
77cdfcdb64 feat: add api shape field to allow distinction between sdks 2026-02-20 13:59:31 -06:00
Aiden Cline
950df3de19 ci: temporarily disable assigning of issues to rekram1-node (#14486) 2026-02-20 13:56:29 -06:00
Aiden Cline
1d9f05e4f5 cache platform binary in postinstall for faster startup (#14467) 2026-02-20 12:19:17 -06:00
22 changed files with 637 additions and 1159 deletions

View File

@@ -5,8 +5,16 @@ import DESCRIPTION from "./github-triage.txt"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"],
tui: ["thdxr", "kommander", "rekram1-node"],
core: ["thdxr", "rekram1-node", "jlongster"],
tui: [
"thdxr",
"kommander",
// "rekram1-node" (on vacation)
],
core: [
"thdxr",
// "rekram1-node", (on vacation)
"jlongster",
],
docs: ["R44VC0RP"],
windows: ["Hona"],
} as const
@@ -42,10 +50,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
export default tool({
description: DESCRIPTION,
args: {
assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]])
.describe("The username of the assignee")
.default("rekram1-node"),
assignee: tool.schema.enum(ASSIGNEES as [string, ...string[]]).describe("The username of the assignee"),
labels: tool.schema
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
.describe("The labels(s) to add to the issue")
@@ -68,7 +73,8 @@ export default tool({
results.push("Dropped label: nix (issue does not mention nix)")
}
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
// const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
const assignee = web ? pick(TEAM.desktop) : args.assignee
if (labels.includes("zen") && !zen) {
throw new Error("Only add the zen label when issue title/body contains 'zen'")

View File

@@ -4,3 +4,5 @@ Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
(Note: rekram1-node is on vacation, do not assign issues to him.)

View File

@@ -44,17 +44,6 @@ function aggregate(comments: Record<string, LineComment[]>) {
.sort((a, b) => a.time - b.time)
}
function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
const next: SelectedLineRange = {
start: selection.start,
end: selection.end,
}
if (selection.side) next.side = selection.side
if (selection.endSide) next.endSide = selection.endSide
return next
}
function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
const [state, setState] = createStore({
focus: null as CommentFocus | null,
@@ -81,7 +70,6 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
id: uuid(),
time: Date.now(),
...input,
selection: cloneSelection(input.selection),
}
batch(() => {

View File

@@ -9,7 +9,7 @@ const MAX_FILE_VIEW_SESSIONS = 20
const MAX_VIEW_FILES = 500
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
if (range.start <= range.end) return { ...range }
if (range.start <= range.end) return range
const startSide = range.side
const endSide = range.endSide ?? startSide

View File

@@ -373,32 +373,11 @@ export default function Page() {
})
}
const isEditableTarget = (target: EventTarget | null | undefined) => {
if (!(target instanceof HTMLElement)) return false
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
}
const deepActiveElement = () => {
let current: Element | null = document.activeElement
while (current instanceof HTMLElement && current.shadowRoot?.activeElement) {
current = current.shadowRoot.activeElement
}
return current instanceof HTMLElement ? current : undefined
}
const handleKeyDown = (event: KeyboardEvent) => {
const path = event.composedPath()
const target = path.find((item): item is HTMLElement => item instanceof HTMLElement)
const activeElement = deepActiveElement()
const protectedTarget = path.some(
(item) => item instanceof HTMLElement && item.closest("[data-prevent-autofocus]") !== null,
)
if (protectedTarget || isEditableTarget(target)) return
const activeElement = document.activeElement as HTMLElement | undefined
if (activeElement) {
const isProtected = activeElement.closest("[data-prevent-autofocus]")
const isInput = isEditableTarget(activeElement)
const isInput = /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(activeElement.tagName) || activeElement.isContentEditable
if (isProtected || isInput) return
}
if (dialog.active) return

View File

@@ -1,17 +1,12 @@
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { createStore, produce } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { useParams } from "@solidjs/router"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
import { createHoverCommentUtility } from "@opencode-ai/ui/pierre/comment-hover"
import { cloneSelectedLineRange, lineInSelectedRange } from "@opencode-ai/ui/pierre/selection-bridge"
import {
createLineCommentAnnotationRenderer,
type LineCommentAnnotationMeta,
} from "@opencode-ai/ui/line-comment-annotations"
import { sampledChecksum } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast"
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
import { Mark } from "@opencode-ai/ui/logo"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
@@ -102,11 +97,11 @@ export function FileTabContent(props: { tab: string }) {
const c = state()?.content
return `data:${c?.mimeType};base64,${c?.content}`
})
const selectedLines = createMemo<SelectedLineRange | null>(() => {
const selectedLines = createMemo(() => {
const p = path()
if (!p) return null
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
if (file.ready()) return file.selectedLines(p) ?? null
return getSessionHandoff(sessionKey())?.files[p] ?? null
})
const selectionPreview = (source: string, selection: FileSelection) => {
@@ -150,148 +145,127 @@ export function FileTabContent(props: { tab: string }) {
})
}
let wrap: HTMLDivElement | undefined
const fileComments = createMemo(() => {
const p = path()
if (!p) return []
return comments.list(p)
})
const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
const commentLayout = createMemo(() => {
return fileComments()
.map((comment) => `${comment.id}:${comment.selection.start}:${comment.selection.end}`)
.join("|")
})
type Annotation = LineCommentAnnotationMeta<ReturnType<typeof fileComments>[number]>
const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
const [note, setNote] = createStore({
openedComment: null as string | null,
commenting: null as SelectedLineRange | null,
selected: null as SelectedLineRange | null,
draft: "",
positions: {} as Record<string, number>,
draftTop: undefined as number | undefined,
})
const activeSelection = () => note.selected ?? selectedLines()
const setCommenting = (range: SelectedLineRange | null) => {
setNote("commenting", range)
scheduleComments()
if (!range) return
setNote("draft", "")
setNote("commenting", range ? cloneSelectedLineRange(range) : null)
}
createEffect(
on(
path,
() => {
setNote("selected", null)
setNote("openedComment", null)
setNote("commenting", null)
},
{ defer: true },
),
)
const getRoot = () => {
const el = wrap
if (!el) return
const annotationLine = (range: SelectedLineRange) => Math.max(range.start, range.end)
const annotations = createMemo(() => {
const list = fileComments().map((comment) => ({
lineNumber: annotationLine(comment.selection),
metadata: {
kind: "comment",
key: `comment:${comment.id}`,
comment,
} satisfies Annotation,
}))
const host = el.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return
if (note.commenting) {
return [
...list,
{
lineNumber: annotationLine(note.commenting),
metadata: {
kind: "draft",
key: `draft:${path() ?? props.tab}`,
range: note.commenting,
} satisfies Annotation,
},
]
const root = host.shadowRoot
if (!root) return
return root
}
const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
const line = Math.max(range.start, range.end)
const node = root.querySelector(`[data-line="${line}"]`)
if (!(node instanceof HTMLElement)) return
return node
}
const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
const wrapperRect = wrapper.getBoundingClientRect()
const rect = marker.getBoundingClientRect()
return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
}
const updateComments = () => {
const el = wrap
const root = getRoot()
if (!el || !root) {
setNote("positions", {})
setNote("draftTop", undefined)
return
}
const range = activeSelection()
if (!range || note.openedComment) return list
return list
})
const estimateTop = (range: SelectedLineRange) => {
const line = Math.max(range.start, range.end)
const height = 24
const offset = 2
return Math.max(0, (line - 1) * height + offset)
}
const annotationRenderer = createLineCommentAnnotationRenderer<ReturnType<typeof fileComments>[number]>({
renderComment: (comment) => ({
id: comment.id,
open: note.openedComment === comment.id,
comment: comment.comment,
selection: formatCommentLabel(comment.selection),
onMouseEnter: () => {
const p = path()
if (!p) return
file.setSelectedLines(p, cloneSelectedLineRange(comment.selection))
},
onClick: () => {
const p = path()
if (!p) return
setCommenting(null)
setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
file.setSelectedLines(p, cloneSelectedLineRange(comment.selection))
},
}),
renderDraft: (range) => ({
value: note.draft,
selection: formatCommentLabel(range),
onInput: (value) => setNote("draft", value),
onCancel: () => setCommenting(null),
onSubmit: (value) => {
const p = path()
if (!p) return
addCommentToContext({ file: p, selection: range, comment: value, origin: "file" })
setCommenting(null)
},
onPopoverFocusOut: (e: FocusEvent) => {
const current = e.currentTarget as HTMLDivElement
const target = e.relatedTarget
if (target instanceof Node && current.contains(target)) return
const large = contents().length > 500_000
setTimeout(() => {
if (!document.activeElement || !current.contains(document.activeElement)) {
setCommenting(null)
const next: Record<string, number> = {}
for (const comment of fileComments()) {
const marker = findMarker(root, comment.selection)
if (marker) next[comment.id] = markerTop(el, marker)
else if (large) next[comment.id] = estimateTop(comment.selection)
}
const removed = Object.keys(note.positions).filter((id) => next[id] === undefined)
const changed = Object.entries(next).filter(([id, top]) => note.positions[id] !== top)
if (removed.length > 0 || changed.length > 0) {
setNote(
"positions",
produce((draft) => {
for (const id of removed) {
delete draft[id]
}
}, 0)
},
}),
})
const renderAnnotation = annotationRenderer.render
for (const [id, top] of changed) {
draft[id] = top
}
}),
)
}
const openDraft = (range: SelectedLineRange) => {
const p = path()
if (!p) return
const next = cloneSelectedLineRange(range)
setNote("openedComment", null)
setNote("selected", next)
file.setSelectedLines(p, cloneSelectedLineRange(next))
setCommenting(next)
const range = note.commenting
if (!range) {
setNote("draftTop", undefined)
return
}
const marker = findMarker(root, range)
if (marker) {
setNote("draftTop", markerTop(el, marker))
return
}
setNote("draftTop", large ? estimateTop(range) : undefined)
}
const renderHoverUtility = (getHoveredLine: () => { lineNumber: number; side?: "additions" | "deletions" }) =>
createHoverCommentUtility({
label: language.t("ui.lineComment.submit"),
getHoveredLine,
onSelect: (hovered) => {
const selected = note.openedComment ? null : activeSelection()
const range =
selected && lineInSelectedRange(selected, hovered.lineNumber, hovered.side)
? cloneSelectedLineRange(selected)
: { start: hovered.lineNumber, end: hovered.lineNumber }
openDraft(range)
},
})
const scheduleComments = () => {
requestAnimationFrame(updateComments)
}
createEffect(() => {
annotationRenderer.reconcile(annotations())
})
onCleanup(() => {
annotationRenderer.cleanup()
commentLayout()
scheduleComments()
})
createEffect(() => {
@@ -305,9 +279,8 @@ export function FileTabContent(props: { tab: string }) {
if (!target) return
setNote("openedComment", target.id)
setNote("selected", cloneSelectedLineRange(target.selection))
setCommenting(null)
file.setSelectedLines(p, cloneSelectedLineRange(target.selection))
file.setSelectedLines(p, target.selection)
requestAnimationFrame(() => comments.clearFocus())
})
@@ -441,7 +414,13 @@ export function FileTabContent(props: { tab: string }) {
})
const renderCode = (source: string, wrapperClass: string) => (
<div class={`relative overflow-hidden ${wrapperClass}`}>
<div
ref={(el) => {
wrap = el
scheduleComments()
}}
class={`relative overflow-hidden ${wrapperClass}`}
>
<Dynamic
component={codeComponent}
file={{
@@ -450,39 +429,83 @@ export function FileTabContent(props: { tab: string }) {
cacheKey: cacheKey(),
}}
enableLineSelection
enableHoverUtility
selectedLines={activeSelection()}
selectedLines={selectedLines()}
commentedLines={commentedLines()}
onRendered={() => {
requestAnimationFrame(restoreScroll)
requestAnimationFrame(scheduleComments)
}}
annotations={annotations()}
renderAnnotation={renderAnnotation}
renderHoverUtility={renderHoverUtility}
onLineSelected={(range: SelectedLineRange | null) => {
setNote("selected", range ? cloneSelectedLineRange(range) : null)
}}
onLineNumberSelectionEnd={(range: SelectedLineRange | null) => {
if (!range) return
openDraft(range)
const p = path()
if (!p) return
file.setSelectedLines(p, range)
if (!range) setCommenting(null)
}}
onLineSelectionEnd={(range: SelectedLineRange | null) => {
const next = range ? cloneSelectedLineRange(range) : null
setNote("selected", next)
const p = path()
if (p) file.setSelectedLines(p, next ? cloneSelectedLineRange(next) : null)
if (!next) {
if (!range) {
setCommenting(null)
return
}
setNote("openedComment", null)
setCommenting(null)
setCommenting(range)
}}
overflow="scroll"
class="select-text"
/>
<For each={fileComments()}>
{(comment) => (
<LineCommentView
id={comment.id}
top={note.positions[comment.id]}
open={note.openedComment === comment.id}
comment={comment.comment}
selection={formatCommentLabel(comment.selection)}
onMouseEnter={() => {
const p = path()
if (!p) return
file.setSelectedLines(p, comment.selection)
}}
onClick={() => {
const p = path()
if (!p) return
setCommenting(null)
setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
file.setSelectedLines(p, comment.selection)
}}
/>
)}
</For>
<Show when={note.commenting}>
{(range) => (
<Show when={note.draftTop !== undefined}>
<LineCommentEditor
top={note.draftTop}
value={note.draft}
selection={formatCommentLabel(range())}
onInput={(value) => setNote("draft", value)}
onCancel={() => setCommenting(null)}
onSubmit={(value) => {
const p = path()
if (!p) return
addCommentToContext({ file: p, selection: range(), comment: value, origin: "file" })
setCommenting(null)
}}
onPopoverFocusOut={(e: FocusEvent) => {
const current = e.currentTarget as HTMLDivElement
const target = e.relatedTarget
if (target instanceof Node && current.contains(target)) return
setTimeout(() => {
if (!document.activeElement || !current.contains(document.activeElement)) {
setCommenting(null)
}
}, 0)
}}
/>
</Show>
)}
</Show>
</div>
)

View File

@@ -25,6 +25,12 @@ if (envPath) {
const scriptPath = fs.realpathSync(__filename)
const scriptDir = path.dirname(scriptPath)
//
const cached = path.join(scriptDir, ".opencode")
if (fs.existsSync(cached)) {
run(cached)
}
const platformMap = {
darwin: "darwin",
linux: "linux",

View File

@@ -109,8 +109,14 @@ async function main() {
// On non-Windows platforms, just verify the binary package exists
// Don't replace the wrapper script - it handles binary execution
const { binaryPath } = findBinary()
console.log(`Platform binary verified at: ${binaryPath}`)
console.log("Wrapper script will handle binary execution")
const target = path.join(__dirname, "bin", ".opencode")
if (fs.existsSync(target)) fs.unlinkSync(target)
try {
fs.linkSync(binaryPath, target)
} catch {
fs.copyFileSync(binaryPath, target)
}
fs.chmodSync(target, 0o755)
} catch (error) {
console.error("Failed to setup opencode binary:", error.message)
process.exit(1)

View File

@@ -65,7 +65,13 @@ export namespace ModelsDev {
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
options: z.record(z.string(), z.any()),
headers: z.record(z.string(), z.string()).optional(),
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
provider: z
.object({
npm: z.string().optional(),
api: z.string().optional(),
shape: z.enum(["responses", "completions"]).optional(),
})
.optional(),
variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
})
export type Model = z.infer<typeof Model>

View File

@@ -109,7 +109,7 @@ export namespace Provider {
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
}
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
type CustomModelLoader = (sdk: any, model: Model, options?: Record<string, any>) => Promise<any>
type CustomLoader = (provider: Info) => Promise<{
autoload: boolean
getModel?: CustomModelLoader
@@ -153,8 +153,9 @@ export namespace Provider {
openai: async () => {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
return sdk.responses(modelID)
async getModel(sdk: any, model: Model, _options?: Record<string, any>) {
if (model.api.shape === "completions") return sdk.chat(model.api.id)
return sdk.responses(model.api.id)
},
options: {},
}
@@ -162,9 +163,12 @@ export namespace Provider {
"github-copilot": async () => {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID)
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
async getModel(sdk: any, model: Model, _options?: Record<string, any>) {
const shape = model.api.shape
if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(model.api.id)
if (shape === "responses") return sdk.responses(model.api.id)
if (shape === "completions") return sdk.chat(model.api.id)
return shouldUseCopilotResponsesApi(model.api.id) ? sdk.responses(model.api.id) : sdk.chat(model.api.id)
},
options: {},
}
@@ -172,9 +176,12 @@ export namespace Provider {
"github-copilot-enterprise": async () => {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID)
return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
async getModel(sdk: any, model: Model, _options?: Record<string, any>) {
const shape = model.api.shape
if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(model.api.id)
if (shape === "responses") return sdk.responses(model.api.id)
if (shape === "completions") return sdk.chat(model.api.id)
return shouldUseCopilotResponsesApi(model.api.id) ? sdk.responses(model.api.id) : sdk.chat(model.api.id)
},
options: {},
}
@@ -182,12 +189,12 @@ export namespace Provider {
azure: async () => {
return {
autoload: false,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
if (options?.["useCompletionUrls"]) {
return sdk.chat(modelID)
} else {
return sdk.responses(modelID)
}
async getModel(sdk: any, model: Model, options?: Record<string, any>) {
if (sdk.responses === undefined || sdk.chat === undefined) return sdk.languageModel(model.api.id)
if (model.api.shape === "completions") return sdk.chat(model.api.id)
if (model.api.shape === "responses") return sdk.responses(model.api.id)
if (options?.["useCompletionUrls"]) return sdk.chat(model.api.id)
return sdk.responses(model.api.id)
},
options: {},
}
@@ -196,12 +203,12 @@ export namespace Provider {
const resourceName = Env.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
return {
autoload: false,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
if (options?.["useCompletionUrls"]) {
return sdk.chat(modelID)
} else {
return sdk.responses(modelID)
}
async getModel(sdk: any, model: Model, options?: Record<string, any>) {
if (sdk.responses === undefined || sdk.chat === undefined) return sdk.languageModel(model.api.id)
if (model.api.shape === "completions") return sdk.chat(model.api.id)
if (model.api.shape === "responses") return sdk.responses(model.api.id)
if (options?.["useCompletionUrls"]) return sdk.chat(model.api.id)
return sdk.responses(model.api.id)
},
options: {
baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
@@ -269,7 +276,8 @@ export namespace Provider {
return {
autoload: true,
options: providerOptions,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
async getModel(sdk: any, model: Model, options?: Record<string, any>) {
let modelID = model.api.id
// Skip region prefixing if model already has a cross-region inference profile prefix
// Models from models.dev may already include prefixes like us., eu., global., etc.
const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
@@ -406,8 +414,8 @@ export namespace Provider {
return fetch(input, { ...init, headers })
},
},
async getModel(sdk: any, modelID: string) {
const id = String(modelID).trim()
async getModel(sdk: any, model: Model) {
const id = String(model.api.id).trim()
return sdk.languageModel(id)
},
}
@@ -423,8 +431,8 @@ export namespace Provider {
project,
location,
},
async getModel(sdk: any, modelID) {
const id = String(modelID).trim()
async getModel(sdk: any, model: Model) {
const id = String(model.api.id).trim()
return sdk.languageModel(id)
},
}
@@ -448,8 +456,8 @@ export namespace Provider {
return {
autoload: !!envServiceKey,
options: envServiceKey ? { deploymentId, resourceGroup } : {},
async getModel(sdk: any, modelID: string) {
return sdk(modelID)
async getModel(sdk: any, model: Model) {
return sdk(model.api.id)
},
}
},
@@ -494,8 +502,8 @@ export namespace Provider {
...(providerConfig?.options?.featureFlags || {}),
},
},
async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string) {
return sdk.agenticChat(modelID, {
async getModel(sdk: ReturnType<typeof createGitLab>, model: Model) {
return sdk.agenticChat(model.api.id, {
aiGatewayHeaders,
featureFlags: {
duo_agent_platform_agentic_chat: true,
@@ -524,8 +532,8 @@ export namespace Provider {
apiKey,
baseURL: `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`,
},
async getModel(sdk: any, modelID: string) {
return sdk.languageModel(modelID)
async getModel(sdk: any, model: Model) {
return sdk.languageModel(model.api.id)
},
}
},
@@ -560,9 +568,9 @@ export namespace Provider {
return {
autoload: true,
async getModel(_sdk: any, modelID: string, _options?: Record<string, any>) {
async getModel(_sdk: any, model: Model, _options?: Record<string, any>) {
// Model IDs use Unified API format: provider/model (e.g., "anthropic/claude-sonnet-4-5")
return aigateway(unified(modelID))
return aigateway(unified(model.api.id))
},
options: {},
}
@@ -598,6 +606,7 @@ export namespace Provider {
id: z.string(),
url: z.string(),
npm: z.string(),
shape: z.enum(["responses", "completions"]).optional(),
}),
name: z.string(),
family: z.string().optional(),
@@ -686,6 +695,7 @@ export namespace Provider {
id: model.id,
url: model.provider?.api ?? provider.api!,
npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
shape: model.provider?.shape,
},
status: model.status ?? "active",
headers: model.headers ?? {},
@@ -836,6 +846,7 @@ export namespace Provider {
existingModel?.api.npm ??
modelsDev[providerID]?.npm ??
"@ai-sdk/openai-compatible",
shape: model.provider?.shape ?? existingModel?.api.shape,
url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api,
},
status: model.status ?? existingModel?.status ?? "active",
@@ -1177,7 +1188,7 @@ export namespace Provider {
try {
const language = s.modelLoaders[model.providerID]
? await s.modelLoaders[model.providerID](sdk, model.api.id, provider.options)
? await s.modelLoaders[model.providerID](sdk, model, provider.options)
: sdk.languageModel(model.api.id)
s.models.set(key, language)
return language

View File

@@ -12,8 +12,6 @@ import {
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Portal } from "solid-js/web"
import { createDefaultOptions, styleVariables } from "../pierre"
import { markCommentedFileLines } from "../pierre/commented-lines"
import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge"
import { getWorkerPool } from "../pierre/worker"
import { Icon } from "./icon"
@@ -31,7 +29,6 @@ export type CodeProps<T = {}> = FileOptions<T> & {
annotations?: LineAnnotation<T>[]
selectedLines?: SelectedLineRange | null
commentedLines?: SelectedLineRange[]
onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
onRendered?: () => void
onLineSelectionEnd?: (selection: SelectedLineRange | null) => void
class?: string
@@ -158,7 +155,6 @@ export function Code<T>(props: CodeProps<T>) {
let dragMoved = false
let lastSelection: SelectedLineRange | null = null
let pendingSelectionEnd = false
const bridge = createLineNumberSelectionBridge()
const [local, others] = splitProps(props, [
"file",
@@ -167,9 +163,6 @@ export function Code<T>(props: CodeProps<T>) {
"annotations",
"selectedLines",
"commentedLines",
"onLineSelected",
"onLineSelectionEnd",
"onLineNumberSelectionEnd",
"onRendered",
])
@@ -205,16 +198,6 @@ export function Code<T>(props: CodeProps<T>) {
const options = createMemo(() => ({
...createDefaultOptions<T>("unified"),
...others,
onLineSelected: (range: SelectedLineRange | null) => {
lastSelection = range
local.onLineSelected?.(range)
},
onLineSelectionEnd: (range: SelectedLineRange | null) => {
lastSelection = range
local.onLineSelectionEnd?.(range)
if (!bridge.consume(range)) return
requestAnimationFrame(() => local.onLineNumberSelectionEnd?.(range))
},
}))
const getRoot = () => {
@@ -573,6 +556,41 @@ export function Code<T>(props: CodeProps<T>) {
})
})
const applyCommentedLines = (ranges: SelectedLineRange[]) => {
const root = getRoot()
if (!root) return
const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
for (const node of existing) {
if (!(node instanceof HTMLElement)) continue
node.removeAttribute("data-comment-selected")
}
const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
for (const range of ranges) {
const start = Math.max(1, Math.min(range.start, range.end))
const end = Math.max(range.start, range.end)
for (let line = start; line <= end; line++) {
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`))
for (const node of nodes) {
if (!(node instanceof HTMLElement)) continue
node.setAttribute("data-comment-selected", "")
}
}
for (const annotation of annotations) {
const line = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
if (Number.isNaN(line)) continue
if (line < start || line > end) continue
annotation.setAttribute("data-comment-selected", "")
}
}
}
const text = () => {
const value = local.file.contents as unknown
if (typeof value === "string") return value
@@ -700,7 +718,7 @@ export function Code<T>(props: CodeProps<T>) {
observer.observe(container, { childList: true, subtree: true })
}
const updateSelection = (preserveTextSelection = false) => {
const updateSelection = () => {
const root = getRoot()
if (!root) return
@@ -739,9 +757,6 @@ export function Code<T>(props: CodeProps<T>) {
if (endSide && side && endSide !== side) selected.endSide = endSide
setSelectedLines(selected)
if (!preserveTextSelection || !domRange) return
restoreShadowTextSelection(root, domRange.cloneRange())
}
const setSelectedLines = (range: SelectedLineRange | null) => {
@@ -754,12 +769,11 @@ export function Code<T>(props: CodeProps<T>) {
selectionFrame = requestAnimationFrame(() => {
selectionFrame = undefined
const finishing = pendingSelectionEnd
updateSelection(finishing)
updateSelection()
if (!pendingSelectionEnd) return
pendingSelectionEnd = false
local.onLineSelectionEnd?.(lastSelection)
props.onLineSelectionEnd?.(lastSelection)
})
}
@@ -808,13 +822,9 @@ export function Code<T>(props: CodeProps<T>) {
if (event.button !== 0) return
const { line, numberColumn } = lineFromMouseEvent(event)
if (numberColumn) {
bridge.begin(true, line)
return
}
if (numberColumn) return
if (line === undefined) return
bridge.begin(false, line)
dragStart = line
dragEnd = line
dragMoved = false
@@ -822,21 +832,16 @@ export function Code<T>(props: CodeProps<T>) {
const handleMouseMove = (event: MouseEvent) => {
if (props.enableLineSelection !== true) return
const next = lineFromMouseEvent(event)
if (bridge.track(event.buttons, next.line)) return
if (dragStart === undefined) return
if ((event.buttons & 1) === 0) {
dragStart = undefined
dragEnd = undefined
dragMoved = false
bridge.finish()
return
}
const { line } = next
const { line } = lineFromMouseEvent(event)
if (line === undefined) return
dragEnd = line
@@ -846,18 +851,13 @@ export function Code<T>(props: CodeProps<T>) {
const handleMouseUp = () => {
if (props.enableLineSelection !== true) return
if (bridge.finish() === "numbers") {
return
}
if (dragStart === undefined) return
if (!dragMoved) {
pendingSelectionEnd = false
const line = dragStart
setSelectedLines({ start: line, end: line })
local.onLineSelectionEnd?.(lastSelection)
props.onLineSelectionEnd?.(lastSelection)
dragStart = undefined
dragEnd = undefined
dragMoved = false
@@ -920,7 +920,7 @@ export function Code<T>(props: CodeProps<T>) {
const value = text()
instance.render({
file: typeof local.file.contents === "string" ? local.file : { ...local.file, contents: value },
lineAnnotations: [],
lineAnnotations: local.annotations,
containerWrapper: container,
})
@@ -942,22 +942,10 @@ export function Code<T>(props: CodeProps<T>) {
onCleanup(() => monitor.disconnect())
})
createEffect(() => {
rendered()
const active = instance
if (!active) return
active.setLineAnnotations(local.annotations ?? [])
active.rerender()
})
createEffect(() => {
rendered()
const ranges = local.commentedLines ?? []
requestAnimationFrame(() => {
const root = getRoot()
if (!root) return
markCommentedFileLines(root, ranges)
})
requestAnimationFrame(() => applyCommentedLines(ranges))
})
createEffect(() => {
@@ -1010,7 +998,6 @@ export function Code<T>(props: CodeProps<T>) {
dragStart = undefined
dragEnd = undefined
dragMoved = false
bridge.reset()
lastSelection = null
pendingSelectionEnd = false
})

View File

@@ -3,7 +3,6 @@ import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Dynamic, isServer } from "solid-js/web"
import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre"
import { findDiffSide, markCommentedDiffLines } from "../pierre/commented-lines"
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
import { useWorkerPool } from "../context/worker-pool"
@@ -22,7 +21,6 @@ export function Diff<T>(props: SSRDiffProps<T>) {
"annotations",
"selectedLines",
"commentedLines",
"onLineNumberSelectionEnd",
])
const workerPool = useWorkerPool(props.diffStyle)
@@ -74,7 +72,7 @@ export function Diff<T>(props: SSRDiffProps<T>) {
const targetSide = side ?? "additions"
for (const node of nodes) {
if (findDiffSide(node) === targetSide) return lineIndex(split, node)
if (findSide(node) === targetSide) return lineIndex(split, node)
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node)
}
}
@@ -123,6 +121,100 @@ export function Diff<T>(props: SSRDiffProps<T>) {
diff.setSelectedLines(fixed)
}
const findSide = (element: HTMLElement): "additions" | "deletions" => {
const line = element.closest("[data-line], [data-alt-line]")
if (line instanceof HTMLElement) {
const type = line.dataset.lineType
if (type === "change-deletion") return "deletions"
if (type === "change-addition" || type === "change-additions") return "additions"
}
const code = element.closest("[data-code]")
if (!(code instanceof HTMLElement)) return "additions"
return code.hasAttribute("data-deletions") ? "deletions" : "additions"
}
const applyCommentedLines = (ranges: SelectedLineRange[]) => {
const root = getRoot()
if (!root) return
const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
for (const node of existing) {
if (!(node instanceof HTMLElement)) continue
node.removeAttribute("data-comment-selected")
}
const diffs = root.querySelector("[data-diff]")
if (!(diffs instanceof HTMLElement)) return
const split = diffs.dataset.diffType === "split"
const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (rows.length === 0) return
const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
const lineIndex = (element: HTMLElement) => {
const raw = element.dataset.lineIndex
if (!raw) return
const values = raw
.split(",")
.map((value) => parseInt(value, 10))
.filter((value) => !Number.isNaN(value))
if (values.length === 0) return
if (!split) return values[0]
if (values.length === 2) return values[1]
return values[0]
}
const rowIndex = (line: number, side: "additions" | "deletions" | undefined) => {
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (nodes.length === 0) return
const targetSide = side ?? "additions"
for (const node of nodes) {
if (findSide(node) === targetSide) return lineIndex(node)
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(node)
}
}
for (const range of ranges) {
const start = rowIndex(range.start, range.side)
if (start === undefined) continue
const end = (() => {
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
if (same) return start
return rowIndex(range.end, range.endSide ?? range.side)
})()
if (end === undefined) continue
const first = Math.min(start, end)
const last = Math.max(start, end)
for (const row of rows) {
const idx = lineIndex(row)
if (idx === undefined) continue
if (idx < first || idx > last) continue
row.setAttribute("data-comment-selected", "")
}
for (const annotation of annotations) {
const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
if (Number.isNaN(idx)) continue
if (idx < first || idx > last) continue
annotation.setAttribute("data-comment-selected", "")
}
}
}
onMount(() => {
if (isServer || !props.preloadedDiff) return
@@ -169,10 +261,7 @@ export function Diff<T>(props: SSRDiffProps<T>) {
setSelectedLines(local.selectedLines ?? null)
createEffect(() => {
const diff = fileDiffInstance
if (!diff) return
diff.setLineAnnotations(local.annotations ?? [])
diff.rerender()
fileDiffInstance?.setLineAnnotations(local.annotations ?? [])
})
createEffect(() => {
@@ -181,11 +270,7 @@ export function Diff<T>(props: SSRDiffProps<T>) {
createEffect(() => {
const ranges = local.commentedLines ?? []
requestAnimationFrame(() => {
const root = getRoot()
if (!root) return
markCommentedDiffLines(root, ranges)
})
requestAnimationFrame(() => applyCommentedLines(ranges))
})
// Hydrate annotation slots with interactive SolidJS components

View File

@@ -3,8 +3,6 @@ import { FileDiff, type FileDiffOptions, type SelectedLineRange, VirtualizedFile
import { createMediaQuery } from "@solid-primitives/media"
import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js"
import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
import { findDiffSide, markCommentedDiffLines } from "../pierre/commented-lines"
import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge"
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
import { getWorkerPool } from "../pierre/worker"
@@ -37,7 +35,19 @@ function findLineNumber(node: Node | null): number | undefined {
function findSide(node: Node | null): SelectionSide | undefined {
const element = findElement(node)
if (!element) return
return findDiffSide(element)
const line = element.closest("[data-line], [data-alt-line]")
if (line instanceof HTMLElement) {
const type = line.dataset.lineType
if (type === "change-deletion") return "deletions"
if (type === "change-addition" || type === "change-additions") return "additions"
}
const code = element.closest("[data-code]")
if (!(code instanceof HTMLElement)) return
if (code.hasAttribute("data-deletions")) return "deletions"
return "additions"
}
export function Diff<T>(props: DiffProps<T>) {
@@ -54,7 +64,6 @@ export function Diff<T>(props: DiffProps<T>) {
let dragMoved = false
let lastSelection: SelectedLineRange | null = null
let pendingSelectionEnd = false
const bridge = createLineNumberSelectionBridge()
const [local, others] = splitProps(props, [
"before",
@@ -64,9 +73,6 @@ export function Diff<T>(props: DiffProps<T>) {
"annotations",
"selectedLines",
"commentedLines",
"onLineSelected",
"onLineSelectionEnd",
"onLineNumberSelectionEnd",
"onRendered",
])
@@ -88,20 +94,6 @@ export function Diff<T>(props: DiffProps<T>) {
const base = {
...createDefaultOptions(props.diffStyle),
...others,
onLineSelected: (range: SelectedLineRange | null) => {
const fixed = fixSelection(range)
const next = fixed === undefined ? range : fixed
lastSelection = next
local.onLineSelected?.(next)
},
onLineSelectionEnd: (range: SelectedLineRange | null) => {
const fixed = fixSelection(range)
const next = fixed === undefined ? range : fixed
lastSelection = next
local.onLineSelectionEnd?.(next)
if (!bridge.consume(next)) return
requestAnimationFrame(() => local.onLineNumberSelectionEnd?.(next))
},
}
const perf = large() ? { ...base, ...largeOptions } : base
@@ -286,7 +278,61 @@ export function Diff<T>(props: DiffProps<T>) {
observer.observe(container, { childList: true, subtree: true })
}
const setSelectedLines = (range: SelectedLineRange | null, preserve?: { root: ShadowRoot; text: Range }) => {
const applyCommentedLines = (ranges: SelectedLineRange[]) => {
const root = getRoot()
if (!root) return
const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
for (const node of existing) {
if (!(node instanceof HTMLElement)) continue
node.removeAttribute("data-comment-selected")
}
const diffs = root.querySelector("[data-diff]")
if (!(diffs instanceof HTMLElement)) return
const split = diffs.dataset.diffType === "split"
const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (rows.length === 0) return
const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
for (const range of ranges) {
const start = rowIndex(root, split, range.start, range.side)
if (start === undefined) continue
const end = (() => {
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
if (same) return start
return rowIndex(root, split, range.end, range.endSide ?? range.side)
})()
if (end === undefined) continue
const first = Math.min(start, end)
const last = Math.max(start, end)
for (const row of rows) {
const idx = lineIndex(split, row)
if (idx === undefined) continue
if (idx < first || idx > last) continue
row.setAttribute("data-comment-selected", "")
}
for (const annotation of annotations) {
const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
if (Number.isNaN(idx)) continue
if (idx < first || idx > last) continue
annotation.setAttribute("data-comment-selected", "")
}
}
}
const setSelectedLines = (range: SelectedLineRange | null) => {
const active = current()
if (!active) return
@@ -298,10 +344,9 @@ export function Diff<T>(props: DiffProps<T>) {
lastSelection = fixed
active.setSelectedLines(fixed)
restoreShadowTextSelection(preserve?.root, preserve?.text)
}
const updateSelection = (preserveTextSelection = false) => {
const updateSelection = () => {
const root = getRoot()
if (!root) return
@@ -339,12 +384,6 @@ export function Diff<T>(props: DiffProps<T>) {
if (side) selected.side = side
if (endSide && side && endSide !== side) selected.endSide = endSide
const text = preserveTextSelection && domRange ? domRange.cloneRange() : undefined
if (text) {
setSelectedLines(selected, { root, text })
return
}
setSelectedLines(selected)
}
@@ -353,12 +392,11 @@ export function Diff<T>(props: DiffProps<T>) {
selectionFrame = requestAnimationFrame(() => {
selectionFrame = undefined
const finishing = pendingSelectionEnd
updateSelection(finishing)
updateSelection()
if (!pendingSelectionEnd) return
pendingSelectionEnd = false
local.onLineSelectionEnd?.(lastSelection)
props.onLineSelectionEnd?.(lastSelection)
})
}
@@ -428,13 +466,9 @@ export function Diff<T>(props: DiffProps<T>) {
if (event.button !== 0) return
const { line, numberColumn, side } = lineFromMouseEvent(event)
if (numberColumn) {
bridge.begin(true, line)
return
}
if (numberColumn) return
if (line === undefined) return
bridge.begin(false, line)
dragStart = line
dragEnd = line
dragSide = side
@@ -444,10 +478,6 @@ export function Diff<T>(props: DiffProps<T>) {
const handleMouseMove = (event: MouseEvent) => {
if (props.enableLineSelection !== true) return
const next = lineFromMouseEvent(event)
if (bridge.track(event.buttons, next.line)) return
if (dragStart === undefined) return
if ((event.buttons & 1) === 0) {
@@ -456,11 +486,10 @@ export function Diff<T>(props: DiffProps<T>) {
dragSide = undefined
dragEndSide = undefined
dragMoved = false
bridge.finish()
return
}
const { line, side } = next
const { line, side } = lineFromMouseEvent(event)
if (line === undefined) return
dragEnd = line
@@ -471,11 +500,6 @@ export function Diff<T>(props: DiffProps<T>) {
const handleMouseUp = () => {
if (props.enableLineSelection !== true) return
if (bridge.finish() === "numbers") {
return
}
if (dragStart === undefined) return
if (!dragMoved) {
@@ -487,7 +511,7 @@ export function Diff<T>(props: DiffProps<T>) {
}
if (dragSide) selected.side = dragSide
setSelectedLines(selected)
local.onLineSelectionEnd?.(lastSelection)
props.onLineSelectionEnd?.(lastSelection)
dragStart = undefined
dragEnd = undefined
dragSide = undefined
@@ -521,6 +545,7 @@ export function Diff<T>(props: DiffProps<T>) {
const opts = options()
const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
const virtualizer = getVirtualizer()
const annotations = local.annotations
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
@@ -547,7 +572,7 @@ export function Diff<T>(props: DiffProps<T>) {
contents: afterContents,
cacheKey: cacheKey(afterContents),
},
lineAnnotations: [],
lineAnnotations: annotations,
containerWrapper: container,
})
@@ -569,22 +594,10 @@ export function Diff<T>(props: DiffProps<T>) {
onCleanup(() => monitor.disconnect())
})
createEffect(() => {
rendered()
const active = current()
if (!active) return
active.setLineAnnotations(local.annotations ?? [])
active.rerender()
})
createEffect(() => {
rendered()
const ranges = local.commentedLines ?? []
requestAnimationFrame(() => {
const root = getRoot()
if (!root) return
markCommentedDiffLines(root, ranges)
})
requestAnimationFrame(() => applyCommentedLines(ranges))
})
createEffect(() => {
@@ -626,7 +639,6 @@ export function Diff<T>(props: DiffProps<T>) {
dragSide = undefined
dragEndSide = undefined
dragMoved = false
bridge.reset()
lastSelection = null
pendingSelectionEnd = false

View File

@@ -1,121 +0,0 @@
import { type SelectedLineRange } from "@pierre/diffs"
import { createMemo, createSignal, type JSX } from "solid-js"
import { render as renderSolid } from "solid-js/web"
import { LineComment, LineCommentEditor } from "./line-comment"
export type LineCommentAnnotationMeta<T> =
| { kind: "comment"; key: string; comment: T }
| { kind: "draft"; key: string; range: SelectedLineRange }
type CommentProps = {
id?: string
open: boolean
comment: JSX.Element
selection: JSX.Element
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
}
type DraftProps = {
value: string
selection: JSX.Element
onInput: (value: string) => void
onCancel: VoidFunction
onSubmit: (value: string) => void
onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
}
export function createLineCommentAnnotationRenderer<T>(props: {
renderComment: (comment: T) => CommentProps
renderDraft: (range: SelectedLineRange) => DraftProps
}) {
const nodes = new Map<
string,
{
host: HTMLDivElement
dispose: VoidFunction
setMeta: (meta: LineCommentAnnotationMeta<T>) => void
}
>()
const mount = (meta: LineCommentAnnotationMeta<T>) => {
if (typeof document === "undefined") return
const host = document.createElement("div")
host.setAttribute("data-prevent-autofocus", "")
const [current, setCurrent] = createSignal(meta)
if (meta.kind === "comment") {
const view = createMemo(() => {
const next = current()
if (next.kind !== "comment") return props.renderComment(meta.comment)
return props.renderComment(next.comment)
})
const dispose = renderSolid(
() => (
<LineComment
inline
id={view().id}
open={view().open}
comment={view().comment}
selection={view().selection}
onClick={view().onClick}
onMouseEnter={view().onMouseEnter}
/>
),
host,
)
const node = { host, dispose, setMeta: setCurrent }
nodes.set(meta.key, node)
return node
}
const view = createMemo(() => {
const next = current()
if (next.kind !== "draft") return props.renderDraft(meta.range)
return props.renderDraft(next.range)
})
const dispose = renderSolid(
() => (
<LineCommentEditor
inline
value={view().value}
selection={view().selection}
onInput={view().onInput}
onCancel={view().onCancel}
onSubmit={view().onSubmit}
onPopoverFocusOut={view().onPopoverFocusOut}
/>
),
host,
)
const node = { host, dispose, setMeta: setCurrent }
nodes.set(meta.key, node)
return node
}
const render = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotation: A) => {
const meta = annotation.metadata
const node = nodes.get(meta.key) ?? mount(meta)
if (!node) return
node.setMeta(meta)
return node.host
}
const reconcile = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotations: A[]) => {
const next = new Set(annotations.map((annotation) => annotation.metadata.key))
for (const [key, node] of nodes) {
if (next.has(key)) continue
node.dispose()
nodes.delete(key)
}
}
const cleanup = () => {
for (const [, node] of nodes) node.dispose()
nodes.clear()
}
return { render, reconcile, cleanup }
}

View File

@@ -1,17 +1,9 @@
export const lineCommentStyles = `
[data-component="line-comment"] {
position: absolute;
right: 24px;
z-index: var(--line-comment-z, 30);
}
[data-component="line-comment"][data-inline] {
position: relative;
right: auto;
display: inline-flex;
align-items: flex-start;
}
[data-component="line-comment"][data-open] {
z-index: var(--line-comment-open-z, 100);
}
@@ -29,20 +21,10 @@ export const lineCommentStyles = `
border: none;
}
[data-component="line-comment"][data-variant="add"] [data-slot="line-comment-button"] {
background: var(--syntax-diff-add);
}
[data-component="line-comment"] [data-component="icon"] {
color: var(--white);
}
[data-component="line-comment"] [data-slot="line-comment-icon"] {
width: 12px;
height: 12px;
color: var(--white);
}
[data-component="line-comment"] [data-slot="line-comment-button"]:focus {
outline: none;
}
@@ -64,21 +46,6 @@ export const lineCommentStyles = `
padding: 12px;
}
[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"] {
position: relative;
top: auto;
right: auto;
margin-left: 8px;
}
[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"][data-inline-body] {
margin-left: 0;
}
[data-component="line-comment"][data-inline][data-variant="default"] [data-slot="line-comment-popover"][data-inline-body] {
cursor: pointer;
}
[data-component="line-comment"][data-variant="editor"] [data-slot="line-comment-popover"] {
width: 380px;
max-width: min(380px, calc(100vw - 48px));
@@ -146,50 +113,3 @@ export const lineCommentStyles = `
[data-component="line-comment"] [data-slot="line-comment-editor-label"] {
margin-right: auto;
}
[data-component="line-comment"] [data-slot="line-comment-action"] {
border: 1px solid var(--border-base);
background: var(--surface-base);
color: var(--text-strong);
border-radius: var(--radius-md);
height: 28px;
padding: 0 10px;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
}
[data-component="line-comment"] [data-slot="line-comment-action"][data-variant="ghost"] {
background: transparent;
}
[data-component="line-comment"] [data-slot="line-comment-action"][data-variant="primary"] {
background: var(--text-strong);
border-color: var(--text-strong);
color: var(--background-base);
}
[data-component="line-comment"] [data-slot="line-comment-action"]:disabled {
opacity: 0.5;
pointer-events: none;
}
`
let installed = false
export function installLineCommentStyles() {
if (installed) return
if (typeof document === "undefined") return
const id = "opencode-line-comment-styles"
if (document.getElementById(id)) {
installed = true
return
}
const style = document.createElement("style")
style.id = id
style.textContent = lineCommentStyles
document.head.appendChild(style)
installed = true
}

View File

@@ -1,121 +1,52 @@
import { createEffect, createSignal, onMount, Show, splitProps, type JSX } from "solid-js"
import { onMount, Show, splitProps, type JSX } from "solid-js"
import { Button } from "./button"
import { Icon } from "./icon"
import { installLineCommentStyles } from "./line-comment-styles"
import { useI18n } from "../context/i18n"
installLineCommentStyles()
export type LineCommentVariant = "default" | "editor" | "add"
function InlineGlyph(props: { icon: "comment" | "plus" }) {
return (
<svg data-slot="line-comment-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<Show
when={props.icon === "comment"}
fallback={
<path
d="M10 5.41699V10.0003M10 10.0003V14.5837M10 10.0003H5.4165M10 10.0003H14.5832"
stroke="currentColor"
stroke-linecap="square"
/>
}
>
<path d="M16.25 3.75H3.75V16.25L6.875 14.4643H16.25V3.75Z" stroke="currentColor" stroke-linecap="square" />
</Show>
</svg>
)
}
export type LineCommentVariant = "default" | "editor"
export type LineCommentAnchorProps = {
id?: string
top?: number
inline?: boolean
hideButton?: boolean
open: boolean
variant?: LineCommentVariant
icon?: "comment" | "plus"
buttonLabel?: string
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
class?: string
popoverClass?: string
children?: JSX.Element
children: JSX.Element
}
export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
const hidden = () => !props.inline && props.top === undefined
const hidden = () => props.top === undefined
const variant = () => props.variant ?? "default"
const icon = () => props.icon ?? "comment"
const inlineBody = () => props.inline && props.hideButton
return (
<div
data-component="line-comment"
data-prevent-autofocus=""
data-variant={variant()}
data-comment-id={props.id}
data-open={props.open ? "" : undefined}
data-inline={props.inline ? "" : undefined}
classList={{
[props.class ?? ""]: !!props.class,
}}
style={
props.inline
? undefined
: {
top: `${props.top ?? 0}px`,
opacity: hidden() ? 0 : 1,
"pointer-events": hidden() ? "none" : "auto",
}
}
style={{
top: `${props.top ?? 0}px`,
opacity: hidden() ? 0 : 1,
"pointer-events": hidden() ? "none" : "auto",
}}
>
<Show
when={inlineBody()}
fallback={
<>
<button
type="button"
aria-label={props.buttonLabel}
data-slot="line-comment-button"
on:mousedown={(e) => e.stopPropagation()}
on:mouseup={(e) => e.stopPropagation()}
on:click={props.onClick as any}
on:mouseenter={props.onMouseEnter as any}
>
<Show
when={props.inline}
fallback={<Icon name={icon() === "plus" ? "plus-small" : "comment"} size="small" />}
>
<InlineGlyph icon={icon()} />
</Show>
</button>
<Show when={props.open}>
<div
data-slot="line-comment-popover"
classList={{
[props.popoverClass ?? ""]: !!props.popoverClass,
}}
on:mousedown={(e) => e.stopPropagation()}
on:focusout={props.onPopoverFocusOut as any}
>
{props.children}
</div>
</Show>
</>
}
>
<button type="button" data-slot="line-comment-button" onClick={props.onClick} onMouseEnter={props.onMouseEnter}>
<Icon name="comment" size="small" />
</button>
<Show when={props.open}>
<div
data-slot="line-comment-popover"
data-inline-body=""
classList={{
[props.popoverClass ?? ""]: !!props.popoverClass,
}}
on:mousedown={(e) => e.stopPropagation()}
on:click={props.onClick as any}
on:mouseenter={props.onMouseEnter as any}
on:focusout={props.onPopoverFocusOut as any}
onFocusOut={props.onPopoverFocusOut}
>
{props.children}
</div>
@@ -134,7 +65,7 @@ export const LineComment = (props: LineCommentProps) => {
const [split, rest] = splitProps(props, ["comment", "selection"])
return (
<LineCommentAnchor {...rest} variant="default" hideButton={props.inline}>
<LineCommentAnchor {...rest} variant="default">
<div data-slot="line-comment-content">
<div data-slot="line-comment-text">{split.comment}</div>
<div data-slot="line-comment-label">
@@ -147,25 +78,6 @@ export const LineComment = (props: LineCommentProps) => {
)
}
export type LineCommentAddProps = Omit<LineCommentAnchorProps, "children" | "variant" | "open" | "icon"> & {
label?: string
}
export const LineCommentAdd = (props: LineCommentAddProps) => {
const [split, rest] = splitProps(props, ["label"])
const i18n = useI18n()
return (
<LineCommentAnchor
{...rest}
open={false}
variant="add"
icon="plus"
buttonLabel={split.label ?? i18n.t("ui.lineComment.submit")}
/>
)
}
export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "open" | "variant" | "onClick"> & {
value: string
selection: JSX.Element
@@ -197,16 +109,11 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
const refs = {
textarea: undefined as HTMLTextAreaElement | undefined,
}
const [text, setText] = createSignal(split.value)
const focus = () => refs.textarea?.focus()
createEffect(() => {
setText(split.value)
})
const submit = () => {
const value = text().trim()
const value = split.value.trim()
if (!value) return
split.onSubmit(value)
}
@@ -217,7 +124,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
})
return (
<LineCommentAnchor {...rest} open={true} variant="editor" hideButton={props.inline} onClick={() => focus()}>
<LineCommentAnchor {...rest} open={true} variant="editor" onClick={() => focus()}>
<div data-slot="line-comment-editor">
<textarea
ref={(el) => {
@@ -226,23 +133,19 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
data-slot="line-comment-textarea"
rows={split.rows ?? 3}
placeholder={split.placeholder ?? i18n.t("ui.lineComment.placeholder")}
value={text()}
on:input={(e) => {
const value = (e.currentTarget as HTMLTextAreaElement).value
setText(value)
split.onInput(value)
}}
on:keydown={(e) => {
const event = e as KeyboardEvent
event.stopPropagation()
value={split.value}
onInput={(e) => split.onInput(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
event.preventDefault()
e.preventDefault()
e.stopPropagation()
split.onCancel()
return
}
if (e.key !== "Enter") return
if (e.shiftKey) return
event.preventDefault()
e.preventDefault()
e.stopPropagation()
submit()
}}
/>
@@ -252,37 +155,12 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
{split.selection}
{i18n.t("ui.lineComment.editorLabel.suffix")}
</div>
<Show
when={!props.inline}
fallback={
<>
<button
type="button"
data-slot="line-comment-action"
data-variant="ghost"
on:click={split.onCancel as any}
>
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
</button>
<button
type="button"
data-slot="line-comment-action"
data-variant="primary"
disabled={text().trim().length === 0}
on:click={submit as any}
>
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
</button>
</>
}
>
<Button size="small" variant="ghost" onClick={split.onCancel}>
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
</Button>
<Button size="small" variant="primary" disabled={text().trim().length === 0} onClick={submit}>
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
</Button>
</Show>
<Button size="small" variant="ghost" onClick={split.onCancel}>
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
</Button>
<Button size="small" variant="primary" disabled={split.value.trim().length === 0} onClick={submit}>
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
</Button>
</div>
</div>
</LineCommentAnchor>

View File

@@ -4,6 +4,7 @@ import { RadioGroup } from "./radio-group"
import { DiffChanges } from "./diff-changes"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
import { LineComment, LineCommentEditor } from "./line-comment"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { Tooltip } from "./tooltip"
import { ScrollView } from "./scroll-view"
@@ -11,15 +12,12 @@ import { useDiffComponent } from "../context/diff"
import { useI18n } from "../context/i18n"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { checksum } from "@opencode-ai/util/encode"
import { createEffect, createMemo, createSignal, For, Match, onCleanup, Show, Switch, type JSX } from "solid-js"
import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { type DiffLineAnnotation, type SelectedLineRange } from "@pierre/diffs"
import { type SelectedLineRange } from "@pierre/diffs"
import { Dynamic } from "solid-js/web"
import { createHoverCommentUtility } from "../pierre/comment-hover"
import { cloneSelectedLineRange, lineInSelectedRange } from "../pierre/selection-bridge"
import { createLineCommentAnnotationRenderer, type LineCommentAnnotationMeta } from "./line-comment-annotations"
const MAX_DIFF_CHANGED_LINES = 500
@@ -139,7 +137,42 @@ type SessionReviewSelection = {
range: SelectedLineRange
}
type SessionReviewAnnotation = LineCommentAnnotationMeta<SessionReviewComment>
function findSide(element: HTMLElement): "additions" | "deletions" | undefined {
const typed = element.closest("[data-line-type]")
if (typed instanceof HTMLElement) {
const type = typed.dataset.lineType
if (type === "change-deletion") return "deletions"
if (type === "change-addition" || type === "change-additions") return "additions"
}
const code = element.closest("[data-code]")
if (!(code instanceof HTMLElement)) return
return code.hasAttribute("data-deletions") ? "deletions" : "additions"
}
function findMarker(root: ShadowRoot, range: SelectedLineRange) {
const marker = (line: number, side?: "additions" | "deletions") => {
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (nodes.length === 0) return
if (!side) return nodes[0]
const match = nodes.find((node) => findSide(node) === side)
return match ?? nodes[0]
}
const a = marker(range.start, range.side)
const b = marker(range.end, range.endSide ?? range.side)
if (!a) return b
if (!b) return a
return a.getBoundingClientRect().top > b.getBoundingClientRect().top ? a : b
}
function markerTop(wrapper: HTMLElement, marker: HTMLElement) {
const wrapperRect = wrapper.getBoundingClientRect()
const rect = marker.getBoundingClientRect()
return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
}
export const SessionReview = (props: SessionReviewProps) => {
let scroll: HTMLDivElement | undefined
@@ -203,7 +236,7 @@ export const SessionReview = (props: SessionReviewProps) => {
setOpened(focus)
const comment = (props.comments ?? []).find((c) => c.file === focus.file && c.id === focus.id)
if (comment) setSelection({ file: comment.file, range: cloneSelectedLineRange(comment.selection) })
if (comment) setSelection({ file: comment.file, range: comment.selection })
const current = open()
if (!current.includes(focus.file)) {
@@ -216,11 +249,11 @@ export const SessionReview = (props: SessionReviewProps) => {
const root = scroll
if (!root) return
const wrapper = anchors.get(focus.file)
const anchor = wrapper?.querySelector(`[data-comment-id="${focus.id}"]`)
const ready = anchor instanceof HTMLElement
const anchor = root.querySelector(`[data-comment-id="${focus.id}"]`)
const ready =
anchor instanceof HTMLElement && anchor.style.pointerEvents !== "none" && anchor.style.opacity !== "0"
const target = ready ? anchor : wrapper
const target = ready ? anchor : anchors.get(focus.file)
if (!target) {
if (attempt >= 120) return
requestAnimationFrame(() => scrollTo(attempt + 1))
@@ -343,114 +376,51 @@ export const SessionReview = (props: SessionReviewProps) => {
})
const [draft, setDraft] = createSignal("")
const [positions, setPositions] = createSignal<Record<string, number>>({})
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
const annotationLine = (range: SelectedLineRange) => Math.max(range.start, range.end)
const annotationSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
const selected = () => selectedLines()
const getRoot = () => {
const el = wrapper
if (!el) return
const annotations = createMemo<DiffLineAnnotation<SessionReviewAnnotation>[]>(() => {
const list = comments().map((comment) => ({
side: annotationSide(comment.selection),
lineNumber: annotationLine(comment.selection),
metadata: {
kind: "comment",
key: `comment:${comment.id}`,
comment,
} satisfies SessionReviewAnnotation,
}))
const host = el.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return
return host.shadowRoot ?? undefined
}
const updateAnchors = () => {
const el = wrapper
if (!el) return
const root = getRoot()
if (!root) return
const next: Record<string, number> = {}
for (const item of comments()) {
const marker = findMarker(root, item.selection)
if (!marker) continue
next[item.id] = markerTop(el, marker)
}
setPositions(next)
const range = draftRange()
if (range) {
return [
...list,
{
side: annotationSide(range),
lineNumber: annotationLine(range),
metadata: {
kind: "draft",
key: `draft:${file}`,
range,
} satisfies SessionReviewAnnotation,
},
]
if (!range) {
setDraftTop(undefined)
return
}
return list
})
const marker = findMarker(root, range)
if (!marker) {
setDraftTop(undefined)
return
}
const annotationRenderer = createLineCommentAnnotationRenderer<SessionReviewComment>({
renderComment: (comment) => ({
id: comment.id,
open: isCommentOpen(comment),
comment: comment.comment,
selection: selectionLabel(comment.selection),
onMouseEnter: () =>
setSelection({ file: comment.file, range: cloneSelectedLineRange(comment.selection) }),
onClick: () => {
if (isCommentOpen(comment)) {
setOpened(null)
return
}
setDraftTop(markerTop(el, marker))
}
openComment(comment)
},
}),
renderDraft: (range) => ({
value: draft(),
selection: selectionLabel(range),
onInput: setDraft,
onCancel: () => {
setDraft("")
setCommenting(null)
},
onSubmit: (comment) => {
props.onLineComment?.({
file,
selection: cloneSelectedLineRange(range),
comment,
preview: selectionPreview(item(), range),
})
setDraft("")
setCommenting(null)
},
}),
})
const renderAnnotation = (annotation: DiffLineAnnotation<SessionReviewAnnotation>) =>
annotationRenderer.render(annotation)
const renderHoverUtility = (
getHoveredLine: () => { lineNumber: number; side?: "additions" | "deletions" },
) =>
createHoverCommentUtility({
label: i18n.t("ui.lineComment.submit"),
getHoveredLine,
onSelect: (hovered) => {
const current = opened()?.file === file ? null : selected()
const range = (() => {
if (current && lineInSelectedRange(current, hovered.lineNumber, hovered.side)) {
return cloneSelectedLineRange(current)
}
const next: SelectedLineRange = {
start: hovered.lineNumber,
end: hovered.lineNumber,
}
if (hovered.side) next.side = hovered.side
return next
})()
openDraft(range)
},
})
createEffect(() => {
annotationRenderer.reconcile(annotations())
})
onCleanup(() => {
anchors.delete(file)
annotationRenderer.cleanup()
})
const scheduleAnchors = () => {
requestAnimationFrame(updateAnchors)
}
createEffect(() => {
if (!isImage()) return
@@ -468,8 +438,15 @@ export const SessionReview = (props: SessionReviewProps) => {
})
createEffect(() => {
draftRange()
comments()
scheduleAnchors()
})
createEffect(() => {
const range = draftRange()
if (!range) return
setDraft("")
scheduleAnchors()
})
createEffect(() => {
@@ -529,39 +506,27 @@ export const SessionReview = (props: SessionReviewProps) => {
if (!range) {
setSelection(null)
setDraft("")
setCommenting(null)
return
}
setSelection({ file, range: cloneSelectedLineRange(range) })
}
const openDraft = (range: SelectedLineRange) => {
const next = cloneSelectedLineRange(range)
setOpened(null)
setSelection({ file, range: cloneSelectedLineRange(next) })
setCommenting({ file, range: next })
setSelection({ file, range })
}
const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
if (!props.onLineComment) return
if (!range) {
setDraft("")
setCommenting(null)
return
}
const next = cloneSelectedLineRange(range)
setOpened(null)
setSelection({ file, range: next })
setCommenting(null)
setSelection({ file, range })
setCommenting({ file, range })
}
const openComment = (comment: SessionReviewComment) => {
setOpened({ file: comment.file, id: comment.id })
setSelection({ file: comment.file, range: cloneSelectedLineRange(comment.selection) })
setSelection({ file: comment.file, range: comment.selection })
}
const isCommentOpen = (comment: SessionReviewComment) => {
@@ -642,6 +607,7 @@ export const SessionReview = (props: SessionReviewProps) => {
ref={(el) => {
wrapper = el
anchors.set(file, el)
scheduleAnchors()
}}
>
<Show when={expanded()}>
@@ -692,18 +658,11 @@ export const SessionReview = (props: SessionReviewProps) => {
diffStyle={diffStyle()}
onRendered={() => {
props.onDiffRendered?.()
scheduleAnchors()
}}
enableLineSelection={props.onLineComment != null}
enableHoverUtility={props.onLineComment != null}
onLineSelected={handleLineSelected}
onLineSelectionEnd={handleLineSelectionEnd}
onLineNumberSelectionEnd={(range: SelectedLineRange | null) => {
if (!range) return
openDraft(range)
}}
annotations={annotations()}
renderAnnotation={renderAnnotation}
renderHoverUtility={props.onLineComment ? renderHoverUtility : undefined}
selectedLines={selectedLines()}
commentedLines={commentedLines()}
before={{
@@ -717,6 +676,50 @@ export const SessionReview = (props: SessionReviewProps) => {
/>
</Match>
</Switch>
<For each={comments()}>
{(comment) => (
<LineComment
id={comment.id}
top={positions()[comment.id]}
onMouseEnter={() => setSelection({ file: comment.file, range: comment.selection })}
onClick={() => {
if (isCommentOpen(comment)) {
setOpened(null)
return
}
openComment(comment)
}}
open={isCommentOpen(comment)}
comment={comment.comment}
selection={selectionLabel(comment.selection)}
/>
)}
</For>
<Show when={draftRange()}>
{(range) => (
<Show when={draftTop() !== undefined}>
<LineCommentEditor
top={draftTop()}
value={draft()}
selection={selectionLabel(range())}
onInput={setDraft}
onCancel={() => setCommenting(null)}
onSubmit={(comment) => {
props.onLineComment?.({
file,
selection: range(),
comment,
preview: selectionPreview(item(), range()),
})
setCommenting(null)
}}
/>
</Show>
)}
</Show>
</Show>
</div>
</Accordion.Content>

View File

@@ -1,73 +0,0 @@
export type HoverCommentLine = {
lineNumber: number
side?: "additions" | "deletions"
}
export function createHoverCommentUtility(props: {
label: string
getHoveredLine: () => HoverCommentLine | undefined
onSelect: (line: HoverCommentLine) => void
}) {
if (typeof document === "undefined") return
const button = document.createElement("button")
button.type = "button"
button.ariaLabel = props.label
button.textContent = "+"
button.style.width = "20px"
button.style.height = "20px"
button.style.display = "flex"
button.style.alignItems = "center"
button.style.justifyContent = "center"
button.style.border = "none"
button.style.borderRadius = "var(--radius-md)"
button.style.background = "var(--syntax-diff-add)"
button.style.color = "var(--white)"
button.style.boxShadow = "var(--shadow-xs)"
button.style.fontSize = "14px"
button.style.lineHeight = "1"
button.style.cursor = "pointer"
button.style.position = "relative"
button.style.left = "22px"
let line: HoverCommentLine | undefined
const sync = () => {
const next = props.getHoveredLine()
if (!next) return
line = next
}
const loop = () => {
if (!button.isConnected) return
sync()
requestAnimationFrame(loop)
}
const open = () => {
const next = props.getHoveredLine() ?? line
if (!next) return
props.onSelect(next)
}
requestAnimationFrame(loop)
button.addEventListener("mouseenter", sync)
button.addEventListener("mousemove", sync)
button.addEventListener("pointerdown", (event) => {
event.preventDefault()
event.stopPropagation()
sync()
})
button.addEventListener("mousedown", (event) => {
event.preventDefault()
event.stopPropagation()
sync()
})
button.addEventListener("click", (event) => {
event.preventDefault()
event.stopPropagation()
open()
})
return button
}

View File

@@ -1,130 +0,0 @@
import { type SelectedLineRange } from "@pierre/diffs"
export type CommentSide = "additions" | "deletions"
function annotationIndex(node: HTMLElement) {
const value = node.dataset.lineAnnotation?.split(",")[1]
if (!value) return
const line = parseInt(value, 10)
if (Number.isNaN(line)) return
return line
}
function clear(root: ShadowRoot) {
const marked = Array.from(root.querySelectorAll("[data-comment-selected]"))
for (const node of marked) {
if (!(node instanceof HTMLElement)) continue
node.removeAttribute("data-comment-selected")
}
}
export function findDiffSide(node: HTMLElement): CommentSide {
const line = node.closest("[data-line], [data-alt-line]")
if (line instanceof HTMLElement) {
const type = line.dataset.lineType
if (type === "change-deletion") return "deletions"
if (type === "change-addition" || type === "change-additions") return "additions"
}
const code = node.closest("[data-code]")
if (!(code instanceof HTMLElement)) return "additions"
return code.hasAttribute("data-deletions") ? "deletions" : "additions"
}
function lineIndex(split: boolean, node: HTMLElement) {
const raw = node.dataset.lineIndex
if (!raw) return
const values = raw
.split(",")
.map((x) => parseInt(x, 10))
.filter((x) => !Number.isNaN(x))
if (values.length === 0) return
if (!split) return values[0]
if (values.length === 2) return values[1]
return values[0]
}
function rowIndex(root: ShadowRoot, split: boolean, line: number, side: CommentSide | undefined) {
const rows = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (rows.length === 0) return
const target = side ?? "additions"
for (const row of rows) {
if (findDiffSide(row) === target) return lineIndex(split, row)
if (parseInt(row.dataset.altLine ?? "", 10) === line) return lineIndex(split, row)
}
}
export function markCommentedDiffLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
clear(root)
const diffs = root.querySelector("[data-diff]")
if (!(diffs instanceof HTMLElement)) return
const split = diffs.dataset.diffType === "split"
const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (rows.length === 0) return
const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
for (const range of ranges) {
const start = rowIndex(root, split, range.start, range.side)
if (start === undefined) continue
const end = (() => {
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
if (same) return start
return rowIndex(root, split, range.end, range.endSide ?? range.side)
})()
if (end === undefined) continue
const first = Math.min(start, end)
const last = Math.max(start, end)
for (const row of rows) {
const idx = lineIndex(split, row)
if (idx === undefined || idx < first || idx > last) continue
row.setAttribute("data-comment-selected", "")
}
for (const annotation of annotations) {
const idx = annotationIndex(annotation)
if (idx === undefined || idx < first || idx > last) continue
annotation.setAttribute("data-comment-selected", "")
}
}
}
export function markCommentedFileLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
clear(root)
const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
for (const range of ranges) {
const start = Math.max(1, Math.min(range.start, range.end))
const end = Math.max(range.start, range.end)
for (let line = start; line <= end; line++) {
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`))
for (const node of nodes) {
if (!(node instanceof HTMLElement)) continue
node.setAttribute("data-comment-selected", "")
}
}
for (const annotation of annotations) {
const line = annotationIndex(annotation)
if (line === undefined || line < start || line > end) continue
annotation.setAttribute("data-comment-selected", "")
}
}
}

View File

@@ -1,6 +1,5 @@
import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRange } from "@pierre/diffs"
import { ComponentProps } from "solid-js"
import { lineCommentStyles } from "../components/line-comment-styles"
export type DiffProps<T = {}> = FileDiffOptions<T> & {
before: FileContents
@@ -8,7 +7,6 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
annotations?: DiffLineAnnotation<T>[]
selectedLines?: SelectedLineRange | null
commentedLines?: SelectedLineRange[]
onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
onRendered?: () => void
class?: string
classList?: ComponentProps<"div">["classList"]
@@ -127,11 +125,7 @@ const unsafeCSS = `
overflow-x: auto !important;
overflow-y: hidden !important;
}
}
${lineCommentStyles}
`
}`
export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"]) {
return {

View File

@@ -1,105 +0,0 @@
import { type SelectedLineRange } from "@pierre/diffs"
type PointerMode = "none" | "text" | "numbers"
type Side = SelectedLineRange["side"]
export function cloneSelectedLineRange(range: SelectedLineRange): SelectedLineRange {
const next: SelectedLineRange = {
start: range.start,
end: range.end,
}
if (range.side) next.side = range.side
if (range.endSide) next.endSide = range.endSide
return next
}
export function lineInSelectedRange(range: SelectedLineRange | null | undefined, line: number, side?: Side) {
if (!range) return false
const start = Math.min(range.start, range.end)
const end = Math.max(range.start, range.end)
if (line < start || line > end) return false
if (!side) return true
const first = range.side
const last = range.endSide ?? first
if (!first && !last) return true
if (!first || !last) return (first ?? last) === side
if (first === last) return first === side
if (line === start) return first === side
if (line === end) return last === side
return true
}
export function isSingleLineSelection(range: SelectedLineRange | null) {
if (!range) return false
return range.start === range.end && (range.endSide == null || range.endSide === range.side)
}
export function restoreShadowTextSelection(root: ShadowRoot | undefined, range: Range | undefined) {
if (!root || !range) return
requestAnimationFrame(() => {
const selection =
(root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
if (!selection) return
try {
selection.removeAllRanges()
selection.addRange(range)
} catch {}
})
}
export function createLineNumberSelectionBridge() {
let mode: PointerMode = "none"
let line: number | undefined
let moved = false
let pending = false
const clear = () => {
mode = "none"
line = undefined
moved = false
}
return {
begin(numberColumn: boolean, next: number | undefined) {
if (!numberColumn) {
mode = "text"
return
}
mode = "numbers"
line = next
moved = false
},
track(buttons: number, next: number | undefined) {
if (mode !== "numbers") return false
if ((buttons & 1) === 0) {
clear()
return true
}
if (next !== undefined && line !== undefined && next !== line) moved = true
return true
},
finish() {
const current = mode
pending = current === "numbers" && moved
clear()
return current
},
consume(range: SelectedLineRange | null) {
const result = pending && !isSingleLineSelection(range)
pending = false
return result
},
reset() {
pending = false
clear()
},
}
}

View File

@@ -28,6 +28,7 @@
@import "../components/icon-button.css" layer(components);
@import "../components/image-preview.css" layer(components);
@import "../components/keybind.css" layer(components);
@import "../components/line-comment.css" layer(components);
@import "../components/text-field.css" layer(components);
@import "../components/inline-input.css" layer(components);
@import "../components/list.css" layer(components);