comments.tsx 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. import { batch, createMemo, createRoot, onCleanup } from "solid-js"
  2. import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
  3. import { createSimpleContext } from "@opencode-ai/ui/context"
  4. import { useParams } from "@solidjs/router"
  5. import { base64Encode } from "@opencode-ai/core/util/encode"
  6. import { Persist, persisted } from "@/utils/persist"
  7. import { useServerSDK } from "./server-sdk"
  8. import type { ServerScope } from "@/utils/server-scope"
  9. import { createScopedCache } from "@/utils/scoped-cache"
  10. import { uuid } from "@/utils/uuid"
  11. import type { SelectedLineRange } from "@/context/file"
  12. import { useSDK } from "./sdk"
  13. export type LineComment = {
  14. id: string
  15. file: string
  16. selection: SelectedLineRange
  17. comment: string
  18. time: number
  19. }
  20. type CommentFocus = { file: string; id: string }
  21. const WORKSPACE_KEY = "__workspace__"
  22. const MAX_COMMENT_SESSIONS = 20
  23. function sessionKey(dir: string, id: string | undefined) {
  24. return `${dir}\n${id ?? WORKSPACE_KEY}`
  25. }
  26. function decodeSessionKey(key: string) {
  27. const split = key.lastIndexOf("\n")
  28. if (split < 0) return { dir: key, id: WORKSPACE_KEY }
  29. return {
  30. dir: key.slice(0, split),
  31. id: key.slice(split + 1),
  32. }
  33. }
  34. type CommentStore = {
  35. comments: Record<string, LineComment[]>
  36. }
  37. function aggregate(comments: Record<string, LineComment[]>) {
  38. return Object.keys(comments)
  39. .flatMap((file) => comments[file] ?? [])
  40. .slice()
  41. .sort((a, b) => a.time - b.time)
  42. }
  43. function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
  44. const next: SelectedLineRange = {
  45. start: selection.start,
  46. end: selection.end,
  47. }
  48. if (selection.side) next.side = selection.side
  49. if (selection.endSide) next.endSide = selection.endSide
  50. return next
  51. }
  52. function cloneComment(comment: LineComment): LineComment {
  53. return {
  54. ...comment,
  55. selection: cloneSelection(comment.selection),
  56. }
  57. }
  58. function group(comments: LineComment[]) {
  59. return comments.reduce<Record<string, LineComment[]>>((acc, comment) => {
  60. const list = acc[comment.file]
  61. const next = cloneComment(comment)
  62. if (list) {
  63. list.push(next)
  64. return acc
  65. }
  66. acc[comment.file] = [next]
  67. return acc
  68. }, {})
  69. }
  70. function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
  71. const [state, setState] = createStore({
  72. focus: null as CommentFocus | null,
  73. active: null as CommentFocus | null,
  74. })
  75. const all = () => aggregate(store.comments)
  76. const setRef = (
  77. key: "focus" | "active",
  78. value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null),
  79. ) => setState(key, value)
  80. const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
  81. setRef("focus", value)
  82. const setActive = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
  83. setRef("active", value)
  84. const list = (file: string) => store.comments[file] ?? []
  85. const add = (input: Omit<LineComment, "id" | "time">) => {
  86. const next: LineComment = {
  87. id: uuid(),
  88. time: Date.now(),
  89. ...input,
  90. selection: cloneSelection(input.selection),
  91. }
  92. batch(() => {
  93. setStore("comments", input.file, (items) => [...(items ?? []), next])
  94. setFocus({ file: input.file, id: next.id })
  95. })
  96. return next
  97. }
  98. const remove = (file: string, id: string) => {
  99. batch(() => {
  100. setStore("comments", file, (items) => (items ?? []).filter((item) => item.id !== id))
  101. setFocus((current) => (current?.file === file && current.id === id ? null : current))
  102. })
  103. }
  104. const update = (file: string, id: string, comment: string) => {
  105. setStore("comments", file, (items) =>
  106. (items ?? []).map((item) => {
  107. if (item.id !== id) return item
  108. return { ...item, comment }
  109. }),
  110. )
  111. }
  112. const replace = (comments: LineComment[]) => {
  113. batch(() => {
  114. setStore("comments", reconcile(group(comments)))
  115. setFocus(null)
  116. setActive(null)
  117. })
  118. }
  119. const clear = () => {
  120. batch(() => {
  121. setStore("comments", reconcile({}))
  122. setFocus(null)
  123. setActive(null)
  124. })
  125. }
  126. return {
  127. list,
  128. all,
  129. add,
  130. remove,
  131. update,
  132. replace,
  133. clear,
  134. focus: () => state.focus,
  135. setFocus,
  136. clearFocus: () => setRef("focus", null),
  137. active: () => state.active,
  138. setActive,
  139. clearActive: () => setRef("active", null),
  140. }
  141. }
  142. export function createCommentSessionForTest(comments: Record<string, LineComment[]> = {}) {
  143. const [store, setStore] = createStore<CommentStore>({ comments })
  144. return createCommentSessionState(store, setStore)
  145. }
  146. function createCommentSession(scope: ServerScope, dir: string, id: string | undefined) {
  147. const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
  148. const [store, setStore, _, ready] = persisted(
  149. Persist.serverScoped(scope, dir, id, "comments", [legacy]),
  150. createStore<CommentStore>({
  151. comments: {},
  152. }),
  153. )
  154. const session = createCommentSessionState(store, setStore)
  155. return {
  156. ready,
  157. list: session.list,
  158. all: session.all,
  159. add: session.add,
  160. remove: session.remove,
  161. update: session.update,
  162. replace: session.replace,
  163. clear: session.clear,
  164. focus: session.focus,
  165. setFocus: session.setFocus,
  166. clearFocus: session.clearFocus,
  167. active: session.active,
  168. setActive: session.setActive,
  169. clearActive: session.clearActive,
  170. }
  171. }
  172. export const { use: useComments, provider: CommentsProvider } = createSimpleContext({
  173. name: "Comments",
  174. gate: false,
  175. init: () => {
  176. const params = useParams()
  177. const sdk = useSDK()
  178. const serverSDK = useServerSDK()
  179. const cache = createScopedCache(
  180. (key) => {
  181. const decoded = decodeSessionKey(key)
  182. return createRoot((dispose) => ({
  183. value: createCommentSession(
  184. serverSDK().scope,
  185. decoded.dir,
  186. decoded.id === WORKSPACE_KEY ? undefined : decoded.id,
  187. ),
  188. dispose,
  189. }))
  190. },
  191. {
  192. maxEntries: MAX_COMMENT_SESSIONS,
  193. dispose: (entry) => entry.dispose(),
  194. },
  195. )
  196. onCleanup(() => cache.clear())
  197. const load = (dir: string, id: string | undefined) => {
  198. const key = sessionKey(dir, id)
  199. return cache.get(key).value
  200. }
  201. const session = createMemo(() => load(base64Encode(sdk().directory), params.id))
  202. return {
  203. ready: () => session().ready(),
  204. list: (file: string) => session().list(file),
  205. all: () => session().all(),
  206. add: (input: Omit<LineComment, "id" | "time">) => session().add(input),
  207. remove: (file: string, id: string) => session().remove(file, id),
  208. update: (file: string, id: string, comment: string) => session().update(file, id, comment),
  209. replace: (comments: LineComment[]) => session().replace(comments),
  210. clear: () => session().clear(),
  211. focus: () => session().focus(),
  212. setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
  213. clearFocus: () => session().clearFocus(),
  214. active: () => session().active(),
  215. setActive: (active: CommentFocus | null) => session().setActive(active),
  216. clearActive: () => session().clearActive(),
  217. }
  218. },
  219. })