Skip to content

Commit

Permalink
feat: better code completions
Browse files Browse the repository at this point in the history
  • Loading branch information
nextchamp-saqib committed Dec 31, 2024
1 parent ef6a0bd commit f3914d2
Show file tree
Hide file tree
Showing 7 changed files with 1,289 additions and 338 deletions.
20 changes: 20 additions & 0 deletions frontend/src2/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { FIELDTYPES } from './constants'
import { createToast } from './toasts'
import { getFormattedDate } from '../query/helpers'
import { call } from 'frappe-ui'

export function getUniqueId(length = 8) {
return (+new Date() * Math.random()).toString(36).substring(0, length)
Expand Down Expand Up @@ -431,3 +432,22 @@ function areValidDates(data: string[]) {
function isValidDate(value: string) {
return !isNaN(new Date(value).getTime())
}

const fetchCache = new Map<string, any>()
export function fetchCall(url: string, options: any): Promise<any> {
// a function that makes a fetch call, but also caches the response for the same url & options
const key = JSON.stringify({ url, options })
if (fetchCache.has(key)) {
return Promise.resolve(fetchCache.get(key))
}

return call(url, options)
.then((response: any) => {
fetchCache.set(key, response)
return response
})
.catch((err: Error) => {
fetchCache.delete(key)
throw err
})
}
176 changes: 132 additions & 44 deletions frontend/src2/query/components/ExpressionEditor.vue
Original file line number Diff line number Diff line change
@@ -1,73 +1,161 @@
<script setup lang="ts">
import { call } from 'frappe-ui'
import { ref } from 'vue'
import { debounce } from 'frappe-ui'
import { nextTick, ref } from 'vue'
import Code from '../../components/Code.vue'
import { fetchCall } from '../../helpers'
import { ColumnOption } from '../../types/query.types'
const props = defineProps<{ columnOptions: ColumnOption[]; placeholder?: string }>()
const expression = defineModel<string>({
required: true,
})
const functionList = ref<string[]>([])
call('insights.insights.doctype.insights_data_source_v3.ibis_functions.get_function_list').then(
(res: any) => {
functionList.value = res
}
)
const codeContainer = ref<HTMLElement | null>(null)
const suggestionElement = ref<HTMLElement | null>(null)
const cursorElementClass = '.cm-cursor.cm-cursor-primary'
type Completion = {
name: string
type: string
completion: string
}
const completions = ref<Completion[]>([])
type FunctionSignature = {
name: string
definition: string
description: string
current_param: string
current_param_description: string
params: { name: string; description: string }[]
}
const currentFunctionSignature = ref<FunctionSignature>()
const fetchCompletions = debounce((args: any) => {
const cursor_pos = args.cursorPos
let code = expression.value
code = code.slice(0, cursor_pos) + '|' + code.slice(cursor_pos)
function getCompletions(context: any, syntaxTree: any) {
const word = context.matchBefore(/\w+/)
const nodeBefore = syntaxTree.resolveInner(context.pos, -1)
fetchCall('insights.insights.doctype.insights_data_source_v3.ibis.utils.get_code_completions', {
code,
columns: props.columnOptions.map((column) => column.label),
})
.then((res: any) => {
completions.value = res.completions
currentFunctionSignature.value = res.current_function
// if there is a current_param, then we need to update the definition
// add <b> & underline tags before and after the current_param value in the definition
if (currentFunctionSignature.value?.current_param) {
const current_param = res.current_function.current_param
const definition = res.current_function.definition
const current_param_index = definition.indexOf(current_param)
if (current_param_index !== -1) {
const updated_definition =
definition.slice(0, current_param_index) +
`<b><u>${current_param}</u></b>` +
definition.slice(current_param_index + current_param.length)
currentFunctionSignature.value.definition = updated_definition
}
}
})
.catch((e: any) => {
console.error(e)
})
}, 500)
if (nodeBefore.name === 'VariableName') {
const columnMatches = getColumnMatches(word.text)
const functionMatches = getFunctionMatches(word.text)
return {
from: word.from,
options: [...columnMatches, ...functionMatches],
}
}
if (nodeBefore.name) {
const columnMatches = getColumnMatches(nodeBefore.name)
const functionMatches = getFunctionMatches(nodeBefore.name)
return {
from: nodeBefore.from,
options: [...columnMatches, ...functionMatches],
function setSuggestionElementPosition() {
// get left & top positions of the cursor
// set the suggestion element to that position
setTimeout(() => {
const containerRect = codeContainer.value?.getBoundingClientRect()
const cursorElement = codeContainer.value?.querySelector(cursorElementClass)
const cursorRect = cursorElement?.getBoundingClientRect()
if (cursorRect && containerRect) {
let left = cursorRect.left
let top = cursorRect.top + 20
if (left <= 0 || top <= 0) {
return
}
suggestionElement.value!.style.left = `${left}px`
suggestionElement.value!.style.top = `${top}px`
}
}
}, 100)
}
function getColumnMatches(word: string) {
return props.columnOptions
.filter((c) => c.value.includes(word))
.map((c) => ({
label: c.value,
detail: 'column',
}))
}
const codeEditor = ref<any>(null)
function applyCompletion(completion: any) {
const currentCursorPos = codeEditor.value.cursorPos
const expressionValue = expression.value
const newExpressionValue =
expressionValue.slice(0, currentCursorPos) +
completion.completion +
expressionValue.slice(currentCursorPos)
expression.value = newExpressionValue
function getFunctionMatches(word: string) {
return functionList.value
.filter((f) => f.includes(word))
.map((f) => ({
label: f,
apply: `${f}()`,
detail: 'function',
}))
codeEditor.value.focus()
let newCursorPos = codeEditor.value.cursorPos + completion.completion.length
if (completion.type === 'function') {
newCursorPos -= 1
}
nextTick(() => {
codeEditor.value.setCursorPos(newCursorPos)
})
}
</script>

<template>
<div ref="codeContainer" class="flex h-[14rem] w-full overflow-scroll rounded border text-base">
<div
ref="codeContainer"
class="relative flex h-[14rem] w-full overflow-scroll rounded border text-base"
>
<Code
ref="codeEditor"
language="python"
class="column-expression"
v-model="expression"
:placeholder="placeholder"
:completions="getCompletions"
:completions="() => undefined"
@view-update="
(args) => {
fetchCompletions(args), setSuggestionElementPosition()
}
"
></Code>

<Teleport to="body">
<div
ref="suggestionElement"
class="absolute z-10 mt-1 h-fit max-h-[14rem] min-w-[14rem] max-w-[26rem] overflow-y-auto rounded-lg bg-white p-1.5 shadow-2xl transition-all"
:class="completions.length || currentFunctionSignature ? 'block' : 'hidden'"
>
<div v-if="currentFunctionSignature?.description" class="flex flex-col gap-2">
<p
v-if="currentFunctionSignature.definition"
v-html="currentFunctionSignature.definition"
class="font-mono text-p-sm text-gray-700"
></p>
<hr v-if="currentFunctionSignature.definition" />
<div class="whitespace-pre-wrap font-mono text-p-sm text-gray-700">
{{ currentFunctionSignature.description }}
</div>
</div>

<div v-else class="relative">
<ul>
<li
class="flex h-7 cursor-pointer items-center justify-between rounded px-2.5 text-base hover:bg-gray-100"
v-for="completion in completions"
:key="completion.name"
@click.prevent.stop="applyCompletion(completion)"
>
<span class="flex-[2] truncate">{{ completion.name }}</span>
<span class="flex-1 truncate text-right text-sm text-gray-600">
{{ completion.type }}
</span>
</li>
</ul>
</div>
</div>
</Teleport>
</div>
</template>

Expand Down
Empty file.
Loading

0 comments on commit f3914d2

Please sign in to comment.