Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 62 additions & 6 deletions app/components/Diff/ViewerPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,17 @@ const isLargeFile = computed(() => {
)
})

const apiUrl = computed(
() =>
`/api/registry/compare-file/${props.packageName}/v/${props.fromVersion}...${props.toVersion}/${props.file.path}`,
)
function encodePathSegments(value: string): string {
return value.split('/').map(encodeURIComponent).join('/')
}

const apiUrl = computed(() => {
const packagePath = encodePathSegments(props.packageName)
const versionRange = `${encodeURIComponent(props.fromVersion)}...${encodeURIComponent(props.toVersion)}`
const filePath = encodePathSegments(props.file.path)
return `/api/registry/compare-file/${packagePath}/v/${versionRange}/${filePath}`
})
const rawDiffUrl = computed(() => `${apiUrl.value}?format=diff`)

const apiQuery = computed(() => {
if (isLargeFile.value) return {}
Expand Down Expand Up @@ -94,6 +101,32 @@ function getCodeUrl(version: string): string {
}

const { announce } = useCommandPalette()
const {
copy: copyRawDiffToClipboard,
copied: rawDiffCopied,
isSupported: rawDiffCopySupported,
} = useClipboardItems({ copiedDuring: 2000 })
const mounted = useMounted()
const copyingRawDiff = ref(false)

async function copyRawDiff() {
if (copyingRawDiff.value || !rawDiffCopySupported.value) return

copyingRawDiff.value = true
try {
// Hand the browser a pending blob before awaiting the network request.
// Safari requires clipboard.write() to begin during the click activation.
const rawDiff = $fetch<string>(rawDiffUrl.value, { responseType: 'text' }).then(
value => new Blob([value], { type: 'text/plain' }),
)
await copyRawDiffToClipboard([new ClipboardItem({ 'text/plain': rawDiff })])
announce($t('command_palette.announcements.copied_to_clipboard'))
} catch {
// useClipboardItems keeps the copied state false when the write fails.
} finally {
copyingRawDiff.value = false
}
}

useCommandPaletteContextCommands(
computed((): CommandPaletteContextCommandInput[] => {
Expand Down Expand Up @@ -193,7 +226,30 @@ useCommandPaletteContextCommands(
</span>
</div>

<div class="flex items-center gap-2 shrink-0">
<div class="flex flex-wrap items-center justify-end gap-2">
<button
type="button"
class="px-2 py-1 text-xs text-fg-muted hover:text-fg bg-bg-muted border border-border rounded transition-colors flex items-center gap-1.5 disabled:opacity-50"
:disabled="copyingRawDiff || !mounted || !rawDiffCopySupported"
@click="copyRawDiff"
>
<span
:class="rawDiffCopied ? 'i-lucide:check' : 'i-lucide:copy'"
class="w-3.5 h-3.5"
aria-hidden="true"
/>
{{ rawDiffCopied ? $t('common.copied') : $t('compare.copy_diff') }}
</button>

<a
:href="rawDiffUrl"
target="_blank"
rel="noopener"
class="px-2 py-1 text-xs text-fg-muted hover:text-fg bg-bg-muted border border-border rounded transition-colors"
>
{{ $t('compare.view_diff') }}
</a>

<!-- Options dropdown -->
<div ref="optionsDropdownRef" class="relative">
<button
Expand Down Expand Up @@ -364,7 +420,7 @@ useCommandPaletteContextCommands(
class="px-2 py-1 text-xs text-fg-muted hover:text-fg bg-bg-muted border border-border rounded transition-colors"
target="_blank"
>
{{ $t('compare.view_file') }}
{{ $t('compare.view_in_code_browser') }}
</NuxtLink>
</div>
</div>
Expand Down
3 changes: 2 additions & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1553,6 +1553,7 @@
"lines_hidden": "{count} line hidden | {count} lines hidden",
"compare_versions": "diff",
"compare_versions_title": "Compare with latest version",
"copy_diff": "Copy .diff",
"comparing_versions_label": "Comparing versions...",
"version_back_to_package": "Back to package",
"version_error_message": "Failed to compare versions.",
Expand Down Expand Up @@ -1599,7 +1600,7 @@
"merge_modified_lines": "Merge modified lines",
"no_content_changes": "No content changes detected",
"options": "Options",
"view_file": "View file",
"view_diff": "View .diff",
"view_in_code_browser": "View in code browser",
"word_wrap": "Word wrap"
},
Expand Down
5 changes: 4 additions & 1 deletion i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -4663,6 +4663,9 @@
"compare_versions_title": {
"type": "string"
},
"copy_diff": {
"type": "string"
},
"comparing_versions_label": {
"type": "string"
},
Expand Down Expand Up @@ -4801,7 +4804,7 @@
"options": {
"type": "string"
},
"view_file": {
"view_diff": {
"type": "string"
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"view_in_code_browser": {
Expand Down
22 changes: 20 additions & 2 deletions server/api/registry/compare-file/[...pkg].get.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import * as v from 'valibot'
import { PackageFileDiffQuerySchema } from '#shared/schemas/package'
import { countDiffStats, createDiff, insertSkipBlocks, truncateDiffHunks } from '#shared/utils/diff'
import {
countDiffStats,
createDiff,
createUnifiedDiff,
insertSkipBlocks,
truncateDiffHunks,
} from '#shared/utils/diff'
import type { DiffHunk, DiffSkipBlock } from '#shared/types/compare'

const CACHE_VERSION = 3
Expand Down Expand Up @@ -172,6 +178,7 @@ export default defineCachedEventHandler(
try {
// Get diff options from query params
const query = getQuery(event)
const rawDiffRequested = query.format === 'diff'
const diffOptions = {
mergeModifiedLines: query.mergeModifiedLines !== 'false',
maxChangeRatio: parseFloat(query.maxChangeRatio as string) || 0.45,
Expand Down Expand Up @@ -233,6 +240,12 @@ export default defineCachedEventHandler(
}
: diffOptions

if (rawDiffRequested) {
setResponseHeader(event, 'content-type', 'text/x-diff; charset=utf-8')
setResponseHeader(event, 'x-content-type-options', 'nosniff')
return createUnifiedDiff(fromContent ?? '', toContent ?? '', filePath, type)
}

// Create diff with options
const diff = createDiff(fromContent ?? '', toContent ?? '', filePath, effectiveDiffOptions)

Expand Down Expand Up @@ -329,14 +342,19 @@ export default defineCachedEventHandler(
getKey: event => {
const pkg = getRouterParam(event, 'pkg') ?? ''
const query = getQuery(event)
const format = query.format === 'diff' ? 'diff' : 'json'
const normalizedPkg = pkg.replace(/\/+$/, '').trim()
if (format === 'diff') {
return `compare-file:v${CACHE_VERSION}:${normalizedPkg}:diff`
}
// Normalize option values to prevent cache pollution from arbitrary floats.
// These match the parsing logic used in the handler body.
const merge = query.mergeModifiedLines !== 'false'
const ratio = Math.round((parseFloat(query.maxChangeRatio as string) || 0.45) * 100)
const distance = parseInt(query.maxDiffDistance as string, 10) || 30
const charEdits = parseInt(query.inlineMaxCharEdits as string, 10) || 2
const optionsKey = `${merge}:${ratio}:${distance}:${charEdits}`
return `compare-file:v${CACHE_VERSION}:${pkg.replace(/\/+$/, '').trim()}:${optionsKey}`
return `compare-file:v${CACHE_VERSION}:${normalizedPkg}:${format}:${optionsKey}`
},
},
)
21 changes: 15 additions & 6 deletions shared/utils/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,18 +381,27 @@ export function createDiff(
filePath: string,
options: Partial<ParseOptions> = {},
): FileDiff | null {
const diffText = createTwoFilesPatch(
`a/${filePath}`,
`b/${filePath}`,
const diffText = createUnifiedDiff(oldContent, newContent, filePath)

const files = parseUnifiedDiff(diffText, options)
return files[0] ?? null
}

export function createUnifiedDiff(
oldContent: string,
newContent: string,
filePath: string,
type: 'add' | 'delete' | 'modify' = 'modify',
): string {
return createTwoFilesPatch(
type === 'add' ? '/dev/null' : `a/${filePath}`,
type === 'delete' ? '/dev/null' : `b/${filePath}`,
oldContent,
newContent,
'',
'',
{ context: 3 },
)

const files = parseUnifiedDiff(diffText, options)
return files[0] ?? null
}

export function countDiffStats(hunks: (DiffHunk | DiffSkipBlock)[]): {
Expand Down
114 changes: 114 additions & 0 deletions test/nuxt/components/diff/ViewerPanel.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { mountSuspended } from '@nuxt/test-utils/runtime'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockFetch, mockClipboardWrite } = vi.hoisted(() => ({
mockFetch: vi.fn(),
mockClipboardWrite: vi.fn(),
}))

const props = {
packageName: '@scope/package',
fromVersion: '1.0.0',
toVersion: '2.0.0',
file: {
path: 'src/file #1.ts',
type: 'modified' as const,
oldSize: 100,
newSize: 120,
},
}

const fileDiffResponse = {
package: props.packageName,
from: props.fromVersion,
to: props.toVersion,
path: props.file.path,
type: 'modify',
hunks: [],
stats: { additions: 0, deletions: 0 },
meta: { large: false, truncated: false, computeTime: 1 },
}

describe('DiffViewerPanel raw diff actions', () => {
beforeEach(() => {
mockFetch.mockReset()
mockClipboardWrite.mockReset()
mockFetch.mockImplementation((url: string) =>
Promise.resolve(
url.includes('?format=diff')
? '--- a/src/index.ts\n+++ b/src/index.ts\n'
: fileDiffResponse,
),
)
mockClipboardWrite.mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { write: mockClipboardWrite },
})
})

it('links to and copies the selected file as a raw diff', async () => {
const { default: DiffViewerPanel } = await import('~/components/Diff/ViewerPanel.vue')
vi.stubGlobal('$fetch', mockFetch)
const wrapper = await mountSuspended(DiffViewerPanel, { props })
const rawDiffUrl =
'/api/registry/compare-file/%40scope/package/v/1.0.0...2.0.0/src/file%20%231.ts?format=diff'

const viewLink = wrapper.get(`a[href="${rawDiffUrl}"]`)
expect(viewLink.text()).toBe('View .diff')

const copyButton = wrapper
.findAll('button')
.find(button => button.text().trim() === 'Copy .diff')
expect(copyButton).toBeDefined()

await copyButton!.trigger('click')

await vi.waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(rawDiffUrl, { responseType: 'text' })
expect(mockClipboardWrite).toHaveBeenCalledOnce()
})

wrapper.unmount()
})

it('starts the clipboard write before a slow raw diff request resolves', async () => {
let resolveRawDiff!: (value: string) => void
mockFetch.mockImplementation((url: string) =>
url.includes('?format=diff')
? new Promise(resolve => (resolveRawDiff = resolve))
: Promise.resolve(fileDiffResponse),
)
vi.stubGlobal('$fetch', mockFetch)
const { default: DiffViewerPanel } = await import('~/components/Diff/ViewerPanel.vue')
const wrapper = await mountSuspended(DiffViewerPanel, { props })

const copyButton = wrapper
.findAll('button')
.find(button => button.text().trim() === 'Copy .diff')
await copyButton!.trigger('click')

expect(mockClipboardWrite).toHaveBeenCalledOnce()
resolveRawDiff('--- a/src/index.ts\n+++ b/src/index.ts\n')
await vi.waitFor(() => expect(copyButton!.attributes('disabled')).toBeUndefined())

wrapper.unmount()
})

it('does not report a failed clipboard write as copied', async () => {
mockClipboardWrite.mockRejectedValueOnce(new Error('clipboard denied'))
vi.stubGlobal('$fetch', mockFetch)
const { default: DiffViewerPanel } = await import('~/components/Diff/ViewerPanel.vue')
const wrapper = await mountSuspended(DiffViewerPanel, { props })

const copyButton = wrapper
.findAll('button')
.find(button => button.text().trim() === 'Copy .diff')
await copyButton!.trigger('click')
await vi.waitFor(() => expect(copyButton!.attributes('disabled')).toBeUndefined())

expect(copyButton!.text()).toContain('Copy .diff')
expect(copyButton!.text()).not.toContain('Copied')
wrapper.unmount()
})
})
23 changes: 22 additions & 1 deletion test/unit/shared/utils/diff.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { truncateDiffHunks } from '#shared/utils/diff'
import { createUnifiedDiff, truncateDiffHunks } from '#shared/utils/diff'
import type { DiffHunk, DiffSkipBlock } from '#shared/types/compare'

function createHunk(lineCount: number): DiffHunk {
Expand All @@ -19,6 +19,27 @@ function createHunk(lineCount: number): DiffHunk {
}
}

describe('createUnifiedDiff', () => {
it('creates a unified patch for a modified file', () => {
const result = createUnifiedDiff('old\n', 'new\n', 'src/index.ts')

expect(result).toContain('--- a/src/index.ts')
expect(result).toContain('+++ b/src/index.ts')
expect(result).toContain('-old')
expect(result).toContain('+new')
})

it('uses /dev/null for added and deleted files', () => {
const added = createUnifiedDiff('', 'new\n', 'added.ts', 'add')
const deleted = createUnifiedDiff('old\n', '', 'deleted.ts', 'delete')

expect(added).toContain('--- /dev/null')
expect(added).toContain('+++ b/added.ts')
expect(deleted).toContain('--- a/deleted.ts')
expect(deleted).toContain('+++ /dev/null')
})
})

describe('truncateDiffHunks', () => {
it('leaves hunks untouched when they fit within the line budget', () => {
const hunk = createHunk(2)
Expand Down
Loading