···33import type { ErrorWithDiff, File } from 'vitest'
44import { createTooltip, destroyTooltip } from 'floating-vue'
55import { client, isReport } from '~/composables/client'
66+import { finished } from '~/composables/client/state'
67import { codemirrorRef } from '~/composables/codemirror'
78import { openInEditor } from '~/composables/error'
89import { lineNumber } from '~/composables/params'
···1718const serverCode = shallowRef<string | undefined>(undefined)
1819const draft = ref(false)
1920const loading = ref(true)
2121+const saving = ref(false)
2222+const currentPosition = ref<CodeMirror.Position | undefined>()
20232124watch(
2225 () => props.file,
2326 async () => {
2727+ // this watcher will be called multiple times when saving the file in the view editor
2828+ // since we are saving the file and changing the content inside onSave we just return here
2929+ if (saving.value) {
3030+ return
3131+ }
2432 loading.value = true
2533 try {
2634 if (!props.file || !props.file?.filepath) {
2735 code.value = ''
2836 serverCode.value = code.value
2937 draft.value = false
3838+ loading.value = false
3039 return
3140 }
3241···3443 serverCode.value = code.value
3544 draft.value = false
3645 }
3737- finally {
3838- // fire focusing editor after loading
3939- nextTick(() => (loading.value = false))
4646+ catch (e) {
4747+ console.error('cannot fetch file', e)
4048 }
4949+5050+ await nextTick()
5151+5252+ // fire focusing editor after loading
5353+ loading.value = false
4154 },
4255 { immediate: true },
4356)
44574545-watch(() => [loading.value, props.file, lineNumber.value] as const, ([loadingFile, _, l]) => {
4646- if (!loadingFile) {
5858+watch(() => [loading.value, saving.value, props.file, lineNumber.value] as const, ([loadingFile, s, _, l]) => {
5959+ if (!loadingFile && !s) {
4760 if (l != null) {
4861 nextTick(() => {
4949- const line = { line: l ?? 0, ch: 0 }
5050- codemirrorRef.value?.scrollIntoView(line, 100)
5151- nextTick(() => {
5252- codemirrorRef.value?.focus()
5353- codemirrorRef.value?.setCursor(line)
5454- })
6262+ const cp = currentPosition.value
6363+ const line = cp ?? { line: l ?? 0, ch: 0 }
6464+ // restore caret position: the watchDebounced below will use old value
6565+ if (cp) {
6666+ currentPosition.value = undefined
6767+ }
6868+ else {
6969+ codemirrorRef.value?.scrollIntoView(line, 100)
7070+ nextTick(() => {
7171+ codemirrorRef.value?.focus()
7272+ codemirrorRef.value?.setCursor(line)
7373+ })
7474+ }
5575 })
5676 }
5777 else {
···6585const ext = computed(() => props.file?.filepath?.split(/\./g).pop() || 'js')
6686const editor = ref<any>()
67876868-const cm = computed<CodeMirror.EditorFromTextArea | undefined>(
6969- () => editor.value?.cm,
7070-)
7188const failed = computed(
7289 () => props.file?.tasks.filter(i => i.result?.state === 'fail') || [],
7390)
7474-7591const widgets: CodeMirror.LineWidget[] = []
7692const handles: CodeMirror.LineHandle[] = []
7793const listeners: [el: HTMLSpanElement, l: EventListener, t: () => void][] = []
···134150 const el: EventListener = async () => {
135151 await openInEditor(stack.file, stack.line, stack.column)
136152 }
153153+ span.addEventListener('click', el)
137154 div.appendChild(span)
138155 listeners.push([span, el, () => destroyTooltip(span)])
139156 handles.push(codemirrorRef.value!.addLineClass(stack.line - 1, 'wrap', 'bg-red-500/10'))
140157 widgets.push(codemirrorRef.value!.addLineWidget(stack.line - 1, div))
141158}
142159143143-watch(
144144- [cm, failed],
145145- ([cmValue]) => {
160160+const { pause, resume } = watch(
161161+ [codemirrorRef, failed, finished] as const,
162162+ ([cmValue, f, end]) => {
146163 if (!cmValue) {
164164+ widgets.length = 0
165165+ handles.length = 0
147166 clearListeners()
148167 return
149168 }
150169170170+ // if still running
171171+ if (!end) {
172172+ return
173173+ }
174174+175175+ // cleanup previous data when not saving just reloading
176176+ cmValue.off('changes', codemirrorChanges)
177177+178178+ // cleanup previous data
179179+ clearListeners()
180180+ widgets.forEach(widget => widget.clear())
181181+ handles.forEach(h => cmValue?.removeLineClass(h, 'wrap'))
182182+ widgets.length = 0
183183+ handles.length = 0
184184+151185 setTimeout(() => {
152152- clearListeners()
153153- widgets.forEach(widget => widget.clear())
154154- handles.forEach(h => codemirrorRef.value?.removeLineClass(h, 'wrap'))
155155- widgets.length = 0
156156- handles.length = 0
157157-158158- cmValue.on('changes', codemirrorChanges)
159159-160160- failed.value.forEach((i) => {
186186+ // add new data
187187+ f.forEach((i) => {
161188 i.result?.errors?.forEach(createErrorElement)
162189 })
190190+191191+ // Prevent getting access to initial state
163192 if (!hasBeenEdited.value) {
164193 cmValue.clearHistory()
165165- } // Prevent getting access to initial state
194194+ }
195195+196196+ cmValue.on('changes', codemirrorChanges)
166197 }, 100)
167198 },
168199 { flush: 'post' },
169200)
170201202202+watchDebounced(() => [finished.value, saving.value, currentPosition.value] as const, ([f, s], old) => {
203203+ if (f && !s && old && old[2]) {
204204+ codemirrorRef.value?.setCursor(old[2])
205205+ }
206206+}, { debounce: 100, flush: 'post' })
207207+171208async function onSave(content: string) {
172172- hasBeenEdited.value = true
173173- await client.rpc.saveTestFile(props.file!.filepath, content)
174174- serverCode.value = content
175175- draft.value = false
209209+ if (saving.value) {
210210+ return
211211+ }
212212+ pause()
213213+ saving.value = true
214214+ await nextTick()
215215+216216+ // clear previous state
217217+ const cmValue = codemirrorRef.value
218218+ if (cmValue) {
219219+ cmValue.setOption('readOnly', true)
220220+ await nextTick()
221221+ cmValue.refresh()
222222+ }
223223+ // save cursor position
224224+ currentPosition.value = cmValue?.getCursor()
225225+ cmValue?.off('changes', codemirrorChanges)
226226+227227+ // cleanup previous data
228228+ clearListeners()
229229+ widgets.forEach(widget => widget.clear())
230230+ handles.forEach(h => cmValue?.removeLineClass(h, 'wrap'))
231231+ widgets.length = 0
232232+ handles.length = 0
233233+234234+ try {
235235+ hasBeenEdited.value = true
236236+ // save the file changes
237237+ await client.rpc.saveTestFile(props.file!.filepath, content)
238238+ // update original server code
239239+ serverCode.value = content
240240+ // update draft indicator in the tab title (</> * Code)
241241+ draft.value = false
242242+ }
243243+ catch (e) {
244244+ console.error('error saving file', e)
245245+ }
246246+247247+ // Prevent getting access to initial state
248248+ if (!hasBeenEdited.value) {
249249+ cmValue?.clearHistory()
250250+ }
251251+252252+ try {
253253+ // the server will send a few events in a row
254254+ // await to re-run test
255255+ await until(finished).toBe(false, { flush: 'sync', timeout: 1000, throwOnTimeout: true })
256256+ // await to finish
257257+ await until(finished).toBe(true, { flush: 'sync', timeout: 1000, throwOnTimeout: false })
258258+ }
259259+ catch {
260260+ // ignore errors
261261+ }
262262+263263+ // add new data
264264+ failed.value.forEach((i) => {
265265+ i.result?.errors?.forEach(createErrorElement)
266266+ })
267267+268268+ cmValue?.on('changes', codemirrorChanges)
269269+270270+ saving.value = false
271271+ await nextTick()
272272+ if (cmValue) {
273273+ cmValue.setOption('readOnly', false)
274274+ await nextTick()
275275+ cmValue.refresh()
276276+ }
277277+ // activate watcher
278278+ resume()
176279}
280280+281281+// we need to remove listeners before unmounting the component: the watcher will not be called
282282+onBeforeUnmount(clearListeners)
177283</script>
178284179285<template>
···181287 ref="editor"
182288 v-model="code"
183289 h-full
184184- v-bind="{ lineNumbers: true, readOnly: isReport }"
290290+ v-bind="{ lineNumbers: true, readOnly: isReport, saving }"
185291 :mode="ext"
186292 data-testid="code-mirror"
187293 @save="onSave"
+5-1
packages/ui/client/composables/client/index.ts
···3232 },
3333 onFinished(_files, errors) {
3434 explorerTree.endRun()
3535- testRunState.value = 'idle'
3535+ // don't change the testRunState.value here:
3636+ // - when saving the file in the codemirror requires explorer tree endRun to finish (multiple microtasks)
3737+ // - if we change here the state before the tasks states are updated, the cursor position will be lost
3838+ // - line moved to composables/explorer/collector.ts::refreshExplorer after calling updateRunningTodoTests
3939+ // testRunState.value = 'idle'
3640 unhandledErrors.value = (errors || []).map(parseError)
3741 },
3842 onFinishedReportCoverage() {