EdgeFunctionsDiffPanel.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import { basename } from 'path'
  2. import { IS_PLATFORM } from 'common'
  3. import { Circle, Code, Minus, Plus, Wind } from 'lucide-react'
  4. import Link from 'next/link'
  5. import { useEffect, useMemo, useState } from 'react'
  6. import { Card, CardContent, CardHeader, CardTitle, cn, Skeleton } from 'ui'
  7. import { DiffEditor } from '@/components/ui/DiffEditor'
  8. import type { EdgeFunctionBodyData } from '@/data/edge-functions/edge-function-body-query'
  9. import type {
  10. EdgeFunctionsDiffResult,
  11. FileInfo,
  12. FileStatus,
  13. } from '@/hooks/branches/useEdgeFunctionsDiff'
  14. import { EMPTY_ARR } from '@/lib/void'
  15. const EMPTY_FUNCTION_BODY: EdgeFunctionBodyData = {
  16. files: EMPTY_ARR,
  17. }
  18. interface EdgeFunctionsDiffPanelProps {
  19. diffResults: EdgeFunctionsDiffResult
  20. currentBranchRef?: string
  21. }
  22. interface FunctionDiffProps {
  23. functionSlug: string
  24. currentBody: EdgeFunctionBodyData
  25. mainBody: EdgeFunctionBodyData
  26. currentBranchRef?: string
  27. fileInfos: FileInfo[]
  28. }
  29. // Helper to canonicalize file identifiers to prevent mismatch due to differing root paths
  30. const fileKey = (fullPath: string) => basename(fullPath)
  31. // Helper to get the status color for file indicators
  32. const getStatusColor = (status: FileStatus): string => {
  33. switch (status) {
  34. case 'added':
  35. return 'text-brand'
  36. case 'removed':
  37. return 'text-destructive'
  38. case 'modified':
  39. return 'text-warning'
  40. case 'unchanged':
  41. return 'text-muted'
  42. default:
  43. return 'text-muted'
  44. }
  45. }
  46. // Helper to get the status icon for file indicators
  47. const getStatusIcon = (status: FileStatus) => {
  48. switch (status) {
  49. case 'added':
  50. return Plus
  51. case 'removed':
  52. return Minus
  53. case 'modified':
  54. return Circle
  55. case 'unchanged':
  56. return Circle
  57. default:
  58. return Circle
  59. }
  60. }
  61. const FunctionDiff = ({
  62. functionSlug,
  63. currentBody,
  64. mainBody,
  65. currentBranchRef,
  66. fileInfos,
  67. }: FunctionDiffProps) => {
  68. // Get all file keys from fileInfos
  69. const allFileKeys = useMemo(() => fileInfos.map((info) => info.key), [fileInfos])
  70. const [activeFileKey, setActiveFileKey] = useState<string | undefined>(() => allFileKeys[0])
  71. // Keep active tab in sync when allFileKeys changes (e.g. data fetch completes)
  72. useEffect(() => {
  73. if (!activeFileKey || !allFileKeys.includes(activeFileKey)) {
  74. setActiveFileKey(allFileKeys[0])
  75. }
  76. }, [allFileKeys, activeFileKey])
  77. const currentFile = currentBody.files.find(
  78. (f: EdgeFunctionBodyData['files'][number]) => fileKey(f.name) === activeFileKey
  79. )
  80. const mainFile = mainBody.files.find(
  81. (f: EdgeFunctionBodyData['files'][number]) => fileKey(f.name) === activeFileKey
  82. )
  83. const language = useMemo(() => {
  84. if (!activeFileKey) return 'plaintext'
  85. if (activeFileKey.endsWith('.ts') || activeFileKey.endsWith('.tsx')) {
  86. return 'typescript'
  87. }
  88. if (activeFileKey.endsWith('.js') || activeFileKey.endsWith('.jsx')) {
  89. return 'javascript'
  90. }
  91. if (activeFileKey.endsWith('.json')) return 'json'
  92. if (activeFileKey.endsWith('.sql')) return 'sql'
  93. return 'plaintext'
  94. }, [activeFileKey])
  95. if (allFileKeys.length === 0) return null
  96. return (
  97. <Card>
  98. <CardHeader>
  99. <CardTitle>
  100. <Link
  101. href={`/project/${currentBranchRef}/functions/${functionSlug}${IS_PLATFORM ? '' : '/details'}`}
  102. className="flex items-center gap-2"
  103. >
  104. <Code strokeWidth={1.5} size={16} className="text-foreground-muted" />
  105. {functionSlug}
  106. </Link>
  107. </CardTitle>
  108. </CardHeader>
  109. <CardContent className="p-0 h-96">
  110. <div className="flex h-full min-h-0">
  111. <div className="w-48 border-r bg-surface-200 flex flex-col overflow-y-auto">
  112. <ul className="divide-y divide-border">
  113. {fileInfos.map((fileInfo) => {
  114. const Icon = getStatusIcon(fileInfo.status)
  115. return (
  116. <li key={fileInfo.key} className="flex">
  117. <button
  118. type="button"
  119. onClick={() => setActiveFileKey(fileInfo.key)}
  120. className={cn(
  121. 'flex-1 text-left text-xs px-4 py-2 flex items-center gap-2',
  122. activeFileKey === fileInfo.key
  123. ? 'bg-surface-300 text-foreground'
  124. : 'text-foreground-light hover:bg-surface-300'
  125. )}
  126. >
  127. <Icon
  128. className={cn('shrink-0', getStatusColor(fileInfo.status))}
  129. size={12}
  130. strokeWidth={1}
  131. />
  132. <span className="truncate">{fileInfo.key}</span>
  133. </button>
  134. </li>
  135. )
  136. })}
  137. </ul>
  138. </div>
  139. <div className="flex-1 min-h-0">
  140. <DiffEditor
  141. language={language}
  142. original={mainFile?.content || ''}
  143. modified={currentFile?.content || ''}
  144. options={{ readOnly: true }}
  145. />
  146. </div>
  147. </div>
  148. </CardContent>
  149. </Card>
  150. )
  151. }
  152. export const EdgeFunctionsDiffPanel = ({
  153. diffResults,
  154. currentBranchRef,
  155. }: EdgeFunctionsDiffPanelProps) => {
  156. if (diffResults.isLoading) {
  157. return <Skeleton className="h-64" />
  158. }
  159. const noChanges = diffResults.addedSlugs.length === 0 && diffResults.modifiedSlugs.length === 0
  160. if (noChanges) {
  161. return (
  162. <div className="p-6 text-center">
  163. <Wind size={32} strokeWidth={1.5} className="text-foreground-muted mx-auto mb-8" />
  164. <h3 className="mb-1">No changes detected between branches</h3>
  165. <p className="text-sm text-foreground-light">
  166. Any changes to your edge functions will be shown here for review
  167. </p>
  168. </div>
  169. )
  170. }
  171. return (
  172. <div className="space-y-6">
  173. {diffResults.addedSlugs.length > 0 && (
  174. <div>
  175. <div className="space-y-4">
  176. {diffResults.addedSlugs.map((slug) => (
  177. <FunctionDiff
  178. key={slug}
  179. functionSlug={slug}
  180. currentBody={diffResults.addedBodiesMap[slug]!}
  181. mainBody={EMPTY_FUNCTION_BODY}
  182. currentBranchRef={currentBranchRef}
  183. fileInfos={diffResults.functionFileInfo[slug] || EMPTY_ARR}
  184. />
  185. ))}
  186. </div>
  187. </div>
  188. )}
  189. {/* TODO: Removing functions is not supported yet */}
  190. {/* {diffResults.removedSlugs.length > 0 && (
  191. <div>
  192. <div className="space-y-4">
  193. {diffResults.removedSlugs.map((slug) => (
  194. <FunctionDiff
  195. key={slug}
  196. functionSlug={slug}
  197. currentBody={EMPTY_ARR}
  198. mainBody={diffResults.removedBodiesMap[slug]!}
  199. currentBranchRef={mainBranchRef}
  200. fileInfos={diffResults.functionFileInfo[slug] || EMPTY_ARR}
  201. />
  202. ))}
  203. </div>
  204. </div>
  205. )} */}
  206. {diffResults.modifiedSlugs.length > 0 && (
  207. <div className="space-y-4">
  208. {diffResults.modifiedSlugs.map((slug) => (
  209. <FunctionDiff
  210. key={slug}
  211. functionSlug={slug}
  212. currentBody={diffResults.currentBodiesMap[slug]!}
  213. mainBody={diffResults.mainBodiesMap[slug]!}
  214. currentBranchRef={currentBranchRef}
  215. fileInfos={diffResults.functionFileInfo[slug] || EMPTY_ARR}
  216. />
  217. ))}
  218. </div>
  219. )}
  220. </div>
  221. )
  222. }