diff options
| author | Adam <[email protected]> | 2026-01-21 06:17:55 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-22 22:12:12 -0600 |
| commit | cb481d9ac861813d4ff091ed33bcac9e882da1a1 (patch) | |
| tree | c08be4b96815b74ac6dc1e3bab6359cd5dbb27b3 /packages/app/src | |
| parent | 0ce0cacb282c47943348a2af21ea00e721bcb9d9 (diff) | |
| download | opencode-cb481d9ac861813d4ff091ed33bcac9e882da1a1.tar.gz opencode-cb481d9ac861813d4ff091ed33bcac9e882da1a1.zip | |
wip(app): line selection
Diffstat (limited to 'packages/app/src')
| -rw-r--r-- | packages/app/src/app.tsx | 17 | ||||
| -rw-r--r-- | packages/app/src/components/prompt-input.tsx | 21 | ||||
| -rw-r--r-- | packages/app/src/context/comments.tsx | 140 | ||||
| -rw-r--r-- | packages/app/src/context/prompt.tsx | 6 | ||||
| -rw-r--r-- | packages/app/src/pages/session.tsx | 20 |
5 files changed, 195 insertions, 9 deletions
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 56d6ec406..4fee0852f 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -19,6 +19,7 @@ import { SettingsProvider } from "@/context/settings" import { TerminalProvider } from "@/context/terminal" import { PromptProvider } from "@/context/prompt" import { FileProvider } from "@/context/file" +import { CommentsProvider } from "@/context/comments" import { NotificationProvider } from "@/context/notification" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { CommandProvider } from "@/context/command" @@ -128,13 +129,15 @@ export function AppInterface(props: { defaultUrl?: string }) { component={(p) => ( <Show when={p.params.id ?? "new"}> <TerminalProvider> - <FileProvider> - <PromptProvider> - <Suspense fallback={<Loading />}> - <Session /> - </Suspense> - </PromptProvider> - </FileProvider> + <FileProvider> + <PromptProvider> + <CommentsProvider> + <Suspense fallback={<Loading />}> + <Session /> + </Suspense> + </CommentsProvider> + </PromptProvider> + </FileProvider> </TerminalProvider> </Show> )} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 5e936737a..b2c8cccca 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -30,6 +30,7 @@ import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" import { useNavigate, useParams } from "@solidjs/router" import { useSync } from "@/context/sync" +import { useComments } from "@/context/comments" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" @@ -115,6 +116,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const files = useFile() const prompt = usePrompt() const layout = useLayout() + const comments = useComments() const params = useParams() const dialog = useDialog() const providers = useProviders() @@ -158,6 +160,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) + const view = createMemo(() => layout.view(sessionKey())) const activeFile = createMemo(() => { const tab = tabs().active() if (!tab) return @@ -1555,7 +1558,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => { {(item) => { const preview = createMemo(() => selectionPreview(item.path, item.selection, item.preview)) return ( - <div class="shrink-0 flex flex-col gap-1 rounded-md bg-surface-base border border-border-base px-2 py-1 max-w-[320px]"> + <div + classList={{ + "shrink-0 flex flex-col gap-1 rounded-md bg-surface-base border border-border-base px-2 py-1 max-w-[320px]": true, + "cursor-pointer hover:bg-surface-raised-base-hover": !!item.commentID, + }} + onClick={() => { + if (!item.commentID) return + comments.setFocus({ file: item.path, id: item.commentID }) + view().reviewPanel.open() + tabs().open("review") + }} + > <div class="flex items-center gap-1.5"> <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" /> <div class="flex items-center text-11-regular min-w-0"> @@ -1576,7 +1590,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => { icon="close" variant="ghost" class="h-5 w-5" - onClick={() => prompt.context.remove(item.key)} + onClick={(e) => { + e.stopPropagation() + prompt.context.remove(item.key) + }} aria-label={language.t("prompt.context.removeFile")} /> </div> diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx new file mode 100644 index 000000000..12ee977e9 --- /dev/null +++ b/packages/app/src/context/comments.tsx @@ -0,0 +1,140 @@ +import { batch, createMemo, createRoot, createSignal, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { useParams } from "@solidjs/router" +import { Persist, persisted } from "@/utils/persist" +import type { SelectedLineRange } from "@/context/file" + +export type LineComment = { + id: string + file: string + selection: SelectedLineRange + comment: string + time: number +} + +type CommentFocus = { file: string; id: string } + +const WORKSPACE_KEY = "__workspace__" +const MAX_COMMENT_SESSIONS = 20 + +type CommentSession = ReturnType<typeof createCommentSession> + +type CommentCacheEntry = { + value: CommentSession + dispose: VoidFunction +} + +function createCommentSession(dir: string, id: string | undefined) { + const legacy = `${dir}/comments${id ? "/" + id : ""}.v1` + + const [store, setStore, _, ready] = persisted( + Persist.scoped(dir, id, "comments", [legacy]), + createStore<{ + comments: Record<string, LineComment[]> + }>({ + comments: {}, + }), + ) + + const [focus, setFocus] = createSignal<CommentFocus | null>(null) + + const list = (file: string) => store.comments[file] ?? [] + + const add = (input: Omit<LineComment, "id" | "time">) => { + const next: LineComment = { + id: crypto.randomUUID(), + time: Date.now(), + ...input, + } + + batch(() => { + setStore("comments", input.file, (items) => [...(items ?? []), next]) + setFocus({ file: input.file, id: next.id }) + }) + + return next + } + + const remove = (file: string, id: string) => { + setStore("comments", file, (items) => (items ?? []).filter((x) => x.id !== id)) + setFocus((current) => (current?.id === id ? null : current)) + } + + const all = createMemo(() => { + const files = Object.keys(store.comments) + const items = files.flatMap((file) => store.comments[file] ?? []) + return items.slice().sort((a, b) => a.time - b.time) + }) + + return { + ready, + list, + all, + add, + remove, + focus: createMemo(() => focus()), + setFocus, + clearFocus: () => setFocus(null), + } +} + +export const { use: useComments, provider: CommentsProvider } = createSimpleContext({ + name: "Comments", + gate: false, + init: () => { + const params = useParams() + const cache = new Map<string, CommentCacheEntry>() + + const disposeAll = () => { + for (const entry of cache.values()) { + entry.dispose() + } + cache.clear() + } + + onCleanup(disposeAll) + + const prune = () => { + while (cache.size > MAX_COMMENT_SESSIONS) { + const first = cache.keys().next().value + if (!first) return + const entry = cache.get(first) + entry?.dispose() + cache.delete(first) + } + } + + const load = (dir: string, id: string | undefined) => { + const key = `${dir}:${id ?? WORKSPACE_KEY}` + const existing = cache.get(key) + if (existing) { + cache.delete(key) + cache.set(key, existing) + return existing.value + } + + const entry = createRoot((dispose) => ({ + value: createCommentSession(dir, id), + dispose, + })) + + cache.set(key, entry) + prune() + return entry.value + } + + const session = createMemo(() => load(params.dir!, params.id)) + + return { + ready: () => session().ready(), + list: (file: string) => session().list(file), + all: () => session().all(), + add: (input: Omit<LineComment, "id" | "time">) => session().add(input), + remove: (file: string, id: string) => session().remove(file, id), + focus: () => session().focus(), + setFocus: (focus: CommentFocus | null) => session().setFocus(focus), + clearFocus: () => session().clearFocus(), + } + }, +}) diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index a76d9d5f1..40baa0ef5 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -43,6 +43,7 @@ export type FileContextItem = { path: string selection?: FileSelection comment?: string + commentID?: string preview?: string } @@ -139,6 +140,11 @@ function createPromptSession(dir: string, id: string | undefined) { const start = item.selection?.startLine const end = item.selection?.endLine const key = `${item.type}:${item.path}:${start}:${end}` + + if (item.commentID) { + return `${key}:c=${item.commentID}` + } + const comment = item.comment?.trim() if (!comment) return key const digest = checksum(comment) ?? comment diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 1e0d7a89e..b2d9747c7 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -51,6 +51,7 @@ import { UserMessage } from "@opencode-ai/sdk/v2" import type { FileDiff } from "@opencode-ai/sdk/v2/client" import { useSDK } from "@/context/sdk" import { usePrompt } from "@/context/prompt" +import { useComments, type LineComment } from "@/context/comments" import { extractPromptFromParts } from "@/utils/prompt" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { usePermission } from "@/context/permission" @@ -82,6 +83,9 @@ interface SessionReviewTabProps { onDiffStyleChange?: (style: DiffStyle) => void onViewFile?: (file: string) => void onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void + comments?: LineComment[] + focusedComment?: { file: string; id: string } | null + onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void classes?: { root?: string header?: string @@ -168,6 +172,9 @@ function SessionReviewTab(props: SessionReviewTabProps) { onViewFile={props.onViewFile} readFile={readFile} onLineComment={props.onLineComment} + comments={props.comments} + focusedComment={props.focusedComment} + onFocusedCommentChange={props.onFocusedCommentChange} /> ) } @@ -187,6 +194,7 @@ export default function Page() { const navigate = useNavigate() const sdk = useSDK() const prompt = usePrompt() + const comments = useComments() const permission = usePermission() const [pendingMessage, setPendingMessage] = createSignal<string | undefined>(undefined) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) @@ -513,11 +521,17 @@ export default function Page() { }) => { const selection = selectionFromLines(input.selection) const preview = input.preview ?? selectionPreview(input.file, selection) + const saved = comments.add({ + file: input.file, + selection: input.selection, + comment: input.comment, + }) prompt.context.add({ type: "file", path: input.file, selection, comment: input.comment, + commentID: saved.id, preview, }) } @@ -1433,6 +1447,9 @@ export default function Page() { view={view} diffStyle="unified" onLineComment={addCommentToContext} + comments={comments.all()} + focusedComment={comments.focus()} + onFocusedCommentChange={comments.setFocus} onViewFile={(path) => { const value = file.tab(path) tabs().open(value) @@ -1749,6 +1766,9 @@ export default function Page() { diffStyle={layout.review.diffStyle()} onDiffStyleChange={layout.review.setDiffStyle} onLineComment={addCommentToContext} + comments={comments.all()} + focusedComment={comments.focus()} + onFocusedCommentChange={comments.setFocus} onViewFile={(path) => { const value = file.tab(path) tabs().open(value) |
