DiffViewer.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. <script setup lang="ts">
  2. import type { Ace } from 'ace-builds'
  3. import type { ConfigBackup } from '@/api/config'
  4. import ace from 'ace-builds'
  5. // Import required modules
  6. import extLanguageToolsUrl from 'ace-builds/src-min-noconflict/ext-language_tools?url'
  7. import { formatDateTime } from '@/lib/helper'
  8. import 'ace-builds/src-noconflict/mode-nginx'
  9. import 'ace-builds/src-noconflict/theme-monokai'
  10. const props = defineProps<{
  11. records: ConfigBackup[]
  12. }>()
  13. const emit = defineEmits<{
  14. (e: 'restore'): void
  15. }>()
  16. // Import Range class separately to avoid loading the entire ace package
  17. const Range = ace.Range
  18. // Define modal visibility using defineModel with boolean type
  19. const visible = defineModel<boolean>('visible')
  20. // Define currentContent using defineModel
  21. const currentContent = defineModel<string>('currentContent')
  22. const originalText = ref('')
  23. const modifiedText = ref('')
  24. const diffEditorRef = ref<HTMLElement | null>(null)
  25. const editors: { left?: Ace.Editor, right?: Ace.Editor } = {}
  26. const originalTitle = ref('')
  27. const modifiedTitle = ref('')
  28. const errorMessage = ref('')
  29. // Initialize ace language tools
  30. onMounted(() => {
  31. try {
  32. ace.config.setModuleUrl('ace/ext/language_tools', extLanguageToolsUrl)
  33. }
  34. catch (error) {
  35. console.error('Failed to initialize Ace editor language tools:', error)
  36. }
  37. })
  38. // Check if there is content to display
  39. function hasContent() {
  40. return originalText.value && modifiedText.value
  41. }
  42. // Set editor content based on selected records
  43. function setContent() {
  44. if (!props.records || props.records.length === 0) {
  45. errorMessage.value = $gettext('No records selected')
  46. return false
  47. }
  48. try {
  49. // Set content based on number of selected records
  50. if (props.records.length === 1) {
  51. // Single record - compare with current content
  52. originalText.value = props.records[0]?.content || ''
  53. modifiedText.value = currentContent.value || ''
  54. // Ensure both sides have content for comparison
  55. if (!originalText.value || !modifiedText.value) {
  56. errorMessage.value = $gettext('Cannot compare: Missing content')
  57. return false
  58. }
  59. originalTitle.value = `${props.records[0]?.name || ''} (${formatDateTime(props.records[0]?.created_at || '')})`
  60. modifiedTitle.value = $gettext('Current Content')
  61. }
  62. else if (props.records.length === 2) {
  63. // Compare two records - sort by time
  64. const sorted = [...props.records].sort((a, b) =>
  65. new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
  66. )
  67. originalText.value = sorted[0]?.content || ''
  68. modifiedText.value = sorted[1]?.content || ''
  69. // Ensure both sides have content for comparison
  70. if (!originalText.value || !modifiedText.value) {
  71. errorMessage.value = $gettext('Cannot compare: Missing content')
  72. return false
  73. }
  74. originalTitle.value = `${sorted[0]?.name || ''} (${formatDateTime(sorted[0]?.created_at || '')})`
  75. modifiedTitle.value = `${sorted[1]?.name || ''} (${formatDateTime(sorted[1]?.created_at || '')})`
  76. }
  77. errorMessage.value = ''
  78. return hasContent()
  79. }
  80. catch (error) {
  81. console.error('Error setting content:', error)
  82. errorMessage.value = $gettext('Error processing content')
  83. return false
  84. }
  85. }
  86. // Create editors
  87. function createEditors() {
  88. if (!diffEditorRef.value)
  89. return false
  90. try {
  91. // Clear editor area
  92. diffEditorRef.value.innerHTML = ''
  93. // Create left and right editor containers
  94. const leftContainer = document.createElement('div')
  95. leftContainer.style.width = '50%'
  96. leftContainer.style.height = '100%'
  97. leftContainer.style.float = 'left'
  98. leftContainer.style.position = 'relative'
  99. const rightContainer = document.createElement('div')
  100. rightContainer.style.width = '50%'
  101. rightContainer.style.height = '100%'
  102. rightContainer.style.float = 'right'
  103. rightContainer.style.position = 'relative'
  104. // Add to DOM
  105. diffEditorRef.value.appendChild(leftContainer)
  106. diffEditorRef.value.appendChild(rightContainer)
  107. // Create editors
  108. editors.left = ace.edit(leftContainer)
  109. editors.left.setTheme('ace/theme/monokai')
  110. editors.left.getSession().setMode('ace/mode/nginx')
  111. editors.left.setReadOnly(true)
  112. editors.left.setOption('showPrintMargin', false)
  113. editors.right = ace.edit(rightContainer)
  114. editors.right.setTheme('ace/theme/monokai')
  115. editors.right.getSession().setMode('ace/mode/nginx')
  116. editors.right.setReadOnly(true)
  117. editors.right.setOption('showPrintMargin', false)
  118. return true
  119. }
  120. catch (error) {
  121. console.error('Error creating editors:', error)
  122. errorMessage.value = $gettext('Error initializing diff viewer')
  123. return false
  124. }
  125. }
  126. // Update editor content
  127. function updateEditors() {
  128. if (!editors.left || !editors.right) {
  129. console.error('Editors not available')
  130. return false
  131. }
  132. try {
  133. // Check if content is empty
  134. if (!originalText.value || !modifiedText.value) {
  135. console.error('Empty content detected', {
  136. originalLength: originalText.value?.length,
  137. modifiedLength: modifiedText.value?.length,
  138. })
  139. return false
  140. }
  141. // Set content
  142. editors.left.setValue(originalText.value, -1)
  143. editors.right.setValue(modifiedText.value, -1)
  144. // Scroll to top
  145. editors.left.scrollToLine(0, false, false)
  146. editors.right.scrollToLine(0, false, false)
  147. // Highlight differences
  148. highlightDiffs()
  149. // Setup sync scroll
  150. setupSyncScroll()
  151. return true
  152. }
  153. catch (error) {
  154. console.error('Error updating editors:', error)
  155. return false
  156. }
  157. }
  158. // Highlight differences
  159. function highlightDiffs() {
  160. if (!editors.left || !editors.right)
  161. return
  162. try {
  163. const leftSession = editors.left.getSession()
  164. const rightSession = editors.right.getSession()
  165. // Clear previous all marks
  166. leftSession.clearBreakpoints()
  167. rightSession.clearBreakpoints()
  168. // Add CSS styles
  169. addHighlightStyles()
  170. // Compare lines
  171. const leftLines = originalText.value.split('\n')
  172. const rightLines = modifiedText.value.split('\n')
  173. // Use difference comparison algorithm
  174. compareAndHighlightLines(leftSession, rightSession, leftLines, rightLines)
  175. }
  176. catch (error) {
  177. console.error('Error highlighting diffs:', error)
  178. }
  179. }
  180. // Add highlight styles
  181. function addHighlightStyles() {
  182. const styleId = 'diff-highlight-style'
  183. if (!document.getElementById(styleId)) {
  184. const style = document.createElement('style')
  185. style.id = styleId
  186. style.textContent = `
  187. .diff-line-deleted {
  188. position: absolute;
  189. background: rgba(255, 100, 100, 0.3);
  190. z-index: 5;
  191. width: 100% !important;
  192. }
  193. .diff-line-added {
  194. position: absolute;
  195. background: rgba(100, 255, 100, 0.3);
  196. z-index: 5;
  197. width: 100% !important;
  198. }
  199. .diff-line-changed {
  200. position: absolute;
  201. background: rgba(255, 255, 100, 0.3);
  202. z-index: 5;
  203. width: 100% !important;
  204. }
  205. `
  206. document.head.appendChild(style)
  207. }
  208. }
  209. // Compare and highlight lines
  210. function compareAndHighlightLines(leftSession: Ace.EditSession, rightSession: Ace.EditSession, leftLines: string[], rightLines: string[]) {
  211. // Create a mapping table to track which lines have been matched
  212. const matchedLeftLines = new Set<number>()
  213. const matchedRightLines = new Set<number>()
  214. // 1. First mark completely identical lines
  215. for (let i = 0; i < leftLines.length; i++) {
  216. for (let j = 0; j < rightLines.length; j++) {
  217. if (leftLines[i] === rightLines[j] && !matchedLeftLines.has(i) && !matchedRightLines.has(j)) {
  218. matchedLeftLines.add(i)
  219. matchedRightLines.add(j)
  220. break
  221. }
  222. }
  223. }
  224. // 2. Mark lines left deleted
  225. for (let i = 0; i < leftLines.length; i++) {
  226. if (!matchedLeftLines.has(i)) {
  227. leftSession.addGutterDecoration(i, 'ace_gutter-active-line')
  228. leftSession.addMarker(
  229. new Range(i, 0, i, leftLines[i].length || 1),
  230. 'diff-line-deleted',
  231. 'fullLine',
  232. )
  233. }
  234. }
  235. // 3. Mark lines right added
  236. for (let j = 0; j < rightLines.length; j++) {
  237. if (!matchedRightLines.has(j)) {
  238. rightSession.addGutterDecoration(j, 'ace_gutter-active-line')
  239. rightSession.addMarker(
  240. new Range(j, 0, j, rightLines[j].length || 1),
  241. 'diff-line-added',
  242. 'fullLine',
  243. )
  244. }
  245. }
  246. }
  247. // Setup sync scroll
  248. function setupSyncScroll() {
  249. if (!editors.left || !editors.right)
  250. return
  251. // Sync scroll
  252. const leftSession = editors.left.getSession()
  253. const rightSession = editors.right.getSession()
  254. leftSession.on('changeScrollTop', (scrollTop: number) => {
  255. rightSession.setScrollTop(scrollTop)
  256. })
  257. rightSession.on('changeScrollTop', (scrollTop: number) => {
  258. leftSession.setScrollTop(scrollTop)
  259. })
  260. }
  261. // Initialize difference comparator
  262. async function initDiffViewer() {
  263. if (!diffEditorRef.value)
  264. return
  265. // Reset error message
  266. errorMessage.value = ''
  267. // Set content
  268. const hasValidContent = setContent()
  269. if (!hasValidContent) {
  270. console.error('No valid content to compare')
  271. return
  272. }
  273. // Create editors
  274. const editorsCreated = createEditors()
  275. if (!editorsCreated) {
  276. console.error('Failed to create editors')
  277. return
  278. }
  279. // Wait for DOM update
  280. await nextTick()
  281. // Update editor content
  282. const editorsUpdated = updateEditors()
  283. if (!editorsUpdated) {
  284. console.error('Failed to update editors')
  285. return
  286. }
  287. // Adjust size to ensure full display
  288. window.setTimeout(() => {
  289. if (editors.left && editors.right) {
  290. editors.left.resize()
  291. editors.right.resize()
  292. }
  293. }, 200)
  294. }
  295. // Listen for records change
  296. watch(() => [props.records, visible.value], async () => {
  297. if (visible.value) {
  298. // When selected records change, update content
  299. await nextTick()
  300. initDiffViewer()
  301. }
  302. })
  303. // Close dialog handler
  304. function handleClose() {
  305. visible.value = false
  306. errorMessage.value = ''
  307. }
  308. // Add restore functionality
  309. function restoreContent() {
  310. if (originalText.value) {
  311. // Update current content with history version
  312. currentContent.value = originalText.value
  313. // Close dialog
  314. handleClose()
  315. emit('restore')
  316. }
  317. }
  318. // Add restore functionality for modified content
  319. function restoreModifiedContent() {
  320. if (modifiedText.value && props.records.length === 2) {
  321. // Update current content with the modified version
  322. currentContent.value = modifiedText.value
  323. // Close dialog
  324. handleClose()
  325. }
  326. }
  327. </script>
  328. <template>
  329. <AModal
  330. v-model:open="visible"
  331. :title="$gettext('Compare Configurations')"
  332. width="100%"
  333. :footer="null"
  334. @cancel="handleClose"
  335. >
  336. <div v-if="errorMessage" class="diff-error">
  337. <AAlert
  338. :message="errorMessage"
  339. type="warning"
  340. show-icon
  341. />
  342. </div>
  343. <div v-else class="diff-container">
  344. <div class="diff-header">
  345. <div class="diff-title-container">
  346. <div class="diff-title">
  347. {{ originalTitle }}
  348. </div>
  349. <AButton
  350. type="link"
  351. size="small"
  352. @click="restoreContent"
  353. >
  354. {{ $gettext('Restore this version') }}
  355. </AButton>
  356. </div>
  357. <div class="diff-title-container">
  358. <div class="diff-title">
  359. {{ modifiedTitle }}
  360. </div>
  361. <AButton
  362. v-if="props.records.length === 2"
  363. type="link"
  364. size="small"
  365. @click="restoreModifiedContent"
  366. >
  367. {{ $gettext('Restore this version') }}
  368. </AButton>
  369. </div>
  370. </div>
  371. <div
  372. ref="diffEditorRef"
  373. class="diff-editor"
  374. />
  375. </div>
  376. </AModal>
  377. </template>
  378. <style lang="less" scoped>
  379. .diff-container {
  380. display: flex;
  381. flex-direction: column;
  382. height: 100%;
  383. }
  384. .diff-error {
  385. margin-bottom: 16px;
  386. }
  387. .diff-header {
  388. display: flex;
  389. justify-content: space-between;
  390. margin-bottom: 8px;
  391. }
  392. .diff-title-container {
  393. display: flex;
  394. align-items: center;
  395. width: 50%;
  396. gap: 8px;
  397. }
  398. .diff-title {
  399. padding: 0 8px;
  400. }
  401. .diff-editor {
  402. height: 500px;
  403. width: 100%;
  404. border: 1px solid #ddd;
  405. border-radius: 4px;
  406. overflow: hidden;
  407. }
  408. </style>