Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(ui): add errors and draft state (*) to the code editor #7044

Merged
merged 5 commits into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions packages/ui/client/components/CodeMirrorContainer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { codemirrorRef } from '~/composables/codemirror'
const { mode, readOnly } = defineProps<{
mode?: string
readOnly?: boolean
saving?: boolean
}>()

const emit = defineEmits<{
Expand Down Expand Up @@ -38,10 +39,16 @@ onMounted(async () => {
readOnly: readOnly ? true : undefined,
extraKeys: {
'Cmd-S': function (cm) {
emit('save', cm.getValue())
const isReadonly = cm.getOption('readOnly')
if (!isReadonly) {
emit('save', cm.getValue())
}
},
'Ctrl-S': function (cm) {
emit('save', cm.getValue())
const isReadonly = cm.getOption('readOnly')
if (!isReadonly) {
emit('save', cm.getValue())
}
},
},
})
Expand All @@ -53,7 +60,7 @@ onMounted(async () => {
</script>

<template>
<div relative font-mono text-sm class="codemirror-scrolls">
<div relative font-mono text-sm class="codemirror-scrolls" :class="saving ? 'codemirror-busy' : undefined">
<textarea ref="el" />
</div>
</template>
2 changes: 1 addition & 1 deletion packages/ui/client/components/FileDetails.vue
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ const projectNameTextColor = computed(() => {
</div>
<ViewEditor
v-if="viewMode === 'editor'"
:key="current.filepath"
:key="current.id"
:file="current"
data-testid="editor"
@draft="onDraft"
Expand Down
170 changes: 138 additions & 32 deletions packages/ui/client/components/views/ViewEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type CodeMirror from 'codemirror'
import type { ErrorWithDiff, File } from 'vitest'
import { createTooltip, destroyTooltip } from 'floating-vue'
import { client, isReport } from '~/composables/client'
import { finished } from '~/composables/client/state'
import { codemirrorRef } from '~/composables/codemirror'
import { openInEditor } from '~/composables/error'
import { lineNumber } from '~/composables/params'
Expand All @@ -17,41 +18,60 @@ const code = ref('')
const serverCode = shallowRef<string | undefined>(undefined)
const draft = ref(false)
const loading = ref(true)
const saving = ref(false)
const currentPosition = ref<CodeMirror.Position | undefined>()

watch(
() => props.file,
async () => {
// this watcher will be called multiple times when saving the file in the view editor
// since we are saving the file and changing the content inside onSave we just return here
if (saving.value) {
return
}
loading.value = true
try {
if (!props.file || !props.file?.filepath) {
code.value = ''
serverCode.value = code.value
draft.value = false
loading.value = false
return
}

code.value = (await client.rpc.readTestFile(props.file.filepath)) || ''
serverCode.value = code.value
draft.value = false
}
finally {
// fire focusing editor after loading
nextTick(() => (loading.value = false))
catch (e) {
console.error('cannot fetch file', e)
}

await nextTick()

// fire focusing editor after loading
loading.value = false
},
{ immediate: true },
)

watch(() => [loading.value, props.file, lineNumber.value] as const, ([loadingFile, _, l]) => {
if (!loadingFile) {
watch(() => [loading.value, saving.value, props.file, lineNumber.value] as const, ([loadingFile, s, _, l]) => {
if (!loadingFile && !s) {
if (l != null) {
nextTick(() => {
const line = { line: l ?? 0, ch: 0 }
codemirrorRef.value?.scrollIntoView(line, 100)
nextTick(() => {
codemirrorRef.value?.focus()
codemirrorRef.value?.setCursor(line)
})
const cp = currentPosition.value
const line = cp ?? { line: l ?? 0, ch: 0 }
// restore caret position: the watchDebounced below will use old value
if (cp) {
currentPosition.value = undefined
}
else {
codemirrorRef.value?.scrollIntoView(line, 100)
nextTick(() => {
codemirrorRef.value?.focus()
codemirrorRef.value?.setCursor(line)
})
}
})
}
else {
Expand All @@ -65,13 +85,9 @@ watch(() => [loading.value, props.file, lineNumber.value] as const, ([loadingFil
const ext = computed(() => props.file?.filepath?.split(/\./g).pop() || 'js')
const editor = ref<any>()

const cm = computed<CodeMirror.EditorFromTextArea | undefined>(
() => editor.value?.cm,
)
const failed = computed(
() => props.file?.tasks.filter(i => i.result?.state === 'fail') || [],
)

const widgets: CodeMirror.LineWidget[] = []
const handles: CodeMirror.LineHandle[] = []
const listeners: [el: HTMLSpanElement, l: EventListener, t: () => void][] = []
Expand Down Expand Up @@ -134,54 +150,144 @@ function createErrorElement(e: ErrorWithDiff) {
const el: EventListener = async () => {
await openInEditor(stack.file, stack.line, stack.column)
}
span.addEventListener('click', el)
div.appendChild(span)
listeners.push([span, el, () => destroyTooltip(span)])
handles.push(codemirrorRef.value!.addLineClass(stack.line - 1, 'wrap', 'bg-red-500/10'))
widgets.push(codemirrorRef.value!.addLineWidget(stack.line - 1, div))
}

watch(
[cm, failed],
([cmValue]) => {
const { pause, resume } = watch(
[codemirrorRef, failed, finished] as const,
([cmValue, f, end]) => {
if (!cmValue) {
widgets.length = 0
handles.length = 0
clearListeners()
return
}

setTimeout(() => {
clearListeners()
widgets.forEach(widget => widget.clear())
handles.forEach(h => codemirrorRef.value?.removeLineClass(h, 'wrap'))
widgets.length = 0
handles.length = 0
// if still running
if (!end) {
return
}

cmValue.on('changes', codemirrorChanges)
// cleanup previous data when not saving just reloading
cmValue.off('changes', codemirrorChanges)

// cleanup previous data
clearListeners()
widgets.forEach(widget => widget.clear())
handles.forEach(h => cmValue?.removeLineClass(h, 'wrap'))
widgets.length = 0
handles.length = 0

failed.value.forEach((i) => {
setTimeout(() => {
// add new data
f.forEach((i) => {
i.result?.errors?.forEach(createErrorElement)
})

// Prevent getting access to initial state
if (!hasBeenEdited.value) {
cmValue.clearHistory()
} // Prevent getting access to initial state
}

cmValue.on('changes', codemirrorChanges)
}, 100)
},
{ flush: 'post' },
)

watchDebounced(() => [finished.value, saving.value, currentPosition.value] as const, ([f, s], old) => {
if (f && !s && old && old[2]) {
codemirrorRef.value?.setCursor(old[2])
}
}, { debounce: 100, flush: 'post' })

async function onSave(content: string) {
hasBeenEdited.value = true
await client.rpc.saveTestFile(props.file!.filepath, content)
serverCode.value = content
draft.value = false
if (saving.value) {
return
}
pause()
saving.value = true
await nextTick()

// clear previous state
const cmValue = codemirrorRef.value
if (cmValue) {
cmValue.setOption('readOnly', true)
await nextTick()
cmValue.refresh()
}
// save cursor position
currentPosition.value = cmValue?.getCursor()
cmValue?.off('changes', codemirrorChanges)

// cleanup previous data
clearListeners()
widgets.forEach(widget => widget.clear())
handles.forEach(h => cmValue?.removeLineClass(h, 'wrap'))
widgets.length = 0
handles.length = 0

try {
hasBeenEdited.value = true
// save the file changes
await client.rpc.saveTestFile(props.file!.filepath, content)
// update original server code
serverCode.value = content
// update draft indicator in the tab title (</> * Code)
draft.value = false
}
catch (e) {
console.error('error saving file', e)
}

// Prevent getting access to initial state
if (!hasBeenEdited.value) {
cmValue?.clearHistory()
}

try {
// the server will send a few events in a row
// await to re-run test
await until(finished).toBe(false, { flush: 'sync', timeout: 1000, throwOnTimeout: true })
// await to finish
await until(finished).toBe(true, { flush: 'sync', timeout: 1000, throwOnTimeout: false })
}
catch {
// ignore errors
}

// add new data
failed.value.forEach((i) => {
i.result?.errors?.forEach(createErrorElement)
})

cmValue?.on('changes', codemirrorChanges)

saving.value = false
await nextTick()
if (cmValue) {
cmValue.setOption('readOnly', false)
await nextTick()
cmValue.refresh()
}
// activate watcher
resume()
}

// we need to remove listeners before unmounting the component: the watcher will not be called
onBeforeUnmount(clearListeners)
</script>

<template>
<CodeMirrorContainer
ref="editor"
v-model="code"
h-full
v-bind="{ lineNumbers: true, readOnly: isReport }"
v-bind="{ lineNumbers: true, readOnly: isReport, saving }"
:mode="ext"
data-testid="code-mirror"
@save="onSave"
Expand Down
6 changes: 5 additions & 1 deletion packages/ui/client/composables/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ export const client = (function createVitestClient() {
},
onFinished(_files, errors) {
explorerTree.endRun()
testRunState.value = 'idle'
// don't change the testRunState.value here:
// - when saving the file in the codemirror requires explorer tree endRun to finish (multiple microtasks)
// - if we change here the state before the tasks states are updated, the cursor position will be lost
// - line moved to composables/explorer/collector.ts::refreshExplorer after calling updateRunningTodoTests
// testRunState.value = 'idle'
unhandledErrors.value = (errors || []).map(parseError)
},
onFinishedReportCoverage() {
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/client/composables/explorer/collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { isTestCase } from '@vitest/runner/utils'
import { toArray } from '@vitest/utils'
import { hasFailedSnapshot } from '@vitest/ws-client'
import { client, findById } from '~/composables/client'
import { testRunState } from '~/composables/client/state'
import { expandNodesOnEndRun } from '~/composables/explorer/expand'
import { runFilter, testMatcher } from '~/composables/explorer/filter'
import { explorerTree } from '~/composables/explorer/index'
Expand Down Expand Up @@ -234,6 +235,7 @@ function refreshExplorer(search: string, filter: Filter, end: boolean) {
// update only at the end
if (end) {
updateRunningTodoTests()
testRunState.value = 'idle'
}
}

Expand Down
4 changes: 4 additions & 0 deletions packages/ui/client/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,7 @@ html.dark {
.v-popper__popper .v-popper__arrow-outer {
border-color: var(--background-color);
}

.codemirror-busy > .CodeMirror > .CodeMirror-scroll > .CodeMirror-sizer .CodeMirror-lines {
cursor: wait !important;
}
Loading