add diff popover

This commit is contained in:
swithek 2026-09-26 20:02:48 +02:00
parent 20d87cf8a3
commit 998fb29c46
29 changed files with 1935 additions and 123 deletions

View File

@ -93,6 +93,13 @@ there when none exists.
(custom nodes; `upload-handler.ts` routes dropped files), `comments/`,
`diff/`, `drag-handle/`, `slash/`, `link/`, `ai/`, `hooks/`. Editor-wide
state is [app/stores/editor.ts](app/stores/editor.ts).
- **Diff view**: adding or changing a block or node includes its branch
diff. Expose `diffStatus` on its node view and style it as added, removed
and modified. A block changed through its attributes renders
`DiffChangeMarker` with a `<Block>DiffDetails.vue` card in its own
directory. Attributes that count as one change, count per list item or
are never set by a user are declared in
[diff/change-count.ts](app/components/editor/diff/change-count.ts).
- **Routing**: one dynamic page,
`app/pages/[[organizationSlug]]/[[documentSlug]].vue`;
[app/middleware/01.redirect.global.ts](app/middleware/01.redirect.global.ts)

View File

@ -2,3 +2,6 @@
// editor modules
export const NODE_COMMENT_ID_ATTR = "nodeCommentId"
// set on image and file blocks while their upload is in flight
export const UPLOADING_ATTR = "uploading"

View File

@ -4,6 +4,7 @@ import { computePosition, flip, offset, shift } from "@floating-ui/dom"
import { cn } from "~/lib/utils"
import { DiffStatus } from "~/components/editor/diff/position-map"
import DiffChangeMarker from "~/components/editor/diff/DiffChangeMarker.vue"
import FigmaDiffDetails from "./FigmaDiffDetails.vue"
import { isFigmaUrl, convertToEmbedUrl } from "./index"
const props = defineProps(nodeViewProps)
@ -262,7 +263,9 @@ onBeforeUnmount(() => {
:style="{ height: `${displayHeight}px` }"
allowfullscreen
/>
<DiffChangeMarker :node="props.node" class="absolute top-1.5 right-1.5" />
<DiffChangeMarker :node="props.node" class="absolute top-1.5 right-1.5">
<FigmaDiffDetails :node="props.node" />
</DiffChangeMarker>
<div
v-if="!isEditingDisabled"
aria-hidden="true"
@ -295,7 +298,9 @@ onBeforeUnmount(() => {
<DiffChangeMarker
:node="props.node"
class="absolute top-1/2 right-2 -translate-y-1/2"
/>
>
<FigmaDiffDetails :node="props.node" />
</DiffChangeMarker>
<span class="mt-0.25 text-2sm text-muted-foreground">
{{
isEditingDisabled

View File

@ -0,0 +1,69 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { describe, it } from "vitest"
import FigmaDiffDetails from "./FigmaDiffDetails.vue"
import { makeNode } from "../../test-helpers/node-view"
import { DiffStatus } from "~/components/editor/diff/position-map"
import { t } from "~/components/test-helpers"
const OLD_URL = "https://www.figma.com/design/abc123/Checkout-Flow"
const NEW_URL = "https://www.figma.com/design/xyz789/Sign%20up-Screen"
describe("<FigmaDiffDetails>", () => {
it("names the old and the new file from their URLs", async ({ expect }) => {
const wrapper = await mountDetails({ src: OLD_URL }, { src: NEW_URL })
expect(wrapper.findAll(".font-medium").map((name) => name.text())).toEqual([
"Checkout Flow",
"Sign up Screen",
])
expect(wrapper.text()).toContain(OLD_URL)
})
it("keeps the raw text of a URL it cannot parse", async ({ expect }) => {
const wrapper = await mountDetails({}, { src: "not a url" })
expect(wrapper.get(".font-medium").text()).toBe("not a url")
expect(wrapper.text()).toContain(t("editor.figma.empty"))
})
it("shows auto for a size that was not set before", async ({ expect }) => {
const wrapper = await mountDetails(
{ src: OLD_URL },
{ src: OLD_URL, height: 300 },
)
expect(wrapper.get(".line-through").text()).toBe(
t("editor.diff-change-marker.auto"),
)
expect(wrapper.get(".bg-diff-text-added").text()).toBe(
t("editor.diff-change-marker.pixels", { value: "300" }),
)
})
it("lists a resize as rows", async ({ expect }) => {
const wrapper = await mountDetails(
{ src: OLD_URL, width: 400, height: 300 },
{ src: OLD_URL, width: 600, height: 300 },
)
expect(wrapper.findAll("dt").map((dt) => dt.text())).toEqual([
t("editor.figma.diff.width"),
])
expect(wrapper.find(".font-medium").exists()).toBe(false)
})
})
function mountDetails(
oldAttrs: Record<string, unknown>,
newAttrs: Record<string, unknown>,
) {
return mountSuspended(FigmaDiffDetails, {
props: {
node: makeNode({
...newAttrs,
diffStatus: DiffStatus.Modified,
oldNode: { attrs: oldAttrs },
}),
},
})
}

View File

@ -0,0 +1,94 @@
<script lang="ts" setup>
import type { Node as PMNode } from "@tiptap/pm/model"
import {
collectAttributeChanges,
displayValue,
formatChange,
type FieldChange,
} from "~/components/editor/diff/change-count"
import DiffBeforeAfter from "~/components/editor/diff/DiffBeforeAfter.vue"
import DiffFieldChanges from "~/components/editor/diff/DiffFieldChanges.vue"
const props = defineProps<{
node: PMNode
}>()
const { t } = useI18n({ useScope: "global" })
const changes = computed(() => collectAttributeChanges(props.node))
const srcChange = computed(() =>
changes.value.find((change) => change.name === "src"),
)
const rows = computed<FieldChange[]>(() => {
const labels: Record<string, string> = {
width: t("editor.figma.diff.width"),
height: t("editor.figma.diff.height"),
}
return changes.value.flatMap((change) => {
const label = labels[change.name]
return label
? [
{
label: label,
...formatChange(
change,
pixels,
t("editor.diff-change-marker.auto"),
),
},
]
: []
})
})
function pixels(value: unknown): string {
return t("editor.diff-change-marker.pixels", { value: displayValue(value) })
}
// a figma URL ends in a slug of the file's name, such as
// /design/<key>/Checkout-Flow. The raw URL stands in when it has none.
function describe(src: unknown) {
if (typeof src !== "string") {
return undefined
}
let name = src
try {
const slug = new URL(src).pathname.split("/").filter(Boolean)[2]
if (slug) {
name = decodeURIComponent(slug).replaceAll("-", " ")
}
} catch {
// an unparsable URL keeps its raw text as the name
}
return { name: name, src: src }
}
</script>
<template>
<div class="flex w-80 flex-col gap-3">
<DiffBeforeAfter
v-if="srcChange"
v-slot="{ value }"
:before="describe(srcChange.oldValue)"
:after="describe(srcChange.newValue)"
:empty-label="t('editor.figma.empty')"
>
<div class="flex w-full min-w-0 items-center gap-2 p-2">
<Icon name="simple-icons:figma" class="size-4 shrink-0" />
<span class="flex min-w-0 flex-col">
<span class="line-clamp-6 text-xs font-medium break-words">
{{ value.name }}
</span>
<span class="truncate text-xs text-muted-foreground">
{{ value.src }}
</span>
</span>
</div>
</DiffBeforeAfter>
<DiffFieldChanges v-if="rows.length" :rows="rows" />
</div>
</template>

View File

@ -381,10 +381,14 @@ describe("<FileBlock>", { concurrent: false }, () => {
}),
)
const label = t("editor.diff-change-marker.label", {
removed: 1,
added: 1,
})
expect(wrapper.find(".diff-overlay").exists()).toBe(false)
expect(wrapper.text()).toContain(
t("editor.diff-change-marker.label", { removed: 1, added: 1 }),
)
expect(wrapper.text()).toContain(label)
// hovering a marker inside the card would set off the card's hover
expect(wrapper.get("a").text()).not.toContain(label)
})
it("shows no diff overlay on an unchanged file", async ({ expect }) => {

View File

@ -6,6 +6,7 @@ import type { ProgressToast } from "~/components/toast"
import { cn } from "~/lib/utils"
import { DiffStatus } from "~/components/editor/diff/position-map"
import DiffChangeMarker from "~/components/editor/diff/DiffChangeMarker.vue"
import FileDiffDetails from "./FileDiffDetails.vue"
import {
fileKind,
fileKindStyle,
@ -175,7 +176,7 @@ async function downloadOnDesktop() {
<NodeViewWrapper
:id="props.node.attrs.uid"
as="figure"
class="not-prose flex caret-transparent"
class="not-prose relative flex caret-transparent"
data-type="fileBlock"
:data-uid="props.node.attrs.uid"
:data-node-comment-id="props.node.attrs.nodeCommentId"
@ -233,10 +234,6 @@ async function downloadOnDesktop() {
{{ size }}
</span>
</span>
<DiffChangeMarker
:node="props.node"
class="absolute top-1/2 right-2 -translate-y-1/2"
/>
</component>
<button
v-else
@ -252,10 +249,6 @@ async function downloadOnDesktop() {
@click="openFilePicker"
>
<Icon name="mingcute:attachment-line" class="size-4 text-foreground" />
<DiffChangeMarker
:node="props.node"
class="absolute top-1/2 right-2 -translate-y-1/2"
/>
<div class="mt-0.25 text-2sm text-muted-foreground">
{{
uploading
@ -275,5 +268,13 @@ async function downloadOnDesktop() {
@change="handleFileChange"
/>
</button>
<!-- a sibling of the card rather than its child, so hovering the
marker does not trigger the card's hover style -->
<DiffChangeMarker
:node="props.node"
class="absolute top-1/2 right-2 -translate-y-1/2"
>
<FileDiffDetails :node="props.node" />
</DiffChangeMarker>
</NodeViewWrapper>
</template>

View File

@ -0,0 +1,67 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { describe, it } from "vitest"
import FileDiffDetails from "./FileDiffDetails.vue"
import { makeNode } from "../../test-helpers/node-view"
import { FILE_BLOCK_NAME } from "../node-names"
import { formatFileSize } from "./file-kind"
import { DiffStatus } from "~/components/editor/diff/position-map"
import { t } from "~/components/test-helpers"
describe("<FileDiffDetails>", () => {
it("shows the old and the new file side by side", async ({ expect }) => {
const wrapper = await mountDetails(storedFile("a.zip"), storedFile("b.pdf"))
expect(wrapper.findAll(".font-medium").map((name) => name.text())).toEqual([
"a.zip",
"b.pdf",
])
expect(wrapper.text()).toContain(formatFileSize(1024))
})
it("shows an empty side for a newly uploaded file", async ({ expect }) => {
const wrapper = await mountDetails({}, storedFile("b.pdf"))
expect(wrapper.findAll(".font-medium").map((name) => name.text())).toEqual([
"b.pdf",
])
expect(wrapper.text()).toContain(t("editor.file.empty"))
})
it("renders nothing when the file itself is unchanged", async ({
expect,
}) => {
const wrapper = await mountDetails(
{ ...storedFile("a.zip"), uploading: true },
{ ...storedFile("a.zip"), uploading: false },
)
expect(wrapper.html()).toBe("<!--v-if-->")
})
})
function storedFile(name: string): Record<string, unknown> {
return {
src: `/files/${name}`,
name: name,
size: 1024,
contentType: "application/octet-stream",
}
}
function mountDetails(
oldAttrs: Record<string, unknown>,
newAttrs: Record<string, unknown>,
) {
return mountSuspended(FileDiffDetails, {
props: {
node: makeNode(
{
...newAttrs,
diffStatus: DiffStatus.Modified,
oldNode: { attrs: oldAttrs },
},
{ typeName: FILE_BLOCK_NAME },
),
},
})
}

View File

@ -0,0 +1,64 @@
<script lang="ts" setup>
import type { Node as PMNode } from "@tiptap/pm/model"
import { collectAttributeChanges } from "~/components/editor/diff/change-count"
import DiffBeforeAfter from "~/components/editor/diff/DiffBeforeAfter.vue"
import { fileKind, fileKindStyle, formatFileSize } from "./file-kind"
interface StoredFile {
name?: string
size?: number
contentType?: string
}
const props = defineProps<{
node: PMNode
}>()
const { t } = useI18n({ useScope: "global" })
// the file's attributes change together, so they arrive as one group
const fileChange = computed(() =>
collectAttributeChanges(props.node).find((change) => change.name === "file"),
)
function describe(value: unknown) {
if (!value) {
return undefined
}
const file = value as StoredFile
return {
name: file.name ?? "",
size: formatFileSize(file.size),
icon: fileKindStyle(fileKind(file.name ?? "", file.contentType ?? "")).icon,
}
}
</script>
<template>
<DiffBeforeAfter
v-if="fileChange"
v-slot="{ value }"
:before="describe(fileChange.oldValue)"
:after="describe(fileChange.newValue)"
:empty-label="t('editor.file.empty')"
class="w-80"
>
<div class="flex w-full min-w-0 items-center gap-2 p-2">
<span
class="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"
>
<Icon :name="value.icon" class="size-4" />
</span>
<span class="flex min-w-0 flex-col">
<span class="line-clamp-6 text-xs font-medium break-words">
{{ value.name }}
</span>
<span v-if="value.size" class="text-xs text-muted-foreground">
{{ value.size }}
</span>
</span>
</div>
</DiffBeforeAfter>
</template>

View File

@ -5,6 +5,7 @@ import { showToastMessage } from "~/components/toast"
import { cn } from "~/lib/utils"
import { DiffStatus } from "~/components/editor/diff/position-map"
import DiffChangeMarker from "~/components/editor/diff/DiffChangeMarker.vue"
import ImageDiffDetails from "./ImageDiffDetails.vue"
const MIN_WIDTH = 128
@ -248,7 +249,9 @@ onBeforeUnmount(() => {
]"
draggable="false"
/>
<DiffChangeMarker :node="props.node" class="absolute top-1.5 right-1.5" />
<DiffChangeMarker :node="props.node" class="absolute top-1.5 right-1.5">
<ImageDiffDetails :node="props.node" />
</DiffChangeMarker>
<div
v-if="showResizeHandle"
aria-hidden="true"
@ -275,7 +278,9 @@ onBeforeUnmount(() => {
<DiffChangeMarker
:node="props.node"
class="absolute top-1/2 right-2 -translate-y-1/2"
/>
>
<ImageDiffDetails :node="props.node" />
</DiffChangeMarker>
<div class="mt-0.25 text-2sm text-muted-foreground">
{{
uploading

View File

@ -0,0 +1,82 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { describe, it } from "vitest"
import ImageDiffDetails from "./ImageDiffDetails.vue"
import { makeNode } from "../../test-helpers/node-view"
import { IMAGE_BLOCK_NAME } from "../node-names"
import { DiffStatus } from "~/components/editor/diff/position-map"
import { t } from "~/components/test-helpers"
describe("<ImageDiffDetails>", () => {
it("shows the old and the new image side by side", async ({ expect }) => {
const wrapper = await mountDetails({ src: "a.png" }, { src: "b.png" })
expect(wrapper.findAll("img").map((img) => img.attributes("src"))).toEqual([
"a.png",
"b.png",
])
})
it("shows an empty side for a newly uploaded image", async ({ expect }) => {
const wrapper = await mountDetails({}, { src: "b.png" })
expect(wrapper.findAll("img").map((img) => img.attributes("src"))).toEqual([
"b.png",
])
expect(wrapper.text()).toContain(t("editor.image.empty"))
})
it("lists a resize as a row", async ({ expect }) => {
const wrapper = await mountDetails(
{ src: "a.png", width: 300 },
{ src: "a.png", width: 480 },
)
expect(wrapper.get("dt").text()).toBe(t("editor.image.diff.width"))
expect(wrapper.get(".line-through").text()).toBe(
t("editor.diff-change-marker.pixels", { value: "300" }),
)
expect(wrapper.get(".bg-diff-text-added").text()).toBe(
t("editor.diff-change-marker.pixels", { value: "480" }),
)
expect(wrapper.find("img").exists()).toBe(false)
})
it("shows auto for a width that was not set before", async ({ expect }) => {
const wrapper = await mountDetails({}, { src: "b.png", width: 492 })
expect(wrapper.get(".line-through").text()).toBe(
t("editor.diff-change-marker.auto"),
)
expect(wrapper.get(".bg-diff-text-added").text()).toBe(
t("editor.diff-change-marker.pixels", { value: "492" }),
)
})
it("shows nothing for attributes no one can change", async ({ expect }) => {
const wrapper = await mountDetails(
{ src: "a.png", alt: "a cat", uploading: true },
{ src: "a.png", alt: "a dog", uploading: false },
)
expect(wrapper.find("dl").exists()).toBe(false)
expect(wrapper.find("img").exists()).toBe(false)
})
})
function mountDetails(
oldAttrs: Record<string, unknown>,
newAttrs: Record<string, unknown>,
) {
return mountSuspended(ImageDiffDetails, {
props: {
node: makeNode(
{
...newAttrs,
diffStatus: DiffStatus.Modified,
oldNode: { attrs: oldAttrs },
},
{ typeName: IMAGE_BLOCK_NAME },
),
},
})
}

View File

@ -0,0 +1,57 @@
<script lang="ts" setup>
import type { Node as PMNode } from "@tiptap/pm/model"
import {
collectAttributeChanges,
displayValue,
formatChange,
type FieldChange,
} from "~/components/editor/diff/change-count"
import DiffBeforeAfter from "~/components/editor/diff/DiffBeforeAfter.vue"
import DiffFieldChanges from "~/components/editor/diff/DiffFieldChanges.vue"
const props = defineProps<{
node: PMNode
}>()
const { t } = useI18n({ useScope: "global" })
const changes = computed(() => collectAttributeChanges(props.node))
const srcChange = computed(() =>
changes.value.find((change) => change.name === "src"),
)
const rows = computed<FieldChange[]>(() =>
changes.value
.filter((change) => change.name === "width")
.map((change) => ({
label: t("editor.image.diff.width"),
...formatChange(change, pixels, t("editor.diff-change-marker.auto")),
})),
)
function pixels(value: unknown): string {
return t("editor.diff-change-marker.pixels", { value: displayValue(value) })
}
function imageSrc(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined
}
</script>
<template>
<div class="flex w-72 flex-col gap-3">
<DiffBeforeAfter
v-if="srcChange"
v-slot="{ value }"
:before="imageSrc(srcChange.oldValue)"
:after="imageSrc(srcChange.newValue)"
:empty-label="t('editor.image.empty')"
>
<img
:src="value"
:alt="t('editor.image.diff.image')"
class="h-20 w-full object-cover"
/>
</DiffBeforeAfter>
<DiffFieldChanges v-if="rows.length" :rows="rows" />
</div>
</template>

View File

@ -4,6 +4,7 @@ import { cn } from "~/lib/utils"
import { explicitContentPlaceholder } from "../../placeholder"
import { DiffStatus } from "../../diff/position-map"
import DiffChangeMarker from "../../diff/DiffChangeMarker.vue"
import MermaidDiffDetails from "./MermaidDiffDetails.vue"
import MermaidPreview from "./MermaidPreview.vue"
const props = defineProps(nodeViewProps)
@ -119,7 +120,9 @@ watch(showCode, () => {
{{ lastOtherEditingUser?.name }}
</div>
<div class="absolute top-1 right-1.5 z-1 flex items-center gap-1">
<DiffChangeMarker :node="props.node" />
<DiffChangeMarker :node="props.node">
<MermaidDiffDetails :node="props.node" />
</DiffChangeMarker>
<ShadcnUiButton
variant="ghost-plain"
size="icon-sm"

View File

@ -0,0 +1,94 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { Schema, type Node as PMNode } from "@tiptap/pm/model"
import { describe, it } from "vitest"
import MermaidDiffDetails from "./MermaidDiffDetails.vue"
import {
DIFF_TEXT_ADDED_MARK_NAME,
DIFF_TEXT_REMOVED_MARK_NAME,
} from "~/components/editor/mark-names"
import { t } from "~/components/test-helpers"
const ADDED = DIFF_TEXT_ADDED_MARK_NAME
const REMOVED = DIFF_TEXT_REMOVED_MARK_NAME
const schema = new Schema({
nodes: {
doc: { content: "code" },
code: { content: "text*", marks: "_" },
text: {},
},
marks: {
[ADDED]: {},
[REMOVED]: {},
},
})
describe("<MermaidDiffDetails>", () => {
it("shows only the changed lines, numbered, with their changes marked", async ({
expect,
}) => {
const wrapper = await mountDetails([
["graph TD\nA-->"],
["B", REMOVED],
["C", ADDED],
["\nC-->D"],
])
expect(numbers(wrapper)).toEqual(["2"])
// the raw text content, since text() would trim a stray space away
expect(wrapper.get(".break-all").element.textContent).toBe("A-->BC")
expect(wrapper.get(".line-through").text()).toBe("B")
expect(wrapper.get(".bg-diff-text-added").text()).toBe("C")
})
it("strips the indentation the shown lines share", async ({ expect }) => {
const wrapper = await mountDetails([
["flowchart TD\n A-->"],
["B", REMOVED],
["C", ADDED],
])
expect(wrapper.get(".break-all").element.textContent).toBe("A-->BC")
})
it("separates changed lines that are not neighbours", async ({ expect }) => {
const wrapper = await mountDetails([
["one\n"],
["two", ADDED],
["\nthree\n"],
["four", ADDED],
])
expect(numbers(wrapper)).toEqual(["2", "4"])
expect(wrapper.findAll(".border-dashed")).toHaveLength(1)
})
it("counts the changed lines it leaves out", async ({ expect }) => {
const wrapper = await mountDetails(
Array.from({ length: 12 }, (_, index) => [`\nline ${index}`, ADDED]),
)
expect(numbers(wrapper)).toHaveLength(10)
expect(wrapper.text()).toContain(
t("editor.mermaid.diff.more-lines", { count: 3 }),
)
})
})
function numbers(wrapper: Awaited<ReturnType<typeof mountDetails>>) {
return wrapper.findAll(".select-none").map((cell) => cell.text())
}
function mountDetails(parts: string[][]) {
const node: PMNode = schema.nodes.code.create(
null,
parts.map(([text, ...marks]) =>
schema.text(
text ?? "",
marks.map((mark) => schema.mark(mark)),
),
),
)
return mountSuspended(MermaidDiffDetails, { props: { node: node } })
}

View File

@ -0,0 +1,75 @@
<script lang="ts" setup>
import type { Node as PMNode } from "@tiptap/pm/model"
import { cn } from "~/lib/utils"
import {
TextSegmentKind,
textSegments,
} from "~/components/editor/diff/change-count"
import { dedentLines, diffLines } from "./diff-lines"
const MAX_LINES = 10
const props = defineProps<{
node: PMNode
}>()
const changedLines = computed(() =>
diffLines(textSegments(props.node))
.map((line, index) => ({ ...line, index: index }))
.filter((line) => line.changed),
)
const shownLines = computed(() =>
dedentLines(changedLines.value.slice(0, MAX_LINES)).map(
(line, position, shown) => ({
...line,
// a gap sits between two changed lines that are not neighbours
gapBefore:
position > 0 && line.index - (shown[position - 1]?.index ?? 0) > 1,
}),
),
)
const hiddenCount = computed(() =>
Math.max(changedLines.value.length - MAX_LINES, 0),
)
</script>
<template>
<div class="flex max-w-[26rem] flex-col gap-1.5">
<!-- the number column is as wide as its widest number -->
<div
class="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-1.5 gap-y-1.5 font-mono text-xs"
>
<template v-for="line in shownLines" :key="line.index">
<!-- the negative margin reaches the popover's padding, so the
separator runs from one edge of the card to the other -->
<hr
v-if="line.gapBefore"
class="col-span-2 -mx-3 my-1.5 border-t border-dashed border-border"
/>
<span class="text-muted-foreground select-none">
{{ line.number }}
</span>
<!-- only the segments keep their whitespace, so the template's own
line breaks around them do not show as spaces -->
<span class="break-all">
<span
v-for="(segment, i) in line.segments"
:key="i"
:class="
cn(
'whitespace-pre-wrap',
segment.kind === TextSegmentKind.Removed &&
'bg-diff-text-removed line-through',
segment.kind === TextSegmentKind.Added && 'bg-diff-text-added',
)
"
v-text="segment.text"
/>
</span>
</template>
</div>
<span v-if="hiddenCount" class="text-xs text-muted-foreground">
{{ $t("editor.mermaid.diff.more-lines", { count: hiddenCount }) }}
</span>
</div>
</template>

View File

@ -0,0 +1,137 @@
import { describe, it } from "vitest"
import { dedentLines, diffLines } from "./diff-lines"
import { TextSegmentKind } from "~/components/editor/diff/change-count"
const { Equal, Removed, Added } = TextSegmentKind
describe("diffLines", () => {
it("splits segments into numbered lines and flags the changed ones", ({
expect,
}) => {
const lines = diffLines([
{ kind: Equal, text: "graph TD\nA-->" },
{ kind: Removed, text: "B" },
{ kind: Added, text: "C" },
{ kind: Equal, text: "\nC-->D" },
])
expect(lines).toEqual([
{
number: 1,
changed: false,
segments: [{ kind: Equal, text: "graph TD" }],
},
{
number: 2,
changed: true,
segments: [
{ kind: Equal, text: "A-->" },
{ kind: Removed, text: "B" },
{ kind: Added, text: "C" },
],
},
{ number: 3, changed: false, segments: [{ kind: Equal, text: "C-->D" }] },
])
})
it("leaves a line of only removed text unnumbered", ({ expect }) => {
const lines = diffLines([
{ kind: Equal, text: "graph TD" },
{ kind: Removed, text: "\nA-->B" },
{ kind: Equal, text: "\nB-->C" },
])
expect(lines.map((line) => [line.number, line.changed])).toEqual([
[1, true],
[null, true],
[2, false],
])
})
it("numbers an added line by its place in the new text", ({ expect }) => {
const lines = diffLines([
{ kind: Equal, text: "graph TD" },
{ kind: Added, text: "\nA-->B" },
])
expect(lines.map((line) => [line.number, line.changed])).toEqual([
[1, true],
[2, true],
])
})
it("flags the line whose newline was removed", ({ expect }) => {
const lines = diffLines([
{ kind: Equal, text: "A-->B" },
{ kind: Removed, text: "\n" },
{ kind: Equal, text: "B-->C" },
])
expect(lines.map((line) => [line.number, line.changed])).toEqual([
[1, true],
[1, false],
])
})
})
describe("dedentLines", () => {
it("strips the indentation every line shares", ({ expect }) => {
const lines = dedentLines(
diffLines([
{ kind: Equal, text: " B-->" },
{ kind: Removed, text: "C" },
{ kind: Equal, text: "\n D-->" },
{ kind: Added, text: "E" },
]),
)
expect(lines.map((line) => line.segments)).toEqual([
[
{ kind: Equal, text: "B-->" },
{ kind: Removed, text: "C" },
],
[
{ kind: Equal, text: " D-->" },
{ kind: Added, text: "E" },
],
])
})
it("keeps an indent that is itself a change", ({ expect }) => {
const lines = diffLines([
{ kind: Equal, text: " B-->C\n" },
{ kind: Added, text: " D-->E" },
])
expect(dedentLines(lines)).toEqual(lines)
})
it("ignores empty lines when finding the shared indent", ({ expect }) => {
const lines = dedentLines(
diffLines([
{ kind: Equal, text: " A" },
{ kind: Added, text: "\n" },
{ kind: Equal, text: "\n B" },
]),
)
expect(lines.map((line) => line.segments)).toEqual([
[{ kind: Equal, text: "A" }],
[],
[{ kind: Equal, text: "B" }],
])
})
it("drops a segment that was only indentation", ({ expect }) => {
const lines = dedentLines(
diffLines([
{ kind: Equal, text: " " },
{ kind: Added, text: "A" },
]),
)
expect(lines.map((line) => line.segments)).toEqual([
[{ kind: Added, text: "A" }],
])
})
})

View File

@ -0,0 +1,89 @@
import {
TextSegmentKind,
type TextSegment,
} from "~/components/editor/diff/change-count"
const INDENT_RE = /^[ \t]*/
export interface DiffLine {
// the line's number in the new text. Null for a line that holds only
// removed text, since the new text has no such line.
number: number | null
segments: TextSegment[]
changed: boolean
}
// split the segments of a diffed text into lines. A newline inside removed
// text is gone from the new text, so it does not move the numbering on.
export function diffLines(segments: TextSegment[]): DiffLine[] {
let number = 1
const lines: DiffLine[] = [{ number: number, segments: [], changed: false }]
for (const segment of segments) {
for (const [index, text] of segment.text.split("\n").entries()) {
if (index > 0) {
// a newline that was added or removed changes the line it ends
const ended = lines[lines.length - 1]
if (ended && segment.kind !== TextSegmentKind.Equal) {
ended.changed = true
}
if (segment.kind !== TextSegmentKind.Removed) {
number++
}
lines.push({ number: number, segments: [], changed: false })
}
const line = lines[lines.length - 1]
if (line && text) {
line.segments.push({ kind: segment.kind, text: text })
line.changed ||= segment.kind !== TextSegmentKind.Equal
}
}
}
for (const line of lines) {
const onlyRemoved =
line.segments.length > 0 &&
line.segments.every((s) => s.kind === TextSegmentKind.Removed)
if (onlyRemoved) {
line.number = null
}
}
return lines
}
// strip the indentation all lines share, so a snippet from deep inside the
// code starts at the left edge. Only unchanged text counts as indentation,
// so a changed indent still shows. An empty line has no say.
export function dedentLines<T extends DiffLine>(lines: T[]): T[] {
const indents = lines
.filter((line) => line.segments.length > 0)
.map((line) => {
const first = line.segments[0]
return first?.kind === TextSegmentKind.Equal
? (INDENT_RE.exec(first.text)?.[0].length ?? 0)
: 0
})
const shared = indents.length > 0 ? Math.min(...indents) : 0
if (shared === 0) {
return lines
}
return lines.map((line) => {
const [first, ...rest] = line.segments
if (!first) {
return line
}
const text = first.text.slice(shared)
return {
...line,
segments: text ? [{ ...first, text: text }, ...rest] : rest,
}
})
}

View File

@ -12,6 +12,7 @@ import {
} from "./utils"
import { DiffStatus } from "~/components/editor/diff/position-map"
import DiffChangeMarker from "~/components/editor/diff/DiffChangeMarker.vue"
import MetricDiffDetails from "./MetricDiffDetails.vue"
// legacy blobs may miss fields entirely and may still use the old "type"
// field name, so Partial reflects the actual runtime shape.
@ -371,7 +372,9 @@ function disableVisualizationRefreshTemporarily() {
<div
class="absolute top-1 right-1.5 z-1 flex items-center rounded-md bg-background pl-0.5"
>
<DiffChangeMarker :node="props.node" class="mr-1" />
<DiffChangeMarker :node="props.node" class="mr-1">
<MetricDiffDetails :node="props.node" />
</DiffChangeMarker>
<ShadcnUiTooltip v-if="simulationVisible">
<ShadcnUiTooltipTrigger as-child>
<Icon

View File

@ -0,0 +1,200 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { afterEach, beforeEach, describe, it } from "vitest"
import MetricDiffDetails from "./MetricDiffDetails.vue"
import { makeNode } from "../../test-helpers/node-view"
import { METRIC_BLOCK_NAME } from "../node-names"
import {
RefreshInterval,
TimeRangePreset,
VisualizationTimeUnit,
} from "./utils"
import { DiffStatus } from "~/components/editor/diff/position-map"
import { settleMutations, t } from "~/components/test-helpers"
import {
clearQueryCache,
disposeMockEndpoints,
mockEndpoint,
} from "~/composables/api/test-helpers"
// the data source list lives in the app-wide query cache
describe("<MetricDiffDetails>", { concurrent: false }, () => {
beforeEach(() => {
clearQueryCache()
})
afterEach(() => {
disposeMockEndpoints()
})
it("labels each field of an added query by the query's number", async ({
expect,
}) => {
const wrapper = await mountDetails(
{ queries: [query("up", "")] },
{ queries: [query("up", ""), query("rate(x[5m])", "{{job}}")] },
)
expect(rows(wrapper)).toEqual([
[
t("editor.metrics.config.query-name-default-format", { index: 2 }),
null,
"rate(x[5m])",
],
[t("editor.metrics.diff.legend", { index: 2 }), null, "{{job}}"],
])
expect(wrapper.findAll("dd").every((dd) => dd.classes("font-mono"))).toBe(
true,
)
})
it("shows an empty legend as auto", async ({ expect }) => {
const wrapper = await mountDetails(
{ queries: [query("up", "{{job}}")] },
{ queries: [query("up", "")] },
)
expect(rows(wrapper)).toEqual([
[
t("editor.metrics.diff.legend", { index: 1 }),
"{{job}}",
t("editor.diff-change-marker.auto"),
],
])
})
it.for([
{ name: "decimal places", input: "decimals", label: "decimals-label" },
{ name: "min value", input: "axisBoundsMin", label: "bounds-min-label" },
{ name: "max value", input: "axisBoundsMax", label: "bounds-max-label" },
])(
"shows auto as the old $name when it was not set",
async ({ input, label }, { expect }) => {
const wrapper = await mountDetails({}, { [input]: 1 })
expect(rows(wrapper)).toEqual([
[
t(`editor.metrics.config.${label}`),
t("editor.diff-change-marker.auto"),
"1",
],
])
},
)
it("shows a setting that was not set before as empty", async ({ expect }) => {
const wrapper = await mountDetails(
{},
{ unitType: VisualizationTimeUnit.Seconds },
)
expect(rows(wrapper)).toEqual([
[
t("editor.metrics.config.unit-label"),
t("editor.diff-change-marker.empty"),
t("editor.metrics.config.unit-options.time.options.seconds"),
],
])
})
it("shows the settings by the names the config modal uses", async ({
expect,
}) => {
const wrapper = await mountDetails(
{
timeRange: TimeRangePreset.Last15Minutes,
refreshInterval: RefreshInterval.M1,
thresholds: [{ value: 1 }],
},
{
timeRange: TimeRangePreset.Last5Minutes,
refreshInterval: RefreshInterval.M5,
thresholds: [{ value: 1 }, { value: 2 }],
},
)
expect(rows(wrapper)).toEqual([
[
t("editor.metrics.config.time-range-label"),
t("editor.metrics.config.time-range-options.last_15_minutes"),
t("editor.metrics.config.time-range-options.last_5_minutes"),
],
[
t("editor.metrics.config.refresh-interval-label"),
t("editor.metrics.config.refresh-interval-options.1m"),
t("editor.metrics.config.refresh-interval-options.5m"),
],
[t("editor.metrics.config.thresholds-label"), "1", "2"],
])
})
it("names a data source instead of showing its id", async ({ expect }) => {
mockEndpoint("GET", "/api/data-sources", () => [
{ id: "ds-1", name: "Demo", type: "prometheus", url: "demo://x" },
])
const wrapper = await mountDetails(
{ dataSourceId: "ds-gone" },
{ dataSourceId: "ds-1" },
)
await settleMutations()
expect(rows(wrapper)).toEqual([
[
t("editor.metrics.config.data-source-label"),
t("editor.metrics.config.data-source-label-deleted"),
"Demo",
],
])
})
it("shows an attribute it has no label for by its name", async ({
expect,
}) => {
const wrapper = await mountDetails(
{ config: { title: "CPU" } },
{ config: { title: "RAM" } },
)
expect(rows(wrapper)).toEqual([
["config", '{"title":"CPU"}', '{"title":"RAM"}'],
])
})
})
function query(expr: string, legendFormat: string) {
return { name: "Query", query: expr, legendFormat: legendFormat }
}
// each row as its label, its old value and its new value, null for a side
// the row does not show
function rows(wrapper: Awaited<ReturnType<typeof mountDetails>>) {
const labels = wrapper.findAll("dt")
return wrapper
.findAll("dd")
.map((dd, index) => [
labels[index]?.text(),
dd.find(".line-through").exists() ? dd.get(".line-through").text() : null,
dd.find(".bg-diff-text-added").exists()
? dd.get(".bg-diff-text-added").text()
: null,
])
}
function mountDetails(
oldAttrs: Record<string, unknown>,
newAttrs: Record<string, unknown>,
) {
return mountSuspended(MetricDiffDetails, {
props: {
node: makeNode(
{
...newAttrs,
diffStatus: DiffStatus.Modified,
oldNode: { attrs: oldAttrs },
},
{ typeName: METRIC_BLOCK_NAME },
),
},
})
}

View File

@ -0,0 +1,161 @@
<script lang="ts" setup>
import type { Node as PMNode } from "@tiptap/pm/model"
import {
collectAttributeChanges,
displayValue,
formatChange,
type AttributeChange,
type FieldChange,
} from "~/components/editor/diff/change-count"
import DiffFieldChanges from "~/components/editor/diff/DiffFieldChanges.vue"
import {
VisualizationDataUnit,
VisualizationMiscUnit,
VisualizationTimeUnit,
} from "./utils"
// matches the per-field units of queries, such as "queries.1.legendFormat"
const QUERY_FIELD_RE = /^queries\.(\d+)\.(query|legendFormat)$/
const UNIT_CATEGORIES: Record<string, string> = Object.fromEntries([
...Object.values(VisualizationTimeUnit).map(unitCategory("time")),
...Object.values(VisualizationDataUnit).map(unitCategory("data")),
...Object.values(VisualizationMiscUnit).map(unitCategory("misc")),
])
const props = defineProps<{
node: PMNode
}>()
const { t } = useI18n({ useScope: "global" })
const { fetchDataSources } = useDataSourceAPI()
const rows = computed<FieldChange[]>(() =>
collectAttributeChanges(props.node).map(toRow),
)
function toRow(change: AttributeChange): FieldChange {
const queryField = QUERY_FIELD_RE.exec(change.name)
if (queryField) {
const index = Number(queryField[1]) + 1
const label =
queryField[2] === "query"
? t("editor.metrics.config.query-name-default-format", { index })
: t("editor.metrics.diff.legend", { index })
// an empty legend falls back to the series labels, which the config
// modal calls auto
const format = (value: unknown) =>
value === "" && queryField[2] === "legendFormat"
? t("editor.diff-change-marker.auto")
: displayValue(value)
return { label: label, ...formatChange(change, format), code: true }
}
switch (change.name) {
case "title":
return row(t("editor.metrics.config.title-label"), change)
case "dataSourceId":
return row(
t("editor.metrics.config.data-source-label"),
change,
dataSourceName,
)
case "visualizationType":
return row(t("editor.metrics.diff.visualization"), change, (value) =>
t(
`editor.metrics.config.type-options.${displayValue(value).replace("_", "-")}.title`,
),
)
case "timeRange":
return row(t("editor.metrics.config.time-range-label"), change, (value) =>
t(`editor.metrics.config.time-range-options.${displayValue(value)}`),
)
case "refreshInterval":
return row(
t("editor.metrics.config.refresh-interval-label"),
change,
(value) =>
t(
`editor.metrics.config.refresh-interval-options.${displayValue(value)}`,
),
)
case "thresholds":
return row(t("editor.metrics.config.thresholds-label"), change, (value) =>
String((value as unknown[]).length),
)
case "baseThresholdColor":
return row(t("editor.metrics.diff.base-threshold-color"), change)
case "decimals":
return autoRow(t("editor.metrics.config.decimals-label"), change)
case "unitType":
return row(t("editor.metrics.config.unit-label"), change, unitName)
case "unitCustom":
return row(t("editor.metrics.diff.custom-unit"), change)
case "axisBoundsMin":
return autoRow(t("editor.metrics.config.bounds-min-label"), change)
case "axisBoundsMax":
return autoRow(t("editor.metrics.config.bounds-max-label"), change)
case "simulationPreset":
return row(t("editor.metrics.simulation.preset-label"), change, (value) =>
t(`editor.metrics.simulation.preset-options.${displayValue(value)}`),
)
case "width":
return row(t("editor.metrics.diff.size"), change, (value) =>
t(
`editor.drag-handle.options.metric-block.width-options.${displayValue(value)}`,
),
)
default:
// an attribute this card has no label for still shows, so the
// card lists every change the marker counts
return row(change.name, change)
}
}
// a setting always has a value, so its unset side shows as empty
function row(
label: string,
change: AttributeChange,
format?: (value: unknown) => string,
): FieldChange {
return { label: label, ...formatChange(change, format, "") }
}
// a setting whose unset value lets the chart pick one, as its placeholder
// in the config modal says
function autoRow(label: string, change: AttributeChange): FieldChange {
return {
label: label,
...formatChange(change, displayValue, t("editor.diff-change-marker.auto")),
}
}
function unitCategory(category: string) {
return (unit: string): [string, string] => [unit, category]
}
function dataSourceName(id: unknown): string {
const dataSource = fetchDataSources.state.value.data?.find(
(source) => source.id === id,
)
return (
dataSource?.name ?? t("editor.metrics.config.data-source-label-deleted")
)
}
function unitName(value: unknown): string {
const unit = displayValue(value)
const category = UNIT_CATEGORIES[unit]
return category
? t(`editor.metrics.config.unit-options.${category}.options.${unit}`)
: t(`editor.metrics.config.unit-options.${unit}`)
}
</script>
<template>
<DiffFieldChanges class="w-80" :rows="rows" />
</template>

View File

@ -0,0 +1,52 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { describe, it } from "vitest"
import { h } from "vue"
import DiffBeforeAfter from "./DiffBeforeAfter.vue"
describe("<DiffBeforeAfter>", () => {
it("renders each set side through the slot", async ({ expect }) => {
const wrapper = await mountSides("a.png", "b.png")
expect(wrapper.findAll(".side").map((side) => side.text())).toEqual([
"a.png",
"b.png",
])
expect(wrapper.text()).not.toContain("nothing")
})
it.for([
{
name: "shows the empty label for a side that was not set before",
input: [undefined, "b.png"],
expected: ["nothing", "b.png"],
},
{
name: "shows the empty label for a side that is not set after",
input: ["a.png", undefined],
expected: ["a.png", "nothing"],
},
])("$name", async ({ input, expected }, { expect }) => {
const wrapper = await mountSides(input[0], input[1])
const boxes = wrapper.findAll(".border-2").map((box) => box.text())
expect(boxes).toEqual(expected)
})
it("borders the old side red and the new side green", async ({ expect }) => {
const wrapper = await mountSides("a.png", "b.png")
const boxes = wrapper.findAll(".border-2")
expect(boxes[0]?.classes()).toContain("border-diff-removed")
expect(boxes[1]?.classes()).toContain("border-diff-added")
})
})
function mountSides(before: string | undefined, after: string | undefined) {
return mountSuspended(DiffBeforeAfter, {
props: { before: before, after: after, emptyLabel: "nothing" },
slots: {
default: ({ value }: { value: unknown }) =>
h("span", { class: "side" }, String(value)),
},
})
}

View File

@ -0,0 +1,48 @@
<script lang="ts" setup generic="T">
import { cn } from "~/lib/utils"
// an undefined side had nothing set, so it shows emptyLabel instead. Both
// sides take the same width, and the taller one sets the height of both.
const props = defineProps<{
before?: T
after?: T
emptyLabel: string
}>()
defineSlots<{
default: (props: { value: T }) => unknown
}>()
const sides = computed(() => [
{ key: "before", value: props.before, borderClass: "border-diff-removed" },
{ key: "after", value: props.after, borderClass: "border-diff-added" },
])
</script>
<template>
<div class="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] gap-2">
<template v-for="(side, index) in sides" :key="side.key">
<Icon
v-if="index > 0"
name="mingcute:arrow-right-line"
class="size-3.5 self-center text-muted-foreground"
/>
<div
:class="
cn(
'flex min-h-14 items-center justify-center overflow-hidden rounded-md border-2',
side.borderClass,
)
"
>
<slot v-if="side.value !== undefined" :value="side.value" />
<span
v-else
class="px-2 text-center text-xs text-muted-foreground italic"
>
{{ props.emptyLabel }}
</span>
</div>
</template>
</div>
</template>

View File

@ -1,9 +1,17 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { describe, it } from "vitest"
import { beforeEach, describe, it, vi } from "vitest"
import { flushPromises } from "@vue/test-utils"
import { defineComponent, h } from "vue"
import DiffChangeMarker from "./DiffChangeMarker.vue"
import { DiffStatus } from "./position-map"
import { makeNode } from "../test-helpers/node-view"
import { t } from "~/components/test-helpers"
import { clearTeleportedOverlays, t } from "~/components/test-helpers"
const MODIFIED_IMAGE = {
src: "b.png",
diffStatus: DiffStatus.Modified,
oldNode: { attrs: { src: "a.png" } },
}
describe("DiffChangeMarker", () => {
it("shows the removals and additions of a modified node", async ({
@ -63,10 +71,88 @@ describe("DiffChangeMarker", () => {
expect(wrapper.html()).toBe("<!--v-if-->")
},
)
// the card lands in <body>, which every test in the file shares
describe("when clicked", { concurrent: false }, () => {
beforeEach(() => {
clearTeleportedOverlays()
})
it("opens the details from its slot", async ({ expect }) => {
const wrapper = await mountMarker(MODIFIED_IMAGE, () => "image details")
await wrapper.get("span").trigger("click")
expect(document.body.textContent).toContain("image details")
})
it("closes the details on a click elsewhere", async ({ expect }) => {
const wrapper = await mountMarker(MODIFIED_IMAGE, () => "image details")
await wrapper.get("span").trigger("click")
// the popover's outside-click listener is attached a task after it
// opens
await new Promise((resolve) => setTimeout(resolve, 0))
document.body.dispatchEvent(
new PointerEvent("pointerdown", { bubbles: true, button: 0 }),
)
await flushPromises()
expect(document.body.textContent).not.toContain("image details")
})
it("looks clickable only when it has details", async ({ expect }) => {
const withDetails = await mountMarker(MODIFIED_IMAGE, () => "details")
const withoutDetails = await mountMarker(MODIFIED_IMAGE)
expect(withDetails.get("span").classes()).toEqual(
expect.arrayContaining(["cursor-pointer", "active:opacity-90"]),
)
expect(withoutDetails.get("span").classes()).not.toContain(
"cursor-pointer",
)
expect(withoutDetails.get("span").classes()).not.toContain(
"active:opacity-90",
)
})
it("opens no card when it has no details", async ({ expect }) => {
const wrapper = await mountMarker(MODIFIED_IMAGE)
await wrapper.get("span").trigger("click")
expect(
document.body.querySelector("[data-slot='popover-content']"),
).toBeNull()
})
})
describe("inside a link", () => {
it("keeps a click on the pill from reaching the element around it", async ({
expect,
}) => {
const onClick = vi.fn()
const wrapper = await mountSuspended(
defineComponent({
render: () =>
h("a", { href: "#file", onClick: onClick }, [
h(DiffChangeMarker, { node: makeNode(MODIFIED_IMAGE) }),
]),
}),
)
const event = new MouseEvent("click", { bubbles: true, cancelable: true })
wrapper.get("a > span").element.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
expect(onClick).not.toHaveBeenCalled()
})
})
})
function mountMarker(attrs: Record<string, unknown>) {
function mountMarker(attrs: Record<string, unknown>, details?: () => string) {
return mountSuspended(DiffChangeMarker, {
props: { node: makeNode(attrs) },
slots: details ? { default: details } : {},
})
}

View File

@ -1,8 +1,16 @@
<script lang="ts" setup>
import type { Node as PMNode } from "@tiptap/pm/model"
import { cn } from "~/lib/utils"
import { countNodeChanges } from "./change-count"
import { DiffStatus } from "./position-map"
// the classes that place the marker belong on the pill, not on the popover
// root, which renders no element of its own. The pill sits inside the file
// card's link and the empty blocks' buttons, so a click on it stops there.
defineOptions({
inheritAttrs: false,
})
const props = defineProps<{
node: PMNode
}>()
@ -15,27 +23,48 @@ const count = computed(() =>
</script>
<template>
<span
v-if="count && (count.removed || count.added)"
contenteditable="false"
class="pointer-events-none flex overflow-hidden rounded-full border border-border bg-background text-xs font-semibold select-none"
>
<span class="sr-only">
{{ $t("editor.diff-change-marker.label", { ...count }) }}
</span>
<span
v-if="count.removed"
aria-hidden="true"
class="bg-diff-removed px-2 py-0.5 text-diff-removed-foreground"
<ShadcnUiPopover v-if="count && (count.removed || count.added)">
<ShadcnUiPopoverTrigger as-child :disabled="!$slots.default">
<span
v-bind="$attrs"
contenteditable="false"
:class="
cn(
'flex overflow-hidden rounded-full border border-border bg-background text-xs font-semibold transition-all select-none',
$slots.default &&
'cursor-pointer active:opacity-90 [&:hover:not(:active)]:opacity-70',
)
"
@click.stop.prevent
>
<span class="sr-only">
{{ $t("editor.diff-change-marker.label", { ...count }) }}
</span>
<span
v-if="count.removed"
aria-hidden="true"
class="bg-diff-removed px-2 py-0.5 text-diff-removed-foreground"
>
{{
$t("editor.diff-change-marker.removed", { count: count.removed })
}}
</span>
<span
v-if="count.added"
aria-hidden="true"
class="bg-diff-added px-2 py-0.5 text-diff-added-foreground"
>
{{ $t("editor.diff-change-marker.added", { count: count.added }) }}
</span>
</span>
</ShadcnUiPopoverTrigger>
<ShadcnUiPopoverContent
v-if="$slots.default"
align="end"
class="w-auto max-w-[min(28rem,90dvw)] p-3"
>
{{ $t("editor.diff-change-marker.removed", { count: count.removed }) }}
</span>
<span
v-if="count.added"
aria-hidden="true"
class="bg-diff-added px-2 py-0.5 text-diff-added-foreground"
>
{{ $t("editor.diff-change-marker.added", { count: count.added }) }}
</span>
</span>
<!-- the details of the changes, from the block around the marker -->
<slot />
</ShadcnUiPopoverContent>
</ShadcnUiPopover>
</template>

View File

@ -0,0 +1,65 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { describe, it } from "vitest"
import DiffFieldChanges from "./DiffFieldChanges.vue"
import type { FieldChange } from "./change-count"
import { t } from "~/components/test-helpers"
describe("<DiffFieldChanges>", () => {
it("shows a changed field's label, old value and new value", async ({
expect,
}) => {
const wrapper = await mountFields([
{ label: "Title", oldValue: "CPU", newValue: "CPU usage" },
])
expect(wrapper.get("dt").text()).toBe("Title")
expect(wrapper.get(".line-through").text()).toBe("CPU")
expect(wrapper.get(".bg-diff-text-added").text()).toBe("CPU usage")
expect(wrapper.find(".iconify").exists()).toBe(true)
})
it("shows only the new value of a newly set field", async ({ expect }) => {
const wrapper = await mountFields([
{ label: "Unit", oldValue: null, newValue: "percent" },
])
expect(wrapper.find(".line-through").exists()).toBe(false)
expect(wrapper.get(".bg-diff-text-added").text()).toBe("percent")
expect(wrapper.find(".iconify").exists()).toBe(false)
})
it("shows only the old value of a cleared field", async ({ expect }) => {
const wrapper = await mountFields([
{ label: "Unit", oldValue: "percent", newValue: null },
])
expect(wrapper.get(".line-through").text()).toBe("percent")
expect(wrapper.find(".bg-diff-text-added").exists()).toBe(false)
})
it("names an empty value instead of leaving it blank", async ({ expect }) => {
const wrapper = await mountFields([
{ label: "Legend 2", oldValue: null, newValue: "" },
])
expect(wrapper.get(".bg-diff-text-added").text()).toBe(
t("editor.diff-change-marker.empty"),
)
})
it("sets code values in a monospace font", async ({ expect }) => {
const wrapper = await mountFields([
{ label: "Query 2", oldValue: null, newValue: "up", code: true },
{ label: "Title", oldValue: null, newValue: "CPU" },
])
expect(wrapper.findAll("dd").map((dd) => dd.classes("font-mono"))).toEqual([
true,
false,
])
})
})
function mountFields(rows: FieldChange[]) {
return mountSuspended(DiffFieldChanges, { props: { rows: rows } })
}

View File

@ -0,0 +1,44 @@
<script lang="ts" setup>
import { cn } from "~/lib/utils"
import type { FieldChange } from "./change-count"
const props = defineProps<{
rows: FieldChange[]
}>()
</script>
<template>
<dl
class="grid grid-cols-[auto_minmax(0,1fr)] items-baseline gap-x-4 gap-y-1.5 text-xs"
>
<template v-for="(row, index) in props.rows" :key="index">
<dt class="whitespace-nowrap text-muted-foreground">{{ row.label }}</dt>
<dd
:class="
cn(
'flex min-w-0 flex-wrap items-baseline gap-1',
row.code && 'font-mono',
)
"
>
<span
v-if="row.oldValue !== null"
class="rounded-sm bg-diff-text-removed px-1 break-all line-through"
>
{{ row.oldValue || $t("editor.diff-change-marker.empty") }}
</span>
<Icon
v-if="row.oldValue !== null && row.newValue !== null"
name="mingcute:arrow-right-line"
class="size-3 shrink-0 self-center text-muted-foreground"
/>
<span
v-if="row.newValue !== null"
class="rounded-sm bg-diff-text-added px-1 break-all"
>
{{ row.newValue || $t("editor.diff-change-marker.empty") }}
</span>
</dd>
</template>
</dl>
</template>

View File

@ -1,6 +1,13 @@
import { Schema, type Node as PMNode } from "@tiptap/pm/model"
import { describe, it } from "vitest"
import { countNodeChanges } from "./change-count"
import {
collectAttributeChanges,
countNodeChanges,
displayValue,
formatChange,
TextSegmentKind,
textSegments,
} from "./change-count"
import { DiffStatus } from "./position-map"
import {
COMMENT_MARK_NAME,
@ -11,6 +18,7 @@ import { NODE_COMMENT_ID_ATTR } from "~/components/editor/attribute-names"
import { SIMULATION_ACTIVE_ATTR } from "~/components/editor/blocks/metrics/simulation"
import {
FILE_BLOCK_NAME,
IMAGE_BLOCK_NAME,
METRIC_BLOCK_NAME,
} from "~/components/editor/blocks/node-names"
@ -39,6 +47,19 @@ const schema = new Schema({
oldNode: { default: null },
},
},
[IMAGE_BLOCK_NAME]: {
group: "block",
atom: true,
attrs: {
uid: { default: null },
src: { default: null },
alt: { default: null },
title: { default: null },
width: { default: null },
diffStatus: { default: null },
oldNode: { default: null },
},
},
[FILE_BLOCK_NAME]: {
group: "block",
atom: true,
@ -143,6 +164,24 @@ describe("countNodeChanges", () => {
expect(countNodeChanges(node)).toEqual(expected)
})
it("ignores the image attributes no one can change from the editor", ({
expect,
}) => {
const node = schema.nodes[IMAGE_BLOCK_NAME].create({
uid: "image-1",
src: "a.png",
alt: "a dog",
title: "Dog",
diffStatus: DiffStatus.Modified,
oldNode: {
type: IMAGE_BLOCK_NAME,
attrs: { uid: "image-1", src: "a.png", alt: "a cat" },
},
})
expect(countNodeChanges(node)).toEqual({ removed: 0, added: 0 })
})
it("counts no attribute changes on a node without its original", ({
expect,
}) => {
@ -172,12 +211,12 @@ describe("countNodeChanges", () => {
expected: { removed: 1, added: 0 },
},
{
name: "counts the attributes outside the file group on their own",
name: "ignores the flag of an upload in flight",
input: {
old: storedFile("a.zip"),
new: { ...storedFile("a.zip"), uploading: true },
},
expected: { removed: 1, added: 1 },
expected: { removed: 0, added: 0 },
},
])("$name", ({ input, expected }, { expect }) => {
const node = schema.nodes[FILE_BLOCK_NAME].create({
@ -294,6 +333,134 @@ describe("countNodeChanges", () => {
})
})
describe("collectAttributeChanges", () => {
it("lists each changed unit with both of its sides", ({ expect }) => {
const node = schema.nodes.atom.create({
uid: "block-1",
src: "b.png",
alt: "a cat",
diffStatus: DiffStatus.Modified,
oldNode: {
type: "atom",
attrs: { uid: "block-1", src: "a.png", width: 300 },
},
})
expect(collectAttributeChanges(node)).toEqual([
{ name: "src", oldValue: "a.png", newValue: "b.png" },
{ name: "width", oldValue: 300, newValue: undefined },
{ name: "alt", oldValue: undefined, newValue: "a cat" },
])
})
it("gives a group's side as the record of its set attributes", ({
expect,
}) => {
const node = schema.nodes[FILE_BLOCK_NAME].create({
uid: "file-1",
...storedFile("b.zip"),
diffStatus: DiffStatus.Modified,
oldNode: {
type: FILE_BLOCK_NAME,
attrs: { uid: "file-1", uploading: false },
},
})
expect(collectAttributeChanges(node)).toEqual([
{ name: "file", oldValue: undefined, newValue: storedFile("b.zip") },
])
})
it("names a list item's field by its list, index and field", ({ expect }) => {
const node = schema.nodes[METRIC_BLOCK_NAME].create({
uid: "metric-1",
queries: [query("up", ""), query("rate", "")],
diffStatus: DiffStatus.Modified,
oldNode: {
type: METRIC_BLOCK_NAME,
attrs: { uid: "metric-1", queries: [query("up", "")] },
},
})
expect(collectAttributeChanges(node)).toEqual([
{ name: "queries.1.query", oldValue: undefined, newValue: "rate" },
{ name: "queries.1.legendFormat", oldValue: undefined, newValue: "" },
])
})
it("lists nothing for a node without its original", ({ expect }) => {
const node = schema.nodes.atom.create({
src: "a.png",
diffStatus: DiffStatus.Modified,
})
expect(collectAttributeChanges(node)).toEqual([])
})
})
describe("displayValue", () => {
it.for([
{ name: "keeps a string as it is", input: "a cat", expected: "a cat" },
{ name: "prints a number", input: 480, expected: "480" },
{ name: "prints a boolean", input: true, expected: "true" },
{
name: "prints an object as stable JSON",
input: { b: 1, a: 2 },
expected: '{"a":2,"b":1}',
},
])("$name", ({ input, expected }, { expect }) => {
expect(displayValue(input)).toBe(expected)
})
})
describe("formatChange", () => {
it("formats both sides with the given format", ({ expect }) => {
expect(
formatChange(
{ name: "width", oldValue: 300, newValue: 480 },
(value) => `${displayValue(value)}!`,
),
).toEqual({ oldValue: "300!", newValue: "480!" })
})
it("shows the unset text for a side that is not set", ({ expect }) => {
expect(
formatChange(
{ name: "width", oldValue: undefined, newValue: 480 },
displayValue,
"auto",
),
).toEqual({ oldValue: "auto", newValue: "480" })
})
it("gives null for a side that is not set", ({ expect }) => {
expect(
formatChange({ name: "alt", oldValue: undefined, newValue: "a cat" }),
).toEqual({ oldValue: null, newValue: "a cat" })
})
})
describe("textSegments", () => {
it("joins neighbouring text of the same kind into one segment", ({
expect,
}) => {
const node = schema.nodes.code.create({ uid: "code-1" }, [
textNode("A-->", []),
textNode("B", [REMOVED]),
textNode("C", [ADDED]),
textNode("D", [ADDED, COMMENT_MARK_NAME]),
textNode(" end", []),
])
expect(textSegments(node)).toEqual([
{ kind: TextSegmentKind.Equal, text: "A-->" },
{ kind: TextSegmentKind.Removed, text: "B" },
{ kind: TextSegmentKind.Added, text: "CD" },
{ kind: TextSegmentKind.Equal, text: " end" },
])
})
})
function query(expr: string, legendFormat: string): Record<string, unknown> {
return { name: "Query", query: expr, legendFormat: legendFormat }
}

View File

@ -4,10 +4,14 @@ import {
DIFF_TEXT_ADDED_MARK_NAME,
DIFF_TEXT_REMOVED_MARK_NAME,
} from "~/components/editor/mark-names"
import { NODE_COMMENT_ID_ATTR } from "~/components/editor/attribute-names"
import {
NODE_COMMENT_ID_ATTR,
UPLOADING_ATTR,
} from "~/components/editor/attribute-names"
import { SIMULATION_ACTIVE_ATTR } from "~/components/editor/blocks/metrics/simulation"
import {
FILE_BLOCK_NAME,
IMAGE_BLOCK_NAME,
METRIC_BLOCK_NAME,
} from "~/components/editor/blocks/node-names"
@ -29,8 +33,15 @@ const IGNORED_ATTRIBUTES = new Set([
...DIFF_META_ATTRIBUTES,
NODE_COMMENT_ID_ATTR,
SIMULATION_ACTIVE_ATTR,
UPLOADING_ATTR,
])
// attributes no one can change from the editor, per node type. They only
// arrive with pasted HTML, so they are not counted.
const UNTRACKED_ATTRIBUTES: Record<string, string[]> = {
[IMAGE_BLOCK_NAME]: ["alt", "title"],
}
// attributes that change together and count as one change, per node
// type. Each attribute maps to the name of its group.
const ATTRIBUTE_GROUPS: Record<string, Record<string, string>> = {
@ -50,47 +61,60 @@ const ITEM_FIELDS: Record<string, Record<string, string[]>> = {
},
}
export interface AttributeChange {
// the attribute's name, its group's name, or "<list>.<index>.<field>"
// for a field of a list item
name: string
// undefined on the side where the unit is not set. A group's value is
// the record of its set attributes.
oldValue: unknown
newValue: unknown
}
// one row of a card that lists changed fields
export interface FieldChange {
label: string
// null on the side where the field is not set
oldValue: string | null
newValue: string | null
// code such as a query reads better in a monospace font
code?: boolean
}
export enum TextSegmentKind {
Equal = "equal",
Removed = "removed",
Added = "added",
}
export interface TextSegment {
kind: TextSegmentKind
text: string
}
// count the changes of a modified node against its original. A changed
// attribute counts as one removal and one addition, a newly set one as an
// addition and a cleared one as a removal. A group of attributes counts
// like one attribute. Changed text counts by run.
export function countNodeChanges(node: PMNode): ChangeCount {
// an expanded textblock drops its oldNode, since its marks carry the
// original instead
const oldNode = node.attrs.oldNode as JSONContent | null
const attributes = oldNode
? countAttributeChanges(node.type.name, oldNode.attrs ?? {}, node.attrs)
: { removed: 0, added: 0 }
const text = countTextChanges(node)
return {
removed: attributes.removed + text.removed,
added: attributes.added + text.added,
}
}
function countAttributeChanges(
typeName: string,
oldAttrs: Record<string, unknown>,
newAttrs: Record<string, unknown>,
): ChangeCount {
const count: ChangeCount = { removed: 0, added: 0 }
const oldUnits = attributeUnits(typeName, oldAttrs)
const newUnits = attributeUnits(typeName, newAttrs)
const names = new Set([...oldUnits.keys(), ...newUnits.keys()])
for (const name of names) {
const oldUnit = oldUnits.get(name)
const newUnit = newUnits.get(name)
if (jsonStableStringify(oldUnit) === jsonStableStringify(newUnit)) {
continue
}
if (oldUnit) {
for (const change of collectAttributeChanges(node)) {
if (change.oldValue !== undefined) {
count.removed++
}
if (newUnit) {
if (change.newValue !== undefined) {
count.added++
}
}
for (const segment of textSegments(node)) {
if (segment.kind === TextSegmentKind.Removed) {
count.removed++
}
if (segment.kind === TextSegmentKind.Added) {
count.added++
}
}
@ -98,6 +122,88 @@ function countAttributeChanges(
return count
}
export function collectAttributeChanges(node: PMNode): AttributeChange[] {
// an expanded textblock drops its oldNode, since its marks carry the
// original instead
const oldNode = node.attrs.oldNode as JSONContent | null
if (!oldNode) {
return []
}
const oldUnits = attributeUnits(node.type.name, oldNode.attrs ?? {})
const newUnits = attributeUnits(node.type.name, node.attrs)
const names = new Set([...oldUnits.keys(), ...newUnits.keys()])
const changes: AttributeChange[] = []
for (const name of names) {
const oldValue = oldUnits.get(name)
const newValue = newUnits.get(name)
if (jsonStableStringify(oldValue) !== jsonStableStringify(newValue)) {
changes.push({ name: name, oldValue: oldValue, newValue: newValue })
}
}
return changes
}
// the text a card shows for an attribute value
export function displayValue(value: unknown): string {
if (typeof value === "string") {
return value
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value)
}
return jsonStableStringify(value)
}
// both sides of a change as text. A side that is not set shows unset, or
// is left out when unset is null.
export function formatChange(
change: AttributeChange,
format: (value: unknown) => string = displayValue,
unset: string | null = null,
): { oldValue: string | null; newValue: string | null } {
return {
oldValue: change.oldValue === undefined ? unset : format(change.oldValue),
newValue: change.newValue === undefined ? unset : format(change.newValue),
}
}
// a modified textblock holds both sides of its text, told apart by the
// added and removed marks. Each unbroken run of one kind becomes one
// segment, so several changes can sit on the same line.
export function textSegments(node: PMNode): TextSegment[] {
const segments: TextSegment[] = []
node.descendants((child) => {
if (!child.isText) {
return true
}
const marks = child.marks.map((mark) => mark.type.name)
let kind = TextSegmentKind.Equal
if (marks.includes(DIFF_TEXT_REMOVED_MARK_NAME)) {
kind = TextSegmentKind.Removed
} else if (marks.includes(DIFF_TEXT_ADDED_MARK_NAME)) {
kind = TextSegmentKind.Added
}
const last = segments.at(-1)
if (last?.kind === kind) {
last.text += child.text ?? ""
} else {
segments.push({ kind: kind, text: child.text ?? "" })
}
return false
})
return segments
}
// sort the set attributes into units, keyed by their group name, or by
// their own name when they have no group. A list attribute with item fields
// becomes one unit per field of each item instead. A unit whose attributes
@ -105,13 +211,19 @@ function countAttributeChanges(
function attributeUnits(
typeName: string,
attrs: Record<string, unknown>,
): Map<string, Record<string, unknown>> {
): Map<string, unknown> {
const groups = ATTRIBUTE_GROUPS[typeName] ?? {}
const itemFields = ITEM_FIELDS[typeName] ?? {}
const units = new Map<string, Record<string, unknown>>()
const untracked = UNTRACKED_ATTRIBUTES[typeName] ?? []
const units = new Map<string, unknown>()
for (const [key, value] of Object.entries(attrs)) {
if (IGNORED_ATTRIBUTES.has(key) || value === null || value === undefined) {
if (
IGNORED_ATTRIBUTES.has(key) ||
untracked.includes(key) ||
value === null ||
value === undefined
) {
continue
}
@ -121,15 +233,19 @@ function attributeUnits(
continue
}
const name = groups[key] ?? key
units.set(name, { ...units.get(name), [key]: value })
const group = groups[key]
if (group) {
units.set(group, { ...(units.get(group) as object), [key]: value })
} else {
units.set(key, value)
}
}
return units
}
function addItemUnits(
units: Map<string, Record<string, unknown>>,
units: Map<string, unknown>,
key: string,
items: Record<string, unknown>[],
fields: string[],
@ -138,44 +254,8 @@ function addItemUnits(
for (const field of fields) {
const value = item[field]
if (value !== null && value !== undefined) {
units.set(`${key}.${index}.${field}`, { [field]: value })
units.set(`${key}.${index}.${field}`, value)
}
}
}
}
// a modified textblock holds both sides of its text, told apart by the
// added and removed marks. Each unbroken run of one mark is one change,
// so several can sit on the same line.
function countTextChanges(node: PMNode): ChangeCount {
const count: ChangeCount = { removed: 0, added: 0 }
let previous: string | null = null
node.descendants((child) => {
if (!child.isText) {
return true
}
const marks = child.marks.map((mark) => mark.type.name)
let current: string | null = null
if (marks.includes(DIFF_TEXT_REMOVED_MARK_NAME)) {
current = DIFF_TEXT_REMOVED_MARK_NAME
} else if (marks.includes(DIFF_TEXT_ADDED_MARK_NAME)) {
current = DIFF_TEXT_ADDED_MARK_NAME
}
if (current === DIFF_TEXT_REMOVED_MARK_NAME && previous !== current) {
count.removed++
}
if (current === DIFF_TEXT_ADDED_MARK_NAME && previous !== current) {
count.added++
}
previous = current
return false
})
return count
}

View File

@ -136,13 +136,21 @@
"more-indicator": "+{count}"
},
"figma": {
"diff": {
"width": "Width",
"height": "Height"
},
"description": "Embed Figma",
"placeholder": "https://www.figma.com/... + Enter",
"edit": "Edit URL",
"empty": "No Figma embed",
"empty": "No Figma file",
"iframe-title": "Figma embed"
},
"image": {
"diff": {
"image": "Image",
"width": "Width"
},
"description": "Upload an image",
"empty": "Image not uploaded",
"uploading": "Uploading...",
@ -164,6 +172,9 @@
}
},
"mermaid": {
"diff": {
"more-lines": "+{count} more"
},
"preview": {
"loading": "The diagram is loading...",
"empty": "Add Mermaid code to see a diagram",
@ -776,6 +787,13 @@
"1d": "1d"
}
},
"diff": {
"visualization": "Visualization",
"legend": "Legend {index}",
"base-threshold-color": "Base threshold colour",
"custom-unit": "Custom unit",
"size": "Size"
},
"simulation": {
"tooltip": "Showing simulated data until real data is available",
"section-title": "Simulated data",
@ -842,7 +860,10 @@
"diff-change-marker": {
"label": "{removed} removed, {added} added",
"removed": "−{count}",
"added": "+{count}"
"added": "+{count}",
"empty": "empty",
"auto": "auto",
"pixels": "{value} px"
},
"tags": {
"label": "Tags",