Skip to content

Commit 555a705

Browse files
committed
feat: support experimental zsh capture completions
1 parent c34bbeb commit 555a705

File tree

6 files changed

+228
-40
lines changed

6 files changed

+228
-40
lines changed

addons/settings/locales/zh-CN.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424
"Requires Shell Integration to be enabled#!settings.comments.1.terminal.shell.stickyScroll": "需要启用 Shell 集成",
2525
"Enable Zsh Capture Completion#!settings.label.terminal.shell.experimentalZshCaptureCompletion": "启用 Zsh 捕获补全",
2626
"Get completions from zsh pty session#!settings.comments.0.terminal.shell.experimentalZshCaptureCompletion": "从 zsh pty 会话获取补全",
27-
"Requires Shell Integration to be enabled#!settings.comments.1.terminal.shell.experimentalZshCaptureCompletion": "需要启用 Shell 集成",
27+
"This allows you to customize the completions using zsh completions#!settings.comments.1.terminal.shell.experimentalZshCaptureCompletion": "这使你可以借助 zsh completions 自定义补全",
28+
"rather than just the built-in ones#!settings.comments.2.terminal.shell.experimentalZshCaptureCompletion": "而不仅限于内置的补全",
29+
"Requires Shell Integration to be enabled#!settings.comments.3.terminal.shell.experimentalZshCaptureCompletion": "需要启用 Shell 集成",
2830
"External#!settings.group.terminal.external": "外部",
2931
"Open External Path In#!settings.label.terminal.external.openPathIn": "打开外部路径于",
3032
"Specify how to open external paths#!settings.comments.0.terminal.external.openPathIn": "指定如何打开外部路径",

bin/zsh-capture-completion.sh

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
#!/bin/zsh
2+
# https://github.com/Valodim/zsh-capture-completion
3+
4+
zmodload zsh/zpty || { echo 'error: missing module zsh/zpty' >&2; exit 1 }
5+
6+
# spawn shell
7+
zpty z zsh -f -i
8+
9+
# line buffer for pty output
10+
local line
11+
12+
setopt rcquotes
13+
() {
14+
zpty -w z source $1
15+
repeat 4; do
16+
zpty -r z line
17+
[[ $line == ok* ]] && return
18+
done
19+
echo 'error initializing.' >&2
20+
exit 2
21+
} =( <<< '
22+
# no prompt!
23+
PROMPT=
24+
25+
# load completion system
26+
autoload compinit
27+
compinit -d ~/.zcompdump_capture
28+
29+
# never run a command
30+
bindkey ''^M'' undefined
31+
bindkey ''^J'' undefined
32+
bindkey ''^I'' complete-word
33+
34+
# send a line with null-byte at the end before and after completions are output
35+
null-line () {
36+
echo -E - $''\0''
37+
}
38+
compprefuncs=( null-line )
39+
comppostfuncs=( null-line exit )
40+
41+
# never group stuff!
42+
zstyle '':completion:*'' list-grouped false
43+
# don''t insert tab when attempting completion on empty line
44+
zstyle '':completion:*'' insert-tab false
45+
# no list separator, this saves some stripping later on
46+
zstyle '':completion:*'' list-separator ''''
47+
48+
# we use zparseopts
49+
zmodload zsh/zutil
50+
51+
# override compadd (this our hook)
52+
compadd () {
53+
54+
# check if any of -O, -A or -D are given
55+
if [[ ${@[1,(i)(-|--)]} == *-(O|A|D)\ * ]]; then
56+
# if that is the case, just delegate and leave
57+
builtin compadd "$@"
58+
return $?
59+
fi
60+
61+
# ok, this concerns us!
62+
# echo -E - got this: "$@"
63+
64+
# be careful with namespacing here, we don''t want to mess with stuff that
65+
# should be passed to compadd!
66+
typeset -a __hits __dscr __tmp
67+
68+
# do we have a description parameter?
69+
# note we don''t use zparseopts here because of combined option parameters
70+
# with arguments like -default- confuse it.
71+
if (( $@[(I)-d] )); then # kind of a hack, $+@[(r)-d] doesn''t work because of line noise overload
72+
# next param after -d
73+
__tmp=${@[$[${@[(i)-d]}+1]]}
74+
# description can be given as an array parameter name, or inline () array
75+
if [[ $__tmp == \(* ]]; then
76+
eval "__dscr=$__tmp"
77+
else
78+
__dscr=( "${(@P)__tmp}" )
79+
fi
80+
fi
81+
82+
# capture completions by injecting -A parameter into the compadd call.
83+
# this takes care of matching for us.
84+
builtin compadd -A __hits -D __dscr "$@"
85+
86+
# JESUS CHRIST IT TOOK ME FOREVER TO FIGURE OUT THIS OPTION WAS SET AND WAS MESSING WITH MY SHIT HERE
87+
setopt localoptions norcexpandparam extendedglob
88+
89+
# extract prefixes and suffixes from compadd call. we can''t do zsh''s cool
90+
# -r remove-func magic, but it''s better than nothing.
91+
typeset -A apre hpre hsuf asuf
92+
zparseopts -E P:=apre p:=hpre S:=asuf s:=hsuf
93+
94+
# append / to directories? we are only emulating -f in a half-assed way
95+
# here, but it''s better than nothing.
96+
integer dirsuf=0
97+
# don''t be fooled by -default- >.>
98+
if [[ -z $hsuf && "${${@//-default-/}% -# *}" == *-[[:alnum:]]#f* ]]; then
99+
dirsuf=1
100+
fi
101+
102+
# just drop
103+
[[ -n $__hits ]] || return
104+
105+
# this is the point where we have all matches in $__hits and all
106+
# descriptions in $__dscr!
107+
108+
# display all matches
109+
local dsuf dscr
110+
for i in {1..$#__hits}; do
111+
112+
# add a dir suffix?
113+
(( dirsuf )) && [[ -d $__hits[$i] ]] && dsuf=/ || dsuf=
114+
# description to be displayed afterwards
115+
(( $#__dscr >= $i )) && dscr=" -- ${${__dscr[$i]}##$__hits[$i] #}" || dscr=
116+
117+
echo -E - $IPREFIX$apre$hpre$__hits[$i]$dsuf$hsuf$asuf$dscr
118+
119+
done
120+
121+
}
122+
123+
# signal success!
124+
echo ok')
125+
126+
zpty -w z "$*"$'\t'
127+
128+
integer tog=0
129+
# read from the pty, and parse linewise
130+
while zpty -r z; do :; done | while IFS= read -r line; do
131+
if [[ $line == *$'\0\r' ]]; then
132+
(( tog++ )) && return 0 || continue
133+
fi
134+
# display between toggles
135+
(( tog )) && echo -E - $line
136+
done
137+
138+
return 2

resources/settings.spec.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,21 @@
137137
},
138138
"default": true
139139
},
140+
{
141+
"key": "terminal.shell.captureCompletion",
142+
"label": "Enable Zsh Capture Completion",
143+
"comments": [
144+
"Get completions from zsh pty session",
145+
"This allows you to customize the completions using zsh completions",
146+
"rather than just the built-in ones",
147+
"Requires Shell Integration to be enabled"
148+
],
149+
"schema": {
150+
"type": "boolean"
151+
},
152+
"experimental": true,
153+
"default": false
154+
},
140155
{
141156
"key": "terminal.external.openPathIn",
142157
"label": "Open External Path In",

src/main/lib/terminal.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ async function createTerminal(
111111
}
112112

113113
function handleTerminalMessages() {
114+
const settings = useSettings()
114115
ipcMain.handle('create-terminal', (event, data) => {
115116
return createTerminal(event.sender, data)
116117
})
@@ -159,7 +160,7 @@ function handleTerminalMessages() {
159160
}
160161
})
161162
ipcMain.handle('get-completions', (event, input, cwd) => {
162-
return getCompletions(input, cwd)
163+
return getCompletions(input, cwd, settings['terminal.shell.captureCompletion'])
163164
})
164165
ipcMain.on('terminal-prompt-end', () => {
165166
refreshCompletions()

src/main/utils/completion.ts

Lines changed: 66 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { CommandCompletion } from '@commas/types/terminal'
99
import * as commas from '../../api/core-main'
1010
import { resolveHome } from '../../shared/terminal'
1111
import { execa, memoizeAsync } from './helper'
12-
import { loginExecute } from './shell'
12+
import { BIN_PATH, loginExecute } from './shell'
1313

1414
function isCommandLineArgument(query: string) {
1515
return query.startsWith('-')
@@ -321,6 +321,29 @@ async function getHistoryCompletions(query: string, command: string) {
321321
}))
322322
}
323323

324+
async function getZshCaptureCompletions(input: string, query: string, cwd: string) {
325+
try {
326+
const { stdout } = await loginExecute(quote([path.join(BIN_PATH, 'zsh-capture-completion.sh'), input]), {
327+
shell: '/bin/zsh',
328+
cwd,
329+
})
330+
const choices = uniq(stdout.trim().split(/[\r\n]+/))
331+
return choices.map<CommandCompletion>(choice => {
332+
const matches = choice.match(/^(.+?)\s+--\s+(.+)$/)
333+
const value = matches ? matches[1] : choice
334+
const description = matches ? matches[2] : undefined
335+
return {
336+
type: 'command',
337+
query,
338+
value,
339+
description,
340+
}
341+
})
342+
} catch {
343+
return []
344+
}
345+
}
346+
324347
async function getAllCommands() {
325348
if (process.platform === 'win32') return []
326349
try {
@@ -381,13 +404,14 @@ function isCommandEntry(entry: ParseEntry): entry is string {
381404
return typeof entry === 'string' && /^\w/.test(entry)
382405
}
383406

384-
async function getCompletions(input: string, cwd: string) {
407+
async function getCompletions(input: string, cwd: string, capture?: boolean) {
385408
const entries = parse(input).filter(item => {
386409
return !(typeof item === 'object' && 'comment' in item)
387410
})
388411
if (!entries.length) return []
389412
const lastToken = entries[entries.length - 1]
390413
const isWordStart = /\s$/.test(input) || typeof lastToken !== 'string'
414+
const currentWord = isWordStart ? '' : lastToken
391415
const tokenIndex = entries.findLastIndex(item => {
392416
return isControlOperatorEntry(item) && item.op !== '>'
393417
})
@@ -398,8 +422,6 @@ async function getCompletions(input: string, cwd: string) {
398422
const subcommandIndex = args.findIndex(isCommandEntry)
399423
const undeterminedSubcommand = subcommandIndex !== -1 ? args[subcommandIndex] as string : undefined
400424
const subcommandArgs = subcommandIndex !== -1 ? args.slice(subcommandIndex + 1) : []
401-
const currentWord = isWordStart ? '' : lastToken
402-
const isInputingArgs = isCommandLineArgument(currentWord)
403425
const command = undeterminedCommand && (isWordStart || args.length > 0)
404426
? undeterminedCommand.toLowerCase()
405427
: ''
@@ -417,40 +439,48 @@ async function getCompletions(input: string, cwd: string) {
417439
subcommand,
418440
})),
419441
)
420-
// History
421-
if (currentWord) {
422-
asyncCompletionLists.push(
423-
getHistoryCompletions(currentWord, command),
424-
)
425-
}
426-
// Commands
427-
if (!command && !/^(.+|~)?[\\/]/.test(currentWord)) {
442+
if (capture) {
443+
// Zsh capture
428444
asyncCompletionLists.push(
429-
getCommandCompletions(currentWord),
430-
)
431-
}
432-
// Files
433-
const frequentlyUsedFileCommands = ['.', 'cat', 'cd', 'cp', 'diff', 'more', 'mv', 'rm', 'source', 'vi']
434-
if (!isInputingArgs && (
435-
isControlOperatorEntry(lastToken) && lastToken.op === '>'
436-
|| command && (currentWord || frequentlyUsedFileCommands.includes(command))
437-
|| !command && /^(.+|~)?[\\/]/.test(currentWord)
438-
)) {
439-
const directoryCommands = ['cd', 'dir', 'ls']
440-
const directoryOnly = directoryCommands.includes(command)
441-
asyncCompletionLists.push(
442-
getFileCompletions(currentWord, cwd, directoryOnly),
443-
)
444-
}
445-
if (command) {
446-
asyncCompletionLists.push(
447-
getManPageCompletions(currentWord, command, subcommand),
448-
)
449-
}
450-
if (command === 'npm' && subcommand === 'run') {
451-
asyncCompletionLists.push(
452-
getFrequentlyUsedProgramCompletions(currentWord, cwd, command, subcommand),
445+
getZshCaptureCompletions(input, currentWord, cwd),
453446
)
447+
} else {
448+
// History
449+
if (currentWord) {
450+
asyncCompletionLists.push(
451+
getHistoryCompletions(currentWord, command),
452+
)
453+
}
454+
// Commands
455+
if (!command && !/^(.+|~)?[\\/]/.test(currentWord)) {
456+
asyncCompletionLists.push(
457+
getCommandCompletions(currentWord),
458+
)
459+
}
460+
// Files
461+
const isInputingArgs = isCommandLineArgument(currentWord)
462+
const frequentlyUsedFileCommands = ['.', 'cat', 'cd', 'cp', 'diff', 'more', 'mv', 'rm', 'source', 'vi']
463+
if (!isInputingArgs && (
464+
isControlOperatorEntry(lastToken) && lastToken.op === '>'
465+
|| command && (currentWord || frequentlyUsedFileCommands.includes(command))
466+
|| !command && /^(.+|~)?[\\/]/.test(currentWord)
467+
)) {
468+
const directoryCommands = ['cd', 'dir', 'ls']
469+
const directoryOnly = directoryCommands.includes(command)
470+
asyncCompletionLists.push(
471+
getFileCompletions(currentWord, cwd, directoryOnly),
472+
)
473+
}
474+
if (command) {
475+
asyncCompletionLists.push(
476+
getManPageCompletions(currentWord, command, subcommand),
477+
)
478+
}
479+
if (command === 'npm' && subcommand === 'run') {
480+
asyncCompletionLists.push(
481+
getFrequentlyUsedProgramCompletions(currentWord, cwd, command, subcommand),
482+
)
483+
}
454484
}
455485
const lists = await Promise.all(asyncCompletionLists)
456486
const completions = lists.flat()

src/main/utils/shell.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,22 +86,24 @@ function integrateShell(context: ShellContext) {
8686
}
8787
}
8888

89-
function loginExecute(command: string, options: Pick<ExecOptions, 'shell' | 'env'> = {}) {
89+
function loginExecute(command: string, options: Pick<ExecOptions, 'shell' | 'env' | 'cwd'> = {}) {
9090
const env = getDefaultEnv()
9191
if (process.platform === 'win32') {
9292
return execa(command, {
9393
env: { ...env, ...options.env },
94+
cwd: options.cwd,
9495
})
9596
} else {
9697
const shell = options.shell ?? getDefaultShell()
9798
const expression = options.env
9899
? Object.entries(options.env).map(kv => kv.join('=')).concat(command).join(' ')
99100
: command
100-
return execa(quote([shell!, '-lic', expression]), { env })
101+
return execa(quote([shell!, '-lic', expression]), { env, cwd: options.cwd })
101102
}
102103
}
103104

104105
export {
106+
BIN_PATH,
105107
getDefaultShell,
106108
getDefaultEnv,
107109
integrateShell,

0 commit comments

Comments
 (0)