1
0
Fork 0
mirror of synced 2024-06-26 02:31:09 -04:00
ultimate-vim/sources_non_forked/coc.nvim/src/list/worker.ts
2022-07-20 13:20:15 +08:00

421 lines
13 KiB
TypeScript

'use strict'
import { Neovim } from '@chemzqm/neovim'
import { CancellationToken, CancellationTokenSource, Emitter, Event } from 'vscode-languageserver-protocol'
import { IList, ListContext, ListHighlights, ListItem, ListItemsEvent, ListItemWithHighlights, ListOptions, ListTask } from '../types'
import { parseAnsiHighlights } from '../util/ansiparse'
import { filter } from '../util/async'
import { patchLine } from '../util/diff'
import { hasMatch, positions, score } from '../util/fzy'
import { Mutex } from '../util/mutex'
import { getMatchResult } from '../util/score'
import { byteIndex, byteLength } from '../util/string'
import Prompt from './prompt'
const logger = require('../util/logger')('list-worker')
const controlCode = '\x1b'
export interface WorkerConfiguration {
interactiveDebounceTime: number
extendedSearchMode: boolean
}
export interface FilterOption {
append?: boolean
reload?: boolean
}
export type OnFilter = (arr: ListItemWithHighlights[], finished: boolean, sort?: boolean) => void
// perform loading task
export default class Worker {
private _loading = false
private _finished = false
private mutex: Mutex = new Mutex()
private filteredCount: number
private totalItems: ListItem[] = []
private tokenSource: CancellationTokenSource
private filterTokenSource: CancellationTokenSource
private _onDidChangeItems = new Emitter<ListItemsEvent>()
private _onDidChangeLoading = new Emitter<boolean>()
public readonly onDidChangeItems: Event<ListItemsEvent> = this._onDidChangeItems.event
public readonly onDidChangeLoading: Event<boolean> = this._onDidChangeLoading.event
constructor(
private nvim: Neovim,
private list: IList,
private prompt: Prompt,
private listOptions: ListOptions,
private config: WorkerConfiguration
) {
}
private set loading(loading: boolean) {
if (this._loading == loading) return
this._loading = loading
this._onDidChangeLoading.fire(loading)
}
public get isLoading(): boolean {
return this._loading
}
public async loadItems(context: ListContext, reload = false): Promise<void> {
this.cancelFilter()
this.filteredCount = 0
this._finished = false
let { list, listOptions } = this
this.loading = true
let { interactive } = listOptions
this.tokenSource = new CancellationTokenSource()
let token = this.tokenSource.token
let items = await list.loadItems(context, token)
if (token.isCancellationRequested) return
items = items ?? []
if (Array.isArray(items)) {
this.tokenSource = null
this.totalItems = items
this.loading = false
this._finished = true
let filtered: ListItemWithHighlights[]
if (!interactive) {
let tokenSource = this.filterTokenSource = new CancellationTokenSource()
await this.mutex.use(async () => {
let token = tokenSource.token
if (token.isCancellationRequested) return
await this.filterItems(items as ListItem[], { reload }, token)
})
} else {
filtered = this.convertToHighlightItems(items)
this._onDidChangeItems.fire({
items: filtered,
reload,
finished: true
})
}
} else {
let task = items as ListTask
let totalItems = this.totalItems = []
let taken = 0
let currInput = context.input
let filtering = false
this.filterTokenSource = new CancellationTokenSource()
let _onData = async (finished?: boolean) => {
filtering = true
await this.mutex.use(async () => {
let inputChanged = this.input != currInput
if (inputChanged) {
currInput = this.input
taken = this.filteredCount ?? 0
}
if (taken >= totalItems.length) return
let append = taken > 0
let remain = totalItems.slice(taken)
taken = totalItems.length
if (!interactive) {
let tokenSource = this.filterTokenSource
if (tokenSource && !tokenSource.token.isCancellationRequested) {
await this.filterItems(remain, { append, reload }, tokenSource.token)
}
} else {
let items = this.convertToHighlightItems(remain)
this._onDidChangeItems.fire({ items, append, reload, finished })
}
})
filtering = false
}
let promise: Promise<void> = Promise.resolve()
let interval = setInterval(() => {
if (filtering) return
promise = _onData()
}, 50)
task.on('data', item => {
if (token.isCancellationRequested) return
totalItems.push(item)
})
let onEnd = () => {
if (task == null) return
this.tokenSource = null
task = null
this.loading = false
this._finished = true
disposable.dispose()
clearInterval(interval)
promise.then(() => {
if (token.isCancellationRequested) return
if (totalItems.length == 0) {
this._onDidChangeItems.fire({ items: [], append: false, reload, finished: true })
return
}
return _onData(true)
}).catch(e => {
logger.error('Error on filter', e)
})
}
let disposable = token.onCancellationRequested(() => {
task?.dispose()
onEnd()
})
task.on('error', async (error: Error | string) => {
if (task == null) return
task = null
this.tokenSource = null
this.loading = false
disposable.dispose()
clearInterval(interval)
this.nvim.call('coc#prompt#stop_prompt', ['list'], true)
this.nvim.echoError(`Task error: ${error.toString()}`)
logger.error('Task error:', error)
})
task.on('end', onEnd)
}
}
/*
* Draw all items with filter if necessary
*/
public async drawItems(): Promise<void> {
let { totalItems } = this
if (totalItems.length === 0) return
this.cancelFilter()
let tokenSource = this.filterTokenSource = new CancellationTokenSource()
let token = tokenSource.token
await this.mutex.use(async () => {
if (token.isCancellationRequested) return
let { totalItems } = this
this.filteredCount = totalItems.length
await this.filterItems(totalItems, {}, tokenSource.token)
})
}
public cancelFilter(): void {
if (this.filterTokenSource) {
this.filterTokenSource.cancel()
this.filterTokenSource = null
}
}
public stop(): void {
this.cancelFilter()
if (this.tokenSource) {
this.tokenSource.cancel()
this.tokenSource = null
}
this.loading = false
}
public get length(): number {
return this.totalItems.length
}
private get input(): string {
return this.prompt.input
}
/**
* Add highlights for interactive list
*/
private convertToHighlightItems(items: ListItem[]): ListItemWithHighlights[] {
let input = this.input ?? ''
let res = items.map(item => {
this.convertItemLabel(item)
let highlights = input.length > 0 ? getItemHighlights(input, item) : undefined
return Object.assign({}, item, { highlights })
})
return res
}
private async filterItemsByInclude(inputs: string[], items: ListItem[], token: CancellationToken, onFilter: OnFilter): Promise<void> {
let { ignorecase } = this.listOptions
if (ignorecase) inputs = inputs.map(s => s.toLowerCase())
await filter(items, item => {
this.convertItemLabel(item)
let spans: [number, number][] = []
let filterLabel = getFilterLabel(item)
let match = true
for (let input of inputs) {
let idx = ignorecase ? filterLabel.toLowerCase().indexOf(input) : filterLabel.indexOf(input)
if (idx == -1) {
match = false
break
}
spans.push([byteIndex(filterLabel, idx), byteIndex(filterLabel, idx + byteLength(input))])
}
if (!match) return false
return { highlights: { spans } }
}, onFilter, token)
}
private async filterItemsByRegex(inputs: string[], items: ListItem[], token: CancellationToken, onFilter: OnFilter): Promise<void> {
let { ignorecase } = this.listOptions
let flags = ignorecase ? 'iu' : 'u'
let regexes = inputs.reduce((p, c) => {
try {
p.push(new RegExp(c, flags))
} catch (e) {}
return p
}, [])
await filter(items, item => {
this.convertItemLabel(item)
let spans: [number, number][] = []
let filterLabel = getFilterLabel(item)
let match = true
for (let regex of regexes) {
let ms = filterLabel.match(regex)
if (ms == null) {
match = false
break
}
spans.push([byteIndex(filterLabel, ms.index), byteIndex(filterLabel, ms.index + byteLength(ms[0]))])
}
if (!match) return false
return { highlights: { spans } }
}, onFilter, token)
}
private async filterItemsByFuzzyMatch(inputs: string[], items: ListItem[], token: CancellationToken, onFilter: OnFilter): Promise<void> {
let { sort } = this.listOptions
let idx = 0
await filter(items, item => {
this.convertItemLabel(item)
let filterText = item.filterText || item.label
let matchScore = 0
let matches: number[] = []
let filterLabel = getFilterLabel(item)
let match = true
for (let input of inputs) {
if (!hasMatch(input, filterText)) {
match = false
break
}
matches.push(...positions(input, filterLabel))
if (sort) matchScore += score(input, filterText)
}
idx = idx + 1
if (!match) return false
return {
sortText: typeof item.sortText === 'string' ? item.sortText : String.fromCharCode(idx),
score: matchScore,
highlights: getHighlights(filterLabel, matches)
}
}, (items, done) => {
onFilter(items, done, sort)
}, token)
}
private async filterItems(arr: ListItem[], opts: FilterOption, token: CancellationToken): Promise<void> {
let { input } = this
if (input.length === 0) {
let items = arr.map(item => {
return this.convertItemLabel(item)
})
this._onDidChangeItems.fire({ items, finished: this._finished, ...opts })
return
}
let inputs = this.config.extendedSearchMode ? parseInput(input) : [input]
let called = false
const onFilter = (items: ListItemWithHighlights[], finished: boolean, sort?: boolean) => {
finished = finished && this._finished
if (token.isCancellationRequested || (!finished && items.length == 0)) return
if (sort) {
items.sort((a, b) => {
if (a.score != b.score) return b.score - a.score
if (a.sortText > b.sortText) return 1
return -1
})
}
let append = opts.append === true || called
called = true
this._onDidChangeItems.fire({ items, append, reload: opts.reload, finished })
}
switch (this.listOptions.matcher) {
case 'strict':
await this.filterItemsByInclude(inputs, arr, token, onFilter)
break
case 'regex':
await this.filterItemsByRegex(inputs, arr, token, onFilter)
break
default:
await this.filterItemsByFuzzyMatch(inputs, arr, token, onFilter)
}
}
// set correct label, add ansi highlights
private convertItemLabel(item: ListItem): ListItem {
let { label, converted } = item
if (converted) return item
if (label.includes('\n')) {
label = item.label = label.replace(/\r?\n/g, ' ')
}
if (label.includes(controlCode)) {
let { line, highlights } = parseAnsiHighlights(label)
item.label = line
if (!Array.isArray(item.ansiHighlights)) item.ansiHighlights = highlights
}
item.converted = true
return item
}
public dispose(): void {
this.stop()
}
}
function getFilterLabel(item: ListItem): string {
return item.filterText != null ? patchLine(item.filterText, item.label) : item.label
}
/**
* `a\ b` => [`a b`]
* `a b` => ['a', 'b']
*/
export function parseInput(input: string): string[] {
let res: string[] = []
let startIdx = 0
let currIdx = 0
let prev = ''
for (; currIdx < input.length; currIdx++) {
let ch = input[currIdx]
if (ch.charCodeAt(0) === 32) {
// find space
if (prev && prev != '\\' && startIdx != currIdx) {
res.push(input.slice(startIdx, currIdx))
startIdx = currIdx + 1
}
} else {
}
prev = ch
}
if (startIdx != input.length) {
res.push(input.slice(startIdx, input.length))
}
return res.map(s => s.replace(/\\\s/g, ' ').trim()).filter(s => s.length > 0)
}
export function getHighlights(text: string, matches?: number[]): ListHighlights {
let spans: [number, number][] = []
if (matches && matches.length) {
let start = matches.shift()
let next = matches.shift()
let curr = start
while (next) {
if (next == curr + 1) {
curr = next
next = matches.shift()
continue
}
spans.push([byteIndex(text, start), byteIndex(text, curr) + 1])
start = next
curr = start
next = matches.shift()
}
spans.push([byteIndex(text, start), byteIndex(text, curr) + 1])
}
return { spans }
}
export function getItemHighlights(input: string, item: ListItem): ListHighlights {
let filterLabel = getFilterLabel(item)
let res = getMatchResult(filterLabel, input)
if (!res?.score) return { spans: [] }
return getHighlights(filterLabel, res.matches)
}