Skip to content

Commit faca4de

Browse files
authoredJan 14, 2025··
fix(ui): add errors and draft state (*) to the code editor (#7044)
1 parent 9b21885 commit faca4de

File tree

6 files changed

+160
-37
lines changed

6 files changed

+160
-37
lines changed
 

‎packages/ui/client/components/CodeMirrorContainer.vue

+10-3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { codemirrorRef } from '~/composables/codemirror'
44
const { mode, readOnly } = defineProps<{
55
mode?: string
66
readOnly?: boolean
7+
saving?: boolean
78
}>()
89
910
const emit = defineEmits<{
@@ -38,10 +39,16 @@ onMounted(async () => {
3839
readOnly: readOnly ? true : undefined,
3940
extraKeys: {
4041
'Cmd-S': function (cm) {
41-
emit('save', cm.getValue())
42+
const isReadonly = cm.getOption('readOnly')
43+
if (!isReadonly) {
44+
emit('save', cm.getValue())
45+
}
4246
},
4347
'Ctrl-S': function (cm) {
44-
emit('save', cm.getValue())
48+
const isReadonly = cm.getOption('readOnly')
49+
if (!isReadonly) {
50+
emit('save', cm.getValue())
51+
}
4552
},
4653
},
4754
})
@@ -53,7 +60,7 @@ onMounted(async () => {
5360
</script>
5461

5562
<template>
56-
<div relative font-mono text-sm class="codemirror-scrolls">
63+
<div relative font-mono text-sm class="codemirror-scrolls" :class="saving ? 'codemirror-busy' : undefined">
5764
<textarea ref="el" />
5865
</div>
5966
</template>

‎packages/ui/client/components/FileDetails.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ const projectNameTextColor = computed(() => {
253253
</div>
254254
<ViewEditor
255255
v-if="viewMode === 'editor'"
256-
:key="current.filepath"
256+
:key="current.id"
257257
:file="current"
258258
data-testid="editor"
259259
@draft="onDraft"

‎packages/ui/client/components/views/ViewEditor.vue

+138-32
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type CodeMirror from 'codemirror'
33
import type { ErrorWithDiff, File } from 'vitest'
44
import { createTooltip, destroyTooltip } from 'floating-vue'
55
import { client, isReport } from '~/composables/client'
6+
import { finished } from '~/composables/client/state'
67
import { codemirrorRef } from '~/composables/codemirror'
78
import { openInEditor } from '~/composables/error'
89
import { lineNumber } from '~/composables/params'
@@ -17,41 +18,60 @@ const code = ref('')
1718
const serverCode = shallowRef<string | undefined>(undefined)
1819
const draft = ref(false)
1920
const loading = ref(true)
21+
const saving = ref(false)
22+
const currentPosition = ref<CodeMirror.Position | undefined>()
2023
2124
watch(
2225
() => props.file,
2326
async () => {
27+
// this watcher will be called multiple times when saving the file in the view editor
28+
// since we are saving the file and changing the content inside onSave we just return here
29+
if (saving.value) {
30+
return
31+
}
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
38+
loading.value = false
3039
return
3140
}
3241
3342
code.value = (await client.rpc.readTestFile(props.file.filepath)) || ''
3443
serverCode.value = code.value
3544
draft.value = false
3645
}
37-
finally {
38-
// fire focusing editor after loading
39-
nextTick(() => (loading.value = false))
46+
catch (e) {
47+
console.error('cannot fetch file', e)
4048
}
49+
50+
await nextTick()
51+
52+
// fire focusing editor after loading
53+
loading.value = false
4154
},
4255
{ immediate: true },
4356
)
4457
45-
watch(() => [loading.value, props.file, lineNumber.value] as const, ([loadingFile, _, l]) => {
46-
if (!loadingFile) {
58+
watch(() => [loading.value, saving.value, props.file, lineNumber.value] as const, ([loadingFile, s, _, l]) => {
59+
if (!loadingFile && !s) {
4760
if (l != null) {
4861
nextTick(() => {
49-
const line = { line: l ?? 0, ch: 0 }
50-
codemirrorRef.value?.scrollIntoView(line, 100)
51-
nextTick(() => {
52-
codemirrorRef.value?.focus()
53-
codemirrorRef.value?.setCursor(line)
54-
})
62+
const cp = currentPosition.value
63+
const line = cp ?? { line: l ?? 0, ch: 0 }
64+
// restore caret position: the watchDebounced below will use old value
65+
if (cp) {
66+
currentPosition.value = undefined
67+
}
68+
else {
69+
codemirrorRef.value?.scrollIntoView(line, 100)
70+
nextTick(() => {
71+
codemirrorRef.value?.focus()
72+
codemirrorRef.value?.setCursor(line)
73+
})
74+
}
5575
})
5676
}
5777
else {
@@ -65,13 +85,9 @@ watch(() => [loading.value, props.file, lineNumber.value] as const, ([loadingFil
6585
const ext = computed(() => props.file?.filepath?.split(/\./g).pop() || 'js')
6686
const editor = ref<any>()
6787
68-
const cm = computed<CodeMirror.EditorFromTextArea | undefined>(
69-
() => editor.value?.cm,
70-
)
7188
const failed = computed(
7289
() => props.file?.tasks.filter(i => i.result?.state === 'fail') || [],
7390
)
74-
7591
const widgets: CodeMirror.LineWidget[] = []
7692
const handles: CodeMirror.LineHandle[] = []
7793
const listeners: [el: HTMLSpanElement, l: EventListener, t: () => void][] = []
@@ -134,54 +150,144 @@ function createErrorElement(e: ErrorWithDiff) {
134150
const el: EventListener = async () => {
135151
await openInEditor(stack.file, stack.line, stack.column)
136152
}
153+
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
}
142159
143-
watch(
144-
[cm, failed],
145-
([cmValue]) => {
160+
const { pause, resume } = watch(
161+
[codemirrorRef, failed, finished] as const,
162+
([cmValue, f, end]) => {
146163
if (!cmValue) {
164+
widgets.length = 0
165+
handles.length = 0
147166
clearListeners()
148167
return
149168
}
150169
151-
setTimeout(() => {
152-
clearListeners()
153-
widgets.forEach(widget => widget.clear())
154-
handles.forEach(h => codemirrorRef.value?.removeLineClass(h, 'wrap'))
155-
widgets.length = 0
156-
handles.length = 0
170+
// if still running
171+
if (!end) {
172+
return
173+
}
157174
158-
cmValue.on('changes', codemirrorChanges)
175+
// cleanup previous data when not saving just reloading
176+
cmValue.off('changes', codemirrorChanges)
177+
178+
// cleanup previous data
179+
clearListeners()
180+
widgets.forEach(widget => widget.clear())
181+
handles.forEach(h => cmValue?.removeLineClass(h, 'wrap'))
182+
widgets.length = 0
183+
handles.length = 0
159184
160-
failed.value.forEach((i) => {
185+
setTimeout(() => {
186+
// add new data
187+
f.forEach((i) => {
161188
i.result?.errors?.forEach(createErrorElement)
162189
})
190+
191+
// Prevent getting access to initial state
163192
if (!hasBeenEdited.value) {
164193
cmValue.clearHistory()
165-
} // Prevent getting access to initial state
194+
}
195+
196+
cmValue.on('changes', codemirrorChanges)
166197
}, 100)
167198
},
168199
{ flush: 'post' },
169200
)
170201
202+
watchDebounced(() => [finished.value, saving.value, currentPosition.value] as const, ([f, s], old) => {
203+
if (f && !s && old && old[2]) {
204+
codemirrorRef.value?.setCursor(old[2])
205+
}
206+
}, { debounce: 100, flush: 'post' })
207+
171208
async function onSave(content: string) {
172-
hasBeenEdited.value = true
173-
await client.rpc.saveTestFile(props.file!.filepath, content)
174-
serverCode.value = content
175-
draft.value = false
209+
if (saving.value) {
210+
return
211+
}
212+
pause()
213+
saving.value = true
214+
await nextTick()
215+
216+
// clear previous state
217+
const cmValue = codemirrorRef.value
218+
if (cmValue) {
219+
cmValue.setOption('readOnly', true)
220+
await nextTick()
221+
cmValue.refresh()
222+
}
223+
// save cursor position
224+
currentPosition.value = cmValue?.getCursor()
225+
cmValue?.off('changes', codemirrorChanges)
226+
227+
// cleanup previous data
228+
clearListeners()
229+
widgets.forEach(widget => widget.clear())
230+
handles.forEach(h => cmValue?.removeLineClass(h, 'wrap'))
231+
widgets.length = 0
232+
handles.length = 0
233+
234+
try {
235+
hasBeenEdited.value = true
236+
// save the file changes
237+
await client.rpc.saveTestFile(props.file!.filepath, content)
238+
// update original server code
239+
serverCode.value = content
240+
// update draft indicator in the tab title (</> * Code)
241+
draft.value = false
242+
}
243+
catch (e) {
244+
console.error('error saving file', e)
245+
}
246+
247+
// Prevent getting access to initial state
248+
if (!hasBeenEdited.value) {
249+
cmValue?.clearHistory()
250+
}
251+
252+
try {
253+
// the server will send a few events in a row
254+
// await to re-run test
255+
await until(finished).toBe(false, { flush: 'sync', timeout: 1000, throwOnTimeout: true })
256+
// await to finish
257+
await until(finished).toBe(true, { flush: 'sync', timeout: 1000, throwOnTimeout: false })
258+
}
259+
catch {
260+
// ignore errors
261+
}
262+
263+
// add new data
264+
failed.value.forEach((i) => {
265+
i.result?.errors?.forEach(createErrorElement)
266+
})
267+
268+
cmValue?.on('changes', codemirrorChanges)
269+
270+
saving.value = false
271+
await nextTick()
272+
if (cmValue) {
273+
cmValue.setOption('readOnly', false)
274+
await nextTick()
275+
cmValue.refresh()
276+
}
277+
// activate watcher
278+
resume()
176279
}
280+
281+
// we need to remove listeners before unmounting the component: the watcher will not be called
282+
onBeforeUnmount(clearListeners)
177283
</script>
178284

179285
<template>
180286
<CodeMirrorContainer
181287
ref="editor"
182288
v-model="code"
183289
h-full
184-
v-bind="{ lineNumbers: true, readOnly: isReport }"
290+
v-bind="{ lineNumbers: true, readOnly: isReport, saving }"
185291
:mode="ext"
186292
data-testid="code-mirror"
187293
@save="onSave"

‎packages/ui/client/composables/client/index.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ export const client = (function createVitestClient() {
3232
},
3333
onFinished(_files, errors) {
3434
explorerTree.endRun()
35-
testRunState.value = 'idle'
35+
// don't change the testRunState.value here:
36+
// - when saving the file in the codemirror requires explorer tree endRun to finish (multiple microtasks)
37+
// - if we change here the state before the tasks states are updated, the cursor position will be lost
38+
// - line moved to composables/explorer/collector.ts::refreshExplorer after calling updateRunningTodoTests
39+
// testRunState.value = 'idle'
3640
unhandledErrors.value = (errors || []).map(parseError)
3741
},
3842
onFinishedReportCoverage() {

‎packages/ui/client/composables/explorer/collector.ts

+2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { isTestCase } from '@vitest/runner/utils'
55
import { toArray } from '@vitest/utils'
66
import { hasFailedSnapshot } from '@vitest/ws-client'
77
import { client, findById } from '~/composables/client'
8+
import { testRunState } from '~/composables/client/state'
89
import { expandNodesOnEndRun } from '~/composables/explorer/expand'
910
import { runFilter, testMatcher } from '~/composables/explorer/filter'
1011
import { explorerTree } from '~/composables/explorer/index'
@@ -234,6 +235,7 @@ function refreshExplorer(search: string, filter: Filter, end: boolean) {
234235
// update only at the end
235236
if (end) {
236237
updateRunningTodoTests()
238+
testRunState.value = 'idle'
237239
}
238240
}
239241

‎packages/ui/client/styles/main.css

+4
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,7 @@ html.dark {
211211
.v-popper__popper .v-popper__arrow-outer {
212212
border-color: var(--background-color);
213213
}
214+
215+
.codemirror-busy > .CodeMirror > .CodeMirror-scroll > .CodeMirror-sizer .CodeMirror-lines {
216+
cursor: wait !important;
217+
}

0 commit comments

Comments
 (0)
Please sign in to comment.