Skip to content

Commit

Permalink
feat: support auto completion
Browse files Browse the repository at this point in the history
  • Loading branch information
CyanSalt committed Jan 12, 2023
1 parent b7c4088 commit 6445db8
Show file tree
Hide file tree
Showing 14 changed files with 459 additions and 14 deletions.
2 changes: 1 addition & 1 deletion addons/iterm2/src/renderer/xterm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,8 +226,8 @@ export class ITerm2Addon implements ITerminalAddon {
this.markMarkers.sort((a, b) => a.line - b.line)
this.recentMarkMarker = undefined
decoration.onRender(el => {
el.style.setProperty('--color', `${rgba.r} ${rgba.g} ${rgba.b}`)
el.classList.add('iterm2-mark')
el.style.setProperty('--color', `${rgba.r} ${rgba.g} ${rgba.b}`)
})
}

Expand Down
6 changes: 3 additions & 3 deletions addons/launcher/src/renderer/session.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import type { Terminal } from 'xterm'
import { SerializeAddon } from 'xterm-addon-serialize'

const launcherSessionMap = new Map<number, string>()
const launcherSessionMap = new Map<string, string>()

export class LauncherSessionAddon {

id: number
id: string
serializeAddon: SerializeAddon

constructor(id: number) {
constructor(id: string) {
this.id = id
this.serializeAddon = new SerializeAddon()
}
Expand Down
5 changes: 4 additions & 1 deletion addons/settings/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@
"Enable Shell Integration#!settings.label.terminal.shell.integration": "启用 Shell 集成",
"Allow the terminal to inject scripts into the shell for enhanced functionality#!settings.comments.0.terminal.shell.integration": "允许终端向 Shell 注入脚本以增强功能",
"Highlight Command Errors#!settings.label.terminal.shell.highlightErrors": "高亮命令错误",
"Whether to highlight commands that were executed with errors#!settings.comments.0.terminal.shell.highlightErrors": "是否高亮显示执行出错的命令",
"Highlight commands that were executed with errors#!settings.comments.0.terminal.shell.highlightErrors": "高亮显示执行出错的命令",
"Requires Shell Integration to be enabled#!settings.comments.1.terminal.shell.highlightErrors": "需要启用 Shell 集成",
"Auto Trigger Completion#!settings.label.terminal.shell.autoCompletion": "自动触发补全",
"Trigger intelligent completion automatically when inputing#!settings.comments.0.terminal.shell.autoCompletion": "在输入时自动触发智能补全",
"Requires Shell Integration to be enabled#!settings.comments.1.terminal.shell.autoCompletion": "需要启用 Shell 集成",
"External#!settings.group.terminal.external": "外部",
"Open External Path In#!settings.label.terminal.external.openPathIn": "打开外部路径于",
"Specify how to open external paths#!settings.comments.0.terminal.external.openPathIn": "指定如何打开外部路径",
Expand Down
14 changes: 13 additions & 1 deletion resources/settings.spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,19 @@
"key": "terminal.shell.highlightErrors",
"label": "Highlight Command Errors",
"comments": [
"Whether to highlight commands that were executed with errors",
"Highlight commands that were executed with errors",
"Requires Shell Integration to be enabled"
],
"schema": {
"type": "boolean"
},
"default": true
},
{
"key": "terminal.shell.autoCompletion",
"label": "Auto Trigger Completion",
"comments": [
"Trigger intelligent completion automatically when inputing",
"Requires Shell Integration to be enabled"
],
"schema": {
Expand Down
4 changes: 4 additions & 0 deletions src/main/lib/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { WebContents } from 'electron'
import * as pty from 'node-pty'
import type { IPty, IPtyForkOptions } from 'node-pty'
import type { TerminalInfo } from '../../typings/terminal'
import { getCompletions } from '../utils/completion'
import { execa } from '../utils/helper'
import { integrateShell, getDefaultEnv, getDefaultShell } from '../utils/shell'
import { useSettings, whenSettingsReady } from './settings'
Expand Down Expand Up @@ -131,6 +132,9 @@ function handleTerminalMessages() {
return []
}
})
ipcMain.handle('get-completions', (event, input: string, cwd: string) => {
return getCompletions(input, cwd)
})
}

export {
Expand Down
137 changes: 137 additions & 0 deletions src/main/utils/completion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import * as fs from 'fs'
import * as path from 'path'
import { findLastIndex } from 'lodash'
import type { ParseEntry } from 'shell-quote'
import { parse, quote } from 'shell-quote'
import { resolveHome } from '../../shared/terminal'
import type { CommandCompletion } from '../../typings/terminal'
import { execa, memoizeAsync } from './helper'

async function getFileCompletions(
currentWord: string,
cwd: string,
directoryOnly: boolean,
) {
let context = cwd
let prefix = currentWord
if (currentWord.includes(path.sep)) {
const joined = path.resolve(cwd, resolveHome(currentWord))
if (currentWord.endsWith(path.sep)) {
context = joined
prefix = ''
} else {
const parsed = path.parse(joined)
context = parsed.dir
prefix = parsed.base
}
}
let files: fs.Dirent[]
try {
const entities = await fs.promises.readdir(context, { withFileTypes: true })
files = directoryOnly ? entities.filter(entity => entity.isDirectory()) : entities
} catch {
return []
}
if (currentWord) {
files = files.filter(entity => entity.name.startsWith(prefix))
} else {
files = files.filter(entity => !entity.name.startsWith('.'))
}
return files.map<CommandCompletion>(entity => ({
label: entity.name,
value: entity.name.slice(prefix.length) + (directoryOnly ? path.sep : ''),
}))
}

const getManPageRawCompletions = memoizeAsync(async (command: string) => {
// Not supported yet
if (process.platform === 'win32') return []
try {
const { stdout } = await execa(quote(['man', command]), { env: {} })
// eslint-disable-next-line no-control-regex
const lines = stdout.replace(/.\x08/g, '').trim().split('\n')
const titleIndex = lines.indexOf('DESCRIPTION')
if (titleIndex === -1) return []
const paragraphs: string[][] = []
let currentParagraph: string[] = []
for (let i = titleIndex + 1; i < lines.length; i += 1) {
const line = lines[i]
if (line) {
if (!/^\s/.test(line)) break
currentParagraph.push(line)
} else if (currentParagraph.length) {
paragraphs.push(currentParagraph)
currentParagraph = []
}
}
if (currentParagraph.length) {
paragraphs.push(currentParagraph)
}
const completions: CommandCompletion[] = []
for (const paragraph of paragraphs) {
const matches = paragraph[0].match(/^\s*(-{1,2}\w),?\s*(.*)$/)
if (matches) {
completions.push({
label: matches[1],
value: matches[1],
description: (matches[2] ? [matches[2], ...paragraph.slice(1)] : paragraph.slice(1))
.map(line => line.trim()).join(' '),
})
}
}
return completions
} catch {
// ignore error
}
return []
})

async function getManPageCompletions(currentWord: string, command: string) {
const completions = await getManPageRawCompletions(command)
return completions.map(item => ({ ...item, value: item.value.slice(currentWord.length) }))
}

async function getCompletions(input: string, cwd: string) {
const entries = parse(input).filter(item => {
return !(typeof item === 'object' && 'comment' in item)
})
if (!entries.length) return []
const isWordStart = /\s$/.test(input) || typeof entries[entries.length - 1] !== 'string'
const tokenIndex = findLastIndex(entries, item => {
return typeof item === 'object' && 'op' in item && item.op !== 'glob'
})
const command = tokenIndex !== entries.length - 1
? (entries[tokenIndex + 1] as string).toLowerCase()
: undefined
const args = entries.slice(tokenIndex + 2)
const currentWord = isWordStart ? '' : entries[entries.length - 1] as string
const isInputingArgs = currentWord === '-' || currentWord.startsWith('--')
// Commands
if (!isWordStart && !args.length) {
// TODO:
return []
}
let completions: CommandCompletion[] = []
// Files
const fileCommands: ParseEntry[] = ['cat', 'sh', 'diff', 'head', 'more', 'tail']
const directoryCommands: ParseEntry[] = ['cd', 'ls', 'rmdir']
const fileOrDirectoryCommands: ParseEntry[] = ['chmod', 'chown', 'cp', 'file', 'ln', 'mv', 'rm']
if (command && !isInputingArgs && [
...fileCommands,
...directoryCommands,
...fileOrDirectoryCommands,
].includes(command)) {
const directoryOnly = directoryCommands.includes(command)
const result = await getFileCompletions(currentWord, cwd, directoryOnly)
completions = completions.concat(result)
}
if (command && (isInputingArgs || isWordStart)) {
const result = await getManPageCompletions(currentWord, command)
completions = completions.concat(result)
}
return completions
}

export {
getCompletions,
}
29 changes: 29 additions & 0 deletions src/main/utils/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,37 @@ async function getStream(input: Readable, encoding?: BufferEncoding) {
return encoding ? buffer.toString(encoding) : buffer
}

function memoizeAsync<
T extends (...args: unknown[]) => unknown,
R extends ((...args: Parameters<T>) => unknown) | undefined,
>(func: T, resolver?: R) {
const cache = new Map<R extends Function ? ReturnType<R> : Parameters<T>[0], ReturnType<T>>()
const memoized = function (
this: T extends (this: infer U, ...args: unknown[]) => unknown ? U : unknown,
...args: Parameters<T>
) {
const key = resolver ? resolver.apply(this, args) : args[0]
if (cache.has(key)) {
return cache.get(key)!
}
const result: ReturnType<T> = func.apply(this, args)
cache.set(key, result)
// `Promise.resolve` will return the value itself if the value is already a promise
Promise.resolve(result).catch(() => {
if (cache.get(key) === result) {
cache.delete(key)
}
})
return result
}
memoized.cache = cache
return memoized
}


export {
execa,
until,
getStream,
memoizeAsync,
}
3 changes: 2 additions & 1 deletion src/main/utils/shell.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as os from 'os'
import * as path from 'path'
import { app } from 'electron'
import { quote } from 'shell-quote'
import { userFile } from './directory'
import { execa } from './helper'

Expand Down Expand Up @@ -89,7 +90,7 @@ function loginExecute(command: string) {
return execa(command, { env })
} else {
const shell = getDefaultShell()
return execa(`${shell} -lic '${command}'`, { env })
return execa(quote([shell!, '-lic', command]), { env })
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/renderer/assets/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,9 @@ export default [
command: 'xterm:send',
args: ['\u0017'],
},
{
label: 'Trigger Completion',
accelerator: 'CmdOrCtrl+I',
command: 'xterm:completion',
},
] as KeyBinding[]
Loading

0 comments on commit 6445db8

Please sign in to comment.