comments.tsx 6.8 KB

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