'use strict' import { Neovim } from '@chemzqm/neovim' import debounce from 'debounce' import { CancellationTokenSource, Disposable } from 'vscode-languageserver-protocol' import events from '../events' import extensions from '../extensions' import { IList, ListItem, ListOptions, ListTask, Matcher } from '../types' import { disposeAll } from '../util' import workspace from '../workspace' import window from '../window' import ListConfiguration from './configuration' import Mappings from './mappings' import Prompt from './prompt' import ListSession from './session' import CommandsList from './source/commands' import DiagnosticsList from './source/diagnostics' import ExtensionList from './source/extensions' import FolderList from './source/folders' import LinksList from './source/links' import ListsList from './source/lists' import LocationList from './source/location' import OutlineList from './source/outline' import ServicesList from './source/services' import SourcesList from './source/sources' import SymbolsList from './source/symbols' import stripAnsi from 'strip-ansi' const logger = require('../util/logger')('list-manager') const mouseKeys = ['', '', '', '<2-LeftMouse>'] export class ListManager implements Disposable { public prompt: Prompt public config: ListConfiguration public mappings: Mappings private nvim: Neovim private plugTs = 0 private sessionsMap: Map = new Map() private lastSession: ListSession | undefined private disposables: Disposable[] = [] private listMap: Map = new Map() public init(nvim: Neovim): void { this.nvim = nvim this.config = new ListConfiguration() this.prompt = new Prompt(nvim, this.config) this.mappings = new Mappings(this, nvim, this.config) let signText = this.config.get('selectedSignText', '*') nvim.command(`sign define CocSelected text=${signText} texthl=CocSelectedText linehl=CocSelectedLine`, true) events.on('InputChar', this.onInputChar, this, this.disposables) let debounced = debounce(async () => { let session = await this.getCurrentSession() if (session) this.prompt.drawPrompt() }, 100) events.on('FocusGained', debounced, null, this.disposables) events.on('WinEnter', winid => { let session = this.getSessionByWinid(winid) if (session) this.prompt.start(session.listOptions) }, null, this.disposables) let timer: NodeJS.Timer events.on('WinLeave', winid => { if (timer) clearTimeout(timer) let session = this.getSessionByWinid(winid) if (session) { setTimeout(() => { this.prompt.cancel() }, workspace.isVim ? 50 : 0) } }, null, this.disposables) this.disposables.push({ dispose: () => { debounced.clear() } }) // filter history on input this.prompt.onDidChangeInput(() => { let { session } = this if (!session) return session.onInputChange() session.history.filter() }) this.registerList(new LinksList(nvim)) this.registerList(new LocationList(nvim)) this.registerList(new SymbolsList(nvim)) this.registerList(new OutlineList(nvim)) this.registerList(new CommandsList(nvim)) this.registerList(new ExtensionList(nvim)) this.registerList(new DiagnosticsList(nvim, this)) this.registerList(new SourcesList(nvim)) this.registerList(new ServicesList(nvim)) this.registerList(new ListsList(nvim, this.listMap)) this.registerList(new FolderList(nvim)) } public async start(args: string[]): Promise { let res = this.parseArgs(args) if (!res) return let { name } = res.list let curr = this.sessionsMap.get(name) if (curr) curr.dispose() this.prompt.start(res.options) let session = new ListSession(this.nvim, this.prompt, res.list, res.options, res.listArgs, this.config) this.sessionsMap.set(name, session) this.lastSession = session try { await session.start(args) } catch (e) { this.nvim.call('coc#prompt#stop_prompt', ['list'], true) let msg = e instanceof Error ? e.message : e.toString() window.showMessage(`Error on "CocList ${name}": ${msg}`, 'error') logger.error(e) } } private getSessionByWinid(winid: number): ListSession | null { for (let session of this.sessionsMap.values()) { if (session && session.winid == winid) { this.lastSession = session return session } } return null } private async getCurrentSession(): Promise { let { id } = await this.nvim.window for (let session of this.sessionsMap.values()) { if (session && session.winid == id) { this.lastSession = session return session } } return null } public async resume(name?: string): Promise { if (!name) { await this.session?.resume() } else { let session = this.sessionsMap.get(name) if (!session) { window.showMessage(`Can't find exists ${name} list`) return } await session.resume() } } public async doAction(name?: string): Promise { let lastSession = this.lastSession if (!lastSession) return await lastSession.doAction(name) } public async first(name?: string): Promise { let s = this.getSession(name) if (s) await s.first() } public async last(name?: string): Promise { let s = this.getSession(name) if (s) await s.last() } public async previous(name?: string): Promise { let s = this.getSession(name) if (s) await s.previous() } public async next(name?: string): Promise { let s = this.getSession(name) if (s) await s.next() } public getSession(name?: string): ListSession { if (!name) return this.session return this.sessionsMap.get(name) } public async cancel(close = true): Promise { this.prompt.cancel() if (!close) return if (this.session) await this.session.hide() } /** * Clear all list sessions */ public reset(): void { this.prompt.cancel() this.lastSession = undefined for (let session of this.sessionsMap.values()) { session.dispose() } this.sessionsMap.clear() this.nvim.call('coc#prompt#stop_prompt', ['list'], true) } public async switchMatcher(): Promise { await this.session?.switchMatcher() } public async togglePreview(): Promise { let { nvim } = this let winid = await nvim.call('coc#list#get_preview', [0]) if (winid != -1) { await nvim.call('coc#window#close', [winid]) await nvim.command('redraw') } else { await this.doAction('preview') } } public async chooseAction(): Promise { let { lastSession } = this if (lastSession) await lastSession.chooseAction() } public parseArgs(args: string[]): { list: IList; options: ListOptions; listArgs: string[] } | null { let options: string[] = [] let interactive = false let autoPreview = false let numberSelect = false let noQuit = false let first = false let reverse = false let name: string let input = '' let matcher: Matcher = 'fuzzy' let position = 'bottom' let listArgs: string[] = [] let listOptions: string[] = [] for (let arg of args) { if (!name && arg.startsWith('-')) { listOptions.push(arg) } else if (!name) { if (!/^\w+$/.test(arg)) { window.showMessage(`Invalid list option: "${arg}"`, 'error') return null } name = arg } else { listArgs.push(arg) } } name = name || 'lists' let config = workspace.getConfiguration(`list.source.${name}`) if (!listOptions.length && !listArgs.length) listOptions = config.get('defaultOptions', []) if (!listArgs.length) listArgs = config.get('defaultArgs', []) for (let opt of listOptions) { if (opt.startsWith('--input')) { input = opt.slice(8) } else if (opt == '--number-select' || opt == '-N') { numberSelect = true } else if (opt == '--auto-preview' || opt == '-A') { autoPreview = true } else if (opt == '--regex' || opt == '-R') { matcher = 'regex' } else if (opt == '--strict' || opt == '-S') { matcher = 'strict' } else if (opt == '--interactive' || opt == '-I') { interactive = true } else if (opt == '--top') { position = 'top' } else if (opt == '--tab') { position = 'tab' } else if (opt == '--ignore-case' || opt == '--normal' || opt == '--no-sort') { options.push(opt.slice(2)) } else if (opt == '--first') { first = true } else if (opt == '--reverse') { reverse = true } else if (opt == '--no-quit') { noQuit = true } else { window.showMessage(`Invalid option "${opt}" of list`, 'error') return null } } let list = this.listMap.get(name) if (!list) { window.showMessage(`List ${name} not found`, 'error') return null } if (interactive && !list.interactive) { window.showMessage(`Interactive mode of "${name}" list not supported`, 'error') return null } return { list, listArgs, options: { numberSelect, autoPreview, reverse, noQuit, first, input, interactive, matcher, position, ignorecase: options.includes('ignore-case') ? true : false, mode: !options.includes('normal') ? 'insert' : 'normal', sort: !options.includes('no-sort') ? true : false }, } } private async onInputChar(session: string, ch: string, charmod: number): Promise { if (session != 'list') return let { mode } = this.prompt let now = Date.now() if (ch == '' || (this.plugTs && now - this.plugTs < 20)) { this.plugTs = now return } if (!ch) return if (ch == '') { await this.cancel() return } if (mode == 'insert') { await this.onInsertInput(ch, charmod) } else { await this.onNormalInput(ch, charmod) } } public async onInsertInput(ch: string, charmod?: number): Promise { let { session } = this if (!session) return if (mouseKeys.includes(ch)) { await this.onMouseEvent(ch) return } let n = await session.doNumberSelect(ch) if (n) return let done = await this.mappings.doInsertKeymap(ch) if (done || charmod) return if (ch.startsWith('<') && ch.endsWith('>')) { await this.feedkeys(ch, false) return } for (let s of ch) { let code = s.codePointAt(0) if (code == 65533) return // exclude control character if (code < 32 || code >= 127 && code <= 159) return await this.prompt.acceptCharacter(s) } } public async onNormalInput(ch: string, _charmod?: number): Promise { if (mouseKeys.includes(ch)) { await this.onMouseEvent(ch) return } let used = await this.mappings.doNormalKeymap(ch) if (!used) await this.feedkeys(ch) } private onMouseEvent(key): Promise { if (this.session) return this.session.onMouseEvent(key) } public async feedkeys(key: string, remap = true): Promise { let { nvim } = this key = key.startsWith('<') && key.endsWith('>') ? `\\${key}` : key await nvim.call('coc#prompt#stop_prompt', ['list']) await nvim.call('eval', [`feedkeys("${key}", "${remap ? 'i' : 'in'}")`]) this.prompt.start() } public async command(command: string): Promise { let { nvim } = this await nvim.call('coc#prompt#stop_prompt', ['list']) await nvim.command(command) this.prompt.start() } public async normal(command: string, bang = true): Promise { let { nvim } = this await nvim.call('coc#prompt#stop_prompt', ['list']) await nvim.command(`normal${bang ? '!' : ''} ${command}`) this.prompt.start() } public async call(fname: string): Promise { if (this.session) return await this.session.call(fname) } public get session(): ListSession | undefined { return this.lastSession } public registerList(list: IList): Disposable { let { name } = list let exists = this.listMap.get(name) if (this.listMap.has(name)) { if (exists) { if (typeof exists.dispose == 'function') { exists.dispose() } this.listMap.delete(name) } window.showMessage(`list "${name}" recreated.`) } this.listMap.set(name, list) extensions.addSchemeProperty(`list.source.${name}.defaultAction`, { type: 'string', default: null, description: `Default action of "${name}" list.` }) extensions.addSchemeProperty(`list.source.${name}.defaultOptions`, { type: 'array', default: list.interactive ? ['--interactive'] : [], description: `Default list options of "${name}" list, only used when both list option and argument are empty.`, uniqueItems: true, items: { type: 'string', enum: ['--top', '--normal', '--no-sort', '--input', '--tab', '--strict', '--regex', '--ignore-case', '--number-select', '--interactive', '--auto-preview', '--first', '--no-quit'] } }) extensions.addSchemeProperty(`list.source.${name}.defaultArgs`, { type: 'array', default: [], description: `Default argument list of "${name}" list, only used when list argument is empty.`, uniqueItems: true, items: { type: 'string' } }) return Disposable.create(() => { if (typeof list.dispose == 'function') { list.dispose() } this.listMap.delete(name) }) } public get names(): string[] { return Array.from(this.listMap.keys()) } public get descriptions(): { [name: string]: string } { let d = {} for (let name of this.listMap.keys()) { let list = this.listMap.get(name) d[name] = list.description } return d } /** * Get items of {name} list, not work with interactive list. * * @param {string} name * @returns {Promise} */ public async loadItems(name: string): Promise { let args = [name] let res = this.parseArgs(args) if (!res) return let { list, options, listArgs } = res let source = new CancellationTokenSource() let token = source.token let arr = await this.nvim.eval('[win_getid(),bufnr("%")]') let items = await list.loadItems({ options, args: listArgs, input: '', cwd: workspace.cwd, window: this.nvim.createWindow(arr[0]), buffer: this.nvim.createBuffer(arr[1]), listWindow: null }, token) if (!items || Array.isArray(items)) { return items } let task = items as ListTask let newItems = await new Promise((resolve, reject) => { let items = [] task.on('data', item => { item.label = stripAnsi(item.label) items.push(item) }) task.on('end', () => { resolve(items) }) task.on('error', msg => { reject(msg) }) }) return newItems } public toggleMode(): void { let lastSession = this.lastSession if (lastSession) lastSession.toggleMode() } public get isActivated(): boolean { return this.session?.winid != null } public stop(): void { let lastSession = this.lastSession if (lastSession) lastSession.stop() } public dispose(): void { for (let session of this.sessionsMap.values()) { session.dispose() } this.sessionsMap.clear() if (this.config) { this.config.dispose() } this.lastSession = undefined disposeAll(this.disposables) } } export default new ListManager()