Compare commits

...
Author SHA1 Message Date
François-X. T. 8697b58a43 Add refresh button in files tab behind feature flag 2026-02-25 21:01:05 -05:00
45387e5fb6 Fix fields for pix payout (#5428)
Co-authored-by: Creeperkatze <178587183+Creeperkatze@users.noreply.github.com>
2026-02-25 21:30:31 +00:00
e362de45fb Fix library routerview race condition (#5412)
Co-authored-by: Creeperkatze <178587183+Creeperkatze@users.noreply.github.com>
2026-02-24 16:52:50 +00:00
Truman GaoandGitHub bacc10d2f5 feat: better auth error handling (#5403)
* add log

* add log

* Revert "add log"

This reverts commit 2412a3de5f.

* add new minecraft auth error modal

* add other auth errors

* polish the styles

* update link text

* add unknown error state

* pnpm prepr

* fix link

* fix lint
2026-02-21 01:39:27 +00:00
aecsocketandGitHub 5b49af1fe8 Fix creating projects from mod install flow (#5402)
* Explicitly state if a mod is incompatible when installing

* wip: debug create instance modal

* Fix mod install createInstance
2026-02-21 00:22:06 +00:00
f052ecd702 Batch of tech review backend fixes (#5398)
* Don't enter project into tech review if no new traces

* Send tech review exited message if files are deleted

* change PATCH /issue-detail/{id} to batch update details

* Fix sorting

* store delphi jar in backend

* show jar in tech review card

* improve jar display in frontend

* Fix live/in review label for tech review cards

* sqlx prepare

* polish: decode segments + code qual fix

* fix: skip first seg

* fix: only slice if needed

* Fix tech rev card styling

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-02-20 12:54:36 +00:00
33 changed files with 1085 additions and 333 deletions
+4
View File
@@ -65,6 +65,7 @@ import IncompatibilityWarningModal from '@/components/ui/install_flow/Incompatib
import InstallConfirmModal from '@/components/ui/install_flow/InstallConfirmModal.vue'
import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
import NavButton from '@/components/ui/NavButton.vue'
@@ -388,6 +389,7 @@ loading.setEnabled(false)
const error = useError()
const errorModal = ref()
const minecraftAuthErrorModal = ref()
const install = useInstall()
const modInstallModal = ref()
@@ -466,6 +468,7 @@ onMounted(() => {
invoke('show_window')
error.setErrorModal(errorModal.value)
error.setMinecraftAuthErrorModal(minecraftAuthErrorModal.value)
install.setIncompatibilityWarningModal(incompatibilityWarningModal)
install.setInstallConfirmModal(installConfirmModal)
@@ -1131,6 +1134,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<I18nDebugPanel />
<NotificationPanel has-sidebar />
<ErrorModal ref="errorModal" />
<MinecraftAuthErrorModal ref="minecraftAuthErrorModal" />
<ModInstallModal ref="modInstallModal" />
<IncompatibilityWarningModal ref="incompatibilityWarningModal" />
<InstallConfirmModal ref="installConfirmModal" />
@@ -33,6 +33,7 @@ const metadata = ref({})
defineExpose({
async show(errorVal, context, canClose = true, source = null) {
console.log(errorVal, context, canClose, source)
closable.value = canClose
if (errorVal.message && errorVal.message.includes('Minecraft authentication error:')) {
@@ -49,19 +49,14 @@ const creatingInstance = ref(false)
const profiles = ref([])
const shownProfiles = computed(() =>
profiles.value
.filter((profile) => {
return profile.name.toLowerCase().includes(searchFilter.value.toLowerCase())
})
.filter((profile) => {
const version = {
game_versions: versions.value.flatMap((v) => v.game_versions),
loaders: versions.value.flatMap((v) => v.loaders),
}
return isVersionCompatible(version, project.value, profile)
}),
profiles.value.filter((profile) => {
return profile.name.toLowerCase().includes(searchFilter.value.toLowerCase())
}),
)
const isProfileCompatible = (profile) =>
versions.value?.some((version) => isVersionCompatible(version, project.value, profile))
const onInstall = ref(() => {})
defineExpose({
@@ -164,13 +159,13 @@ const createInstance = async () => {
const gameVersion = gameVersions[0]
const loaders = versions.value[0].loaders
const loader = loaders.contains('fabric')
const loader = loaders.includes('fabric')
? 'fabric'
: loaders.contains('neoforge')
: loaders.includes('neoforge')
? 'neoforge'
: loaders.contains('forge')
: loaders.includes('forge')
? 'forge'
: loaders.contains('quilt')
: loaders.includes('quilt')
? 'quilt'
: 'vanilla'
@@ -240,17 +235,23 @@ const createInstance = async () => {
"
>
<Button
:disabled="profile.installedMod || profile.installing"
:disabled="
!isProfileCompatible(profile) || profile.installedMod || profile.installing
"
@click="install(profile)"
>
<DownloadIcon v-if="!profile.installedMod && !profile.installing" />
<DownloadIcon
v-if="isProfileCompatible(profile) && !profile.installedMod && !profile.installing"
/>
<CheckIcon v-else-if="profile.installedMod" />
{{
profile.installing
? 'Installing...'
: profile.installedMod
? 'Installed'
: 'Install'
: !isProfileCompatible(profile)
? 'Incompatible'
: 'Install'
}}
</Button>
</div>
@@ -0,0 +1,184 @@
<script setup lang="ts">
import {
CheckIcon,
CopyIcon,
DropdownIcon,
LogInIcon,
MessagesSquareIcon,
WrenchIcon,
} from '@modrinth/assets'
import { Admonition, ButtonStyled, Collapsible, NewModal } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { handleSevereError } from '@/store/error.js'
import { type MinecraftAuthError, minecraftAuthErrors } from './minecraft-auth-errors'
const modal = ref<InstanceType<typeof NewModal>>()
const rawError = ref<string>('')
const matchedError = ref<MinecraftAuthError | null>(null)
const debugCollapsed = ref(true)
const copied = ref(false)
const loadingSignIn = ref(false)
function show(errorVal: { message?: string }) {
rawError.value = errorVal?.message ?? String(errorVal)
matchedError.value = minecraftAuthErrors.find((e) => rawError.value.includes(e.errorCode)) ?? null
debugCollapsed.value = true
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
defineExpose({
show,
hide,
})
async function signInAgain() {
try {
loadingSignIn.value = true
const loggedIn = await login_flow()
if (loggedIn) {
await set_default_user(loggedIn.profile.id)
}
loadingSignIn.value = false
modal.value?.hide()
} catch (err) {
loadingSignIn.value = false
handleSevereError(err)
}
}
const debugInfo = computed(() => rawError.value || 'No error message.')
async function copyToClipboard(text: string) {
await navigator.clipboard.writeText(text)
copied.value = true
setTimeout(() => {
copied.value = false
}, 3000)
}
</script>
<template>
<NewModal ref="modal" header="Sign in Failed" :max-width="'548px'">
<div class="flex flex-col gap-6">
<Admonition
type="warning"
body=" We couldn't sign you into your Microsoft account. This may be due to account restrictions or
regional limitations."
>
</Admonition>
<!-- Matched error details -->
<div class="bg-surface-2 rounded-2xl p-4 px-5 flex flex-col gap-3">
<template v-if="matchedError">
<div class="flex flex-col gap-1.5">
<h3 class="text-base font-bold m-0">What we think happened</h3>
<p class="text-sm text-secondary m-0">
{{ matchedError.whatHappened }}
</p>
</div>
<div class="flex flex-col gap-1.5">
<h3 class="text-base font-bold m-0">How to fix it</h3>
<ol class="list-none flex flex-col gap-2 m-0 pl-0">
<li
v-for="(step, index) in matchedError.stepsToFix"
:key="index"
class="flex items-baseline gap-2"
>
<span
class="inline-flex items-center justify-center shrink-0 w-5 h-5 rounded-full bg-surface-4 border border-solid border-surface-5 text-xs font-medium"
>
{{ index + 1 }}
</span>
<!-- eslint-disable-next-line vue/no-v-html -->
<span
class="text-sm [&_a]:text-info [&_a]:font-medium [&_a]:underline"
v-html="step"
/>
</li>
</ol>
</div>
</template>
<template v-else>
<div class="flex flex-col gap-1.5">
<h3 class="text-base font-bold m-0">Unknown error</h3>
<p class="text-sm text-secondary m-0">
We dont recognize this error and cant recommend specific steps to resolve it.
</p>
<p class="text-sm text-secondary m-0">
Try visiting
<a
class="text-info font-medium underline hover:underline"
href="https://www.minecraft.net/en-us/login"
>Minecraft Login</a
>
and signing in, as it may prompt you with the necessary steps. You can also contact
support and we can look into it further.
</p>
</div>
</template>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2">
<ButtonStyled>
<a href="https://support.modrinth.com" class="!w-full" @click="modal?.hide()">
<MessagesSquareIcon /> Contact support
</a>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="loadingSignIn" class="!w-full" @click="signInAgain">
<LogInIcon /> Sign in again
</button>
</ButtonStyled>
</div>
<div class="flex flex-col gap-2">
<div class="w-full h-[1px] bg-surface-5"></div>
<!-- Debug info -->
<div class="overflow-clip">
<button
class="flex items-center justify-between w-full bg-transparent border-0 py-4 cursor-pointer"
@click="debugCollapsed = !debugCollapsed"
>
<span class="flex items-center gap-2 text-contrast font-extrabold m-0">
<WrenchIcon class="h-4 w-4" />
Debug information
</span>
<DropdownIcon
class="h-5 w-5 text-secondary transition-transform"
:class="{ 'rotate-180': !debugCollapsed }"
/>
</button>
<Collapsible :collapsed="debugCollapsed">
<div class="p-3 bg-surface-2 rounded-2xl text-xs flex items-start">
<div class="m-0 p-0 rounded-none bg-transparent text-sm font-mono">
{{ debugInfo }}
</div>
<ButtonStyled circular>
<button
v-tooltip="'Copy debug info'"
:disabled="copied"
@click="copyToClipboard(debugInfo)"
>
<template v-if="copied"> <CheckIcon class="text-green" /> </template>
<template v-else> <CopyIcon /> </template>
</button>
</ButtonStyled>
</div>
</Collapsible>
</div>
</div>
</div>
</NewModal>
</template>
@@ -0,0 +1,90 @@
export interface MinecraftAuthError {
errorCode: string
whatHappened: string
stepsToFix: string[]
}
export const minecraftAuthErrors: MinecraftAuthError[] = [
{
errorCode: '2148916222',
whatHappened:
'Your Minecraft/Xbox Live account requires age verification to comply with UK regulations. You must complete this before signing in.',
stepsToFix: [
'Go to the <a href="https://www.minecraft.net/en-us/login">Minecraft Login</a> page and sign in',
'Follow the instructions to verify your age',
'Once verified, try signing in again',
'For additional help, visit <a href="https://support.xbox.com/en-GB/help/family-online-safety/online-safety/UK-age-verification">UK age verification on Xbox</a>',
],
},
{
errorCode: '2148916233',
whatHappened: "This account doesn't have an Xbox profile set up or doesn't own Minecraft.",
stepsToFix: [
'Make sure Minecraft is purchased on this account',
'Visit <a href="https://www.minecraft.net/en-us/login">Minecraft Login</a> and sign in',
'Complete Xbox profile setup if prompted',
'Once finished, try signing in again',
],
},
{
errorCode: '2148916235',
whatHappened: "Xbox Live isn't available in your region, so sign-in is blocked.",
stepsToFix: [
'Xbox services must be supported in your country before you can sign in',
'Check <a href="https://www.xbox.com/en-US/regions">Xbox Availability</a> for supported regions',
],
},
{
errorCode: '2148916236',
whatHappened: 'This account requires adult verification under South Korean regulations.',
stepsToFix: [
'Visit <a href="https://www.xbox.com">Xbox</a> and sign in',
'Complete the identity verification process',
'Once finished, try signing in again',
],
},
{
errorCode: '2148916237',
whatHappened: 'This account requires adult verification under South Korean regulations.',
stepsToFix: [
'Visit <a href="https://www.xbox.com">Xbox</a> and sign in',
'Complete the identity verification process',
'Once finished, try signing in again',
],
},
{
errorCode: '2148916238',
whatHappened: 'This account is underage and not linked to a Microsoft family group.',
stepsToFix: [
'Review the <a href="https://help.minecraft.net/hc/en-us/articles/4408968616077">Family Setup Guide</a>',
'Join or create a family group as instructed',
'Once finished, try signing in again',
],
},
{
errorCode: '2148916227',
whatHappened: 'This account was suspended for violating Xbox Community Standards.',
stepsToFix: [
'Visit <a href="https://support.xbox.com">Xbox Support</a> and review the enforcement details',
'Submit an appeal if one is available',
],
},
{
errorCode: '2148916229',
whatHappened: "This account is restricted and doesn't have permission to play online.",
stepsToFix: [
'Have a guardian sign in to <a href="https://account.microsoft.com/family/">Microsoft Family</a>',
'Update online play permissions',
'Once finished, try signing in again',
],
},
{
errorCode: '2148916234',
whatHappened: "This account hasn't accepted Xbox's Terms of Service.",
stepsToFix: [
'Visit <a href="https://www.xbox.com">Xbox</a> and sign in',
'Accept the Terms if prompted',
'Once finished, try signing in again',
],
},
]
@@ -48,7 +48,7 @@ onUnmounted(() => {
]"
/>
<template v-if="instances.length > 0">
<RouterView :instances="instances" />
<RouterView v-if="route.path.startsWith('/library')" :instances="instances" />
</template>
<div v-else class="no-instance">
<div class="icon">
+12
View File
@@ -3,12 +3,24 @@ import { defineStore } from 'pinia'
export const useError = defineStore('errorsStore', {
state: () => ({
errorModal: null,
minecraftAuthErrorModal: null,
}),
actions: {
setErrorModal(ref) {
this.errorModal = ref
},
setMinecraftAuthErrorModal(ref) {
this.minecraftAuthErrorModal = ref
},
showError(error, context, closable = true, source = null) {
if (
error.message &&
error.message.includes('Minecraft authentication error:') &&
this.minecraftAuthErrorModal
) {
this.minecraftAuthErrorModal.show(error)
return
}
this.errorModal.show(error, context, closable, source)
},
},
@@ -100,7 +100,7 @@
/>
</div>
<div v-for="field in selectedRail?.fields" :key="field.name" class="flex flex-col gap-2.5">
<div v-for="field in visibleFields" :key="field.name" class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ formatMessage(field.label) }}
@@ -398,6 +398,26 @@ const isBusinessEntity = computed(() => {
return providerDataValue.kycData?.type === 'business'
})
const visibleFields = computed(() => {
const rail = selectedRail.value
if (!rail) return []
return rail.fields.filter((field) => {
if (!field.dependsOn) return true
const { field: dependsOnField, value: dependsOnValue } = field.dependsOn
const currentValue = formData.value[dependsOnField]
if (!currentValue) return false
if (Array.isArray(dependsOnValue)) {
return dependsOnValue.includes(currentValue)
} else {
return currentValue === dependsOnValue
}
})
})
const allRequiredFieldsFilled = computed(() => {
const rail = selectedRail.value
if (!rail) return false
@@ -407,7 +427,7 @@ const allRequiredFieldsFilled = computed(() => {
if (rail.requiresBankName && !formData.value.bankName) return false
const requiredFields = rail.fields.filter((f) => f.required)
const requiredFields = visibleFields.value.filter((f) => f.required)
const allRequiredPresent = requiredFields.every((f) => {
const value = formData.value[f.name]
return value !== undefined && value !== null && value !== ''
@@ -5,6 +5,7 @@ import {
CheckCircleIcon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
ClipboardCopyIcon,
CodeIcon,
CopyIcon,
@@ -159,6 +160,15 @@ watch(selectedFile, (newFile) => {
const client = injectModrinthClient()
async function updateIssueDetails(data: { detail_id: string; verdict: 'safe' | 'unsafe' }[]) {
await client.request('/moderation/tech-review/issue-detail', {
api: 'labrinth',
version: 'internal',
method: 'PATCH',
body: data,
})
}
const severityOrder = { severe: 3, high: 2, medium: 1, low: 0 } as Record<string, number>
const detailDecisions = reactive<Map<string, 'safe' | 'malware'>>(new Map())
@@ -393,11 +403,7 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
isBatchUpdating.value = true
try {
await Promise.all(
detailIds.map((detailId) =>
client.labrinth.tech_review_internal.updateIssueDetail(detailId, { verdict }),
),
)
await updateIssueDetails(detailIds.map((detailId) => ({ detail_id: detailId, verdict })))
const decision = verdict === 'safe' ? 'safe' : 'malware'
for (const detailId of detailIds) {
@@ -445,7 +451,7 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
updatingDetails.add(detailId)
try {
await client.labrinth.tech_review_internal.updateIssueDetail(detailId, { verdict })
await updateIssueDetails([{ detail_id: detailId, verdict }])
const decision = verdict === 'safe' ? 'safe' : 'malware'
@@ -484,7 +490,7 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
for (const classGroup of groupedByClass.value) {
const hasThisDetail = classGroup.flags.some((f) => f.detail.id === detailId)
if (hasThisDetail && getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
expandedClasses.delete(classGroup.filePath)
expandedClasses.delete(classGroup.key)
break
}
}
@@ -533,6 +539,8 @@ const expandedClasses = reactive<Set<string>>(new Set())
const showCopyFeedback = reactive<Map<string, boolean>>(new Map())
interface ClassGroup {
key: string
jar: string | null
filePath: string
flags: Array<{
issueId: string
@@ -543,6 +551,26 @@ interface ClassGroup {
}>
}
interface JarGroup {
key: string
jar: string | null
segments: string[]
classes: ClassGroup[]
}
function splitJarSegments(jar: string | null, currentFileName: string | null): string[] {
if (!jar) return []
const segments = jar
.split('#')
.map((s) => decodeURIComponent(s.trim()))
.filter((s) => s.length > 0)
// Skip the first segment if it matches the current file tab (it's already shown in the file list)
if (segments.length > 0 && currentFileName && segments[0] === currentFileName) {
return segments.slice(1)
}
return segments
}
const groupedByClass = computed<ClassGroup[]>(() => {
if (!selectedFile.value) return []
@@ -550,14 +578,20 @@ const groupedByClass = computed<ClassGroup[]>(() => {
for (const issue of selectedFile.value.issues) {
for (const detail of issue.details) {
if (!classMap.has(detail.file_path)) {
classMap.set(detail.file_path, { filePath: detail.file_path, flags: [] })
const classKey = `${detail.jar ?? ''}::${detail.file_path}`
if (!classMap.has(classKey)) {
classMap.set(classKey, {
key: classKey,
jar: detail.jar ?? null,
filePath: detail.file_path,
flags: [],
})
}
// Cast detail to include status (backend will provide this field)
const detailWithStatus = detail as Labrinth.TechReview.Internal.ReportIssueDetail & {
status: Labrinth.TechReview.Internal.DelphiReportIssueStatus
}
classMap.get(detail.file_path)!.flags.push({
classMap.get(classKey)!.flags.push({
issueId: issue.id,
issueType: issue.issue_type,
detail: detailWithStatus,
@@ -585,12 +619,35 @@ const groupedByClass = computed<ClassGroup[]>(() => {
})
})
const groupedByJar = computed<JarGroup[]>(() => {
const jarMap = new Map<string, JarGroup>()
for (const classItem of groupedByClass.value) {
const jarKey = classItem.jar ?? ''
if (!jarMap.has(jarKey)) {
jarMap.set(jarKey, {
key: jarKey,
jar: classItem.jar,
segments: splitJarSegments(classItem.jar, selectedFile.value?.file_name ?? null),
classes: [],
})
}
jarMap.get(jarKey)!.classes.push(classItem)
}
return Array.from(jarMap.values()).sort((a, b) => {
const aSeverity = getHighestSeverityInClass(a.classes.flatMap((classItem) => classItem.flags))
const bSeverity = getHighestSeverityInClass(b.classes.flatMap((classItem) => classItem.flags))
return (severityOrder[bSeverity] ?? 0) - (severityOrder[aSeverity] ?? 0)
})
})
// Auto-expand if there's only one class in the file
watch(
groupedByClass,
(classes) => {
if (classes.length === 1) {
expandedClasses.add(classes[0].filePath)
expandedClasses.add(classes[0].key)
}
},
{ immediate: true },
@@ -608,11 +665,11 @@ function getHighestSeverityInClass(
)
}
function toggleClass(filePath: string) {
if (expandedClasses.has(filePath)) {
expandedClasses.delete(filePath)
function toggleClass(classKey: string) {
if (expandedClasses.has(classKey)) {
expandedClasses.delete(classKey)
} else {
expandedClasses.add(filePath)
expandedClasses.add(classKey)
}
}
@@ -1097,204 +1154,234 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
</ButtonStyled>
</div>
<div
v-for="(classItem, idx) in groupedByClass"
:key="classItem.filePath"
class="border-x border-b border-t-0 border-solid border-surface-3 bg-surface-2"
:class="{
'rounded-bl-2xl rounded-br-2xl':
idx === groupedByClass.length - 1 && remainingUnmarkedCount === 0,
}"
v-for="jarGroup in groupedByJar"
:key="jarGroup.key"
class="border-x border-b-0 border-t-0 border-solid border-surface-3 bg-surface-2"
>
<div
class="flex cursor-pointer items-center justify-between p-4 transition-colors duration-200 hover:bg-surface-4"
@click="toggleClass(classItem.filePath)"
v-if="jarGroup.segments.length > 0"
class="border-b border-solid border-surface-1 px-4 py-3"
>
<div class="my-auto flex items-center gap-2">
<ButtonStyled type="transparent" circular>
<button
class="transition-transform"
:class="{ 'rotate-180': expandedClasses.has(classItem.filePath) }"
<div class="flex flex-wrap items-center gap-1">
<template
v-for="(segment, index) in jarGroup.segments"
:key="`${jarGroup.key}-${index}`"
>
<span
class="font-mono text-sm"
:class="
index === jarGroup.segments.length - 1
? 'font-semibold text-contrast'
: 'text-secondary'
"
>
<ChevronDownIcon class="h-5 w-5 text-contrast" />
</button>
</ButtonStyled>
<span v-tooltip="classItem.filePath" class="font-mono font-semibold">{{
truncateMiddle(classItem.filePath)
}}</span>
<div
class="rounded-full border-solid px-2.5 py-1"
:class="getSeverityBadgeColor(getHighestSeverityInClass(classItem.flags))"
>
<span class="text-sm font-medium">{{
capitalizeString(getHighestSeverityInClass(classItem.flags))
}}</span>
</div>
<div
class="flex items-center gap-1 rounded-full border border-solid px-2.5 py-1 text-sm"
:class="
getMarkedFlagsCount(classItem.flags) === classItem.flags.length
? 'border-green/60 bg-highlight-green text-green'
: 'border-red/60 bg-highlight-red text-red'
"
>
<CheckIcon
v-if="getMarkedFlagsCount(classItem.flags) === classItem.flags.length"
class="size-4"
{{ segment }}
</span>
<ChevronRightIcon
v-if="index < jarGroup.segments.length - 1"
class="size-4 text-secondary"
/>
{{ getMarkedFlagsCount(classItem.flags) }}/{{ classItem.flags.length }} flags
</div>
<Transition name="fade">
<div
v-if="classItem.flags.some((f) => loadingIssues.has(f.issueId))"
class="rounded-full border border-solid border-surface-5 bg-surface-3 px-2.5 py-1"
>
<span class="flex items-center gap-1.5 text-sm font-medium text-secondary">
<LoaderCircleIcon class="size-4 animate-spin" />
Loading source...
</span>
</div>
</Transition>
</template>
</div>
</div>
<Collapsible :collapsed="!expandedClasses.has(classItem.filePath)">
<div class="mt-2 flex flex-col gap-2 px-4 pb-4">
<div
v-for="flag in classItem.flags"
:key="`${flag.issueId}-${flag.detail.id}`"
class="flex flex-col gap-2 rounded-lg border-[1px] border-b border-solid border-surface-5 bg-surface-3 py-2 pl-4 last:border-b-0"
>
<div class="grid grid-cols-[1fr_auto] items-center">
<div
class="flex items-center gap-2"
:class="{
'opacity-50': isPreReviewed(flag.detail.id, flag.detail.status),
}"
>
<span class="text-base font-semibold text-contrast">{{
flag.issueType.replace(/_/g, ' ')
}}</span>
<div
class="rounded-full border-solid px-2.5 py-1"
:class="getSeverityBadgeColor(flag.detail.severity)"
>
<span class="text-sm font-medium">{{
capitalizeString(flag.detail.severity)
}}</span>
</div>
</div>
<div class="flex w-40 items-center justify-center gap-2">
<ButtonStyled
color="brand"
:type="
getDetailDecision(flag.detail.id, flag.detail.status) === 'safe'
? undefined
: 'outlined'
"
>
<button
class="!border-[1px]"
:disabled="updatingDetails.has(flag.detail.id)"
@click="updateDetailStatus(flag.detail.id, 'safe')"
>
Pass
</button>
</ButtonStyled>
<ButtonStyled
color="red"
:type="
getDetailDecision(flag.detail.id, flag.detail.status) === 'malware'
? undefined
: 'outlined'
"
>
<button
class="!border-[1px]"
:disabled="updatingDetails.has(flag.detail.id)"
@click="updateDetailStatus(flag.detail.id, 'unsafe')"
>
Fail
</button>
</ButtonStyled>
</div>
</div>
<div
v-if="flag.detail.data && Object.keys(flag.detail.data).length > 0"
class="flex flex-wrap gap-x-4 gap-y-1 pr-4 text-sm"
>
<div
v-for="[key, value] in Object.entries(flag.detail.data).sort(([a], [b]) =>
a.localeCompare(b),
)"
:key="key"
class="flex items-center gap-1.5"
>
<span class="text-secondary">{{ key }}:</span>
<a
v-if="typeof value === 'string' && value.startsWith('http')"
:href="value"
target="_blank"
rel="noopener noreferrer"
class="text-brand-blue hover:underline"
>
{{ value }}
</a>
<span v-else class="font-mono text-contrast">{{ value }}</span>
</div>
</div>
</div>
<div
v-if="getClassDecompiledSource(classItem)"
class="relative inset-0 overflow-hidden rounded-lg border border-solid border-surface-5 bg-surface-4"
>
<ButtonStyled circular type="transparent">
<div
v-for="classItem in jarGroup.classes"
:key="classItem.key"
class="border-b border-solid border-surface-1 last:border-b-0"
>
<div
class="flex cursor-pointer items-center justify-between p-4 transition-colors duration-200 hover:bg-surface-4"
@click="toggleClass(classItem.key)"
>
<div class="my-auto flex items-center gap-2">
<ButtonStyled type="transparent" circular>
<button
v-tooltip="`Copy code`"
class="absolute right-2 top-2 border-[1px]"
@click="
copyToClipboard(getClassDecompiledSource(classItem)!, classItem.filePath)
"
class="transition-transform"
:class="{ 'rotate-180': expandedClasses.has(classItem.key) }"
>
<CopyIcon v-if="!showCopyFeedback.get(classItem.filePath)" />
<CheckIcon v-else />
<ChevronDownIcon class="h-5 w-5 text-contrast" />
</button>
</ButtonStyled>
<div class="overflow-x-auto bg-surface-3 py-3">
<span v-tooltip="classItem.filePath" class="font-mono font-semibold">{{
truncateMiddle(classItem.filePath)
}}</span>
<div
class="rounded-full border-solid px-2.5 py-1"
:class="getSeverityBadgeColor(getHighestSeverityInClass(classItem.flags))"
>
<span class="text-sm font-medium">{{
capitalizeString(getHighestSeverityInClass(classItem.flags))
}}</span>
</div>
<div
class="flex items-center gap-1 rounded-full border border-solid px-2.5 py-1 text-sm"
:class="
getMarkedFlagsCount(classItem.flags) === classItem.flags.length
? 'border-green/60 bg-highlight-green text-green'
: 'border-red/60 bg-highlight-red text-red'
"
>
<CheckIcon
v-if="getMarkedFlagsCount(classItem.flags) === classItem.flags.length"
class="size-4"
/>
{{ getMarkedFlagsCount(classItem.flags) }}/{{ classItem.flags.length }} flags
</div>
<Transition name="fade">
<div
v-for="(line, n) in highlightCodeLines(
getClassDecompiledSource(classItem)!,
'java',
)"
:key="n"
class="flex font-mono text-[13px] leading-[1.6]"
v-if="classItem.flags.some((f) => loadingIssues.has(f.issueId))"
class="rounded-full border border-solid border-surface-5 bg-surface-3 px-2.5 py-1"
>
<span class="flex items-center gap-1.5 text-sm font-medium text-secondary">
<LoaderCircleIcon class="size-4 animate-spin" />
Loading source...
</span>
</div>
</Transition>
</div>
</div>
<Collapsible :collapsed="!expandedClasses.has(classItem.key)">
<div class="mt-2 flex flex-col gap-2 px-4 pb-4">
<div
v-for="flag in classItem.flags"
:key="`${flag.issueId}-${flag.detail.id}`"
class="flex flex-col gap-2 rounded-lg border-[1px] border-b border-solid border-surface-5 bg-surface-3 py-2 pl-4 last:border-b-0"
>
<div class="grid grid-cols-[1fr_auto] items-center">
<div
class="flex items-center gap-2"
:class="{
'opacity-50': isPreReviewed(flag.detail.id, flag.detail.status),
}"
>
<span class="text-base font-semibold text-contrast">{{
flag.issueType.replace(/_/g, ' ')
}}</span>
<div
class="rounded-full border-solid px-2.5 py-1"
:class="getSeverityBadgeColor(flag.detail.severity)"
>
<span class="text-sm font-medium">{{
capitalizeString(flag.detail.severity)
}}</span>
</div>
</div>
<div class="flex w-40 items-center justify-center gap-2">
<ButtonStyled
color="brand"
:type="
getDetailDecision(flag.detail.id, flag.detail.status) === 'safe'
? undefined
: 'outlined'
"
>
<button
class="!border-[1px]"
:disabled="updatingDetails.has(flag.detail.id)"
@click="updateDetailStatus(flag.detail.id, 'safe')"
>
Pass
</button>
</ButtonStyled>
<ButtonStyled
color="red"
:type="
getDetailDecision(flag.detail.id, flag.detail.status) === 'malware'
? undefined
: 'outlined'
"
>
<button
class="!border-[1px]"
:disabled="updatingDetails.has(flag.detail.id)"
@click="updateDetailStatus(flag.detail.id, 'unsafe')"
>
Fail
</button>
</ButtonStyled>
</div>
</div>
<div
v-if="flag.detail.data && Object.keys(flag.detail.data).length > 0"
class="flex flex-wrap gap-x-4 gap-y-1 pr-4 text-sm"
>
<div
class="select-none border-0 border-r border-solid border-surface-5 px-4 py-0 text-right text-primary"
style="min-width: 3.5rem"
v-for="[key, value] in Object.entries(flag.detail.data).sort(([a], [b]) =>
a.localeCompare(b),
)"
:key="key"
class="flex items-center gap-1.5"
>
{{ n + 1 }}
</div>
<div class="flex-1 px-4 py-0 text-primary">
<pre v-html="line || ' '"></pre>
<span class="text-secondary">{{ key }}:</span>
<a
v-if="typeof value === 'string' && value.startsWith('http')"
:href="value"
target="_blank"
rel="noopener noreferrer"
class="text-brand-blue hover:underline"
>
{{ value }}
</a>
<span v-else class="font-mono text-contrast">{{ value }}</span>
</div>
</div>
</div>
<div
v-if="getClassDecompiledSource(classItem)"
class="relative inset-0 overflow-hidden rounded-lg border border-solid border-surface-5 bg-surface-4"
>
<ButtonStyled circular type="transparent">
<button
v-tooltip="`Copy code`"
class="absolute right-2 top-2 border-[1px]"
@click="copyToClipboard(getClassDecompiledSource(classItem)!, classItem.key)"
>
<CopyIcon v-if="!showCopyFeedback.get(classItem.key)" />
<CheckIcon v-else />
</button>
</ButtonStyled>
<div class="overflow-x-auto bg-surface-3 py-3">
<div
v-for="(line, n) in highlightCodeLines(
getClassDecompiledSource(classItem)!,
'java',
)"
:key="n"
class="flex font-mono text-[13px] leading-[1.6]"
>
<div
class="select-none border-0 border-r border-solid border-surface-5 px-4 py-0 text-right text-primary"
style="min-width: 3.5rem"
>
{{ n + 1 }}
</div>
<div class="flex-1 px-4 py-0 text-primary">
<pre v-html="line || ' '"></pre>
</div>
</div>
</div>
</div>
<div
v-else
class="rounded-lg border border-solid border-surface-5 bg-surface-3 p-4"
>
<p class="text-sm text-secondary">
Source code not available or failed to decompile for this file.
</p>
</div>
</div>
<div v-else class="rounded-lg border border-solid border-surface-5 bg-surface-3 p-4">
<p class="text-sm text-secondary">
Source code not available or failed to decompile for this file.
</p>
</div>
</div>
</Collapsible>
</Collapsible>
</div>
</div>
</template>
</div>
@@ -32,6 +32,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
projectBackground: false,
searchBackground: false,
advancedDebugInfo: false,
FilesRefreshButton: false,
showProjectPageDownloadModalServersPromo: false,
showProjectPageCreateServersTooltip: true,
showProjectPageQuickServerButton: false,
@@ -10,5 +10,8 @@ useHead({
</script>
<template>
<ServersManageFilesPage :show-debug-info="flags.advancedDebugInfo" />
<ServersManageFilesPage
:show-debug-info="flags.advancedDebugInfo"
:show-refresh-button="flags.FilesRefreshButton"
/>
</template>
+20
View File
@@ -13,6 +13,10 @@ export interface FieldConfig {
pattern?: string
validate?: (value: string) => string | null
autocomplete?: string
dependsOn?: {
field: string
value?: string | string[]
}
}
export interface RailConfig {
@@ -330,6 +334,10 @@ export const MURALPAY_RAILS: Record<string, RailConfig> = {
defaultMessage: 'Enter PIX email',
}),
autocomplete: 'email',
dependsOn: {
field: 'pixAccountType',
value: 'EMAIL',
},
},
{
name: 'pixPhone',
@@ -341,6 +349,10 @@ export const MURALPAY_RAILS: Record<string, RailConfig> = {
defaultMessage: '+55...',
}),
autocomplete: 'tel',
dependsOn: {
field: 'pixAccountType',
value: 'PHONE',
},
},
{
name: 'branchCode',
@@ -352,6 +364,10 @@ export const MURALPAY_RAILS: Record<string, RailConfig> = {
defaultMessage: 'Enter branch code',
}),
autocomplete: 'off',
dependsOn: {
field: 'pixAccountType',
value: 'BANK_ACCOUNT',
},
},
{
name: 'documentNumber',
@@ -367,6 +383,10 @@ export const MURALPAY_RAILS: Record<string, RailConfig> = {
defaultMessage: 'Brazilian tax identification number',
}),
autocomplete: 'off',
dependsOn: {
field: 'pixAccountType',
value: 'DOCUMENT',
},
},
],
},
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n drid.id AS \"id!: DelphiReportIssueDetailsId\",\n drid.issue_id AS \"issue_id!: DelphiReportIssueId\",\n drid.key AS \"key!: String\",\n drid.file_path AS \"file_path!: String\",\n drid.data AS \"data!: sqlx::types::Json<HashMap<String, serde_json::Value>>\",\n drid.severity AS \"severity!: DelphiSeverity\",\n COALESCE(didv.verdict, 'pending'::delphi_report_issue_status) AS \"status!: DelphiStatus\"\n FROM delphi_report_issue_details drid\n INNER JOIN delphi_report_issues dri ON dri.id = drid.issue_id\n INNER JOIN delphi_reports dr ON dr.id = dri.report_id\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n INNER JOIN mods m ON m.id = v.mod_id\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON m.id = didv.project_id AND drid.key = didv.detail_key\n WHERE drid.issue_id = ANY($1::bigint[])\n ",
"query": "\n SELECT\n drid.id AS \"id!: DelphiReportIssueDetailsId\",\n drid.issue_id AS \"issue_id!: DelphiReportIssueId\",\n drid.key AS \"key!: String\",\n drid.jar AS \"jar?: String\",\n drid.file_path AS \"file_path!: String\",\n drid.data AS \"data!: sqlx::types::Json<HashMap<String, serde_json::Value>>\",\n drid.severity AS \"severity!: DelphiSeverity\",\n COALESCE(didv.verdict, 'pending'::delphi_report_issue_status) AS \"status!: DelphiStatus\"\n FROM delphi_report_issue_details drid\n INNER JOIN delphi_report_issues dri ON dri.id = drid.issue_id\n INNER JOIN delphi_reports dr ON dr.id = dri.report_id\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n INNER JOIN mods m ON m.id = v.mod_id\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON m.id = didv.project_id AND drid.key = didv.detail_key\n WHERE drid.issue_id = ANY($1::bigint[])\n ",
"describe": {
"columns": [
{
@@ -20,16 +20,21 @@
},
{
"ordinal": 3,
"name": "file_path!: String",
"name": "jar?: String",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "file_path!: String",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "data!: sqlx::types::Json<HashMap<String, serde_json::Value>>",
"type_info": "Jsonb"
},
{
"ordinal": 5,
"ordinal": 6,
"name": "severity!: DelphiSeverity",
"type_info": {
"Custom": {
@@ -46,7 +51,7 @@
}
},
{
"ordinal": 6,
"ordinal": 7,
"name": "status!: DelphiStatus",
"type_info": {
"Custom": {
@@ -71,11 +76,12 @@
false,
false,
false,
true,
false,
false,
false,
null
]
},
"hash": "80b52a09ca9a056251d1040936f768c266e5814c15638d455f569deed13ee7d0"
"hash": "263ad3654f544ffb6061c839d49dada47fb382a76fdcabad2077fb1ef6d1010a"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n m.id AS \"project_id: DBProjectId\",\n MIN(t.id) AS \"thread_id!: DBThreadId\"\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n INNER JOIN versions v ON v.mod_id = m.id\n INNER JOIN files f ON f.version_id = v.id\n INNER JOIN delphi_reports dr ON dr.file_id = f.id\n INNER JOIN delphi_report_issues dri ON dri.report_id = dr.id\n INNER JOIN delphi_report_issue_details drid\n ON drid.issue_id = dri.id\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON m.id = didv.project_id AND drid.key = didv.detail_key\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON c.id = mc.joining_category_id\n LEFT JOIN threads_messages tm_last\n ON tm_last.thread_id = t.id\n AND tm_last.id = (\n SELECT id FROM threads_messages\n WHERE thread_id = t.id\n ORDER BY created DESC\n LIMIT 1\n )\n LEFT JOIN users u_last\n ON u_last.id = tm_last.author_id\n WHERE\n (cardinality($4::int[]) = 0 OR c.project_type = ANY($4::int[]))\n AND m.status NOT IN ('draft', 'rejected', 'withheld')\n AND (cardinality($6::text[]) = 0 OR m.status = ANY($6::text[]))\n AND (cardinality($7::text[]) = 0 OR dri.issue_type = ANY($7::text[]))\n AND (didv.verdict IS NULL OR didv.verdict = 'pending'::delphi_report_issue_status)\n AND (\n $5::text IS NULL\n OR ($5::text = 'unreplied' AND (tm_last.id IS NULL OR u_last.role IS NULL OR u_last.role NOT IN ('moderator', 'admin')))\n OR ($5::text = 'replied' AND tm_last.id IS NOT NULL AND u_last.role IS NOT NULL AND u_last.role IN ('moderator', 'admin'))\n )\n GROUP BY m.id\n ORDER BY\n CASE WHEN $3 = 'created_asc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END ASC,\n CASE WHEN $3 = 'created_desc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END DESC,\n CASE WHEN $3 = 'severity_asc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END ASC,\n CASE WHEN $3 = 'severity_desc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END DESC,\n -- tie-breaker: oldest reports\n MIN(dr.created) ASC\n LIMIT $1 OFFSET $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id: DBProjectId",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "thread_id!: DBThreadId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Text",
"Int4Array",
"Text",
"TextArray",
"TextArray"
]
},
"nullable": [
false,
null
]
},
"hash": "30a5fa3f44e56c412d07625ea9110238c533a1994e95c805a3babc39cde23004"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id AS \"thread_id: DBThreadId\"\n FROM threads\n WHERE mod_id = $1\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "thread_id: DBThreadId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "555342b0ec9fb808f05a18aaeaf06fb61e968fb3379c9d0c7ad82c8747bd4256"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT EXISTS(\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n didws.project_id = $1\n AND didws.status = 'pending'\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n ) AS \"is_in_tech_review!\"\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "is_in_tech_review!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "8c80f3158fb5772adc8542cdf5419437bb8cd65723a32e587022d0c8decba68d"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO delphi_report_issue_details (issue_id, key, file_path, decompiled_source, data, severity)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id\n ",
"query": "\n INSERT INTO delphi_report_issue_details (issue_id, key, jar, file_path, decompiled_source, data, severity)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id\n ",
"describe": {
"columns": [
{
@@ -15,6 +15,7 @@
"Text",
"Text",
"Text",
"Text",
"Jsonb",
{
"Custom": {
@@ -35,5 +36,5 @@
false
]
},
"hash": "b65094517546487e43b65a76aa38efd9e422151b683d9897a071ee0c4bac1cd4"
"hash": "9369f0659c5fbd08463923a9b2bba49f4963315fd7667c6db96e6153e54a2fd2"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT EXISTS(\n SELECT 1\n FROM unnest($2::text[]) AS incoming(detail_key)\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON didv.project_id = $1 AND didv.detail_key = incoming.detail_key\n WHERE didv.project_id IS NULL\n ) AS \"has_unflagged_issue_details!\"\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "has_unflagged_issue_details!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8",
"TextArray"
]
},
"nullable": [
null
]
},
"hash": "997944b328b628792d84b21747f9e9c670ad40d0f89a175aedece93df1169195"
}
@@ -1,26 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO delphi_issue_detail_verdicts (\n project_id,\n detail_key,\n verdict\n )\n SELECT\n didws.project_id,\n didws.key,\n $1\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n didws.id = $2\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n ON CONFLICT (project_id, detail_key)\n DO UPDATE SET verdict = EXCLUDED.verdict\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
{
"Custom": {
"name": "delphi_report_issue_status",
"kind": {
"Enum": [
"pending",
"safe",
"unsafe"
]
}
}
},
"Int8"
]
},
"nullable": []
},
"hash": "b767ca57e4d8abf164a951ce77f1e721b955fc4f2a4d4ac196611bc8d6b04706"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH incoming AS (\n SELECT *\n FROM unnest($1::bigint[], $2::text[]) WITH ORDINALITY\n AS u(detail_id, verdict, ord)\n ),\n resolved AS (\n SELECT\n i.ord,\n didws.project_id,\n didws.key AS detail_key,\n i.verdict::delphi_report_issue_status AS verdict\n FROM incoming i\n INNER JOIN delphi_issue_details_with_statuses didws ON didws.id = i.detail_id\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n WHERE\n -- see delphi.rs todo comment\n dri.issue_type != '__dummy'\n ),\n validated AS (\n SELECT\n (SELECT COUNT(*) FROM incoming) AS incoming_count,\n (SELECT COUNT(*) FROM resolved) AS resolved_count\n ),\n upserted AS (\n INSERT INTO delphi_issue_detail_verdicts (\n project_id,\n detail_key,\n verdict\n )\n SELECT DISTINCT ON (project_id, detail_key)\n project_id,\n detail_key,\n verdict\n FROM resolved\n ORDER BY project_id, detail_key, ord DESC\n ON CONFLICT (project_id, detail_key)\n DO UPDATE SET verdict = EXCLUDED.verdict\n RETURNING 1\n )\n SELECT\n (v.incoming_count = v.resolved_count) AS \"all_found!\",\n (SELECT COUNT(*) FROM upserted) AS \"upserted_count!\"\n FROM validated v\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "all_found!",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "upserted_count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8Array",
"TextArray"
]
},
"nullable": [
null,
null
]
},
"hash": "ccedb120b05ff47ddc15bb4570025a8e8249050c12f7036d936f9a01f939db1f"
}
@@ -1,34 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT DISTINCT ON (m.id)\n m.id AS \"project_id: DBProjectId\",\n t.id AS \"thread_id: DBThreadId\"\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n INNER JOIN versions v ON v.mod_id = m.id\n INNER JOIN files f ON f.version_id = v.id\n INNER JOIN delphi_reports dr ON dr.file_id = f.id\n INNER JOIN delphi_report_issues dri ON dri.report_id = dr.id\n INNER JOIN delphi_report_issue_details drid\n ON drid.issue_id = dri.id\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON m.id = didv.project_id AND drid.key = didv.detail_key\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON c.id = mc.joining_category_id\n LEFT JOIN threads_messages tm_last\n ON tm_last.thread_id = t.id\n AND tm_last.id = (\n SELECT id FROM threads_messages\n WHERE thread_id = t.id\n ORDER BY created DESC\n LIMIT 1\n )\n LEFT JOIN users u_last\n ON u_last.id = tm_last.author_id\n WHERE\n (cardinality($4::int[]) = 0 OR c.project_type = ANY($4::int[]))\n AND m.status NOT IN ('draft', 'rejected', 'withheld')\n AND (cardinality($6::text[]) = 0 OR m.status = ANY($6::text[]))\n AND (cardinality($7::text[]) = 0 OR dri.issue_type = ANY($7::text[]))\n AND (didv.verdict IS NULL OR didv.verdict = 'pending'::delphi_report_issue_status)\n AND (\n $5::text IS NULL\n OR ($5::text = 'unreplied' AND (tm_last.id IS NULL OR u_last.role IS NULL OR u_last.role NOT IN ('moderator', 'admin')))\n OR ($5::text = 'replied' AND tm_last.id IS NOT NULL AND u_last.role IS NOT NULL AND u_last.role IN ('moderator', 'admin'))\n )\n GROUP BY m.id, t.id\n ORDER BY m.id,\n CASE WHEN $3 = 'created_asc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END ASC,\n CASE WHEN $3 = 'created_desc' THEN MAX(dr.created) ELSE TO_TIMESTAMP(0) END DESC,\n CASE WHEN $3 = 'severity_asc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END ASC,\n CASE WHEN $3 = 'severity_desc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END DESC\n LIMIT $1 OFFSET $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id: DBProjectId",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "thread_id: DBThreadId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Text",
"Int4Array",
"Text",
"TextArray",
"TextArray"
]
},
"nullable": [
false,
false
]
},
"hash": "f10a09a0fb0774dad4933e78db94bfb231020b356edbc58bdb6c5a11ad0fb4ac"
}
@@ -0,0 +1,2 @@
ALTER TABLE delphi_report_issue_details
ADD COLUMN jar TEXT;
@@ -221,6 +221,9 @@ pub struct ReportIssueDetail {
/// This acts as a stable identifier for an issue detail, even across
/// different versions of the same file.
pub key: String,
/// If this detail was found inside a JAR embedded inside the scanned JAR,
/// this will point to the path of that JAR inside the outer JAR.
pub jar: Option<String>,
/// Name of the Java class path in which this issue was found.
pub file_path: String,
/// Decompiled, pretty-printed source of the Java class.
@@ -241,12 +244,13 @@ impl ReportIssueDetail {
) -> Result<DelphiReportIssueDetailsId, DatabaseError> {
Ok(DelphiReportIssueDetailsId(sqlx::query_scalar!(
"
INSERT INTO delphi_report_issue_details (issue_id, key, file_path, decompiled_source, data, severity)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO delphi_report_issue_details (issue_id, key, jar, file_path, decompiled_source, data, severity)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
",
self.issue_id as DelphiReportIssueId,
self.key,
self.jar,
self.file_path,
self.decompiled_source,
sqlx::types::Json(&self.data) as Json<&HashMap<String, serde_json::Value>>,
+143 -10
View File
@@ -59,15 +59,42 @@ static DELPHI_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
.unwrap()
});
#[derive(Deserialize)]
/// Type of [`DelphiReportIssueDetails::key`].
///
/// Delphi may provide `null` for the key, but we require a key for storing
/// issue details in the database, since detail verdicts are keyed by
/// (project id, issue detail key). Keys are opaque strings generated by Delphi
/// which refer to some "unique location" in a JAR file, such that subsequent
/// Delphi scans of different JARs with the same issue detail will result in
/// having the same key.
///
/// If Delphi doesn't provide us with a key, we generate a random one.
#[derive(Debug, Clone)]
pub struct IssueDetailKey(pub String);
impl<'de> Deserialize<'de> for IssueDetailKey {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<String>::deserialize(deserializer)?;
let value = value.unwrap_or_else(|| {
format!("<no-key-{:016x}>", rand::random::<u64>())
});
Ok(Self(value))
}
}
#[derive(Debug, Deserialize)]
struct DelphiReportIssueDetails {
pub file: String,
pub key: String,
pub key: IssueDetailKey,
pub jar: Option<String>,
pub data: HashMap<String, serde_json::Value>,
pub severity: DelphiSeverity,
}
#[derive(Deserialize)]
#[derive(Debug, Deserialize)]
struct DelphiReport {
pub url: String,
pub project_id: crate::models::ids::ProjectId,
@@ -205,11 +232,34 @@ async fn ingest_report_deserialized(
.await
.wrap_internal_err("failed to check if pending issue details exist")?;
if record.pending_issue_details_exist {
info!(
"File's project already has pending issue details, is not entering tech review queue"
);
} else {
let issue_detail_keys = report
.issues
.values()
.flatten()
.map(|issue_detail| issue_detail.key.0.clone())
.collect::<Vec<_>>();
let has_unflagged_issue_details = sqlx::query!(
r#"
SELECT EXISTS(
SELECT 1
FROM unnest($2::text[]) AS incoming(detail_key)
LEFT JOIN delphi_issue_detail_verdicts didv
ON didv.project_id = $1 AND didv.detail_key = incoming.detail_key
WHERE didv.project_id IS NULL
) AS "has_unflagged_issue_details!"
"#,
DBProjectId::from(report.project_id) as _,
&issue_detail_keys
)
.fetch_one(&mut transaction)
.await
.wrap_internal_err("failed to check if report has unflagged issue details")?;
let should_enter_tech_review = !record.pending_issue_details_exist
&& has_unflagged_issue_details.has_unflagged_issue_details;
if should_enter_tech_review {
info!("File's project is entering tech review queue");
ThreadMessageBuilder {
@@ -221,6 +271,10 @@ async fn ingest_report_deserialized(
.insert(&mut transaction)
.await
.wrap_internal_err("failed to add entering tech review message")?;
} else {
info!(
"File's project is not entering tech review queue (already pending or no new unflagged issue details)"
);
}
// TODO: Currently, the way we determine if an issue is in tech review or not
@@ -232,7 +286,7 @@ async fn ingest_report_deserialized(
// This is undesirable, but we can't rework the database schema to fix it
// right now. As a hack, we add a dummy report issue which blocks the
// project from exiting the tech review queue.
{
if should_enter_tech_review {
let dummy_issue_id = DBDelphiReportIssue {
id: DelphiReportIssueId(0), // This will be set by the database
report_id,
@@ -245,6 +299,7 @@ async fn ingest_report_deserialized(
id: DelphiReportIssueDetailsId(0), // This will be set by the database
issue_id: dummy_issue_id,
key: "".into(),
jar: None,
file_path: "".into(),
decompiled_source: None,
data: HashMap::new(),
@@ -275,7 +330,8 @@ async fn ingest_report_deserialized(
ReportIssueDetail {
id: DelphiReportIssueDetailsId(0), // This will be set by the database
issue_id,
key: issue_detail.key,
key: issue_detail.key.0,
jar: issue_detail.jar,
file_path: issue_detail.file,
decompiled_source: decompiled_source.cloned().flatten(),
data: issue_detail.data,
@@ -333,6 +389,83 @@ pub async fn run(
Ok(HttpResponse::NoContent().finish())
}
pub async fn is_project_in_tech_review(
project_id: DBProjectId,
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
) -> Result<bool, ApiError> {
let row = sqlx::query!(
r#"
SELECT EXISTS(
SELECT 1
FROM delphi_issue_details_with_statuses didws
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
WHERE
didws.project_id = $1
AND didws.status = 'pending'
-- see delphi.rs todo comment
AND dri.issue_type != '__dummy'
) AS "is_in_tech_review!"
"#,
project_id as _,
)
.fetch_one(exec)
.await
.wrap_internal_err("failed to fetch project tech review state")?;
Ok(row.is_in_tech_review)
}
pub async fn send_tech_review_exit_file_deleted_message(
project_id: DBProjectId,
txn: &mut crate::database::PgTransaction<'_>,
) -> Result<(), ApiError> {
let thread = sqlx::query!(
r#"
SELECT id AS "thread_id: DBThreadId"
FROM threads
WHERE mod_id = $1
LIMIT 1
"#,
project_id as _,
)
.fetch_optional(&mut *txn)
.await
.wrap_internal_err("failed to fetch thread for tech review exit message")?;
if let Some(thread) = thread {
ThreadMessageBuilder {
author_id: None,
body: MessageBody::TechReviewExitFileDeleted,
thread_id: thread.thread_id,
hide_identity: false,
}
.insert(txn)
.await
.wrap_internal_err("failed to add tech review exit message")?;
}
Ok(())
}
pub async fn send_tech_review_exit_file_deleted_message_if_exited(
project_id: DBProjectId,
was_in_tech_review: bool,
txn: &mut crate::database::PgTransaction<'_>,
) -> Result<(), ApiError> {
if !was_in_tech_review {
return Ok(());
}
let is_still_in_tech_review =
is_project_in_tech_review(project_id, &mut *txn).await?;
if !is_still_in_tech_review {
send_tech_review_exit_file_deleted_message(project_id, txn).await?;
}
Ok(())
}
#[post("run")]
async fn _run(
req: HttpRequest,
@@ -42,7 +42,7 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
.service(get_report)
.service(get_issue)
.service(submit_report)
.service(update_issue_detail)
.service(update_issue_details)
.service(add_report);
}
@@ -388,6 +388,8 @@ pub struct ProjectModerationInfo {
pub thread_id: ThreadId,
/// Project name.
pub name: String,
/// Current project status.
pub status: ProjectStatus,
/// The aggregated project typos of the versions of this project
#[serde(default)]
pub project_types: Vec<String>,
@@ -498,6 +500,7 @@ async fn fetch_project_reports(
drid.id AS "id!: DelphiReportIssueDetailsId",
drid.issue_id AS "issue_id!: DelphiReportIssueId",
drid.key AS "key!: String",
drid.jar AS "jar?: String",
drid.file_path AS "file_path!: String",
drid.data AS "data!: sqlx::types::Json<HashMap<String, serde_json::Value>>",
drid.severity AS "severity!: DelphiSeverity",
@@ -561,6 +564,7 @@ async fn fetch_project_reports(
id: d.id,
issue_id: d.issue_id,
key: d.key,
jar: d.jar,
file_path: d.file_path,
decompiled_source: None,
data: d.data.0,
@@ -698,9 +702,9 @@ async fn search_projects(
let rows = sqlx::query!(
r#"
SELECT DISTINCT ON (m.id)
SELECT
m.id AS "project_id: DBProjectId",
t.id AS "thread_id: DBThreadId"
MIN(t.id) AS "thread_id!: DBThreadId"
FROM mods m
INNER JOIN threads t ON t.mod_id = m.id
INNER JOIN versions v ON v.mod_id = m.id
@@ -734,12 +738,14 @@ async fn search_projects(
OR ($5::text = 'unreplied' AND (tm_last.id IS NULL OR u_last.role IS NULL OR u_last.role NOT IN ('moderator', 'admin')))
OR ($5::text = 'replied' AND tm_last.id IS NOT NULL AND u_last.role IS NOT NULL AND u_last.role IN ('moderator', 'admin'))
)
GROUP BY m.id, t.id
ORDER BY m.id,
CASE WHEN $3 = 'created_asc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END ASC,
CASE WHEN $3 = 'created_desc' THEN MAX(dr.created) ELSE TO_TIMESTAMP(0) END DESC,
CASE WHEN $3 = 'severity_asc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END ASC,
CASE WHEN $3 = 'severity_desc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END DESC
GROUP BY m.id
ORDER BY
CASE WHEN $3 = 'created_asc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END ASC,
CASE WHEN $3 = 'created_desc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END DESC,
CASE WHEN $3 = 'severity_asc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END ASC,
CASE WHEN $3 = 'severity_desc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END DESC,
-- tie-breaker: oldest reports
MIN(dr.created) ASC
LIMIT $1 OFFSET $2
"#,
limit,
@@ -831,6 +837,7 @@ async fn search_projects(
id,
thread_id: project.thread_id,
name: project.name,
status: project.status,
project_types: project.project_types,
icon_url: project.icon_url,
},
@@ -1119,6 +1126,8 @@ async fn submit_report(
/// See [`update_issue`].
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UpdateIssue {
/// ID of the issue detail to update.
pub detail_id: DelphiReportIssueDetailsId,
/// What the moderator has decided the outcome of this issue is.
pub verdict: DelphiVerdict,
}
@@ -1131,14 +1140,13 @@ pub struct UpdateIssue {
security(("bearer_auth" = [])),
responses((status = NO_CONTENT))
)]
#[patch("/issue-detail/{id}")]
async fn update_issue_detail(
#[patch("/issue-detail")]
async fn update_issue_details(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
update_req: web::Json<UpdateIssue>,
path: web::Path<(DelphiReportIssueDetailsId,)>,
update_reqs: web::Json<Vec<UpdateIssue>>,
) -> Result<(), ApiError> {
check_is_moderator_from_headers(
&req,
@@ -1148,44 +1156,76 @@ async fn update_issue_detail(
Scopes::PROJECT_WRITE,
)
.await?;
let (issue_detail_id,) = path.into_inner();
let mut txn = pool
.begin()
.await
.wrap_internal_err("failed to start transaction")?;
let status = match update_req.verdict {
DelphiVerdict::Safe => DelphiStatus::Safe,
DelphiVerdict::Unsafe => DelphiStatus::Unsafe,
};
let results = sqlx::query!(
let updates = update_reqs.into_inner();
let detail_ids = updates.iter().map(|u| u.detail_id.0).collect::<Vec<_>>();
let verdicts = updates
.iter()
.map(|u| match u.verdict {
DelphiVerdict::Safe => "safe".to_string(),
DelphiVerdict::Unsafe => "unsafe".to_string(),
})
.collect::<Vec<_>>();
let record = sqlx::query!(
r#"
INSERT INTO delphi_issue_detail_verdicts (
project_id,
detail_key,
verdict
WITH incoming AS (
SELECT *
FROM unnest($1::bigint[], $2::text[]) WITH ORDINALITY
AS u(detail_id, verdict, ord)
),
resolved AS (
SELECT
i.ord,
didws.project_id,
didws.key AS detail_key,
i.verdict::delphi_report_issue_status AS verdict
FROM incoming i
INNER JOIN delphi_issue_details_with_statuses didws ON didws.id = i.detail_id
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
WHERE
-- see delphi.rs todo comment
dri.issue_type != '__dummy'
),
validated AS (
SELECT
(SELECT COUNT(*) FROM incoming) AS incoming_count,
(SELECT COUNT(*) FROM resolved) AS resolved_count
),
upserted AS (
INSERT INTO delphi_issue_detail_verdicts (
project_id,
detail_key,
verdict
)
SELECT DISTINCT ON (project_id, detail_key)
project_id,
detail_key,
verdict
FROM resolved
ORDER BY project_id, detail_key, ord DESC
ON CONFLICT (project_id, detail_key)
DO UPDATE SET verdict = EXCLUDED.verdict
RETURNING 1
)
SELECT
didws.project_id,
didws.key,
$1
FROM delphi_issue_details_with_statuses didws
INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id
WHERE
didws.id = $2
-- see delphi.rs todo comment
AND dri.issue_type != '__dummy'
ON CONFLICT (project_id, detail_key)
DO UPDATE SET verdict = EXCLUDED.verdict
(v.incoming_count = v.resolved_count) AS "all_found!",
(SELECT COUNT(*) FROM upserted) AS "upserted_count!"
FROM validated v
"#,
status as _,
issue_detail_id as _,
&detail_ids,
&verdicts,
)
.execute(&mut txn)
.fetch_one(&mut txn)
.await
.wrap_internal_err("failed to update issue detail")?;
if results.rows_affected() == 0 {
.wrap_internal_err("failed to update issue details")?;
if !record.all_found {
return Err(ApiError::Request(eyre!("issue detail does not exist")));
}
+13
View File
@@ -28,6 +28,7 @@ use crate::models::threads::MessageBody;
use crate::queue::moderation::AutomatedModerationQueue;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::routes::internal::delphi;
use crate::search::indexing::remove_documents;
use crate::search::{SearchConfig, SearchError, search_for_project};
use crate::util::error::Context;
@@ -2218,6 +2219,18 @@ pub async fn project_delete(
.begin()
.await
.wrap_internal_err("failed to start transaction")?;
let was_in_tech_review =
delphi::is_project_in_tech_review(project.inner.id, &mut transaction)
.await?;
if was_in_tech_review {
delphi::send_tech_review_exit_file_deleted_message(
project.inner.id,
&mut transaction,
)
.await?;
}
let context = ImageContext::Project {
project_id: Some(project.inner.id.into()),
};
@@ -9,6 +9,7 @@ use crate::models::pats::Scopes;
use crate::models::projects::VersionType;
use crate::models::teams::ProjectPermissions;
use crate::queue::session::AuthQueue;
use crate::routes::internal::delphi;
use crate::{database, models};
use actix_web::{HttpRequest, HttpResponse, web};
use dashmap::DashMap;
@@ -688,6 +689,9 @@ pub async fn delete_file(
}
let mut transaction = pool.begin().await?;
let was_in_tech_review =
delphi::is_project_in_tech_review(row.project_id, &mut transaction)
.await?;
sqlx::query!(
"
@@ -709,6 +713,13 @@ pub async fn delete_file(
.execute(&mut transaction)
.await?;
delphi::send_tech_review_exit_file_deleted_message_if_exited(
row.project_id,
was_in_tech_review,
&mut transaction,
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::NoContent().body(""))
+15
View File
@@ -25,6 +25,7 @@ use crate::models::projects::{
use crate::models::projects::{Loader, skip_nulls};
use crate::models::teams::ProjectPermissions;
use crate::queue::session::AuthQueue;
use crate::routes::internal::delphi;
use crate::search::SearchConfig;
use crate::search::indexing::remove_documents;
use crate::util::error::Context;
@@ -959,6 +960,12 @@ pub async fn version_delete(
}
let mut transaction = pool.begin().await?;
let was_in_tech_review = delphi::is_project_in_tech_review(
version.inner.project_id,
&mut transaction,
)
.await?;
let context = ImageContext::Version {
version_id: Some(version.inner.id.into()),
};
@@ -977,6 +984,14 @@ pub async fn version_delete(
&mut transaction,
)
.await?;
delphi::send_tech_review_exit_file_deleted_message_if_exited(
version.inner.project_id,
was_in_tech_review,
&mut transaction,
)
.await?;
transaction.commit().await?;
database::models::DBProject::clear_cache(
+1 -1
View File
@@ -122,7 +122,7 @@ services:
delphi:
profiles:
- with-delphi
image: ghcr.io/modrinth/delphi:feature-schema-rework
image: ghcr.io/modrinth/delphi:main
container_name: labrinth-delphi
ports:
- '127.0.0.1:59999:59999'
+2
View File
@@ -144,6 +144,7 @@ import _ManageIcon from './icons/manage.svg?component'
import _MaximizeIcon from './icons/maximize.svg?component'
import _MemoryStickIcon from './icons/memory-stick.svg?component'
import _MessageIcon from './icons/message.svg?component'
import _MessagesSquareIcon from './icons/messages-square.svg?component'
import _MicrophoneIcon from './icons/microphone.svg?component'
import _MinimizeIcon from './icons/minimize.svg?component'
import _MinusIcon from './icons/minus.svg?component'
@@ -466,6 +467,7 @@ export const ManageIcon = _ManageIcon
export const MaximizeIcon = _MaximizeIcon
export const MemoryStickIcon = _MemoryStickIcon
export const MessageIcon = _MessageIcon
export const MessagesSquareIcon = _MessagesSquareIcon
export const MicrophoneIcon = _MicrophoneIcon
export const MinimizeIcon = _MinimizeIcon
export const MinusIcon = _MinusIcon
+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-messages-square"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M16 10a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 14.286V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
<path d="M20 9a2 2 0 0 1 2 2v10.286a.71.71 0 0 1-1.212.502l-2.202-2.202A2 2 0 0 0 17.172 19H10a2 2 0 0 1-2-2v-1" />
</svg>

After

Width:  |  Height:  |  Size: 544 B

@@ -80,6 +80,17 @@
@update:model-value="$emit('update:searchQuery', $event)"
/>
<ButtonStyled v-if="showRefreshButton" type="outlined">
<button
type="button"
class="flex h-10 items-center gap-2 !border-[1px] !border-surface-5"
@click="$emit('refresh')"
>
<RefreshCwIcon aria-hidden="true" class="h-5 w-5" />
Refresh
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<OverflowMenu
:dropdown-id="`create-new-${baseId}`"
@@ -178,6 +189,7 @@ const props = defineProps<{
editingFilePath?: string
isEditingImage?: boolean
searchQuery: string
showRefreshButton?: boolean
baseId: string
}>()
@@ -190,6 +202,7 @@ defineEmits<{
upload: []
uploadZip: []
unzipFromUrl: [cf: boolean]
refresh: []
save: []
saveAs: []
saveRestart: []
@@ -30,6 +30,7 @@
:editing-file-path="editingFile?.path"
:is-editing-image="fileEditorRef?.isEditingImage"
:search-query="searchQuery"
:show-refresh-button="showRefreshButton"
:base-id="baseId"
@navigate="navigateToSegment"
@navigate-home="() => navigateToSegment(-1)"
@@ -39,6 +40,7 @@
@upload="initiateFileUpload"
@upload-zip="() => {}"
@unzip-from-url="showUnzipFromUrlModal"
@refresh="refreshList"
@save="() => fileEditorRef?.saveFileContent(true)"
@save-as="() => fileEditorRef?.saveFileContent(false)"
@save-restart="() => fileEditorRef?.saveAndRestart()"
@@ -303,6 +305,7 @@ import {
defineProps<{
showDebugInfo?: boolean
showRefreshButton?: boolean
}>()
const notifications = injectNotificationManager()