comments.tsx 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import { batch, createEffect, 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 type { SelectedLineRange } from "@/context/file"
  8. export type LineComment = {
  9. id: string
  10. file: string
  11. selection: SelectedLineRange
  12. comment: string
  13. time: number
  14. }
  15. type CommentFocus = { file: string; id: string }
  16. const WORKSPACE_KEY = "__workspace__"
  17. const MAX_COMMENT_SESSIONS = 20
  18. type CommentStore = {
  19. comments: Record<string, LineComment[]>
  20. }
  21. function aggregate(comments: Record<string, LineComment[]>) {
  22. return Object.keys(comments)
  23. .flatMap((file) => comments[file] ?? [])
  24. .slice()
  25. .sort((a, b) => a.time - b.time)
  26. }
  27. function insert(items: LineComment[], next: LineComment) {
  28. const index = items.findIndex((item) => item.time > next.time)
  29. if (index < 0) return [...items, next]
  30. return [...items.slice(0, index), next, ...items.slice(index)]
  31. }
  32. function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
  33. const [state, setState] = createStore({
  34. focus: null as CommentFocus | null,
  35. active: null as CommentFocus | null,
  36. all: aggregate(store.comments),
  37. })
  38. const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
  39. setState("focus", value)
  40. const setActive = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
  41. setState("active", value)
  42. const list = (file: string) => store.comments[file] ?? []
  43. const add = (input: Omit<LineComment, "id" | "time">) => {
  44. const next: LineComment = {
  45. id: crypto.randomUUID?.() ?? Math.random().toString(16).slice(2),
  46. time: Date.now(),
  47. ...input,
  48. }
  49. batch(() => {
  50. setStore("comments", input.file, (items) => [...(items ?? []), next])
  51. setState("all", (items) => insert(items, next))
  52. setFocus({ file: input.file, id: next.id })
  53. })
  54. return next
  55. }
  56. const remove = (file: string, id: string) => {
  57. batch(() => {
  58. setStore("comments", file, (items) => (items ?? []).filter((item) => item.id !== id))
  59. setState("all", (items) => items.filter((item) => !(item.file === file && item.id === id)))
  60. setFocus((current) => (current?.id === id ? null : current))
  61. })
  62. }
  63. const clear = () => {
  64. batch(() => {
  65. setStore("comments", reconcile({}))
  66. setState("all", [])
  67. setFocus(null)
  68. setActive(null)
  69. })
  70. }
  71. return {
  72. list,
  73. all: () => state.all,
  74. add,
  75. remove,
  76. clear,
  77. focus: () => state.focus,
  78. setFocus,
  79. clearFocus: () => setFocus(null),
  80. active: () => state.active,
  81. setActive,
  82. clearActive: () => setActive(null),
  83. reindex: () => setState("all", aggregate(store.comments)),
  84. }
  85. }
  86. export function createCommentSessionForTest(comments: Record<string, LineComment[]> = {}) {
  87. const [store, setStore] = createStore<CommentStore>({ comments })
  88. return createCommentSessionState(store, setStore)
  89. }
  90. function createCommentSession(dir: string, id: string | undefined) {
  91. const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
  92. const [store, setStore, _, ready] = persisted(
  93. Persist.scoped(dir, id, "comments", [legacy]),
  94. createStore<CommentStore>({
  95. comments: {},
  96. }),
  97. )
  98. const session = createCommentSessionState(store, setStore)
  99. createEffect(() => {
  100. if (!ready()) return
  101. session.reindex()
  102. })
  103. return {
  104. ready,
  105. list: session.list,
  106. all: session.all,
  107. add: session.add,
  108. remove: session.remove,
  109. clear: session.clear,
  110. focus: session.focus,
  111. setFocus: session.setFocus,
  112. clearFocus: session.clearFocus,
  113. active: session.active,
  114. setActive: session.setActive,
  115. clearActive: session.clearActive,
  116. }
  117. }
  118. export const { use: useComments, provider: CommentsProvider } = createSimpleContext({
  119. name: "Comments",
  120. gate: false,
  121. init: () => {
  122. const params = useParams()
  123. const cache = createScopedCache(
  124. (key) => {
  125. const split = key.lastIndexOf("\n")
  126. const dir = split >= 0 ? key.slice(0, split) : key
  127. const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
  128. return createRoot((dispose) => ({
  129. value: createCommentSession(dir, id === WORKSPACE_KEY ? undefined : id),
  130. dispose,
  131. }))
  132. },
  133. {
  134. maxEntries: MAX_COMMENT_SESSIONS,
  135. dispose: (entry) => entry.dispose(),
  136. },
  137. )
  138. onCleanup(() => cache.clear())
  139. const load = (dir: string, id: string | undefined) => {
  140. const key = `${dir}\n${id ?? WORKSPACE_KEY}`
  141. return cache.get(key).value
  142. }
  143. const session = createMemo(() => load(params.dir!, params.id))
  144. return {
  145. ready: () => session().ready(),
  146. list: (file: string) => session().list(file),
  147. all: () => session().all(),
  148. add: (input: Omit<LineComment, "id" | "time">) => session().add(input),
  149. remove: (file: string, id: string) => session().remove(file, id),
  150. clear: () => session().clear(),
  151. focus: () => session().focus(),
  152. setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
  153. clearFocus: () => session().clearFocus(),
  154. active: () => session().active(),
  155. setActive: (active: CommentFocus | null) => session().setActive(active),
  156. clearActive: () => session().clearActive(),
  157. }
  158. },
  159. })