UNPKG

kui-shell

Version:

This is the monorepo for Kui, the hybrid command-line/GUI electron-based Kubernetes tool

588 lines 26.5 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); const debug_1 = require("debug"); const fs_1 = require("fs"); const path_1 = require("path"); const tab_completion_registrar_1 = require("./tab-completion-registrar"); const capabilities_1 = require("@kui-shell/core/api/capabilities"); const REPLUtil = require("@kui-shell/core/api/repl-util"); const UI = require("@kui-shell/core/api/ui-lite"); const LowLevel = require("@kui-shell/core/api/ui-low-level"); const inject_1 = require("@kui-shell/core/api/inject"); const util_1 = require("@kui-shell/core/api/util"); const debug = debug_1.default('plugins/core-support/tab completion'); const listenForUpDown = (prompt) => { const moveTo = (nextOp, evt) => { const block = UI.getCurrentBlock(); const temporaryContainer = block && block.querySelector('.tab-completion-temporary'); if (temporaryContainer) { const current = temporaryContainer.querySelector('.selected'); if (current) { const next = current[nextOp]; if (next) { current.classList.remove('selected'); next.classList.add('selected'); next.scrollIntoView(); evt.preventDefault(); } } } }; const previousKeyDown = prompt.onkeydown; prompt.onkeydown = evt => { const char = evt.keyCode; if (char === UI.Keys.Codes.DOWN) { moveTo('nextSibling', evt); } else if (char === UI.Keys.Codes.UP) { moveTo('previousSibling', evt); } else if (char === UI.Keys.Codes.C && evt.ctrlKey) { LowLevel.doCancel(); } }; return () => { prompt.onkeydown = previousKeyDown; }; }; const listenForEscape = () => { const previousKeyup = document.onkeyup; const cleanup = () => { document.onkeyup = previousKeyup; }; document.onkeyup = evt => { if (evt.keyCode === UI.Keys.Codes.ESCAPE) { const block = UI.getCurrentBlock(); const temporaryContainer = block && block.querySelector('.tab-completion-temporary'); if (temporaryContainer) { evt.preventDefault(); temporaryContainer.cleanup(); } } }; return cleanup; }; const installKeyHandlers = (prompt) => { if (prompt) { return [listenForUpDown(prompt), listenForEscape()]; } else { return []; } }; const makeCompletionContainer = (block, prompt, partial, dirname, lastIdx) => { const temporaryContainer = document.createElement('div'); temporaryContainer.className = 'tab-completion-temporary repl-temporary'; const scrollContainer = document.createElement('div'); scrollContainer.className = 'tab-completion-scroll-container'; temporaryContainer.appendChild(scrollContainer); temporaryContainer.scrollContainer = scrollContainer; if (!process.env.RUNNING_SHELL_TEST) { temporaryContainer.classList.add('fade-in'); } temporaryContainer.partial = partial; temporaryContainer.dirname = dirname; temporaryContainer.lastIdx = lastIdx; temporaryContainer.currentMatches = []; block.appendChild(temporaryContainer); const handlers = installKeyHandlers(prompt); const onChange = () => { if (!prompt.value.endsWith(partial)) { return temporaryContainer.cleanup(); } const args = REPLUtil.split(prompt.value); const currentText = args[temporaryContainer.lastIdx]; const prevMatches = temporaryContainer.currentMatches; const newMatches = prevMatches.filter(({ match }) => match.indexOf(currentText) === 0); const removedMatches = prevMatches.filter(({ match }) => match.indexOf(currentText) !== 0); temporaryContainer.currentMatches = newMatches; removedMatches.forEach(({ option }) => temporaryContainer.removeChild(option)); temporaryContainer['partial'] = currentText; if (temporaryContainer.currentMatches.length === 0) { temporaryContainer.cleanup(); } }; prompt.addEventListener('input', onChange); temporaryContainer.cleanup = () => { try { block.removeChild(temporaryContainer); } catch (err) { } try { handlers.forEach(cleanup => cleanup()); } catch (err) { } prompt.removeEventListener('input', onChange); }; setTimeout(() => block.scrollIntoView(), 0); return temporaryContainer; }; const shellescape = (str) => { return str.replace(/ /g, '\\ '); }; const completeWith = (partial, match, doEscape = false, addSpace = false) => { const escapedMatch = !doEscape ? match : shellescape(match); const partialIdx = escapedMatch.indexOf(partial); const remainder = partialIdx >= 0 ? escapedMatch.substring(partialIdx + partial.length) : escapedMatch; return remainder + (addSpace ? ' ' : ''); }; const isDirectory = (filepath) => new Promise((resolve, reject) => { fs_1.lstat(filepath, (err, stats) => { if (err) { reject(err); } else { if (stats.isSymbolicLink()) { debug('following symlink'); return fs_1.realpath(filepath, (err, realpath) => { if (err) { reject(err); } else { return isDirectory(realpath) .then(resolve) .catch(reject); } }); } resolve(stats.isDirectory()); } }); }); const complete = (match, prompt, options) => { const temporaryContainer = options.temporaryContainer; const partial = options.partial || (temporaryContainer && temporaryContainer.partial); const dirname = options.dirname || (temporaryContainer && temporaryContainer.dirname); const doEscape = options.doEscape || false; const addSpace = options.addSpace || false; debug('completion', match, partial, dirname); const completion = completeWith(partial, match, doEscape, addSpace); if (temporaryContainer) { temporaryContainer.cleanup(); } const addToPrompt = (extra) => { prompt.value = prompt.value + extra; prompt.scrollLeft = prompt.scrollWidth; }; if (dirname) { const filepath = util_1.default.expandHomeDir(path_1.join(dirname, match)); isDirectory(filepath) .then(isDir => { if (isDir) { debug('complete as directory'); addToPrompt(completion + '/'); } else { debug('complete as scalar'); addToPrompt(completion); } }) .catch(err => { console.error(err); }); } else { debug('complete as scalar (alt)'); addToPrompt(completion); } }; const addSuggestion = (temporaryContainer, prefix, dirname, prompt, doEscape = false) => (match, idx) => { const matchLabel = match.label || match; const matchCompletion = match.completion || matchLabel; const option = document.createElement('div'); const optionInnerFill = document.createElement('span'); const optionInner = document.createElement('a'); const innerPre = document.createElement('span'); const innerPost = document.createElement('span'); optionInner.appendChild(innerPre); optionInner.appendChild(innerPost); temporaryContainer.scrollContainer.appendChild(option); option.appendChild(optionInnerFill); optionInnerFill.appendChild(optionInner); optionInnerFill.className = 'tab-completion-temporary-fill'; innerPre.innerText = prefix; innerPost.innerText = matchLabel.replace(new RegExp(`^${prefix}`), ''); if (match.docs) { const optionDocs = document.createElement('span'); optionDocs.className = 'deemphasize deemphasize-partial left-pad'; option.appendChild(optionDocs); optionDocs.innerText = `(${match.docs})`; } option.className = 'tab-completion-option'; optionInner.className = 'clickable plain-anchor'; innerPre.classList.add('tab-completion-option-pre'); innerPost.classList.add('tab-completion-option-post'); if (idx === 0) { option.classList.add('selected'); } option.addEventListener('click', () => { complete(matchCompletion, prompt, { temporaryContainer, dirname, doEscape, addSpace: match.addSpace }); }); option.setAttribute('data-match', matchLabel); option.setAttribute('data-completion', matchCompletion); if (match.addSpace) option.setAttribute('data-add-space', match.addSpace); if (doEscape) option.setAttribute('data-do-escape', 'true'); option.setAttribute('data-value', optionInner.innerText); temporaryContainer.currentMatches.push({ match: matchLabel, completion: matchCompletion, option }); return { option, optionInner, innerPost }; }; const updateReplToReflectLongestPrefix = (prompt, matches, temporaryContainer, partial = temporaryContainer.partial) => { if (matches.length > 0) { const shortest = matches.reduce((minLength, match) => (!minLength ? match.length : Math.min(minLength, match.length)), false); let idx = 0; const partialComplete = (idx) => { const completion = completeWith(partial, matches[0].substring(0, idx), true); if (completion.length > 0) { temporaryContainer.partial = completion; prompt.value = prompt.value + completion; } return temporaryContainer.partial; }; for (idx = 0; idx < shortest; idx++) { const char = matches[0].charAt(idx); for (let jdx = 1; jdx < matches.length; jdx++) { const other = matches[jdx].charAt(idx); if (char !== other) { if (idx > 0) { return partialComplete(idx); } else { return; } } } } if (idx > 0) { return partialComplete(idx); } } }; const presentEnumeratorSuggestions = (block, prompt, temporaryContainer, lastIdx, last) => (filteredList) => { debug('presentEnumeratorSuggestions', filteredList); if (filteredList.length === 1) { complete(filteredList[0], prompt, { partial: last, dirname: false }); } else if (filteredList.length > 0) { const partial = last; const dirname = undefined; if (!temporaryContainer) { temporaryContainer = makeCompletionContainer(block, prompt, partial, dirname, lastIdx); } updateReplToReflectLongestPrefix(prompt, filteredList, temporaryContainer); filteredList.forEach(addSuggestion(temporaryContainer, last, dirname, prompt)); } }; const suggestLocalFile = (last, block, prompt, temporaryContainer, lastIdx) => { const lastIsDir = last.charAt(last.length - 1) === '/'; const dirname = lastIsDir ? last : path_1.dirname(last); debug('suggest local file', dirname, last); if (dirname) { fs_1.readdir(util_1.default.expandHomeDir(dirname), (err, files) => { if (err) { debug('fs.readdir error', err); } else { const partial = path_1.basename(last) + (lastIsDir ? '/' : ''); const matches = files.filter(_f => { const f = shellescape(_f); return (lastIsDir || f.indexOf(partial) === 0) && !f.endsWith('~') && f !== '.' && f !== '..'; }); debug('fs.readdir success', partial, matches); if (matches.length === 1) { debug('singleton file completion', matches[0]); complete(matches[0], prompt, { temporaryContainer, doEscape: true, partial, dirname }); } else if (matches.length > 1) { debug('multi file completion'); if (!temporaryContainer) { temporaryContainer = makeCompletionContainer(block, prompt, partial, dirname, lastIdx); } updateReplToReflectLongestPrefix(prompt, matches, temporaryContainer); matches.forEach((match, idx) => { const { option, optionInner, innerPost } = addSuggestion(temporaryContainer, '', dirname, prompt, true)(match, idx); const filepath = path_1.join(dirname, match); isDirectory(filepath) .then(isDir => { if (isDir) { innerPost.innerText = innerPost.innerText + '/'; } option.setAttribute('data-value', optionInner.innerText); }) .catch(err => { console.error(err); }); }); } } }); } }; const filterAndPresentEntitySuggestions = (last, block, prompt, temporaryContainer, lastIdx) => entities => { debug('filtering these entities', entities); debug('against this filter', last); const filteredList = entities .map(({ name, packageName, namespace }) => { const packageNamePart = packageName ? `${packageName}/` : ''; const actionWithPackage = `${packageNamePart}${name}`; const fqn = `/${namespace}/${actionWithPackage}`; return ((name.indexOf(last) === 0 && actionWithPackage) || (actionWithPackage.indexOf(last) === 0 && actionWithPackage) || (fqn.indexOf(last) === 0 && fqn)); }) .filter(x => x); debug('filtered list', filteredList); if (filteredList.length === 1) { debug('singleton entity match', filteredList[0]); complete(filteredList[0], prompt, { partial: last, dirname: false }); } else if (filteredList.length > 0) { const partial = last; const dirname = undefined; if (!temporaryContainer) { temporaryContainer = makeCompletionContainer(block, prompt, partial, dirname, lastIdx); } updateReplToReflectLongestPrefix(prompt, filteredList, temporaryContainer); filteredList.forEach(addSuggestion(temporaryContainer, last, dirname, prompt)); } }; const suggestCommandCompletions = (_matches, partial, block, prompt, temporaryContainer) => { const matches = _matches .filter(({ usage, docs }) => usage || docs) .map(({ command, docs, usage = { command, docs, commandPrefix: undefined, title: undefined, header: undefined } }) => ({ label: command, completion: command, addSpace: true, docs: usage.title || usage.header || usage.docs })); if (matches.length === 1) { debug('singleton command completion', matches[0]); complete(matches[0].completion, prompt, { partial, dirname: false }); } else if (matches.length > 0) { debug('suggesting command completions', matches, partial); if (!temporaryContainer) { temporaryContainer = makeCompletionContainer(block, prompt, partial); } matches.forEach(addSuggestion(temporaryContainer, partial, undefined, prompt)); } }; const suggest = (param, last, block, prompt, temporaryContainer, lastIdx) => { if (param.file) { return suggestLocalFile(last, block, prompt, temporaryContainer, lastIdx); } else if (param.entity) { const tab = UI.getTabFromTarget(block); return tab.REPL.qexec(`${param.entity} list --limit 200`) .then(response => response.body) .then(filterAndPresentEntitySuggestions(path_1.basename(last), block, prompt, temporaryContainer, lastIdx)); } }; exports.default = () => { if (typeof document === 'undefined') return; if (capabilities_1.default.inBrowser()) { inject_1.injectCSS({ css: require('@kui-shell/plugin-core-support/web/css/tab-completion.css'), key: 'tab-completion.css' }); } else { const root = path_1.dirname(require.resolve('@kui-shell/plugin-core-support/package.json')); inject_1.injectCSS(path_1.join(root, 'web/css/tab-completion.css')); } let currentEnumeratorAsync; document.addEventListener('keydown', (evt) => __awaiter(void 0, void 0, void 0, function* () { const block = UI.getCurrentBlock(); const temporaryContainer = block && block.querySelector('.tab-completion-temporary'); if (evt.keyCode === UI.Keys.Codes.ENTER) { if (temporaryContainer) { const current = temporaryContainer.querySelector('.selected'); if (current) { const completion = current.getAttribute('data-completion'); const doEscape = current.hasAttribute('data-do-escape'); const addSpace = current.hasAttribute('data-add-space'); const prompt = UI.getCurrentPrompt(); complete(completion, prompt, { temporaryContainer, doEscape, addSpace }); } evt.preventDefault(); try { temporaryContainer.cleanup(); } catch (err) { } } } else if (evt.keyCode === UI.Keys.Codes.TAB) { const prompt = UI.getCurrentPrompt(); if (prompt) { if (LowLevel.isUsingCustomPrompt(prompt)) { evt.preventDefault(); return true; } const value = prompt.value; if (value) { evt.preventDefault(); if (temporaryContainer) { const current = temporaryContainer.querySelector('.selected'); const next = (current.nextSibling || temporaryContainer.querySelector('.tab-completion-option:first-child')); if (next) { current.classList.remove('selected'); next.classList.add('selected'); next.scrollIntoView(); } return; } const handleUsage = (usageError) => { const usage = usageError.raw ? usageError.raw.usage || usageError.raw : usageError.usage || usageError; debug('usage', usage, usageError); if (usage.fn) { debug('resolving generator'); handleUsage(usage.fn(usage.command)); } else if (usageError.partialMatches || usageError.available) { suggestCommandCompletions(usageError.partialMatches || usageError.available, prompt.value, block, prompt, temporaryContainer); } else if (usage && usage.command) { const required = usage.required || []; const optionalPositionals = (usage.optional || []).filter(({ positional }) => positional); const oneofs = usage.oneof ? [usage.oneof[0]] : []; const positionals = required.concat(oneofs).concat(optionalPositionals); debug('positionals', positionals); if (positionals.length > 0) { const args = REPLUtil.split(prompt.value).filter(_ => !/^-/.test(_)); const commandIdx = args.indexOf(usage.command); const nActuals = args.length - commandIdx - 1; const lastIdx = Math.max(0, nActuals - 1); const param = positionals[lastIdx]; debug('maybe', args, commandIdx, lastIdx, nActuals, param, args[commandIdx + lastIdx]); if (commandIdx === args.length - 1 && !prompt.value.match(/\s+$/)) { } else if (param) { try { suggest(param, util_1.default.findFile(args[commandIdx + lastIdx + 1], { safe: true }), block, prompt, temporaryContainer, commandIdx + lastIdx); } catch (err) { console.error(err); } } } } else if (!capabilities_1.default.inBrowser()) { const { A: args, endIndices } = REPLUtil._split(prompt.value, true, true); const lastIdx = prompt.selectionStart; debug('falling back on local file completion', args, lastIdx); for (let ii = 0; ii < endIndices.length; ii++) { if (endIndices[ii] >= lastIdx) { const last = prompt.value.substring(endIndices[ii - 1], lastIdx).replace(/^\s+/, ''); suggestLocalFile(last, block, prompt, temporaryContainer, lastIdx); break; } } } }; const lastIdx = prompt.selectionStart; const { A: argv, endIndices } = REPLUtil._split(prompt.value, true, true); const minimist = yield Promise.resolve().then(() => require('yargs-parser')); const options = minimist(argv); const toBeCompletedIdx = endIndices.findIndex(idx => idx >= lastIdx); const completingTrailingEmpty = lastIdx > endIndices[endIndices.length - 1]; if (toBeCompletedIdx >= 0 || completingTrailingEmpty) { const last = completingTrailingEmpty ? '' : prompt.value.substring(endIndices[toBeCompletedIdx - 1], lastIdx).replace(/^\s+/, ''); const argvNoOptions = options._; delete options._; const commandLine = { command: prompt.value, argv, argvNoOptions, parsedOptions: options }; const spec = { toBeCompletedIdx, toBeCompleted: last }; const gotSomeCompletions = yield new Promise(resolve => { if (currentEnumeratorAsync) { clearTimeout(currentEnumeratorAsync); } const myEnumeratorAsync = setTimeout(() => __awaiter(void 0, void 0, void 0, function* () { const completions = yield tab_completion_registrar_1.applyEnumerator(commandLine, spec); if (myEnumeratorAsync !== currentEnumeratorAsync) { return; } if (completions && completions.length > 0) { presentEnumeratorSuggestions(block, prompt, temporaryContainer, lastIdx, last)(completions); currentEnumeratorAsync = undefined; resolve(true); } else { resolve(false); } })); currentEnumeratorAsync = myEnumeratorAsync; }); if (gotSomeCompletions) { return; } } try { debug('fetching usage', value); const tab = UI.getTabFromTarget(block); const usage = tab.REPL.qexec(`${value} --help`, undefined, undefined, { failWithUsage: true }); if (usage.then) { usage.then(handleUsage, handleUsage); } else { handleUsage(usage); } } catch (err) { console.error(err); } } } } })); }; //# sourceMappingURL=tab-completion.js.map