feat(common): add copy functionality to console output entries (#5743)

Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
aparna-bhatt 2026-01-12 13:24:52 +05:30 committed by GitHub
parent 84c3e8642c
commit 6c2128fbed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 100 additions and 11 deletions

View file

@ -1,35 +1,47 @@
<template>
<div
class="flex items-start px-4 py-2 text-tiny text-secondaryDark rounded-md"
:class="color"
>
<div class="console-entry-wrapper group" :class="[color, hoverClass]">
<component :is="icon" class="mr-2 shrink-0 svg-icons" />
<div class="flex flex-col space-y-2 overflow-x-auto text-xs flex-1">
<div class="flex flex-col space-y-2 text-xs flex-1 min-w-0">
<div class="text-secondary">{{ formattedTimestamp }}</div>
<div class="flex flex-col space-y-1">
<ConsoleValue
v-for="(arg, idx) in entry.args"
v-for="(arg, idx) in sanitizedArgs"
:key="idx"
:value="arg"
class="overflow-auto"
/>
</div>
</div>
<button
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:aria-label="t('action.copy')"
class="ml-2 shrink-0 opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity p-1 hover:bg-dividerLight rounded"
@click="copyEntry"
>
<component :is="copyIcon" class="w-4 h-4 svg-icons" />
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue"
import { refAutoReset } from "@vueuse/core"
import IconAlertCircle from "~icons/lucide/alert-circle"
import IconAlertTriangle from "~icons/lucide/alert-triangle"
import IconBug from "~icons/lucide/bug"
import IconInfo from "~icons/lucide/info"
import IconTerminal from "~icons/lucide/terminal"
import IconCopy from "~icons/lucide/copy"
import IconCheck from "~icons/lucide/check"
import { ConsoleEntry, ConsoleLogLevel } from "./Panel.vue"
import { useI18n } from "@composables/i18n"
import { copyToClipboard } from "@helpers/utils/clipboard"
import { useToast } from "@composables/toast"
type LogLevelsWithBgColor = "info" | "warn" | "error"
@ -37,6 +49,10 @@ const props = defineProps<{
entry: ConsoleEntry
}>()
const t = useI18n()
const toast = useToast()
const copyIcon = refAutoReset(IconCopy, 1000)
const bgColors: Pick<Record<ConsoleLogLevel, string>, LogLevelsWithBgColor> = {
info: "bg-bannerInfo",
warn: "bg-bannerWarning",
@ -54,8 +70,55 @@ const icons: Record<ConsoleLogLevel, unknown> = {
const color = computed(() => bgColors[props.entry.type as LogLevelsWithBgColor])
const icon = computed(() => icons[props.entry.type])
// Only apply hover background for entries without semantic backgrounds (log, debug)
const hoverClass = computed(() =>
props.entry.type in bgColors ? "" : "hover:bg-primaryLight"
)
const formattedTimestamp = computed(() => {
const dateEntry = new Date(props.entry.timestamp)
return dateEntry.toLocaleTimeString()
})
const sanitizeArg = (arg: unknown): unknown =>
typeof arg === "bigint" ? `${arg}n` : arg
const sanitizedArgs = computed(() => props.entry.args.map(sanitizeArg))
// Serialize value for clipboard copy
const serializeConsoleValue = (value: unknown): string => {
if (typeof value === "string") return value
if (typeof value === "number" || typeof value === "boolean")
return String(value)
if (value === null) return "null"
if (value === undefined) return "undefined"
try {
const serialized = JSON.stringify(
value,
(_key, val) => (typeof val === "bigint" ? `${val}n` : val),
2
)
return serialized !== undefined ? serialized : "[Unserializable]"
} catch {
return "[Unserializable]"
}
}
const copyEntry = () => {
const content = sanitizedArgs.value.map(serializeConsoleValue).join(" ")
copyToClipboard(content)
copyIcon.value = IconCheck
toast.success(t("state.copied_to_clipboard"))
}
</script>
<style scoped>
.console-entry-wrapper {
@apply flex items-start px-4 py-2 text-tiny text-secondaryDark rounded-md transition-colors;
}
</style>

View file

@ -1,5 +1,5 @@
<template>
<div class="whitespace-pre-wrap font-mono text-[12px]">
<div class="whitespace-pre-wrap font-mono text-[12px] select-text">
<VueJsonPretty
v-if="isObjectOrArray"
:data="parsedValue"
@ -7,16 +7,16 @@
:show-line="false"
:show-line-numbers="true"
:deep="2"
class="p-4 bg-primary text-secondaryDark border !border-dividerLight !text-[12px] rounded !font-mono"
class="p-4 bg-primary text-secondaryDark border !border-dividerLight !text-[12px] rounded !font-mono select-text"
/>
<pre
v-else-if="parsedJSON"
class="overflow-auto max-h-96 p-4 bg-primary text-secondaryDark border !border-dividerLight rounded"
class="overflow-auto max-h-96 p-4 bg-primary text-secondaryDark border !border-dividerLight rounded select-text"
>{{ formattedJSONString }}
</pre>
<pre v-else class="truncate"
<pre v-else class="truncate select-text"
>{{ formattedPrimitive }}
</pre>
</div>
@ -112,3 +112,29 @@ const treeViewTheme = computed(() => (isDarkTheme.value ? "dark" : "light"))
color: var(--secondary-dark-color) !important;
}
</style>
<style scoped>
/* Force scrollbars to always be visible (override macOS auto-hide) */
pre {
scrollbar-width: thin; /* Firefox */
scrollbar-color: var(--divider-color) transparent; /* Firefox: thumb track */
}
pre::-webkit-scrollbar {
width: 8px;
height: 8px;
}
pre::-webkit-scrollbar-track {
background: transparent;
}
pre::-webkit-scrollbar-thumb {
background: var(--divider-color);
border-radius: 4px;
}
pre::-webkit-scrollbar-thumb:hover {
background: var(--secondary-light-color);
}
</style>