Skip to content

Commit

Permalink
debug project switching in editor
Browse files Browse the repository at this point in the history
  • Loading branch information
nighca committed Jan 7, 2025
1 parent 94cdb76 commit 313bfa0
Show file tree
Hide file tree
Showing 7 changed files with 115 additions and 104 deletions.
22 changes: 12 additions & 10 deletions spx-gui/src/components/editor/code-editor/context.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { watchEffect, type InjectionKey, inject, provide, type ShallowRef } from 'vue'
import { watchEffect, type InjectionKey, inject, provide } from 'vue'
import { shikiToMonaco } from '@shikijs/monaco'
import { untilNotNull } from '@/utils/utils'
import { useI18n } from '@/utils/i18n'
import { getHighlighter } from '@/utils/spx/highlighter'
import { untilQueryLoaded, useQuery, type QueryRet } from '@/utils/query'
import { composeQuery, useQuery, type QueryRet } from '@/utils/query'
import type { Project } from '@/models/project'
import type { Runtime } from '@/models/runtime'
import { type Position, type ResourceIdentifier, type TextDocumentIdentifier } from './common'
Expand Down Expand Up @@ -115,8 +114,8 @@ const spxLanguageConfiguration: monaco.languages.LanguageConfiguration = {
}

export function useProvideCodeEditorCtx(
projectRef: ShallowRef<Project | null>,
runtimeRef: ShallowRef<Runtime | null>
projectRet: QueryRet<Project>,
runtimeRet: QueryRet<Runtime>
): QueryRet<unknown> {
const i18n = useI18n()

Expand All @@ -130,13 +129,16 @@ export function useProvideCodeEditorCtx(
})

const editorQueryRet = useQuery<CodeEditor>(
async () => {
async (signal) => {
const [project, runtime, monaco] = await Promise.all([
untilNotNull(projectRef),
untilNotNull(runtimeRef),
untilQueryLoaded(monacoQueryRet)
composeQuery(projectRet),
composeQuery(runtimeRet),
composeQuery(monacoQueryRet)
])
return new CodeEditor(project, runtime, monaco, i18n)
signal.throwIfAborted()
const codeEditor = new CodeEditor(project, runtime, monaco, i18n)
codeEditor.disposeOnSignal(signal)
return codeEditor
},
{ en: 'Failed to load code editor', zh: '加载代码编辑器失败' }
)
Expand Down
114 changes: 40 additions & 74 deletions spx-gui/src/pages/editor/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<UIError v-else-if="allQueryRet.error.value != null" :retry="allQueryRet.refetch">
{{ $t(allQueryRet.error.value.userMessage) }}
</UIError>
<EditorContextProvider v-else :project="project!" :runtime="runtimeRef!" :user-info="userInfo">
<EditorContextProvider v-else :project="project!" :runtime="runtimeQueryRet.data.value!" :user-info="userInfo">
<ProjectEditor />
</EditorContextProvider>
</main>
Expand All @@ -21,14 +21,15 @@ import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { AutoSaveMode, Project } from '@/models/project'
import { getProjectEditorRoute } from '@/router'
import { untilQueryLoaded, useQuery } from '@/utils/query'
import { Cancelled } from '@/utils/exception'
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 { useI18n } from '@/utils/i18n'
import { useNetwork } from '@/utils/network'
import { untilNotNull, useComputedDisposable, usePageTitle } from '@/utils/utils'
import { untilNotNull, usePageTitle } from '@/utils/utils'
import EditorNavbar from '@/components/editor/navbar/EditorNavbar.vue'
import EditorContextProvider from '@/components/editor/EditorContextProvider.vue'
import ProjectEditor from '@/components/editor/ProjectEditor.vue'
Expand Down Expand Up @@ -81,51 +82,38 @@ const askToOpenTargetWithAnotherInCache = (targetName: string, cachedName: strin
)
}
const askToOpenCachedVersionForCurrent = (cachedName: string): Promise<boolean> => {
return new Promise((resolve) =>
withConfirm({
title: t({
en: 'Restore unsaved changes?',
zh: '恢复未保存的变更?'
}),
content: t({
en: `You have unsaved changes for project ${cachedName}. Do you want to open project ${cachedName} and restore them?`,
zh: `项目 ${cachedName} 存在未保存的变更,要打开项目 ${cachedName} 并恢复未保存的变更吗?`
})
})
.then(() => {
resolve(true)
})
.catch(() => {
resolve(false)
})
)
}
const projectQueryRet = useQuery(
() => {
async (signal) => {
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
return loadProject(userInfo.value?.name, props.projectName)
const project = await loadProject(userInfo.value.name, props.projectName, signal)
;(window as any).project = project // for debug purpose, TODO: remove me
return project
},
{ en: 'Failed to load project', zh: '加载项目失败' }
)
const project = projectQueryRet.data
const runtimeRef = useComputedDisposable(() => {
if (project.value == null) return null
return new Runtime(project.value)
const runtimeQueryRet = useQuery(async (signal) => {
const project = await composeQuery(projectQueryRet)
signal.throwIfAborted()
const runtime = new Runtime(project)
runtime.disposeOnSignal(signal)
return runtime
})
const codeEditorQueryRet = useProvideCodeEditorCtx(project, runtimeRef)
const codeEditorQueryRet = useProvideCodeEditorCtx(projectQueryRet, runtimeQueryRet)
const allQueryRet = useQuery((signal) =>
Promise.all([
untilQueryLoaded(projectQueryRet, signal),
untilNotNull(runtimeRef, signal),
untilQueryLoaded(codeEditorQueryRet, signal)
])
const allQueryRet = useQuery(
(signal) =>
Promise.all([
composeQuery(projectQueryRet, signal),
composeQuery(runtimeQueryRet, signal),
composeQuery(codeEditorQueryRet, signal)
]),
{ en: 'Failed to load editor', zh: '加载编辑器失败' }
)
// `?publish`
Expand All @@ -137,56 +125,44 @@ if (getStringParam(router, 'publish') != null) {
})
}
async function loadProject(user: string | undefined, projectName: string | undefined) {
if (user == null) return null
async function loadProject(user: string, projectName: string, signal: AbortSignal) {
let localProject: Project | null
try {
localProject = new Project()
localProject.disposeOnSignal(signal)
await localProject.loadFromLocalCache(LOCAL_CACHE_KEY)
} catch (e) {
console.warn('Failed to load project from local cache', e)
localProject = null
await clear(LOCAL_CACHE_KEY)
}
signal.throwIfAborted()
// https://github.com/goplus/builder/issues/259
// https://github.com/goplus/builder/issues/393
// Local Cache Saving & Restoring
if (localProject && localProject.owner !== user) {
if (localProject != null && localProject.owner !== user) {
// Case 4: Different user: Discard local cache
await clear(LOCAL_CACHE_KEY)
localProject = null
}
if (localProject?.hasUnsyncedChanges) {
if (!projectName) {
if (await askToOpenCachedVersionForCurrent(localProject.name!)) {
// Case 3: User has a project in the cache but not opening any project:
// Open the saved project
openProject(localProject.name!) // FIXME: name should be required?
} else {
// Case 3: Clear local cache
await clear(LOCAL_CACHE_KEY)
localProject = null
}
return null
}
if (localProject.name !== projectName) {
if (await askToOpenTargetWithAnotherInCache(projectName, localProject.name!)) {
await clear(LOCAL_CACHE_KEY)
localProject = null
} else {
openProject(localProject.name!)
return null
}
if (localProject != null && localProject.name !== projectName && localProject.hasUnsyncedChanges) {
const stillOpenTarget = await askToOpenTargetWithAnotherInCache(projectName, localProject.name!)
signal.throwIfAborted()
if (stillOpenTarget) {
await clear(LOCAL_CACHE_KEY)
localProject = null
} else {
openProject(localProject.name!)
throw new Cancelled('Open another project')
}
}
if (!projectName) return null
let newProject = new Project()
newProject.disposeOnSignal(signal)
await newProject.loadFromCloud(user, projectName)
signal.throwIfAborted()
// If there is no newer cloud version, use local version without confirmation.
// If there is a newer cloud version, use cloud version without confirmation.
Expand All @@ -201,6 +177,7 @@ async function loadProject(user: string | undefined, projectName: string | undef
setProjectAutoSaveMode(newProject)
await newProject.startEditing(LOCAL_CACHE_KEY)
signal.throwIfAborted()
return newProject
}
Expand All @@ -210,17 +187,6 @@ function setProjectAutoSaveMode(project: Project | null) {
}
watch(isOnline, () => setProjectAutoSaveMode(project.value))
watch(
// https://vuejs.org/guide/essentials/watchers.html#deep-watchers
// According to the document, we should use `() => project.value` instead of
// `project` to avoid deep watching, which is not expected here.
() => project.value,
(_, oldProject) => {
oldProject?.dispose()
;(window as any).project = project.value // for debug purpose, TODO: remove me
}
)
watchEffect((onCleanup) => {
const cleanup = router.beforeEach(async () => {
if (!project.value?.hasUnsyncedChanges) return true
Expand Down
9 changes: 9 additions & 0 deletions spx-gui/src/utils/disposable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ export class Disposable implements IDisposable {
}
}

/** Dispose when given signal aborted. */
disposeOnSignal(signal: AbortSignal) {
if (signal.aborted) {
this.dispose()
} else {
signal.addEventListener('abort', () => this.dispose(), { signal: this.ctrl.signal })
}
}

getSignal() {
return this.ctrl.signal
}
Expand Down
45 changes: 30 additions & 15 deletions spx-gui/src/utils/query.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { shallowRef, watchEffect, type Ref, type ShallowRef, type WatchSource, computed, ref } from 'vue'
import { shallowRef, watchEffect, type Ref, type ShallowRef, type WatchSource, computed, ref, onUnmounted } from 'vue'
import { useQuery as useVueQuery, useQueryClient as useVueQueryClient } from '@tanstack/vue-query'
import { type LocaleMessage } from './i18n'
import { getCleanupSignal, type OnCleanup } from './disposable'
import { useAction, type ActionException } from './exception'
import { useAction, type ActionException, Cancelled } from './exception'
import { timeout, until } from './utils'

export type QueryRet<T> = {
Expand All @@ -29,8 +28,17 @@ export function useQuery<T>(
const data = shallowRef<T | null>(null)
const error = shallowRef<ActionException | null>(null)

function fetch(onCleanup: OnCleanup) {
const signal = getCleanupSignal(onCleanup)
let lastCtrl: AbortController | null = null
onUnmounted(() => lastCtrl?.abort(new Cancelled('unmounted')))
const getSignal = () => {
if (lastCtrl != null) lastCtrl.abort(new Cancelled('new query'))
const ctrl = new AbortController()
lastCtrl = ctrl
return ctrl.signal
}

function fetch() {
const signal = getSignal()
isLoading.value = true
queryFn(signal).then(
(d) => {
Expand All @@ -39,20 +47,17 @@ export function useQuery<T>(
isLoading.value = false
},
(e) => {
if (e instanceof Cancelled) return
console.warn(e)
error.value = e
isLoading.value = false
}
)
}

function refetch() {
fetch(() => {})
}

watchEffect(fetch)

return { isLoading, data, error, refetch }
return { isLoading, data, error, refetch: fetch }
}

export type QueryWithCacheOptions<T> = {
Expand Down Expand Up @@ -101,12 +106,22 @@ export function useQueryCache<T>() {
}
}

export async function untilQueryLoaded<T>(queryRet: QueryRet<T>, signal?: AbortSignal): Promise<T> {
/**
* Compose query.
* - If the query is loading, wait until it's done.
* - If the query failed, error will be thrown.
* - If the query is successful, the data will be returned.
* - Composed query will be collected as dependencies.
*/
export async function composeQuery<T>(queryRet: QueryRet<T>, signal?: AbortSignal): Promise<T> {
// Trigger failed query to refetch. `timeout(0)` to avoid dependency cycle.
await timeout(0)
if (!queryRet.isLoading.value && queryRet.error.value != null) {
queryRet.refetch(signal)
}
timeout(0).then(() => {
if (!queryRet.isLoading.value && queryRet.error.value != null) {
queryRet.refetch(signal)
}
})

queryRet.isLoading.value // Trigger dependency collection

return new Promise<T>((resolve, reject) => {
until(() => !queryRet.isLoading.value, signal).then(() => {
Expand Down
6 changes: 4 additions & 2 deletions spx-gui/src/utils/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export class TaskManager<P extends any[], T> {

async start(...params: P) {
const lastTask = this.currentTaskRef.value
if (lastTask != null) lastTask.ctrl.abort(new Cancelled('Cancelled for new task'))
if (lastTask != null) lastTask.ctrl.abort(new Cancelled('new task'))

const ctrl = new AbortController()
const task = shallowReactive<Task<T>>({ ctrl, data: null, error: null })
Expand All @@ -29,14 +29,16 @@ export class TaskManager<P extends any[], T> {
ctrl.signal.throwIfAborted()
task.data = data
} catch (e) {
if (e instanceof Cancelled) return
console.warn('Task failed:', e)
task.error = e
}
}

stop() {
const currentTask = this.currentTaskRef.value
if (currentTask != null) {
currentTask.ctrl.abort(new Cancelled('Cancelled by `stop`'))
currentTask.ctrl.abort(new Cancelled('stop'))
this.currentTaskRef.value = null
}
}
Expand Down
7 changes: 6 additions & 1 deletion spx-gui/src/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
* const foo = await untilNotNull(fooRef)
* const bar = await untilNotNull(() => getBar())
* ```
* NOTE: Give value will not be collected as dependency.
*/
export function untilNotNull<T>(valueSource: WatchSource<T | null | undefined>, signal?: AbortSignal) {
return untilConditionMet(
Expand All @@ -129,7 +130,10 @@ export function untilNotNull<T>(valueSource: WatchSource<T | null | undefined>,
) as Promise<NonNullable<T>>
}

/** Wait until given condition is met. */
/**
* Wait until given condition is met.
* NOTE: Give condition will not be collected as dependency.
*/
export async function until(conditionSource: WatchSource<boolean>, signal?: AbortSignal) {
await untilConditionMet(conditionSource, (c) => c, signal)
}
Expand All @@ -140,6 +144,7 @@ export async function until(conditionSource: WatchSource<boolean>, signal?: Abor
* const foo = await untilConditionMet(fooRef, (value) => value !== null)
* const bar = await untilConditionMet(() => getBar(), (value) => value > 10)
* ```
* NOTE: Give value will not be collected as dependency.
*/
function untilConditionMet<T>(
valueSource: WatchSource<T>,
Expand Down
Loading

0 comments on commit 313bfa0

Please sign in to comment.