Repository: goplus/builder
Pull request #1492: Loading UI optimization for project runner
Author: login: nighca
Created: 2025-03-20T13:28:46Z | Merged: 2025-03-26T06:06:34Z

close #1122 

* Module `utils/progress` for progress-reporting mechanism
* UI component `UIDetailedLoading` for detailed progress displaying
* Display detailed loading for `ProjectRunnerV1` & `ProjectRunnerV2`
* Display detailed loading for page `editor`

Linked issue #1122: Loading UI optimization for project runner
For resource-intensive projects, loading can be time-consuming. A user-friendly loading interface can help ease the discomfort of waiting.

We might improve user experience by providing:

* A more friendly UI
* Detailed information like `Loading images: 3/8`.

Now project runner will be used in 2 places:

* Runner in editor
* Runner in project page

Pre-game behaviors can be summarized as follows:

* Load game engine
* Load project information
* Load resources (images, sounds)
* Process resources, e.g., convert SVG files to PNG
* Start game

Diff:
diff --git a/spx-gui/package-lock.json b/spx-gui/package-lock.json
index 09aa9bb98..7bcee922a 100644
--- a/spx-gui/package-lock.json
+++ b/spx-gui/package-lock.json
@@ -9,6 +9,7 @@
       "version": "0.0.0",
       "dependencies": {
         "@jridgewell/resolve-uri": "^3.1.2",
+        "@lottiefiles/dotlottie-vue": "^0.5.8",
         "@monaco-editor/loader": "^1.4.0",
         "@rushstack/eslint-patch": "^1.7.2",
         "@scalar/api-reference": "^1.25.24",
@@ -1692,6 +1693,22 @@
         "@lezer/lr": "^1.4.0"
       }
     },
+    "node_modules/@lottiefiles/dotlottie-vue": {
+      "version": "0.5.8",
+      "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-vue/-/dotlottie-vue-0.5.8.tgz",
+      "integrity": "sha512-VTMj0+QFG1DW/eEWBO/Rh89JZrp/N9zitf6oeyxxO4Pxt/+WT9aK/XGE5/7IDqaYUmqR5MRkfcT9okwvxKe6Vw==",
+      "dependencies": {
+        "@lottiefiles/dotlottie-web": "0.41.0"
+      },
+      "peerDependencies": {
+        "vue": "^3.3.4"
+      }
+    },
+    "node_modules/@lottiefiles/dotlottie-web": {
+      "version": "0.41.0",
+      "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-web/-/dotlottie-web-0.41.0.tgz",
+      "integrity": "sha512-hJR3zN9DiQkNjEA0+OJbuPJPXZtbySrfYa61WD5qFc7dBPHGDUaSC6APvwM2FSrzPy0AU4nrOPfExEe6VNznkg=="
+    },
     "node_modules/@manypkg/find-root": {
       "version": "2.2.3",
       "resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-2.2.3.tgz",
diff --git a/spx-gui/package.json b/spx-gui/package.json
index 04e57a548..8c14a3759 100644
--- a/spx-gui/package.json
+++ b/spx-gui/package.json
@@ -13,6 +13,7 @@
   },
   "dependencies": {
     "@jridgewell/resolve-uri": "^3.1.2",
+    "@lottiefiles/dotlottie-vue": "^0.5.8",
     "@monaco-editor/loader": "^1.4.0",
     "@rushstack/eslint-patch": "^1.7.2",
     "@scalar/api-reference": "^1.25.24",
diff --git a/spx-gui/src/components/editor/code-editor/context.ts b/spx-gui/src/components/editor/code-editor/context.ts
index 757f50292..aaf90e09d 100644
--- a/spx-gui/src/components/editor/code-editor/context.ts
+++ b/spx-gui/src/components/editor/code-editor/context.ts
@@ -1,6 +1,7 @@
 import { watchEffect, type InjectionKey, inject, provide } from 'vue'
 import { shikiToMonaco } from '@shikijs/monaco'
 import { useI18n } from '@/utils/i18n'
+import { ProgressCollector } from '@/utils/progress'
 import { getHighlighter } from '@/utils/spx/highlighter'
 import { composeQuery, useQuery, type QueryRet } from '@/utils/query'
 import type { Project } from '@/models/project'
@@ -141,8 +142,17 @@ export function useProvideCodeEditorCtx(
 ): QueryRet<unknown> {
   const i18n = useI18n()
 
-  const monacoQueryRet = useQuery<Monaco>(async () => {
-    const [monaco, highlighter] = await Promise.all([getMonaco(i18n.lang.value), getHighlighter()])
+  const monacoQueryRet = useQuery<Monaco>(async (ctx) => {
+    const collector = ProgressCollector.collectorFor(ctx.reporter)
+    const monacoReporter = collector.getSubReporter({ en: 'Loading Monaco...', zh: '正在加载 Monaco...' }, 2)
+    const highlighterReporter = collector.getSubReporter(
+      { en: 'Loading syntax highlighter...', zh: '正在加载语法高亮器...' },
+      1
+    )
+    const [monaco, highlighter] = await Promise.all([
+      getMonaco(i18n.lang.value).then((m) => (monacoReporter.report(1), m)),
+      getHighlighter().then((h) => (highlighterReporter.report(1), h))
+    ])
     monaco.languages.register({ id: 'spx' })
     shikiToMonaco(highlighter, monaco)
     monaco.languages.setLanguageConfiguration('spx', spxLanguageConfiguration)
@@ -152,8 +162,8 @@ export function useProvideCodeEditorCtx(
   const editorQueryRet = useQuery<CodeEditor>(
     async (ctx) => {
       const [project, runtime, monaco] = await Promise.all([
-        composeQuery(ctx, projectRet),
-        composeQuery(ctx, runtimeRet),
+        composeQuery(ctx, projectRet, [null, 0]),
+        composeQuery(ctx, runtimeRet, [null, 0]),
         composeQuery(ctx, monacoQueryRet)
       ])
       ctx.signal.throwIfAborted()
diff --git a/spx-gui/src/components/project/runner/v1/IframeDisplay.vue b/spx-gui/src/components/project/runner/v1/IframeDisplay.vue
index 26d3be8e4..3d14ea18b 100644
--- a/spx-gui/src/components/project/runner/v1/IframeDisplay.vue
+++ b/spx-gui/src/components/project/runner/v1/IframeDisplay.vue
@@ -4,10 +4,12 @@
 
 <script lang="ts">
 import { ref, watch } from 'vue'
+import { timeout } from '@/utils/utils'
+import { addPrefetchLink } from '@/utils/dom'
+import { ProgressCollector, type Progress } from '@/utils/progress'
 import wasmExecUrl from '@/assets/wasm/wasm_exec.js?url'
 import ispxWasmUrl from '@/assets/wasm/ispx.wasm?url'
 import ispxRunnerHtml from './ispx/runner.html?raw'
-import { addPrefetchLink } from '@/utils/dom'
 
 // preload resources (for example, wasm files) to accelerate the loading
 export function preload() {
@@ -23,6 +25,7 @@ export function preload() {
 const emit = defineEmits<{
   console: [type: 'log' | 'warn', args: unknown[]]
   loaded: []
+  progress: [Progress]
 }>()
 
 interface IframeWindow extends Window {
@@ -34,10 +37,16 @@ const props = defineProps<{ zipData: ArrayBuffer | Uint8Array }>()
 
 const iframe = ref<HTMLIFrameElement>()
 
+const collector = new ProgressCollector()
+const iframeLoadReporter = collector.getSubReporter({ en: 'Preparing environment...', zh: '准备环境中...' }, 1)
+const wasmReadyReporter = collector.getSubReporter({ en: 'Loading game engine...', zh: '加载游戏引擎中...' }, 5)
+const startWithZipBufferReporter = collector.getSubReporter({ en: 'Starting project...', zh: '开始运行项目...' }, 0.01)
+
+collector.onProgress((progress) => emit('progress', progress))
+
 watch(iframe, () => {
-  if (!iframe.value) {
-    return
-  }
+  if (!iframe.value) return
+  iframeLoadReporter.report(1)
   const iframeWindow = iframe.value.contentWindow as IframeWindow | null
   if (!iframeWindow) {
     return
@@ -46,8 +55,18 @@ watch(iframe, () => {
 
   iframeWindow.document.write(runnerHtml) // This resets the iframe's content, including its window object
 
-  iframeWindow.addEventListener('wasmReady', () => {
+  wasmReadyReporter.startAutoReport(10 * 1000)
+  iframeWindow.addEventListener('wasmReady', async () => {
+    wasmReadyReporter.report(1)
+
+    // Ensure the latest progress update to be rendered to UI
+    // This is necessary because now spx runs in the same thread as the main thread of editor.
+    // After spx moved to standalone thread (see details in https://github.com/goplus/builder/issues/1496), timeout here can be removed.
+    // P.S. It makes more sense to use `nextTick` (from vue) instead, while that does not work as expected.
+    await timeout(50)
+
     iframeWindow.startWithZipBuffer(props.zipData)
+    startWithZipBufferReporter.report(1)
     const canvas = iframeWindow.document.querySelector('canvas')
     if (canvas == null) throw new Error('canvas expected in iframe')
     canvas.focus() // focus to canvas by default, so the user can interact with the game immediately
diff --git a/spx-gui/src/components/project/runner/v1/ProjectRunnerV1.vue b/spx-gui/src/components/project/runner/v1/ProjectRunnerV1.vue
index 3d8a97358..b678cfd16 100644
--- a/spx-gui/src/components/project/runner/v1/ProjectRunnerV1.vue
+++ b/spx-gui/src/components/project/runner/v1/ProjectRunnerV1.vue
@@ -1,22 +1,38 @@
 <template>
   <div class="iframe-container">
-    <IframeDisplay v-if="zipData" :zip-data="zipData" @console="handleConsole" @loaded="loading = false" />
+    <IframeDisplay
+      v-if="zipData"
+      :zip-data="zipData"
+      @console="handleConsole"
+      @loaded="loading = false"
+      @progress="(p) => startProjectReporter.report(p)"
+    />
     <UIImg v-show="zipData == null || loading" class="thumbnail" :src="thumbnailUrl" :loading="thumbnailUrlLoading" />
-    <UILoading :visible="loading" cover />
+    <UIDetailedLoading :visible="loading" cover :percentage="progressRef.percentage">
+      <span>{{ $t(progressRef.desc ?? { en: 'Loading...', zh: '加载中' }) }}</span>
+    </UIDetailedLoading>
   </div>
 </template>
 
 <script lang="ts">
 const zipEntryCache = new WeakMap<File, Promise<Blob>>()
 
-function getZipEntry(file: File) {
-  if (zipEntryCache.has(file)) return zipEntryCache.get(file)!
-  const zipEntry = toNativeFile(file).then((nf) => {
+function getZipEntry(file: File, reporter: ProgressReporter) {
+  if (zipEntryCache.has(file)) {
+    reporter.report(1)
+    return zipEntryCache.get(file)!
+  }
+  const collector = ProgressCollector.collectorFor(reporter)
+  const toNativeFileReporter = collector.getSubReporter({ en: 'Downloading file...', zh: '下载文件中...' }, 5)
+  const toPngReporter = collector.getSubReporter({ en: 'Processing image...', zh: '处理图片中...' }, 1)
+  const zipEntry = toNativeFile(file).then(async (blob: Blob) => {
+    toNativeFileReporter.report(1)
     // For svg files, we convert them to png before sending to spx (v1):
     // 1. Compatibility: Many SVG features are not supported in spx v1
     // 2. Improve performance: SVG rendering is slow in spx v1
-    if (nf.type === 'image/svg+xml') return toPng(nf)
-    return nf
+    if (blob.type === 'image/svg+xml') blob = await toPng(blob)
+    toPngReporter.report(1)
+    return blob
   })
   zipEntryCache.set(file, zipEntry)
   zipEntry.catch(() => zipEntryCache.delete(file))
@@ -25,15 +41,17 @@ function getZipEntry(file: File) {
 </script>
 
 <script lang="ts" setup>
+import { throttle } from 'lodash'
 import { onMounted, onUnmounted, ref } from 'vue'
 import JSZip from 'jszip'
 import { registerPlayer } from '@/utils/player-registry'
 import { until } from '@/utils/utils'
 import { useFileUrl } from '@/utils/file'
+import { type Progress, ProgressReporter, ProgressCollector } from '@/utils/progress'
 import { toPng } from '@/utils/img'
 import { File, toNativeFile } from '@/models/common/file'
 import { Project } from '@/models/project'
-import { UIImg, UILoading } from '@/components/ui'
+import { UIImg, UIDetailedLoading } from '@/components/ui'
 import IframeDisplay, { preload } from './IframeDisplay.vue'
 
 const props = defineProps<{ project: Project }>()
@@ -65,23 +83,42 @@ onUnmounted(() => {
   registered.onStopped()
 })
 
-async function getProjectData(signal?: AbortSignal) {
-  const zip = new JSZip()
+async function getProjectData(signal: AbortSignal | undefined, reporter: ProgressReporter) {
+  const collector = ProgressCollector.collectorFor(reporter)
+  const projectExportReporter = collector.getSubReporter({ en: 'Exporting project...', zh: '导出项目中...' }, 1)
+  const filesDesc = { en: 'Loading project files...', zh: '加载项目文件中...' }
+  const filesReporter = collector.getSubReporter(filesDesc, 10)
+  const zipReporter = collector.getSubReporter({ en: 'Zipping project files...', zh: '打包项目文件中...' }, 1)
+
   const [{ filesHash }, files] = await props.project.export()
   signal?.throwIfAborted()
-  Object.entries(files).forEach(([path, file]) => {
+  projectExportReporter.report(1)
+
+  const zip = new JSZip()
+  const filesCollector = ProgressCollector.collectorFor(filesReporter, filesDesc)
+  Object.entries(files).forEach(([path, file], i) => {
     if (file == null) return
-    zip.file(path, getZipEntry(file))
+    zip.file(path, getZipEntry(file, filesCollector.getSubReporter()))
   })
+
   const zipped = await zip.generateAsync({ type: 'arraybuffer' })
+  zipReporter.report(1)
+
   return { filesHash, zipped }
 }
 
+const collector = new ProgressCollector()
+const getProjectDataReporter = collector.getSubReporter({ en: 'Loading project data...', zh: '加载项目数据中...' }, 2)
+const startProjectReporter = collector.getSubReporter({ en: 'Starting project...', zh: '正在启动项目...' }, 1)
+
+const progressRef = ref<Progress>({ percentage: 0, desc: null })
+collector.onProgress(throttle((p) => (progressRef.value = p), 100))
+
 defineExpose({
   async run(signal?: AbortSignal) {
     loading.value = true
     registered.onStart()
-    const projectData = await getProjectData(signal)
+    const projectData = await getProjectData(signal, getProjectDataReporter)
     zipData.value = projectData.zipped
     signal?.throwIfAborted()
     await until(() => !loading.value)
diff --git a/spx-gui/src/components/project/runner/v2/ProjectRunnerV2.vue b/spx-gui/src/components/project/runner/v2/ProjectRunnerV2.vue
index 46ef0e600..58d9c5ca3 100644
--- a/spx-gui/src/components/project/runner/v2/ProjectRunnerV2.vue
+++ b/spx-gui/src/components/project/runner/v2/ProjectRunnerV2.vue
@@ -1,14 +1,16 @@
 <script setup lang="ts">
+import { throttle } from 'lodash'
 import { onMounted, onUnmounted, ref, watch } from 'vue'
 import JSZip from 'jszip'
 import { spxVersion } from '@/utils/env'
-import { untilNotNull } from '@/utils/utils'
+import { timeout, untilNotNull } from '@/utils/utils'
+import { ProgressCollector, ProgressReporter, type Progress } from '@/utils/progress'
 import { useFileUrl } from '@/utils/file'
 import { registerPlayer } from '@/utils/player-registry'
 import { addPrefetchLink } from '@/utils/dom'
 import { toNativeFile } from '@/models/common/file'
 import type { Project } from '@/models/project'
-import { UIImg, UILoading } from '@/components/ui'
+import { UIImg, UIDetailedLoading } from '@/components/ui'
 
 const runnerBaseUrl = `/spx_${spxVersion}`
 const runnerUrl = `${runnerBaseUrl}/runner.html`
@@ -19,7 +21,7 @@ const emit = defineEmits<{
   console: [type: 'log' | 'warn', args: unknown[]]
 }>()
 
-const loading = ref(true)
+const loading = ref(false)
 const [thumbnailUrl, thumbnailUrlLoading] = useFileUrl(() => props.project.thumbnail)
 
 interface IframeWindow extends Window {
@@ -54,13 +56,33 @@ watch(iframeRef, (iframe) => {
   })
 })
 
-async function getProjectData() {
-  const zip = new JSZip()
+async function getProjectData(reporter: ProgressReporter, signal?: AbortSignal) {
+  const collector = ProgressCollector.collectorFor(reporter)
+  const projectExportReporter = collector.getSubReporter(
+    { en: 'Counting project files...', zh: '清点项目文件中...' },
+    1
+  )
+  const filesDesc = { en: 'Loading project files...', zh: '加载项目文件中...' }
+  const filesReporter = collector.getSubReporter(filesDesc, 10)
+  const zipReporter = collector.getSubReporter({ en: 'Zipping project files...', zh: '打包项目文件中...' }, 1)
+
   const [{ filesHash }, files] = await props.project.export()
+  signal?.throwIfAborted()
+  projectExportReporter.report(1)
+
+  const zip = new JSZip()
+  const filesCollector = ProgressCollector.collectorFor(filesReporter, filesDesc)
   Object.entries(files).forEach(([path, file]) => {
-    if (file != null) zip.file(path, toNativeFile(file))
+    if (file == null) return
+    const r = filesCollector.getSubReporter()
+    const nativeFile = toNativeFile(file).then((f) => (r.report(1), f))
+    zip.file(path, nativeFile)
   })
+
   const zipped = await zip.generateAsync({ type: 'arraybuffer' })
+  signal?.throwIfAborted()
+  zipReporter.report(1)
+
   return { filesHash, zipped }
 }
 
@@ -106,16 +128,43 @@ onMounted(() => {
   })
 })
 
+const progressRef = ref<Progress>({ percentage: 0, desc: null })
+
 defineExpose({
   async run(signal?: AbortSignal) {
     loading.value = true
+    const collector = new ProgressCollector()
+    collector.onProgress(throttle((progress) => (progressRef.value = progress), 100))
+    const iframeLoadReporter = collector.getSubReporter({ en: 'Preparing environment...', zh: '准备环境中...' }, 1)
+    const getProjectDataReporter = collector.getSubReporter(
+      { en: 'Loading project data...', zh: '加载项目数据中...' },
+      5
+    )
+    const startGameReporter = collector.getSubReporter(
+      { en: 'Loading engine & starting project...', zh: '加载引擎并启动项目中...' },
+      5
+    )
+
     registered.onStart()
+
     const iframeWindow = await untilNotNull(iframeWindowRef)
     signal?.throwIfAborted()
-    const projectData = await getProjectData()
-    signal?.throwIfAborted()
+    iframeLoadReporter.report(1)
+
+    const projectData = await getProjectData(getProjectDataReporter, signal)
+
+    // Ensure the latest progress update to be rendered to UI
+    // This is necessary because now spx runs in the same thread as the main thread of editor.
+    // After spx moved to standalone thread (see details in https://github.com/goplus/builder/issues/1496), timeout here can be removed.
+    // P.S. It makes more sense to use `nextTick` (from vue) instead, while that does not work as expected.
+    await timeout(50)
+
+    // TODO: get progress for engine-loading, which is now included in `startGame`
+    startGameReporter.startAutoReport(10 * 1000)
     await withLog('startGame', iframeWindow.startGame(projectData.zipped, assetURLs))
     signal?.throwIfAborted()
+    startGameReporter.report(1)
+
     loading.value = false
     return projectData.filesHash
   },
@@ -135,8 +184,10 @@ defineExpose({
 <template>
   <div class="iframe-container">
     <iframe ref="iframeRef" class="iframe" frameborder="0" :src="runnerUrl" />
-    <UIImg v-show="loading" class="thumbnail" :src="thumbnailUrl" :loading="thumbnailUrlLoading" />
-    <UILoading :visible="loading" cover />
+    <UIImg v-show="progressRef.percentage !== 1" class="thumbnail" :src="thumbnailUrl" :loading="thumbnailUrlLoading" />
+    <UIDetailedLoading :visible="loading" cover :percentage="progressRef.percentage">
+      <span>{{ $t(progressRef.desc ?? { en: 'Loading...', zh: '加载中' }) }}</span>
+    </UIDetailedLoading>
   </div>
 </template>
 
diff --git a/spx-gui/src/components/ui/UIImg.vue b/spx-gui/src/components/ui/UIImg.vue
index d992e7a69..1d07a2ad8 100644
--- a/spx-gui/src/components/ui/UIImg.vue
+++ b/spx-gui/src/components/ui/UIImg.vue
@@ -6,7 +6,7 @@
 
 <script setup lang="ts">
 import { computed } from 'vue'
-import UILoading from './UILoading.vue'
+import UILoading from './loading/UILoading.vue'
 
 const props = withDefaults(
   defineProps<{
diff --git a/spx-gui/src/components/ui/index.ts b/spx-gui/src/components/ui/index.ts
index 95bda51cb..888d97933 100644
--- a/spx-gui/src/components/ui/index.ts
+++ b/spx-gui/src/components/ui/index.ts
@@ -15,7 +15,8 @@ export * from './dialog'
 export * from './radio'
 export * from './checkbox'
 export { default as UISwitch } from './UISwitch.vue'
-export { default as UILoading } from './UILoading.vue'
+export { default as UILoading } from './loading/UILoading.vue'
+export { default as UIDetailedLoading } from './loading/UIDetailedLoading.vue'
 export * from './message'
 export { default as UITagButton } from './UITagButton.vue'
 export { default as UITag } from './UITag.vue'
diff --git a/spx-gui/src/components/ui/loading/UIDetailedLoading.vue b/spx-gui/src/components/ui/loading/UIDetailedLoading.vue
new file mode 100644
index 000000000..ae4b6886e
--- /dev/null
+++ b/spx-gui/src/components/ui/loading/UIDetailedLoading.vue
@@ -0,0 +1,121 @@
+<script setup lang="ts">
+import { computed, ref, watch, nextTick } from 'vue'
+import { DotLottieVue, type DotLottieVueInstance } from '@lottiefiles/dotlottie-vue'
+import animationFileUrl from './animation.lottie?url'
+
+export type MaskType = 'none' | 'semi-transparent'
+
+const props = withDefaults(
+  defineProps<{
+    percentage: number
+    cover?: boolean
+    visible?: boolean
+    mask?: boolean | MaskType
+  }>(),
+  {
+    cover: false,
+    visible: true,
+    mask: true
+  }
+)
+
+const mask = computed(() => {
+  if (props.mask === false) return 'none'
+  if (props.mask === true) return 'semi-transparent'
+  return props.mask
+})
+
+const dotLottieRef = ref<DotLottieVueInstance>()
+
+watch(
+  // Correct the animation size when visible
+  () => [props.visible, dotLottieRef.value] as const,
+  async ([visible, dotLottie]) => {
+    if (!visible || dotLottie == null) return
+    const instance = dotLottie.getDotLottieInstance()
+    if (instance == null) return
+    await nextTick()
+    instance.resize()
+  }
+)
+</script>
+
+<template>
+  <div class="ui-detailed-loading" :class="{ cover, visible, [`mask-${mask}`]: true }">
+    <DotLottieVue ref="dotLottieRef" class="animation" autoplay loop :src="animationFileUrl" />
+    <div class="progress-bar">
+      <div class="progress" :style="{ width: `${percentage * 100}%` }"></div>
+    </div>
+    <div class="text">
+      <slot></slot>
+      <span>{{ (percentage * 100).toFixed(2) }}%</span>
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.ui-detailed-loading {
+  width: 100%;
+  height: 80%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  visibility: hidden;
+  opacity: 0;
+  transition:
+    visibility 0.3s,
+    opacity 0.3s;
+
+  &.cover {
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 100%;
+    height: 100%;
+
+    &.mask-semi-transparent {
+      background-color: rgba(36, 41, 47, 0.7);
+
+      .text {
+        color: var(--ui-color-grey-100);
+      }
+    }
+  }
+
+  &.visible {
+    visibility: visible;
+    opacity: 1;
+  }
+}
+
+.animation {
+  flex: 0 0 auto;
+  width: 90px;
+  height: 150px;
+}
+
+.progress-bar {
+  margin-bottom: 4px;
+  width: 180px;
+  height: 5px;
+  border-radius: 2.5px;
+  background-color: var(--ui-color-grey-600);
+
+  .progress {
+    height: 100%;
+    background-color: var(--ui-color-primary-main);
+    border-radius: 2.5px;
+    transition: width 0.3s ease;
+  }
+}
+
+.text {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 13px;
+  line-height: 20px;
+  color: var(--ui-color-title);
+}
+</style>
diff --git a/spx-gui/src/components/ui/UILoading.vue b/spx-gui/src/components/ui/loading/UILoading.vue
similarity index 100%
rename from spx-gui/src/components/ui/UILoading.vue
rename to spx-gui/src/components/ui/loading/UILoading.vue
diff --git a/spx-gui/src/components/ui/loading/animation.lottie b/spx-gui/src/components/ui/loading/animation.lottie
new file mode 100644
index 000000000..b2a105d2f
Binary files /dev/null and b/spx-gui/src/components/ui/loading/animation.lottie differ
diff --git a/spx-gui/src/models/project/index.ts b/spx-gui/src/models/project/index.ts
index f4cdb9bf5..cf42c4b39 100644
--- a/spx-gui/src/models/project/index.ts
+++ b/spx-gui/src/models/project/index.ts
@@ -8,6 +8,7 @@ import { reactive, watch } from 'vue'
 import { join } from '@/utils/path'
 import { debounce } from 'lodash'
 import { Disposable } from '@/utils/disposable'
+import { ProgressCollector, type ProgressReporter } from '@/utils/progress'
 import { Visibility, type ProjectData } from '@/apis/project'
 import { toConfig, type Files, fromConfig, File } from '../common/file'
 import * as cloudHelper from '../common/cloud'
@@ -413,10 +414,28 @@ export class Project extends Disposable {
   }
 
   /** Load from cloud */
-  async loadFromCloud(owner: string, name: string, preferPublishedContent: boolean = false, signal?: AbortSignal) {
+  async loadFromCloud(
+    owner: string,
+    name: string,
+    preferPublishedContent: boolean = false,
+    signal?: AbortSignal,
+    reporter?: ProgressReporter
+  ) {
+    const collector = reporter != null ? ProgressCollector.collectorFor(reporter) : null
+    const cloudLoadReporter = collector?.getSubReporter(
+      { en: 'Downloading project info...', zh: '正在下载项目信息...' },
+      1
+    )
+    const projectLoadReporter = collector?.getSubReporter({ en: 'Loading project...', zh: '正在载入项目...' }, 1)
+
     const { metadata, files } = await cloudHelper.load(owner, name, preferPublishedContent, signal)
     signal?.throwIfAborted()
+    cloudLoadReporter?.report(1)
+
     await this.load(metadata, files)
+    signal?.throwIfAborted()
+    projectLoadReporter?.report(1)
+
     return this as CloudProject
   }
 
diff --git a/spx-gui/src/pages/community/project.vue b/spx-gui/src/pages/community/project.vue
index e12e83bf4..3e8145963 100644
--- a/spx-gui/src/pages/community/project.vue
+++ b/spx-gui/src/pages/community/project.vue
@@ -85,7 +85,7 @@ watch(
   { immediate: true }
 )
 
-const runnerState = ref<'initial' | 'running'>('initial')
+const runnerState = ref<'initial' | 'loading' | 'running'>('initial')
 watch(
   () => [props.owner, props.name],
   () => {
@@ -138,6 +138,7 @@ const ensureSignedIn = useEnsureSignedIn()
 
 const handleRun = useMessageHandle(
   async () => {
+    runnerState.value = 'loading'
     await projectRunnerRef.value?.run()
     runnerState.value = 'running'
   },
diff --git a/spx-gui/src/pages/editor/index.vue b/spx-gui/src/pages/editor/index.vue
index fee5ea097..97064f9d5 100644
--- a/spx-gui/src/pages/editor/index.vue
+++ b/spx-gui/src/pages/editor/index.vue
@@ -4,7 +4,9 @@
       <EditorNavbar :project="project" />
     </header>
     <main v-if="userInfo" class="editor-main">
-      <UILoading v-if="allQueryRet.isLoading.value" />
+      <UIDetailedLoading v-if="allQueryRet.isLoading.value" :percentage="allQueryRet.progress.value.percentage">
+        <span>{{ $t(allQueryRet.progress.value.desc ?? { en: 'Loading...', zh: '加载中' }) }}</span>
+      </UIDetailedLoading>
       <UIError v-else-if="allQueryRet.error.value != null" :retry="allQueryRet.refetch">
         {{ $t(allQueryRet.error.value.userMessage) }}
       </UIError>
@@ -22,11 +24,12 @@ import { useUserStore } from '@/stores/user'
 import { AutoSaveMode, Project } from '@/models/project'
 import { getProjectEditorRoute } from '@/router'
 import { Cancelled } from '@/utils/exception'
+import { ProgressCollector, ProgressReporter } from '@/utils/progress'
 import { composeQuery, useQuery } from '@/utils/query'
 import { getStringParam } from '@/utils/route'
 import { clear } from '@/models/common/local'
 import { Runtime } from '@/models/runtime'
-import { UILoading, UIError, useConfirmDialog, useMessage } from '@/components/ui'
+import { UIDetailedLoading, UIError, useConfirmDialog, useMessage } from '@/components/ui'
 import { useI18n } from '@/utils/i18n'
 import { useNetwork } from '@/utils/network'
 import { untilNotNull, usePageTitle } from '@/utils/utils'
@@ -87,7 +90,7 @@ const projectQueryRet = useQuery(
     if (userInfo.value == null) throw new Error('User not signed in') // This should not happen as the route is protected
     // We need to read `userInfo.value?.name` & `projectName.value` synchronously,
     // so their change will drive `useQuery` to re-fetch
-    const project = await loadProject(userInfo.value.name, props.projectName, ctx.signal)
+    const project = await loadProject(userInfo.value.name, props.projectName, ctx.signal, ctx.reporter)
     ;(window as any).project = project // for debug purpose, TODO: remove me
     return project
   },
@@ -109,9 +112,9 @@ const codeEditorQueryRet = useProvideCodeEditorCtx(projectQueryRet, runtimeQuery
 const allQueryRet = useQuery(
   (ctx) =>
     Promise.all([
-      composeQuery(ctx, projectQueryRet),
-      composeQuery(ctx, runtimeQueryRet),
-      composeQuery(ctx, codeEditorQueryRet)
+      composeQuery(ctx, projectQueryRet, [{ en: 'Loading project...', zh: '加载项目中...' }, 2]),
+      composeQuery(ctx, runtimeQueryRet, [null, 0.1]),
+      composeQuery(ctx, codeEditorQueryRet, [{ en: 'Loading code editor...', zh: '加载代码编辑器中...' }, 1])
     ]),
   { en: 'Failed to load editor', zh: '加载编辑器失败' }
 )
@@ -125,7 +128,21 @@ if (getStringParam(router, 'publish') != null) {
   })
 }
 
-async function loadProject(user: string, projectName: string, signal: AbortSignal) {
+async function loadProject(user: string, projectName: string, signal: AbortSignal, reporter: ProgressReporter) {
+  const collector = ProgressCollector.collectorFor(reporter)
+  const loadFromLocalCacheReporter = collector.getSubReporter(
+    { en: 'Reading local cache...', zh: '读取本地缓存中...' },
+    1
+  )
+  const loadFromCloudReporter = collector.getSubReporter(
+    { en: 'Loading project from cloud...', zh: '从云端加载项目中...' },
+    10
+  )
+  const startEditingReporter = collector.getSubReporter(
+    { en: 'Initializing editing features...', zh: '初始化编辑功能中...' },
+    1
+  )
+
   let localProject: Project | null
   try {
     localProject = new Project()
@@ -137,6 +154,7 @@ async function loadProject(user: string, projectName: string, signal: AbortSigna
     await clear(LOCAL_CACHE_KEY)
   }
   signal.throwIfAborted()
+  loadFromLocalCacheReporter.report(1)
 
   // https://github.com/goplus/builder/issues/259
   // https://github.com/goplus/builder/issues/393
@@ -161,8 +179,7 @@ async function loadProject(user: string, projectName: string, signal: AbortSigna
 
   let newProject = new Project()
   newProject.disposeOnSignal(signal)
-  await newProject.loadFromCloud(user, projectName)
-  signal.throwIfAborted()
+  await newProject.loadFromCloud(user, projectName, undefined, signal, loadFromCloudReporter)
 
   // If there is no newer cloud version, use local version without confirmation.
   // If there is a newer cloud version, use cloud version without confirmation.
@@ -178,6 +195,8 @@ async function loadProject(user: string, projectName: string, signal: AbortSigna
   setProjectAutoSaveMode(newProject)
   await newProject.startEditing(LOCAL_CACHE_KEY)
   signal.throwIfAborted()
+  startEditingReporter.report(1)
+
   return newProject
 }
 
diff --git a/spx-gui/src/utils/env.ts b/spx-gui/src/utils/env.ts
index 6c909fa6b..2762d9b3f 100644
--- a/spx-gui/src/utils/env.ts
+++ b/spx-gui/src/utils/env.ts
@@ -1,6 +1,6 @@
 /**
  * @file env file
- * @desc Exports enviroment variables from `.env.*` file
+ * @desc Exports environment variables from `.env.*` file
  */
 
 /// <reference types="vite/client" />
diff --git a/spx-gui/src/utils/progress.test.ts b/spx-gui/src/utils/progress.test.ts
new file mode 100644
index 000000000..b94dc10d2
--- /dev/null
+++ b/spx-gui/src/utils/progress.test.ts
@@ -0,0 +1,218 @@
+import { describe, expect, it, vitest } from 'vitest'
+import { ProgressCollector, ProgressReporter } from './progress'
+import { timeout } from './utils'
+
+describe('ProgressReporter', () => {
+  it('should report progress well', () => {
+    const onProgress = vitest.fn()
+    const reporter = new ProgressReporter(onProgress)
+    reporter.report(0.1, { en: 'foo', zh: 'foo' })
+    expect(onProgress.mock.lastCall[0]).toMatchObject({ percentage: 0.1, desc: { en: 'foo', zh: 'foo' } })
+    reporter.report(0.2, { en: 'bar', zh: 'bar' })
+    expect(onProgress.mock.lastCall[0]).toMatchObject({ percentage: 0.2, desc: { en: 'bar', zh: 'bar' } })
+
+    onProgress.mockClear()
+    reporter.report(0.3, { en: 'baz', zh: 'baz' })
+    reporter.report(0.4, { en: 'qux', zh: 'qux' })
+    expect(onProgress).toHaveBeenCalledTimes(2)
+
+    reporter.report(0.5)
+    expect(onProgress.mock.lastCall[0]).toMatchObject({ percentage: 0.5, desc: null })
+    reporter.report(0.8, { en: 'quux', zh: 'quux' })
+    expect(onProgress.mock.lastCall[0]).toMatchObject({ percentage: 0.8, desc: { en: 'quux', zh: 'quux' } })
+  })
+  describe('startAutoReport', () => {
+    it('should work well', async () => {
+      async function checkWithTimes(estimatedTimes: number) {
+        const percentages: number[] = []
+        const onProgress = vitest.fn((p) => percentages.push(p.percentage))
+        const reporter = new ProgressReporter(onProgress)
+        const interval = 100
+        const maxTimes = estimatedTimes * 2
+        await reporter.startAutoReport(interval * estimatedTimes, interval)
+        expect([
+          maxTimes,
+          maxTimes + 1 // There may be little difference due to floating-point-number calculation issue in JS
+        ]).includes(onProgress.mock.calls.length)
+        for (let i = 0; i < maxTimes; i++) {
+          expect(percentages[i]).toBeGreaterThan(0)
+          expect(percentages[i]).toBeLessThan(1)
+          if (i > 0) expect(percentages[i]).toBeGreaterThan(percentages[i - 1])
+        }
+      }
+      await Promise.all([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(checkWithTimes))
+    })
+    it('should stop when finished', async () => {
+      const onProgress = vitest.fn()
+      const reporter = new ProgressReporter(onProgress)
+      const autoReportDone = reporter.startAutoReport(5 * 100, 100)
+      await timeout(250)
+      reporter.report(1)
+      await autoReportDone
+      expect(onProgress).toHaveBeenCalledTimes(3)
+    })
+  })
+})
+
+describe('ProgressCollector', () => {
+  it('should collect progress well', () => {
+    const onProgress = vitest.fn()
+    const collector = new ProgressCollector()
+    collector.onProgress(onProgress)
+    const subReporter = collector.getSubReporter()
+    const subReporter2 = collector.getSubReporter({ en: '2', zh: '2' })
+
+    subReporter.report(0.1, { en: 'foo', zh: 'foo' })
+    expect(onProgress.mock.lastCall[0]).toMatchObject({ percentage: 0.05, desc: { en: 'foo', zh: 'foo' } })
+
+    subReporter.report(0.6, null)
+    expect(onProgress.mock.lastCall[0]).toMatchObject({ percentage: 0.3, desc: null })
+
+    subReporter2.report(0.2, { en: 'bar', zh: 'bar' })
+    expect(onProgress.mock.lastCall[0]).toMatchObject({ percentage: 0.4, desc: null })
+
+    subReporter.report(1, null)
+    expect(onProgress.mock.lastCall[0]).toMatchObject({ percentage: 0.6, desc: { en: 'bar', zh: 'bar' } })
+
+    subReporter2.report(0.6, null)
+    expect(onProgress.mock.lastCall[0]).toMatchObject({ percentage: 0.8, desc: { en: '2', zh: '2' } })
+
+    subReporter2.report(1, { en: 'baz', zh: 'baz' })
+    expect(onProgress.mock.lastCall[0]).toMatchObject({ percentage: 1, desc: null })
+  })
+
+  it('should collect progress well with descBase', () => {
+    const onProgress = vitest.fn()
+    const collector = new ProgressCollector({ en: 'base', zh: 'base' })
+    collector.onProgress(onProgress)
+    const subReporter = collector.getSubReporter()
+    const subReporter2 = collector.getSubReporter({ en: '2', zh: '2' })
+    const subReporter3 = collector.getSubReporter()
+
+    subReporter.report(0.9, { en: 'foo', zh: 'foo' })
+    expect(onProgress.mock.lastCall[0]).toMatchObject({ percentage: 0.3, desc: { en: 'foo', zh: 'foo' } })
+
+    subReporter.report(0.9, null)
+    expect(onProgress.mock.lastCall[0]).toMatchObject({
+      percentage: 0.3,
+      desc: { en: 'base (0/3)', zh: 'base（0/3）' }
+    })
+
+    subReporter.report(1)
+    subReporter2.report(0.5, { en: 'bar', zh: 'bar' })
+    expect(onProgress.mock.lastCall[0]).toMatchObject({ percentage: 0.5, desc: { en: 'bar', zh: 'bar' } })
+
+    subReporter2.report(1)
+    subReporter3.report(0.7)
+    expect(onProgress.mock.lastCall[0]).toMatchObject({
+      percentage: 0.9,
+      desc: { en: 'base (2/3)', zh: 'base（2/3）' }
+    })
+  })
+
+  it('should collect progress well with weight', () => {
+    const onProgress = vitest.fn()
+    const collector = new ProgressCollector()
+    collector.onProgress(onProgress)
+    const subReporter = collector.getSubReporter(null, 1)
+    const subReporter2 = collector.getSubReporter(null, 3)
+
+    subReporter.report(0.6)
+    expect(onProgress.mock.lastCall[0]).toMatchObject({ percentage: 0.15, desc: null })
+
+    subReporter2.report(0.6)
+    expect(onProgress.mock.lastCall[0]).toMatchObject({ percentage: 0.6, desc: null })
+
+    subReporter2.report(1)
+    expect(onProgress.mock.lastCall[0]).toMatchObject({ percentage: 0.9, desc: null })
+
+    subReporter.report(1)
+    expect(onProgress.mock.lastCall[0]).toMatchObject({ percentage: 1, desc: null })
+  })
+
+  it('should collect progress well with nested collector', () => {
+    const onProgress = vitest.fn()
+    const collector = new ProgressCollector()
+    collector.onProgress(onProgress)
+    const subReporter1 = collector.getSubReporter()
+    const subReporter2 = collector.getSubReporter()
+    const subCollector1 = ProgressCollector.collectorFor(subReporter1)
+    const subReporter11 = subCollector1.getSubReporter()
+    const subReporter12 = subCollector1.getSubReporter()
+    const subCollector2 = ProgressCollector.collectorFor(subReporter2)
+    const subReporter21 = subCollector2.getSubReporter()
+    const subReporter22 = subCollector2.getSubReporter()
+
+    subReporter11.report(0.2, { en: 'foo', zh: 'foo' })
+    expect(onProgress.mock.lastCall[0]).toMatchObject({ percentage: 0.05, desc: { en: 'foo', zh: 'foo' } })
+
+    subReporter12.report(0.6)
+    expect(onProgress.mock.lastCall[0]).toMatchObject({ percentage: 0.2, desc: { en: 'foo', zh: 'foo' } })
+
+    subReporter21.report(0.2, { en: 'bar', zh: 'bar' })
+    subReporter22.report(1)
+    expect(onProgress.mock.lastCall[0]).toMatchObject({ percentage: 0.5, desc: { en: 'foo', zh: 'foo' } })
+
+    subReporter11.report(1)
+    expect(onProgress.mock.lastCall[0]).toMatchObject({ percentage: 0.7, desc: null })
+
+    subReporter12.report(1)
+    expect(onProgress.mock.lastCall[0]).toMatchObject({ percentage: 0.8, desc: { en: 'bar', zh: 'bar' } })
+
+    subReporter21.report(1)
+    expect(onProgress.mock.lastCall[0]).toMatchObject({ percentage: 1, desc: null })
+  })
+
+  describe('collectorFor', () => {
+    it('should work well', () => {
+      const onReporterProgress = vitest.fn()
+      const reporter = new ProgressReporter(onReporterProgress)
+
+      const collector = ProgressCollector.collectorFor(reporter)
+      expect(collector).toBeInstanceOf(ProgressCollector)
+
+      const subReporter = collector.getSubReporter()
+      subReporter.report(0.5, null)
+      expect(onReporterProgress.mock.lastCall[0]).toMatchObject({ percentage: 0.5, desc: null })
+    })
+    it('should work well with baseDesc', () => {
+      const onReporterProgress = vitest.fn()
+      const reporter = new ProgressReporter(onReporterProgress)
+
+      const collector = ProgressCollector.collectorFor(reporter, { en: 'base', zh: 'base' })
+      expect(collector).toBeInstanceOf(ProgressCollector)
+
+      const subReporter = collector.getSubReporter()
+      subReporter.report(0.5, null)
+      expect(onReporterProgress.mock.lastCall[0]).toMatchObject({
+        percentage: 0.5,
+        desc: { en: 'base (0/1)', zh: 'base（0/1）' }
+      })
+    })
+    it('should work well with collectorFor called multiple times', () => {
+      const onReporterProgress = vitest.fn()
+      const reporter = new ProgressReporter(onReporterProgress)
+
+      const collector1 = ProgressCollector.collectorFor(reporter, { en: 'base1', zh: 'base1' })
+      collector1.getSubReporter().report(0.5, null)
+      collector1.getSubReporter().report(0.5, null)
+
+      const collector2 = ProgressCollector.collectorFor(reporter, { en: 'base2', zh: 'base2' })
+      const subReporter1 = collector2.getSubReporter()
+      const subReporter2 = collector2.getSubReporter()
+
+      subReporter1.report(0.5, null)
+      expect(onReporterProgress.mock.lastCall[0]).toMatchObject({
+        percentage: 0.25,
+        desc: { en: 'base2 (0/2)', zh: 'base2（0/2）' }
+      })
+
+      subReporter1.report(1, null)
+      subReporter2.report(0.5, null)
+      expect(onReporterProgress.mock.lastCall[0]).toMatchObject({
+        percentage: 0.75,
+        desc: { en: 'base2 (1/2)', zh: 'base2（1/2）' }
+      })
+    })
+  })
+})
diff --git a/spx-gui/src/utils/progress.ts b/spx-gui/src/utils/progress.ts
new file mode 100644
index 000000000..7a66bbd94
--- /dev/null
+++ b/spx-gui/src/utils/progress.ts
@@ -0,0 +1,127 @@
+import type { LocaleMessage } from './i18n'
+
+export type Progress = {
+  /** Progress percentage, number in range `[0, 1]` */
+  percentage: number
+  /** Description for the progress, e.g., "Loading local data..." */
+  desc: LocaleMessage | null
+}
+
+export type ProgressHandler = (p: Progress) => void
+
+export class ProgressReporter {
+  private percentage = 0
+
+  constructor(private handler: ProgressHandler) {}
+
+  report(progress: Progress): void
+  report(percentage: number, desc?: LocaleMessage | null): void
+  report(arg1: Progress | number, desc: LocaleMessage | null = null) {
+    const progress = typeof arg1 === 'number' ? { percentage: arg1, desc } : arg1
+    this.percentage = progress.percentage
+    this.handler(progress)
+  }
+
+  /**
+   * Start reporting progress automatically: a series of progress will be reported in given interval.
+   * Reports start from `percentage: 0` and keep increasing.
+   * Reports stop when `estimatedTimeCost * 2` is reached (at `percentage: 0.99`), or `percentage: 1` is reported manually.
+   */
+  startAutoReport(
+    /** Estimated time cost in milliseconds */
+    estimatedTimeCost: number,
+    /** Interval in milliseconds for each report */
+    interval = 300
+  ) {
+    return new Promise<void>((resolve) => {
+      const estimatedTimes = estimatedTimeCost / interval
+      const maxTimes = estimatedTimes * 2
+      const maxPercentage = 0.99
+      // Quadratic function to generate reports: y = x^2 / (x^2 + factor)
+      // when x = maxTimes, y = maxPercentage
+      const factor = (maxTimes ** 2 * (1 - maxPercentage)) / maxPercentage
+      let times = 0
+      const timer = setInterval(() => {
+        const curr = this.percentage
+        if (curr >= maxPercentage) {
+          clearInterval(timer)
+          resolve()
+          return
+        }
+        times++
+        const timesSquared = times ** 2
+        this.report(timesSquared / (timesSquared + factor))
+      }, interval)
+    })
+  }
+}
+
+type CollectingItem = {
+  progress: Progress
+  weight: number
+  desc: LocaleMessage | null
+}
+
+export class ProgressCollector {
+  private collecting: CollectingItem[] = []
+  private handler?: ProgressHandler
+
+  constructor(private descBase?: LocaleMessage | null) {}
+
+  /** Creates a new ProgressCollector which collects progress and reports with given reporter. */
+  static collectorFor(reporter: ProgressReporter, descBase?: LocaleMessage | null) {
+    const collector = new ProgressCollector(descBase)
+    collector.onProgress((p) => reporter.report(p))
+    return collector
+  }
+
+  onProgress(handler: ProgressHandler) {
+    this.handler = handler
+  }
+
+  private getDesc() {
+    if (this.descBase == null) return null
+    const finishedNum = this.collecting.filter((i) => i.progress.percentage >= 1).length
+    const totalNum = this.collecting.length
+    return {
+      en: `${this.descBase.en} (${finishedNum}/${totalNum})`,
+      zh: `${this.descBase.zh}（${finishedNum}/${totalNum}）`
+    }
+  }
+
+  private getProgress() {
+    let totalWeight = 0
+    let finishedWeight = 0
+    let currentItem: CollectingItem | null = null
+    for (const item of this.collecting) {
+      totalWeight += item.weight
+      finishedWeight += item.weight * item.progress.percentage
+      if (currentItem == null && item.progress.percentage < 1) {
+        currentItem = item
+      }
+    }
+    const desc = currentItem?.progress.desc ?? currentItem?.desc ?? this.getDesc()
+    const percentage = totalWeight === 0 ? 1 : finishedWeight / totalWeight
+    const ret = { percentage, desc }
+    if (process.env.NODE_ENV === 'development') {
+      Object.assign(ret, { __debug__: this.collecting })
+    }
+    return ret
+  }
+
+  private handleProgressChange() {
+    this.handler?.(this.getProgress())
+  }
+
+  /** Creates a sub-reporter for a sub-task with given description and weight. */
+  getSubReporter(desc: LocaleMessage | null = null, weight = 1) {
+    const progress: Progress = { percentage: 0, desc: null }
+    const item: CollectingItem = { progress, weight, desc }
+    this.collecting.push(item)
+    const reporter = new ProgressReporter((p) => {
+      item.progress = p
+      this.handleProgressChange()
+    })
+    return reporter
+  }
+}
diff --git a/spx-gui/src/utils/query.test.ts b/spx-gui/src/utils/query.test.ts
index 6c043acc3..2a48bd1e4 100644
--- a/spx-gui/src/utils/query.test.ts
+++ b/spx-gui/src/utils/query.test.ts
@@ -1,6 +1,7 @@
-import { ref } from 'vue'
+import { nextTick, ref } from 'vue'
 import { flushPromises } from '@vue/test-utils'
 import { describe, it, expect, vi } from 'vitest'
+import { type ProgressHandler } from './progress'
 import { useQuery, composeQuery, type QueryContext } from './query'
 import { withSetup } from './test'
 
@@ -119,4 +120,56 @@ describe('composeQuery', () => {
     expect(ret3.data.value).toEqual(['ok', 'ok'])
     expect(ret3.error.value).toBe(null)
   })
+
+  it('should work well with progress', async () => {
+    let report1!: ProgressHandler, report2!: ProgressHandler
+    let resolve1!: () => void, resolve2!: () => void
+    const [ret1, ret2, ret3] = withSetup(() => {
+      const ret1 = useQuery(async (ctx: QueryContext) => {
+        report1 = (p) => ctx.reporter.report(p)
+        await new Promise<void>((r) => (resolve1 = r))
+      })
+      const ret2 = useQuery(async (ctx: QueryContext) => {
+        report2 = (p) => ctx.reporter.report(p)
+        await new Promise<void>((r) => (resolve2 = r))
+      })
+      const ret3 = useQuery(async (ctx: QueryContext) => {
+        await Promise.all([
+          composeQuery(ctx, ret1, [{ en: '1', zh: '1' }, 1]),
+          composeQuery(ctx, ret2, [{ en: '2', zh: '2' }, 3])
+        ])
+      })
+      return [ret1, ret2, ret3] as const
+    })
+
+    report1({ percentage: 0.4, desc: null })
+    await nextTick()
+    expect(ret1.progress.value.percentage).toBeCloseTo(0.4)
+    expect(ret2.progress.value.percentage).toBeCloseTo(0)
+    expect(ret3.progress.value.percentage).toBeCloseTo(0.1)
+    expect(ret3.progress.value.desc).toEqual({ en: '1', zh: '1' })
+
+    report2({ percentage: 0.8, desc: null })
+    await nextTick()
+    expect(ret1.progress.value.percentage).toBeCloseTo(0.4)
+    expect(ret2.progress.value.percentage).toBeCloseTo(0.8)
+    expect(ret3.progress.value.percentage).toBeCloseTo(0.7)
+    expect(ret3.progress.value.desc).toEqual({ en: '1', zh: '1' })
+
+    report1({ percentage: 1, desc: null })
+    resolve1()
+    await nextTick()
+    expect(ret1.progress.value.percentage).toBeCloseTo(1)
+    expect(ret2.progress.value.percentage).toBeCloseTo(0.8)
+    expect(ret3.progress.value.percentage).toBeCloseTo(0.85)
+    expect(ret3.progress.value.desc).toEqual({ en: '2', zh: '2' })
+
+    report2({ percentage: 1, desc: null })
+    resolve2()
+    await nextTick()
+    expect(ret1.progress.value.percentage).toBeCloseTo(1)
+    expect(ret2.progress.value.percentage).toBeCloseTo(1)
+    expect(ret3.progress.value.percentage).toBeCloseTo(1)
+    expect(ret3.progress.value.desc).toBeNull()
+  })
 })
diff --git a/spx-gui/src/utils/query.ts b/spx-gui/src/utils/query.ts
index 78ba0a0c3..4cb46e5cb 100644
--- a/spx-gui/src/utils/query.ts
+++ b/spx-gui/src/utils/query.ts
@@ -7,18 +7,21 @@ import {
   computed,
   ref,
   onUnmounted,
-  toValue
+  toValue,
+  watch
 } from 'vue'
 import { useQuery as useVueQuery, useQueryClient as useVueQueryClient } from '@tanstack/vue-query'
 import { type LocaleMessage } from './i18n'
 import { mergeSignals } from './disposable'
 import { useAction, type ActionException, Cancelled } from './exception'
 import { until } from './utils'
+import { ProgressReporter, type Progress, ProgressCollector } from './progress'
 
 export type QueryRet<T> = {
   isLoading: Ref<boolean>
   data: ShallowRef<T | null>
   error: ShallowRef<ActionException | null>
+  progress: ShallowRef<Progress>
   refetch: (signal?: AbortSignal) => void
 }
 
@@ -34,6 +37,8 @@ export type QueryContext = {
   signal: AbortSignal
   /** Source of query fn calling. */
   source: QuerySource
+  /** Progress reporter for the query */
+  reporter: ProgressReporter
 }
 
 /**
@@ -52,6 +57,7 @@ export function useQuery<T>(
   const isLoading = ref(false)
   const data = shallowRef<T | null>(null)
   const error = shallowRef<ActionException | null>(null)
+  const progress = shallowRef<Progress>({ percentage: 0, desc: null })
 
   let lastCtrl: AbortController | null = null
   onUnmounted(() => lastCtrl?.abort(new Cancelled('unmounted')))
@@ -64,8 +70,9 @@ export function useQuery<T>(
 
   function fetch(source: QuerySource, signal?: AbortSignal) {
     const finalSignal = mergeSignals(signal, getSignal())
+    const reporter = new ProgressReporter((p) => (progress.value = p))
     isLoading.value = true
-    queryFn({ signal: finalSignal, source }).then(
+    queryFn({ signal: finalSignal, source, reporter }).then(
       (d) => {
         data.value = d
         error.value = null
@@ -84,7 +91,7 @@ export function useQuery<T>(
 
   const refetch = (signal?: AbortSignal) => fetch('refetch', signal)
 
-  return { isLoading, data, error, refetch }
+  return { isLoading, data, error, progress, refetch }
 }
 
 export type QueryWithCacheOptions<T> = {
@@ -110,8 +117,9 @@ export function useQueryWithCache<T>(options: QueryWithCacheOptions<T>): QueryRe
   const isLoading = ret.isLoading
   const data = computed(() => ret.data.value ?? null)
   const error = ret.error as Ref<ActionException | null>
+  const progress = shallowRef<Progress>({ percentage: 0, desc: null })
   const refetch = () => ret.refetch()
-  return { isLoading, data, error, refetch }
+  return { isLoading, data, error, progress, refetch }
 }
 
 /** Manage cache of `useQueryWithCache` */
@@ -133,6 +141,19 @@ export function useQueryCache<T>() {
   }
 }
 
+const composedReporterCollectorMap = new WeakMap<ProgressReporter, ProgressCollector>()
+
+function getCollector(reporter: ProgressReporter) {
+  let collector = composedReporterCollectorMap.get(reporter)
+  if (collector == null) {
+    collector = ProgressCollector.collectorFor(reporter)
+    composedReporterCollectorMap.set(reporter, collector)
+  }
+  return collector
+}
+
+export type SubReporterParams = Parameters<ProgressCollector['getSubReporter']>
+
 /**
  * Compose query in another query.
  * - If the query is loading, wait until it's done.
@@ -140,12 +161,21 @@ export function useQueryCache<T>() {
  * - If the query is successful, the data will be returned.
  * - Composed query will be collected as dependencies.
  */
-export async function composeQuery<T>(ctx: QueryContext, queryRet: QueryRet<T>): Promise<T> {
+export async function composeQuery<T>(
+  ctx: QueryContext,
+  queryRet: QueryRet<T>,
+  subReportParams?: SubReporterParams
+): Promise<T> {
   if (ctx.source === 'refetch') {
     queryRet.refetch(ctx.signal)
   } else {
     toValue(queryRet.isLoading) // Trigger dependency collection
   }
+
+  const collector = getCollector(ctx.reporter)
+  const subReporter = collector.getSubReporter(...(subReportParams ?? []))
+  watch(queryRet.progress, (p) => subReporter.report(p), { immediate: true })
+
   return new Promise<T>((resolve, reject) => {
     until(() => !queryRet.isLoading.value, ctx.signal).then(() => {
       if (queryRet.error.value != null) reject(queryRet.error.value)


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR optimizes the loading UI and progress reporting logic for the project runner. Key changes include enhanced progress reporting using ProgressCollector and ProgressReporter, updates to loading screen components to detailed loading UI, and integration of progress reporting into query and project loading flows.

### Reviewed Changes

Copilot reviewed 16 out of 18 changed files in this pull request and generated 2 comments.

<details>
<summary>Show a summary per file</summary>

| File                                                       | Description                                       |
| ---------------------------------------------------------- | ------------------------------------------------- |
| spx-gui/src/utils/progress.test.ts                         | Added tests for progress reporting functionality. |
| spx-gui/src/utils/query.test.ts                            | Extended tests to cover progress reporting in queries. |
| spx-gui/src/utils/progress.ts                              | Added/updated progress reporting and collection APIs. |
| spx-gui/src/components/editor/code-editor/context.ts       | Integrated progress reporting during Monaco loading. |
| spx-gui/src/models/project/index.ts                        | Updated cloud project loading to include progress reporting. |
| spx-gui/src/utils/query.ts                                 | Enhanced query utilities with progress reporting support. |
| spx-gui/src/components/ui/index.ts                         | Updated UI exports for new detailed loading components. |
| spx-gui/src/components/project/runner/v1/IframeDisplay.vue   | Integrated progress reporting into iframe loading. |
| spx-gui/src/components/project/runner/v1/ProjectRunnerV1.vue | Updated project runner V1 to use detailed progress UI. |
| spx-gui/src/components/project/runner/v2/ProjectRunnerV2.vue | Updated project runner V2 to use detailed progress UI. |
| spx-gui/src/components/ui/UIImg.vue                        | Updated to reference new UILoading paths.         |
| spx-gui/src/components/ui/loading/UIDetailedLoading.vue      | Added detailed loading component with progress bar. |
| spx-gui/src/pages/community/project.vue                    | Modified runner state handling for progress loading. |
| spx-gui/src/pages/editor/index.vue                         | Integrated detailed loading UI into the editor page. |
</details>


<details>
<summary>Files not reviewed (2)</summary>

* **spx-gui/package-lock.json**: Language not supported
* **spx-gui/package.json**: Language not supported
</details>



state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-22T06:26:34Z
user: login: aofei
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-26T06:06:27Z

Review comments:
diff_hunk: @@ -34,10 +37,16 @@ const props = defineProps<{ zipData: ArrayBuffer | Uint8Array }>()
 
 const iframe = ref<HTMLIFrameElement>()
 
+const collector = new ProgressCollector()
+const iframeLoadReporter = collector.getSubReporter({ en: 'Preparing enviroment...', zh: '准备环境中...' }, 1)
path: spx-gui/src/components/project/runner/v1/IframeDisplay.vue
user: login: Copilot
body: The word 'enviroment' is misspelled. It should be corrected to 'environment'.
```suggestion
const iframeLoadReporter = collector.getSubReporter({ en: 'Preparing environment...', zh: '准备环境中...' }, 1)
```
created_at: 2025-03-22T06:26:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -106,16 +128,40 @@ onMounted(() => {
   })
 })
 
+const progressRef = ref<Progress>({ percentage: 0, desc: null })
+
 defineExpose({
   async run(signal?: AbortSignal) {
     loading.value = true
+    const collector = new ProgressCollector()
+    collector.onProgress(throttle((progress) => (progressRef.value = progress), 100))
+    const iframeLoadReporter = collector.getSubReporter({ en: 'Preparing enviroment...', zh: '准备环境中...' }, 1)
path: spx-gui/src/components/project/runner/v2/ProjectRunnerV2.vue
user: login: Copilot
body: The word 'enviroment' is misspelled. It should be corrected to 'environment'.
```suggestion
    const iframeLoadReporter = collector.getSubReporter({ en: 'Preparing environment...', zh: '准备环境中...' }, 1)
```
created_at: 2025-03-22T06:26:34Z
author_association: CONTRIBUTOR

PR comments:
user: login: qiniu-prow[bot]
created_at: 2025-03-20T13:28:51Z
body: Skipping CI for Draft Pull Request.
If you want CI signal for your change, please convert it to an actual PR.
You can still manually trigger a test run with `/test all`
author_association: NONE
user: login: qiniu-ci
created_at: 2025-03-24T06:14:01Z
body: 
This PR has been deployed to the preview environment. You can explore it using the [preview URL](http://goplus-builder-pr-1492.goplus-pr-review.svc.jfcs-qa1.local).

> [!WARNING]
> Please note that deployments in the preview environment are temporary and will be automatically cleaned up after a certain period. Make sure to explore it before it is removed. For any questions, contact the Go+ Builder team.

author_association: NONE