mirror of
https://github.com/enso-org/enso.git
synced 2024-11-22 22:10:15 +03:00
Refactor Input
class to be composable (#8244)
Part of #7926 I found myself wanting to use graph store in the `Input` class. As I learned about composables recently, I decided to refactor the class into a composable, to be more vue-like. Putting it in a separate PR because [other task may wanting to do the same](8066) Also contains some preparations for my actual implementation.
This commit is contained in:
parent
579d83a450
commit
3b12b6e17b
@ -2,7 +2,6 @@
|
||||
import { componentBrowserBindings } from '@/bindings'
|
||||
import { makeComponentList, type Component } from '@/components/ComponentBrowser/component'
|
||||
import { Filtering } from '@/components/ComponentBrowser/filtering'
|
||||
import { Input } from '@/components/ComponentBrowser/input'
|
||||
import { default as DocumentationPanel } from '@/components/DocumentationPanel.vue'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import ToggleIcon from '@/components/ToggleIcon.vue'
|
||||
@ -18,6 +17,7 @@ import { Vec2 } from '@/util/vec2'
|
||||
import type { SuggestionId } from 'shared/languageServerTypes/suggestions'
|
||||
import type { ContentRange } from 'shared/yjsModel.ts'
|
||||
import { computed, nextTick, onMounted, ref, watch, type Ref } from 'vue'
|
||||
import { useComponentBrowserInput } from './ComponentBrowser/input'
|
||||
|
||||
const ITEM_SIZE = 32
|
||||
const TOP_BAR_HEIGHT = 32
|
||||
@ -46,6 +46,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const suggestionDbStore = useSuggestionDbStore()
|
||||
|
||||
// === Position ===
|
||||
|
||||
@ -63,7 +64,7 @@ const transform = computed(() => {
|
||||
|
||||
const cbRoot = ref<HTMLElement>()
|
||||
const inputField = ref<HTMLInputElement>()
|
||||
const input = new Input()
|
||||
const input = useComponentBrowserInput()
|
||||
const filterFlags = ref({ showUnstable: false, showLocal: false })
|
||||
|
||||
const currentFiltering = computed(() => {
|
||||
@ -128,8 +129,6 @@ function handleDefocus(e: FocusEvent) {
|
||||
|
||||
// === Components List and Positions ===
|
||||
|
||||
const suggestionDbStore = useSuggestionDbStore()
|
||||
|
||||
const components = computed(() => {
|
||||
return makeComponentList(suggestionDbStore.entries, currentFiltering.value)
|
||||
})
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
} from '@/stores/suggestionDatabase/entry'
|
||||
import { readAstSpan } from '@/util/ast'
|
||||
import { expect, test } from 'vitest'
|
||||
import { Input } from '../input'
|
||||
import { useComponentBrowserInput } from '../input'
|
||||
|
||||
test.each([
|
||||
['', 0, { type: 'insert', position: 0 }, {}],
|
||||
@ -40,6 +40,20 @@ test.each([
|
||||
],
|
||||
['2 +', 3, { type: 'insert', position: 3, oprApp: ['2', '+', null] }, {}],
|
||||
['2 + 3', 5, { type: 'changeLiteral', literal: '3' }, { pattern: '3' }],
|
||||
// TODO[ao] test cases for #7926
|
||||
// [
|
||||
// 'operator1.',
|
||||
// 10,
|
||||
// { type: 'insert', position: 10, oprApp: ['operator1', '.', null] },
|
||||
// { selfType: 'Standard.Base.Number' },
|
||||
// ],
|
||||
// [
|
||||
// 'operator2.',
|
||||
// 10,
|
||||
// { type: 'insert', position: 10, oprApp: ['operator2', '.', null] },
|
||||
// // No self type, as the operator2 local is from another module
|
||||
// { qualifiedNamePattern: 'operator2' },
|
||||
// ],
|
||||
])(
|
||||
"Input context and filtering, when content is '%s' and cursor at %i",
|
||||
(
|
||||
@ -52,9 +66,16 @@ test.each([
|
||||
identifier?: string
|
||||
literal?: string
|
||||
},
|
||||
expFiltering: { pattern?: string; qualifiedNamePattern?: string },
|
||||
expFiltering: { pattern?: string; qualifiedNamePattern?: string; selfType?: string },
|
||||
) => {
|
||||
const input = new Input()
|
||||
// TODO[ao] See above commented cases for #7926
|
||||
// const db = SuggestionDb.mock([
|
||||
// makeLocal('local.Project', 'operator1', 'Standard.Base.Number'),
|
||||
// makeLocal('local.Project.Another_Module', 'operator2', 'Standard.Base.Text'),
|
||||
// makeType('local.Project.operator1'),
|
||||
// makeLocal('local.Project', 'operator3', 'Standard.Base.Text'),
|
||||
// ])
|
||||
const input = useComponentBrowserInput()
|
||||
input.code.value = code
|
||||
input.selection.value = { start: cursorPos, end: cursorPos }
|
||||
const context = input.context.value
|
||||
@ -78,6 +99,7 @@ test.each([
|
||||
}
|
||||
expect(filter.pattern).toStrictEqual(expFiltering.pattern)
|
||||
expect(filter.qualifiedNamePattern).toStrictEqual(expFiltering.qualifiedNamePattern)
|
||||
expect(filter.selfType).toStrictEqual(expFiltering.selfType)
|
||||
},
|
||||
)
|
||||
|
||||
@ -195,7 +217,7 @@ test.each([
|
||||
({ code, cursorPos, suggestion, expected, expectedCursorPos }) => {
|
||||
cursorPos = cursorPos ?? code.length
|
||||
expectedCursorPos = expectedCursorPos ?? expected.length
|
||||
const input = new Input()
|
||||
const input = useComponentBrowserInput()
|
||||
input.code.value = code
|
||||
input.selection.value = { start: cursorPos, end: cursorPos }
|
||||
input.applySuggestion(suggestion)
|
||||
|
@ -13,10 +13,11 @@ import {
|
||||
qnLastSegment,
|
||||
qnParent,
|
||||
qnSplit,
|
||||
tryIdentifier,
|
||||
tryQualifiedName,
|
||||
type QualifiedName,
|
||||
} from '@/util/qualifiedName'
|
||||
import { computed, ref, type ComputedRef, type Ref } from 'vue'
|
||||
import { computed, ref, type ComputedRef } from 'vue'
|
||||
|
||||
/** Input's editing context.
|
||||
*
|
||||
@ -43,79 +44,70 @@ export type EditingContext =
|
||||
| { type: 'changeLiteral'; literal: Ast.Tree.TextLiteral | Ast.Tree.Number }
|
||||
|
||||
/** Component Browser Input Data */
|
||||
export class Input {
|
||||
/** The current input's text (code). */
|
||||
readonly code: Ref<string>
|
||||
/** The current selection (or cursor position if start is equal to end). */
|
||||
readonly selection: Ref<{ start: number; end: number }>
|
||||
/** The editing context deduced from code and selection */
|
||||
readonly context: ComputedRef<EditingContext>
|
||||
/** The filter deduced from code and selection. */
|
||||
readonly filter: ComputedRef<Filter>
|
||||
export function useComponentBrowserInput() {
|
||||
const code = ref('')
|
||||
const selection = ref({ start: 0, end: 0 })
|
||||
|
||||
constructor() {
|
||||
this.code = ref('')
|
||||
this.selection = ref({ start: 0, end: 0 })
|
||||
const context: ComputedRef<EditingContext> = computed(() => {
|
||||
const input = code.value
|
||||
const cursorPosition = selection.value.start
|
||||
if (cursorPosition === 0) return { type: 'insert', position: 0 }
|
||||
const editedPart = cursorPosition - 1
|
||||
const inputAst = parseEnso(input)
|
||||
const editedAst = astContainingChar(editedPart, inputAst).values()
|
||||
const leaf = editedAst.next()
|
||||
if (leaf.done) return { type: 'insert', position: cursorPosition }
|
||||
switch (leaf.value.type) {
|
||||
case Ast.Tree.Type.Ident:
|
||||
return {
|
||||
type: 'changeIdentifier',
|
||||
identifier: leaf.value,
|
||||
...readOprApp(editedAst.next(), leaf.value),
|
||||
}
|
||||
case Ast.Tree.Type.TextLiteral:
|
||||
case Ast.Tree.Type.Number:
|
||||
return { type: 'changeLiteral', literal: leaf.value }
|
||||
default:
|
||||
return {
|
||||
type: 'insert',
|
||||
position: cursorPosition,
|
||||
...readOprApp(leaf),
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.context = computed(() => {
|
||||
const input = this.code.value
|
||||
const cursorPosition = this.selection.value.start
|
||||
if (cursorPosition === 0) return { type: 'insert', position: 0 }
|
||||
const editedPart = cursorPosition - 1
|
||||
const inputAst = parseEnso(input)
|
||||
const editedAst = astContainingChar(editedPart, inputAst).values()
|
||||
const leaf = editedAst.next()
|
||||
if (leaf.done) return { type: 'insert', position: cursorPosition }
|
||||
switch (leaf.value.type) {
|
||||
case Ast.Tree.Type.Ident:
|
||||
return {
|
||||
type: 'changeIdentifier',
|
||||
identifier: leaf.value,
|
||||
...Input.readOprApp(editedAst.next(), input, leaf.value),
|
||||
}
|
||||
case Ast.Tree.Type.TextLiteral:
|
||||
case Ast.Tree.Type.Number:
|
||||
return { type: 'changeLiteral', literal: leaf.value }
|
||||
default:
|
||||
return {
|
||||
type: 'insert',
|
||||
position: cursorPosition,
|
||||
...Input.readOprApp(leaf, input),
|
||||
}
|
||||
}
|
||||
})
|
||||
// Filter deduced from the access (`.` operator) chain written by user.
|
||||
const accessChainFilter: ComputedRef<Filter> = computed(() => {
|
||||
const ctx = context.value
|
||||
if (ctx.type === 'changeLiteral') return {}
|
||||
if (ctx.oprApp == null || ctx.oprApp.lhs == null) return {}
|
||||
const opr = ctx.oprApp.lastOpr()
|
||||
const input = code.value
|
||||
if (opr == null || !opr.ok || readTokenSpan(opr.value, input) !== '.') return {}
|
||||
const selfType = pathAsSelfType(ctx.oprApp, input)
|
||||
if (selfType != null) return { selfType }
|
||||
const qn = pathAsQualifiedName(ctx.oprApp, input)
|
||||
if (qn != null) return { qualifiedNamePattern: qn }
|
||||
return {}
|
||||
})
|
||||
|
||||
const qualifiedNameFilter: ComputedRef<Filter> = computed(() => {
|
||||
const code = this.code.value
|
||||
const ctx = this.context.value
|
||||
if (ctx.type === 'changeLiteral') return {}
|
||||
if (ctx.oprApp == null || ctx.oprApp.lhs == null) return {}
|
||||
const opr = ctx.oprApp.lastOpr()
|
||||
if (opr == null || !opr.ok || readTokenSpan(opr.value, code) !== '.') return {}
|
||||
const qn = Input.pathAsQualifiedName(ctx.oprApp, code)
|
||||
if (qn != null) return { qualifiedNamePattern: qn }
|
||||
else return {}
|
||||
})
|
||||
const filter = computed(() => {
|
||||
const input = code.value
|
||||
const ctx = context.value
|
||||
const filter = { ...accessChainFilter.value }
|
||||
if (ctx.type === 'changeIdentifier') {
|
||||
const start =
|
||||
ctx.identifier.whitespaceStartInCodeParsed + ctx.identifier.whitespaceLengthInCodeParsed
|
||||
const end = selection.value.end
|
||||
filter.pattern = input.substring(start, end)
|
||||
} else if (ctx.type === 'changeLiteral') {
|
||||
filter.pattern = readAstSpan(ctx.literal, input)
|
||||
}
|
||||
return filter
|
||||
})
|
||||
|
||||
this.filter = computed(() => {
|
||||
const code = this.code.value
|
||||
const ctx = this.context.value
|
||||
const filter = { ...qualifiedNameFilter.value }
|
||||
if (ctx.type === 'changeIdentifier') {
|
||||
const start =
|
||||
ctx.identifier.whitespaceStartInCodeParsed + ctx.identifier.whitespaceLengthInCodeParsed
|
||||
const end = this.selection.value.end
|
||||
filter.pattern = code.substring(start, end)
|
||||
} else if (ctx.type === 'changeLiteral') {
|
||||
filter.pattern = readAstSpan(ctx.literal, code)
|
||||
}
|
||||
return filter
|
||||
})
|
||||
}
|
||||
|
||||
private static readOprApp(
|
||||
function readOprApp(
|
||||
leafParent: IteratorResult<Ast.Tree>,
|
||||
code: string,
|
||||
editedAst?: Ast.Tree,
|
||||
): {
|
||||
oprApp?: GeneralOprApp
|
||||
@ -140,9 +132,18 @@ export class Input {
|
||||
}
|
||||
}
|
||||
|
||||
private static pathAsQualifiedName(accessOpr: GeneralOprApp, code: string): QualifiedName | null {
|
||||
const operandsAsIdents = Input.qnIdentifiers(accessOpr, code)
|
||||
const segments = operandsAsIdents.map((ident) => readAstSpan(ident, code))
|
||||
function pathAsSelfType(accessOpr: GeneralOprApp, inputCode: string): QualifiedName | null {
|
||||
if (accessOpr.lhs == null) return null
|
||||
if (accessOpr.lhs.type !== Ast.Tree.Type.Ident) return null
|
||||
if (accessOpr.apps.length > 1) return null
|
||||
const _ident = tryIdentifier(readAstSpan(accessOpr.lhs, inputCode))
|
||||
// TODO[ao]: #7926 add implementation here
|
||||
return null
|
||||
}
|
||||
|
||||
function pathAsQualifiedName(accessOpr: GeneralOprApp, inputCode: string): QualifiedName | null {
|
||||
const operandsAsIdents = qnIdentifiers(accessOpr, inputCode)
|
||||
const segments = operandsAsIdents.map((ident) => readAstSpan(ident, inputCode))
|
||||
const rawQn = segments.join('.')
|
||||
const qn = tryQualifiedName(rawQn)
|
||||
return qn.ok ? qn.value : null
|
||||
@ -155,18 +156,20 @@ export class Input {
|
||||
* @param code The code from which `opr` was generated.
|
||||
* @returns If all path segments are identifiers, return them
|
||||
*/
|
||||
private static qnIdentifiers(opr: GeneralOprApp, code: string): Ast.Tree.Ident[] {
|
||||
const operandsAsIdents = Array.from(opr.operandsOfLeftAssocOprChain(code, '.'), (operand) =>
|
||||
operand?.type === 'ast' && operand.ast.type === Ast.Tree.Type.Ident ? operand.ast : null,
|
||||
function qnIdentifiers(opr: GeneralOprApp, inputCode: string): Ast.Tree.Ident[] {
|
||||
const operandsAsIdents = Array.from(
|
||||
opr.operandsOfLeftAssocOprChain(inputCode, '.'),
|
||||
(operand) =>
|
||||
operand?.type === 'ast' && operand.ast.type === Ast.Tree.Type.Ident ? operand.ast : null,
|
||||
).slice(0, -1)
|
||||
if (operandsAsIdents.some((optIdent) => optIdent == null)) return []
|
||||
else return operandsAsIdents as Ast.Tree.Ident[]
|
||||
}
|
||||
|
||||
/** Apply given suggested entry to the input. */
|
||||
applySuggestion(entry: SuggestionEntry) {
|
||||
const oldCode = this.code.value
|
||||
const changes = Array.from(this.inputChangesAfterApplying(entry)).reverse()
|
||||
function applySuggestion(entry: SuggestionEntry) {
|
||||
const oldCode = code.value
|
||||
const changes = Array.from(inputChangesAfterApplying(entry)).reverse()
|
||||
const newCodeUpToLastChange = changes.reduce(
|
||||
(builder, change) => {
|
||||
const oldCodeFragment = oldCode.substring(builder.oldCodeIndex, change.range[0])
|
||||
@ -183,11 +186,11 @@ export class Input {
|
||||
!isModule && (firstCharAfter == null || /^[a-zA-Z0-9_]$/.test(firstCharAfter))
|
||||
const shouldMoveCursor = !isModule
|
||||
const newCursorPos = newCodeUpToLastChange.code.length + (shouldMoveCursor ? 1 : 0)
|
||||
this.code.value =
|
||||
code.value =
|
||||
newCodeUpToLastChange.code +
|
||||
(shouldInsertSpace ? ' ' : '') +
|
||||
oldCode.substring(newCodeUpToLastChange.oldCodeIndex)
|
||||
this.selection.value = { start: newCursorPos, end: newCursorPos }
|
||||
selection.value = { start: newCursorPos, end: newCursorPos }
|
||||
}
|
||||
|
||||
/** Return all input changes resulting from applying given suggestion.
|
||||
@ -195,11 +198,11 @@ export class Input {
|
||||
* @returns The changes, starting from the rightmost. The `start` and `end` parameters refer
|
||||
* to indices of "old" input content.
|
||||
*/
|
||||
private *inputChangesAfterApplying(
|
||||
function* inputChangesAfterApplying(
|
||||
entry: SuggestionEntry,
|
||||
): Generator<{ range: [number, number]; str: string }> {
|
||||
const ctx = this.context.value
|
||||
const str = this.codeToBeInserted(entry)
|
||||
const ctx = context.value
|
||||
const str = codeToBeInserted(entry)
|
||||
switch (ctx.type) {
|
||||
case 'insert': {
|
||||
yield { range: [ctx.position, ctx.position], str }
|
||||
@ -214,18 +217,18 @@ export class Input {
|
||||
break
|
||||
}
|
||||
}
|
||||
yield* this.qnChangesAfterApplying(entry)
|
||||
yield* qnChangesAfterApplying(entry)
|
||||
}
|
||||
|
||||
private codeToBeInserted(entry: SuggestionEntry): string {
|
||||
const ctx = this.context.value
|
||||
function codeToBeInserted(entry: SuggestionEntry): string {
|
||||
const ctx = context.value
|
||||
const opr = ctx.type !== 'changeLiteral' && ctx.oprApp != null ? ctx.oprApp.lastOpr() : null
|
||||
const oprAppSpacing =
|
||||
ctx.type === 'insert' && opr != null && opr.ok && opr.value.whitespaceLengthInCodeBuffer > 0
|
||||
? ' '.repeat(opr.value.whitespaceLengthInCodeBuffer)
|
||||
: ''
|
||||
const extendingAccessOprChain =
|
||||
opr != null && opr.ok && readTokenSpan(opr.value, this.code.value) === '.'
|
||||
opr != null && opr.ok && readTokenSpan(opr.value, code.value) === '.'
|
||||
// Modules are special case, as we want to encourage user to continue writing path.
|
||||
if (entry.kind === SuggestionKind.Module) {
|
||||
if (extendingAccessOprChain) return `${oprAppSpacing}${entry.name}${oprAppSpacing}.`
|
||||
@ -245,14 +248,14 @@ export class Input {
|
||||
/** All changes to the qualified name already written by the user.
|
||||
*
|
||||
* See `inputChangesAfterApplying`. */
|
||||
private *qnChangesAfterApplying(
|
||||
function* qnChangesAfterApplying(
|
||||
entry: SuggestionEntry,
|
||||
): Generator<{ range: [number, number]; str: string }> {
|
||||
if (entry.selfType != null) return []
|
||||
if (entry.kind === SuggestionKind.Local || entry.kind === SuggestionKind.Function) return []
|
||||
if (this.context.value.type === 'changeLiteral') return []
|
||||
if (this.context.value.oprApp == null) return []
|
||||
const writtenQn = Input.qnIdentifiers(this.context.value.oprApp, this.code.value).reverse()
|
||||
if (context.value.type === 'changeLiteral') return []
|
||||
if (context.value.oprApp == null) return []
|
||||
const writtenQn = qnIdentifiers(context.value.oprApp, code.value).reverse()
|
||||
|
||||
let containingQn =
|
||||
entry.kind === SuggestionKind.Module
|
||||
@ -265,4 +268,17 @@ export class Input {
|
||||
containingQn = parent
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/** The current input's text (code). */
|
||||
code,
|
||||
/** The current selection (or cursor position if start is equal to end). */
|
||||
selection,
|
||||
/** The editing context deduced from code and selection */
|
||||
context,
|
||||
/** The filter deduced from code and selection. */
|
||||
filter,
|
||||
/** Apply given suggested entry to the input. */
|
||||
applySuggestion,
|
||||
}
|
||||
}
|
||||
|
@ -8,11 +8,9 @@ import {
|
||||
qnLastSegment,
|
||||
qnParent,
|
||||
qnSplit,
|
||||
tryQualifiedName,
|
||||
type Identifier,
|
||||
type QualifiedName,
|
||||
} from '@/util/qualifiedName'
|
||||
import { unwrap } from '@/util/result'
|
||||
import type {
|
||||
SuggestionEntryArgument,
|
||||
SuggestionEntryScope,
|
||||
@ -77,7 +75,7 @@ export function entryQn(entry: SuggestionEntry): QualifiedName {
|
||||
} else if (entry.memberOf) {
|
||||
return qnJoin(entry.memberOf, entry.name)
|
||||
} else {
|
||||
return qnJoin(entry.definedIn, unwrap(tryQualifiedName(entry.name)))
|
||||
return qnJoin(entry.definedIn, entry.name)
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user