mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-17 20:51:55 +01:00
Autocomplete using the Newton parser (#599)
I've reworked the autocomplete parser to more closely match Newton, the Fig-compatible parser I prototyped earlier this year. I was able to move a lot faster by reusing patterns that inshellisense proved out, such as for templates and generators. I also support some features that inshellisense doesn't, like proper combining of Posix-compatible flags, handling of option argument separators, and handling of cursors in insertValues.
This commit is contained in:
parent
cd15beba26
commit
13f4203437
@ -24,6 +24,7 @@
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/match-sorter-utils": "^8.8.4",
|
||||
"@tanstack/react-table": "^8.10.3",
|
||||
"@withfig/autocomplete": "^2.652.3",
|
||||
"autobind-decorator": "^2.4.0",
|
||||
"base64-js": "^1.5.1",
|
||||
"clsx": "^2.1.1",
|
||||
@ -79,6 +80,7 @@
|
||||
"@types/throttle-debounce": "^5.0.1",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@types/webpack-env": "^1.18.3",
|
||||
"@withfig/autocomplete-types": "^1.30.0",
|
||||
"babel-loader": "^9.1.3",
|
||||
"babel-plugin-jsx-control-statements": "^4.1.2",
|
||||
"copy-webpack-plugin": "^12.0.0",
|
||||
|
@ -72,3 +72,4 @@ export const ErrorCode_InvalidCwd = "ERRCWD";
|
||||
export const InputAuxView_History = "history";
|
||||
export const InputAuxView_Info = "info";
|
||||
export const InputAuxView_AIChat = "aichat";
|
||||
export const InputAuxView_Suggestions = "suggestions";
|
||||
|
@ -11,10 +11,10 @@ import { Toggle, InlineSettingsTextEdit, SettingsError, Dropdown } from "@/commo
|
||||
import { commandRtnHandler, isBlank } from "@/util/util";
|
||||
import { getTermThemes } from "@/util/themeutil";
|
||||
import * as appconst from "@/app/appconst";
|
||||
import { MainView } from "@/common/elements/mainview";
|
||||
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
||||
|
||||
import "./clientsettings.less";
|
||||
import { MainView } from "../common/elements/mainview";
|
||||
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
||||
|
||||
class ClientSettingsKeybindings extends React.Component<{}, {}> {
|
||||
componentDidMount() {
|
||||
@ -110,6 +110,19 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove
|
||||
GlobalModel.getElectronApi().changeAutoUpdate(val);
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleChangeAutocompleteEnabled(val: boolean): void {
|
||||
const prtn: Promise<CommandRtnType> = GlobalCommandRunner.setAutocompleteEnabled(val);
|
||||
commandRtnHandler(prtn, this.errorMessage);
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleChangeAutocompleteDebuggingEnabled(val: boolean): void {
|
||||
mobx.action(() => {
|
||||
GlobalModel.autocompleteModel.loggingEnabled = val;
|
||||
})();
|
||||
}
|
||||
|
||||
getFontSizes(): DropdownItem[] {
|
||||
const availableFontSizes: DropdownItem[] = [];
|
||||
for (let s = appconst.MinFontSize; s <= appconst.MaxFontSize; s++) {
|
||||
@ -447,6 +460,24 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<div className="settings-label">Command Autocomplete</div>
|
||||
<div className="settings-input">
|
||||
<Toggle
|
||||
checked={cdata.clientopts.autocompleteenabled ?? false}
|
||||
onChange={this.handleChangeAutocompleteEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<div className="settings-label">Command Autocomplete Debugging</div>
|
||||
<div className="settings-input">
|
||||
<Toggle
|
||||
checked={GlobalModel.autocompleteModel.loggingEnabled}
|
||||
onChange={this.handleChangeAutocompleteDebuggingEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsError errorMessage={this.errorMessage} />
|
||||
</div>
|
||||
</MainView>
|
||||
|
@ -19,6 +19,7 @@ import { CenteredIcon, RotateIcon } from "@/common/icons/icons";
|
||||
import { AIChat } from "./aichat";
|
||||
import * as util from "@/util/util";
|
||||
import * as appconst from "@/app/appconst";
|
||||
import { AutocompleteSuggestionView } from "./suggestionview";
|
||||
|
||||
import "./cmdinput.less";
|
||||
|
||||
@ -198,6 +199,9 @@ class CmdInput extends React.Component<{}, {}> {
|
||||
<When condition={openView === appconst.InputAuxView_Info}>
|
||||
<InfoMsg key="infomsg" />
|
||||
</When>
|
||||
<When condition={openView === appconst.InputAuxView_Suggestions}>
|
||||
<AutocompleteSuggestionView />
|
||||
</When>
|
||||
</Choose>
|
||||
<If condition={remote && remote.status != "connected"}>
|
||||
<div className="remote-status-warning">
|
||||
|
25
src/app/workspace/cmdinput/suggestionview.less
Normal file
25
src/app/workspace/cmdinput/suggestionview.less
Normal file
@ -0,0 +1,25 @@
|
||||
.suggestions-view .auxview-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 1em;
|
||||
.suggestion-item {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--table-tr-hover-bg-color);
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
font-weight: bold;
|
||||
color: var(--app-text-primary-color);
|
||||
background-color: var(--table-tr-selected-bg-color);
|
||||
&:hover {
|
||||
background-color: var(--table-tr-selected-hover-bg-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
87
src/app/workspace/cmdinput/suggestionview.tsx
Normal file
87
src/app/workspace/cmdinput/suggestionview.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { getAll, getFirst } from "@/autocomplete/runtime/utils";
|
||||
import { AuxiliaryCmdView } from "./auxview";
|
||||
import { clsx } from "clsx";
|
||||
import { action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { GlobalModel } from "@/models";
|
||||
import React, { useEffect } from "react";
|
||||
import { If } from "tsx-control-statements/components";
|
||||
|
||||
import "./suggestionview.less";
|
||||
|
||||
export const AutocompleteSuggestionView: React.FC = observer(() => {
|
||||
const inputModel = GlobalModel.inputModel;
|
||||
const autocompleteModel = GlobalModel.autocompleteModel;
|
||||
const selectedSuggestion = autocompleteModel.getPrimarySuggestionIndex();
|
||||
|
||||
const updateScroll = action((index: number) => {
|
||||
autocompleteModel.setPrimarySuggestionIndex(index);
|
||||
const element = document.getElementsByClassName("suggestion-item")[index] as HTMLElement;
|
||||
if (element) {
|
||||
element.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
});
|
||||
|
||||
const closeView = action(() => {
|
||||
inputModel.closeAuxView();
|
||||
});
|
||||
|
||||
const setSuggestion = action((idx: number) => {
|
||||
autocompleteModel.applySuggestion(idx);
|
||||
autocompleteModel.loadSuggestions();
|
||||
closeView();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const keybindManager = GlobalModel.keybindManager;
|
||||
|
||||
keybindManager.registerKeybinding("pane", "autocomplete", "generic:confirm", (waveEvent) => {
|
||||
setSuggestion(selectedSuggestion);
|
||||
return true;
|
||||
});
|
||||
keybindManager.registerKeybinding("pane", "autocomplete", "generic:cancel", (waveEvent) => {
|
||||
closeView();
|
||||
return true;
|
||||
});
|
||||
keybindManager.registerKeybinding("pane", "autocomplete", "generic:selectAbove", (waveEvent) => {
|
||||
updateScroll(Math.max(0, selectedSuggestion - 1));
|
||||
return true;
|
||||
});
|
||||
keybindManager.registerKeybinding("pane", "autocomplete", "generic:selectBelow", (waveEvent) => {
|
||||
updateScroll(Math.min(suggestions?.length - 1, selectedSuggestion + 1));
|
||||
return true;
|
||||
});
|
||||
keybindManager.registerKeybinding("pane", "autocomplete", "generic:tab", (waveEvent) => {
|
||||
updateScroll(Math.min(suggestions?.length - 1, selectedSuggestion + 1));
|
||||
return true;
|
||||
});
|
||||
|
||||
return () => {
|
||||
GlobalModel.keybindManager.unregisterDomain("autocomplete");
|
||||
};
|
||||
});
|
||||
|
||||
const suggestions: Fig.Suggestion[] = autocompleteModel.getSuggestions();
|
||||
|
||||
return (
|
||||
<AuxiliaryCmdView title="Suggestions" className="suggestions-view" onClose={closeView} scrollable={true}>
|
||||
<If condition={!suggestions || suggestions.length == 0}>
|
||||
<div className="no-suggestions">No suggestions</div>
|
||||
</If>
|
||||
{suggestions?.map((suggestion, idx) => (
|
||||
<option
|
||||
key={getFirst(suggestion.name)}
|
||||
title={suggestion.description}
|
||||
className={clsx("suggestion-item", { "is-selected": selectedSuggestion === idx })}
|
||||
onClick={() => {
|
||||
setSuggestion(idx);
|
||||
}}
|
||||
>
|
||||
{`${suggestion.icon} ${suggestion.displayName ?? getAll(suggestion.name).join(",")} ${
|
||||
suggestion.description ? `- ${suggestion.description}` : ""
|
||||
}`}
|
||||
</option>
|
||||
))}
|
||||
</AuxiliaryCmdView>
|
||||
);
|
||||
});
|
@ -110,10 +110,21 @@ class CmdInputKeybindings extends React.Component<{ inputObject: TextAreaInput }
|
||||
return;
|
||||
}
|
||||
const inputObject = this.props.inputObject;
|
||||
this.lastTab = false;
|
||||
const keybindManager = GlobalModel.keybindManager;
|
||||
const inputModel = GlobalModel.inputModel;
|
||||
keybindManager.registerKeybinding("pane", "cmdinput", "cmdinput:autocomplete", (waveEvent) => {
|
||||
this.curPress = "tab";
|
||||
// For now, we want to preserve the old behavior if autocomplete is disabled
|
||||
if (GlobalModel.autocompleteModel.isEnabled) {
|
||||
if (this.lastTab) {
|
||||
const curLine = inputModel.curLine;
|
||||
if (curLine != "") {
|
||||
inputModel.setActiveAuxView(appconst.InputAuxView_Suggestions);
|
||||
}
|
||||
} else {
|
||||
this.lastTab = true;
|
||||
}
|
||||
} else {
|
||||
const lastTab = this.lastTab;
|
||||
this.lastTab = true;
|
||||
this.curPress = "tab";
|
||||
@ -136,6 +147,8 @@ class CmdInputKeybindings extends React.Component<{ inputObject: TextAreaInput }
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
keybindManager.registerKeybinding("pane", "cmdinput", "generic:confirm", (waveEvent) => {
|
||||
GlobalModel.closeTabSettings();
|
||||
@ -204,6 +217,9 @@ class CmdInputKeybindings extends React.Component<{ inputObject: TextAreaInput }
|
||||
const rtn = inputObject.arrowDownPressed();
|
||||
return rtn;
|
||||
});
|
||||
keybindManager.registerKeybinding("pane", "cmdinput", "generic:selectRight", (waveEvent) => {
|
||||
return inputObject.arrowRightPressed();
|
||||
});
|
||||
keybindManager.registerKeybinding("pane", "cmdinput", "generic:selectPageAbove", (waveEvent) => {
|
||||
this.curPress = "historyupdown";
|
||||
inputObject.scrollPage(true);
|
||||
@ -255,7 +271,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
|
||||
mobx.makeObservable(this);
|
||||
}
|
||||
|
||||
@mobx.action
|
||||
@mobx.action.bound
|
||||
incVersion(): void {
|
||||
const v = this.version.get();
|
||||
this.version.set(v + 1);
|
||||
@ -417,6 +433,17 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
|
||||
return true;
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
arrowRightPressed(): boolean {
|
||||
// If the cursor is at the end of the line, apply the primary suggestion
|
||||
const curSP = this.getCurSP();
|
||||
if (curSP.pos < curSP.str.length) {
|
||||
return false;
|
||||
}
|
||||
GlobalModel.autocompleteModel.applyPrimarySuggestion();
|
||||
return true;
|
||||
}
|
||||
|
||||
scrollPage(up: boolean) {
|
||||
const inputModel = GlobalModel.inputModel;
|
||||
const infoScroll = inputModel.hasScrollingInfoMsg();
|
||||
@ -444,7 +471,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
|
||||
GlobalModel.inputModel.curLine = e.target.value;
|
||||
}
|
||||
|
||||
@mobx.action.bound
|
||||
@boundMethod
|
||||
onSelect(e: any) {
|
||||
this.incVersion();
|
||||
}
|
||||
@ -621,6 +648,10 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
|
||||
inputModel.shouldRenderAuxViewKeybindings(null) ||
|
||||
inputModel.shouldRenderAuxViewKeybindings(appconst.InputAuxView_Info);
|
||||
const renderHistoryKeybindings = inputModel.shouldRenderAuxViewKeybindings(appconst.InputAuxView_History);
|
||||
|
||||
// Will be null if the feature is disabled
|
||||
const primaryAutocompleteSuggestion = GlobalModel.autocompleteModel.getPrimarySuggestionCompletion();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="textareainput-div control is-expanded"
|
||||
@ -628,15 +659,23 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
|
||||
style={{ height: computedOuterHeight }}
|
||||
>
|
||||
<If condition={renderCmdInputKeybindings}>
|
||||
<CmdInputKeybindings inputObject={this}></CmdInputKeybindings>
|
||||
<CmdInputKeybindings inputObject={this} />
|
||||
</If>
|
||||
<If condition={renderHistoryKeybindings}>
|
||||
<HistoryKeybindings></HistoryKeybindings>
|
||||
<HistoryKeybindings />
|
||||
</If>
|
||||
|
||||
<If condition={!util.isBlank(shellType)}>
|
||||
<div className="shelltag">{shellType}</div>
|
||||
</If>
|
||||
<If condition={primaryAutocompleteSuggestion}>
|
||||
<div
|
||||
className="textarea-ghost"
|
||||
style={{ height: computedInnerHeight, minHeight: computedInnerHeight, fontSize: termFontSize }}
|
||||
>
|
||||
{`${"\xa0".repeat(curLine.length)}${primaryAutocompleteSuggestion}`}
|
||||
</div>
|
||||
</If>
|
||||
<textarea
|
||||
key="main"
|
||||
ref={this.mainInputRef}
|
||||
|
39
src/autocomplete/README.md
Normal file
39
src/autocomplete/README.md
Normal file
@ -0,0 +1,39 @@
|
||||
# Newton autocomplete parser
|
||||
|
||||
Newton is a Fig-compatible autocomplete parser. It builds on a lot of goodness from the [@microsoft/inshellisense project](https://github.com/microsoft/inshellisense), with heavy modifications to minimize recursion and allow for caching of intermediate states. All suggestions, as with inshellisense, come from the [@withfig/autocomplete project](https://github.com/withfig/autocomplete).
|
||||
|
||||
Any exec commands that need to be run are proxied through the Wave backend to ensure no additional permissions are required.
|
||||
|
||||
The following features from Fig's object definitions are not yet supported:
|
||||
|
||||
- Specs
|
||||
- Versioned specs, such as the `az` CLI
|
||||
- Custom specs from your filesystem
|
||||
- Wave's slash commands and bracket syntax
|
||||
- Slash commands will be added in a future PR, we just need to generate the proper specs for them
|
||||
- Bracket syntax should not break the parser right now, you just won't get any suggestions when filling out metacommands within brackets
|
||||
- Suggestions
|
||||
- Rich icons support and icons served from the filesystem
|
||||
- `isDangerous` field
|
||||
- `hidden` field
|
||||
- `deprecated` field
|
||||
- `replaceValue` field - this requires a bit more work to properly parse out the text that needs to be replaced.
|
||||
- `previewComponent` field - this does not appear to be used by any specs right now
|
||||
- Subcommands
|
||||
- `cache` field - All script outputs are currently cached for 5 minutes
|
||||
- Options
|
||||
- `isPersistent` field - this requires a bit of work to make sure we pass forward the correct options to subcommands
|
||||
- `isRequired` field - this should prioritize options that are required
|
||||
- `isRepeatable` field - this should let a flag be repeated a specified number of times before being invalidated and no longer suggested
|
||||
- `requiresEquals` field - this is deprecated, but some popular specs still use it
|
||||
- Args
|
||||
- `suggestCurrentToken` field
|
||||
- `isDangerous` field
|
||||
- `isScript` field
|
||||
- `isModule` field - only Python uses this right now
|
||||
- `debounce` field
|
||||
- `default` field
|
||||
- `parserDirectives.alias` field
|
||||
- Generators
|
||||
- `getQueryTerm` field
|
||||
- `cache` field - All script outputs are currently cached for 5 minutes
|
5
src/autocomplete/index.ts
Normal file
5
src/autocomplete/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
export * from "./runtime/runtime";
|
||||
export * from "./utils/shell";
|
145
src/autocomplete/runtime/generator.ts
Normal file
145
src/autocomplete/runtime/generator.ts
Normal file
@ -0,0 +1,145 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Modified from https://github.com/microsoft/inshellisense/blob/main/src/runtime/generator.ts
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
import log from "../utils/log";
|
||||
import { runTemplates } from "./template";
|
||||
import { buildExecuteShellCommand, getEnvironmentVariables } from "./utils";
|
||||
|
||||
async function getGeneratorContext(cwd: string, env?: Record<string, string>): Promise<Fig.GeneratorContext> {
|
||||
return {
|
||||
environmentVariables: env ?? (await getEnvironmentVariables(cwd)),
|
||||
currentWorkingDirectory: cwd,
|
||||
currentProcess: "", // TODO: define current process
|
||||
sshPrefix: "", // deprecated, should be empt
|
||||
isDangerous: false,
|
||||
searchTerm: "", // TODO: define search term
|
||||
};
|
||||
}
|
||||
|
||||
let lastFirstToken = "";
|
||||
let lastFinalToken = "";
|
||||
let cachedSuggestions: Fig.Suggestion[] = [];
|
||||
|
||||
// TODO: add support getQueryTerm
|
||||
export async function runGenerator(
|
||||
generator: Fig.Generator,
|
||||
tokens: string[],
|
||||
cwd: string,
|
||||
env?: Record<string, string>
|
||||
): Promise<Fig.Suggestion[]> {
|
||||
const { script, postProcess, scriptTimeout, splitOn, custom, template, filterTemplateSuggestions, trigger } =
|
||||
generator;
|
||||
|
||||
const newToken = tokens.at(-1) ?? "";
|
||||
|
||||
if (lastFirstToken == tokens.at(0) && trigger && cachedSuggestions.length > 0) {
|
||||
log.debug("trigger", trigger);
|
||||
if (typeof trigger === "string") {
|
||||
if (!newToken?.includes(trigger)) {
|
||||
log.debug("trigger string", newToken, trigger);
|
||||
return cachedSuggestions;
|
||||
}
|
||||
} else if (typeof trigger === "function") {
|
||||
log.debug("trigger function", "newToken:", newToken, "lastToken: ", lastFinalToken);
|
||||
if (!trigger(newToken, lastFinalToken ?? "")) {
|
||||
log.debug("trigger function false");
|
||||
return cachedSuggestions;
|
||||
} else {
|
||||
log.debug("trigger function true");
|
||||
}
|
||||
} else {
|
||||
switch (trigger.on) {
|
||||
case "change": {
|
||||
log.debug("trigger change", newToken, lastFinalToken);
|
||||
if (lastFinalToken && newToken && lastFinalToken === newToken) {
|
||||
log.debug("trigger change false");
|
||||
return cachedSuggestions;
|
||||
} else {
|
||||
log.debug("trigger change true");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "match": {
|
||||
if (Array.isArray(trigger.string)) {
|
||||
log.debug("trigger match array", newToken, trigger.string);
|
||||
if (!trigger.string.some((t) => newToken === t)) {
|
||||
log.debug("trigger match false");
|
||||
return cachedSuggestions;
|
||||
} else {
|
||||
log.debug("trigger match true");
|
||||
}
|
||||
} else if (trigger.string !== newToken) {
|
||||
log.debug("trigger match single true", newToken, trigger.string);
|
||||
return cachedSuggestions;
|
||||
} else {
|
||||
log.debug("trigger match single false", newToken, trigger.string);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "threshold": {
|
||||
log.debug("trigger threshold", newToken, lastFinalToken, trigger.length);
|
||||
if (Math.abs(newToken.length - lastFinalToken.length) < trigger.length) {
|
||||
log.debug("trigger threshold false");
|
||||
return cachedSuggestions;
|
||||
} else {
|
||||
log.debug("trigger threshold true");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (lastFirstToken === tokens.at(0) && newToken && lastFinalToken === newToken) {
|
||||
log.debug("lastToken === newToken", lastFinalToken, newToken);
|
||||
return cachedSuggestions;
|
||||
}
|
||||
log.debug("lastToken !== newToken", lastFinalToken, newToken);
|
||||
|
||||
const executeShellCommand = buildExecuteShellCommand(scriptTimeout ?? 5000);
|
||||
const suggestions = [];
|
||||
lastFinalToken = tokens[-1];
|
||||
lastFirstToken = tokens[0];
|
||||
try {
|
||||
if (script) {
|
||||
const shellInput = typeof script === "function" ? script(tokens) : script;
|
||||
const scriptOutput = Array.isArray(shellInput)
|
||||
? await executeShellCommand({ command: shellInput.at(0) ?? "", args: shellInput.slice(1), cwd })
|
||||
: await executeShellCommand({ ...shellInput, cwd });
|
||||
|
||||
const scriptStdout = scriptOutput.stdout.trim();
|
||||
const scriptStderr = scriptOutput.stderr.trim();
|
||||
if (scriptStderr) {
|
||||
log.debug("script error, skipping processing", scriptStderr);
|
||||
} else if (postProcess) {
|
||||
suggestions.push(...postProcess(scriptStdout, tokens));
|
||||
} else if (splitOn) {
|
||||
suggestions.push(...scriptStdout.split(splitOn).map((s) => ({ name: s })));
|
||||
}
|
||||
}
|
||||
|
||||
if (custom) {
|
||||
log.debug("custom", custom);
|
||||
const customSuggestions = await custom(tokens, executeShellCommand, await getGeneratorContext(cwd, env));
|
||||
log.debug("customSuggestions", customSuggestions);
|
||||
suggestions.push(...customSuggestions);
|
||||
}
|
||||
|
||||
if (template != null) {
|
||||
const templateSuggestions = await runTemplates(template, cwd);
|
||||
if (filterTemplateSuggestions) {
|
||||
suggestions.push(...filterTemplateSuggestions(templateSuggestions));
|
||||
} else {
|
||||
suggestions.push(...templateSuggestions);
|
||||
}
|
||||
}
|
||||
cachedSuggestions = suggestions;
|
||||
return suggestions;
|
||||
} catch (e) {
|
||||
const err = typeof e === "string" ? e : e instanceof Error ? e.message : e;
|
||||
log.debug({ msg: "generator failed", err, script, splitOn, template });
|
||||
}
|
||||
return suggestions;
|
||||
}
|
114
src/autocomplete/runtime/loadspec.ts
Normal file
114
src/autocomplete/runtime/loadspec.ts
Normal file
@ -0,0 +1,114 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Modified from https://github.com/microsoft/inshellisense/blob/main/src/runtime/runtime.ts
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
import speclist, {
|
||||
diffVersionedCompletions as versionedSpeclist,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
} from "@withfig/autocomplete/build/index";
|
||||
import log from "../utils/log";
|
||||
import { buildExecuteShellCommand, mergeSubcomands } from "./utils";
|
||||
|
||||
const specSet: Record<string, string> = {};
|
||||
|
||||
(speclist as string[]).forEach((s) => {
|
||||
const suffix = versionedSpeclist.includes(s) ? "/index.js" : `.js`;
|
||||
specSet[s] = `${s}${suffix}`;
|
||||
});
|
||||
|
||||
const loadedSpecs: Record<string, Fig.Spec> = {};
|
||||
|
||||
/**
|
||||
* Loads the spec for the current command. If the spec has been loaded already, it will be returned.
|
||||
* If the command defines a `loadSpec` function, that function is run and the result is set as the new spec.
|
||||
* Otherwise, the spec is set to the command itself.
|
||||
* @param specName The name of the spec to load.
|
||||
* @param entries The entries to pass to the spec's `generateSpec` function, if it exists.
|
||||
* @returns The loaded spec, or undefined if the spec could not be loaded.
|
||||
*/
|
||||
export const loadSpec = async (specName: string, entries: string[]): Promise<Fig.Spec | undefined> => {
|
||||
if (!specName) {
|
||||
log.debug("specName empty, returning undefined");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("loading spec: ", specName);
|
||||
|
||||
let spec: any;
|
||||
|
||||
if (loadedSpecs[specName]) {
|
||||
log.debug("loaded spec found");
|
||||
return loadedSpecs[specName];
|
||||
}
|
||||
if (specSet[specName]) {
|
||||
log.debug("loading spec");
|
||||
spec = await import(`@withfig/autocomplete/build/${specSet[specName]}`);
|
||||
} else {
|
||||
log.debug("no spec found, returning undefined");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.hasOwn(spec, "getVersionCommand") && typeof spec.getVersionCommand === "function") {
|
||||
log.debug("has getVersionCommand fn");
|
||||
const commandVersion = await (spec.getVersionCommand as Fig.GetVersionCommand)(
|
||||
buildExecuteShellCommand(5000)
|
||||
);
|
||||
log.debug("commandVersion: " + commandVersion);
|
||||
log.debug("returning as version is not supported");
|
||||
return;
|
||||
}
|
||||
if (typeof spec.default === "object") {
|
||||
const command = spec.default as Fig.Subcommand;
|
||||
log.debug("Spec is valid Subcommand", command);
|
||||
if (command.generateSpec) {
|
||||
log.debug("has generateSpec function");
|
||||
const generatedSpec = await command.generateSpec(entries, buildExecuteShellCommand(5000));
|
||||
log.debug("generatedSpec: ", generatedSpec);
|
||||
spec = mergeSubcomands(command, generatedSpec);
|
||||
} else {
|
||||
log.debug("no generateSpec function");
|
||||
spec = command;
|
||||
}
|
||||
loadedSpecs[specName] = spec;
|
||||
return spec;
|
||||
} else {
|
||||
log.debug("Spec is not valid Subcommand");
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("import failed: ", e);
|
||||
}
|
||||
};
|
||||
|
||||
// this load spec function should only be used for `loadSpec` on the fly as it is cacheless
|
||||
export const lazyLoadSpec = async (key: string): Promise<Fig.Spec | undefined> => {
|
||||
return (await import(`@withfig/autocomplete/build/${key}.js`)).default;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- will be implemented in below TODO
|
||||
export const lazyLoadSpecLocation = async (location: Fig.SpecLocation): Promise<Fig.Spec | undefined> => {
|
||||
return; //TODO: implement spec location loading
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the subcommand from a spec if it exists.
|
||||
* @param spec The spec to get the subcommand from.
|
||||
* @returns The subcommand, or undefined if the spec does not contain a subcommand.
|
||||
*/
|
||||
export const getSubcommand = (spec?: Fig.Spec): Fig.Subcommand | undefined => {
|
||||
// TODO: handle subcommands that are versioned
|
||||
if (spec == null) return;
|
||||
if (typeof spec === "function") {
|
||||
const potentialSubcommand = spec();
|
||||
if (Object.hasOwn(potentialSubcommand, "name")) {
|
||||
return potentialSubcommand as Fig.Subcommand;
|
||||
}
|
||||
return;
|
||||
}
|
||||
return spec;
|
||||
};
|
21
src/autocomplete/runtime/model.ts
Normal file
21
src/autocomplete/runtime/model.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export enum TokenType {
|
||||
UNKNOWN,
|
||||
PATH,
|
||||
FLAG,
|
||||
OPTION,
|
||||
ARGUMENT,
|
||||
WHITESPACE,
|
||||
}
|
||||
|
||||
export interface Token {
|
||||
type: TokenType;
|
||||
value: string | undefined;
|
||||
}
|
||||
|
||||
export interface PathToken extends Token {
|
||||
type: TokenType.PATH;
|
||||
value: string;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
export const whitespace: Token = { type: TokenType.WHITESPACE, value: undefined };
|
1271
src/autocomplete/runtime/newton.ts
Normal file
1271
src/autocomplete/runtime/newton.ts
Normal file
File diff suppressed because it is too large
Load Diff
58
src/autocomplete/runtime/runtime.ts
Normal file
58
src/autocomplete/runtime/runtime.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { Shell } from "../utils/shell";
|
||||
import { Newton } from "./newton";
|
||||
import { MemCache } from "@/util/memcache";
|
||||
import log from "../utils/log";
|
||||
import { Token, whitespace } from "./model";
|
||||
import { determineTokenType } from "./utils";
|
||||
|
||||
const parserCache = new MemCache<string, Newton>(1000 * 60 * 5);
|
||||
|
||||
const controlOperators = new Set(["||", "&&", ";;", "|&", "<(", ">>", ">&", "&", ";", "(", ")", "|", "<", ">"]);
|
||||
|
||||
/**
|
||||
* Starting from the end of the entry array, find the last sequence of strings, stopping when a non-string (i.e. an operand) is found.
|
||||
* @param entry The command line to search.
|
||||
* @returns The last sequence of strings, i.e. the last statement. If no strings are found, returns an empty array.
|
||||
*/
|
||||
function findLastStmt(entry: string, shell: Shell): Token[] {
|
||||
const entrySplit = entry.split(/\s+/g);
|
||||
log.debug(`Entry split: ${entrySplit}`);
|
||||
let entries: Token[] = [];
|
||||
for (let i = entrySplit.length - 1; i >= 0; i--) {
|
||||
let entryValue = entrySplit[i].valueOf();
|
||||
if (controlOperators.has(entryValue)) {
|
||||
break;
|
||||
} else if (entryValue) {
|
||||
entries.unshift({ value: entryValue, type: determineTokenType(entryValue, shell) });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function getSuggestions(curLine: string, cwd: string, shell: Shell): Promise<Fig.Suggestion[]> {
|
||||
if (!curLine) {
|
||||
return [];
|
||||
}
|
||||
const lastStmt = findLastStmt(curLine, shell);
|
||||
log.debug(`Last statement: ${lastStmt}`);
|
||||
if (curLine.endsWith(" ")) {
|
||||
// shell-quote doesn't include trailing space in parse. We need to know this to determine if we should suggest subcommands
|
||||
lastStmt.push(whitespace);
|
||||
}
|
||||
const lastStmtStr = lastStmt.slice(0, lastStmt.length - 2).join(" ");
|
||||
// let parser: Newton = parserCache.get(lastStmtStr);
|
||||
// if (parser) {
|
||||
// console.log("Using cached parser");
|
||||
// parser.cwd = cwd;
|
||||
// parser.shell = shell;
|
||||
// parser.entries = lastStmt;
|
||||
// parser.entryIndex = parser.entryIndex - 1;
|
||||
// } else {
|
||||
// console.log("Creating new parser");
|
||||
// parser = new Newton(undefined, lastStmt, cwd, shell);
|
||||
// }
|
||||
const parser: Newton = new Newton(undefined, lastStmt, cwd, shell);
|
||||
const retVal = await parser.generateSuggestions();
|
||||
parserCache.put(lastStmtStr, parser);
|
||||
return retVal;
|
||||
}
|
47
src/autocomplete/runtime/suggestion.ts
Normal file
47
src/autocomplete/runtime/suggestion.ts
Normal file
@ -0,0 +1,47 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Modified from https://github.com/microsoft/inshellisense/blob/main/src/runtime/suggestion.ts
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
enum SuggestionIcons {
|
||||
File = "📄",
|
||||
Folder = "📁",
|
||||
Subcommand = "📦",
|
||||
Option = "🔗",
|
||||
Argument = "💲",
|
||||
Mixin = "🏝️",
|
||||
Shortcut = "🔥",
|
||||
Special = "⭐",
|
||||
Default = "📀",
|
||||
}
|
||||
|
||||
export const getIcon = (icon: string | undefined, suggestionType: Fig.SuggestionType | undefined): string => {
|
||||
// TODO: enable fig icons once spacing is better
|
||||
// if (icon && /[^\u0000-\u00ff]/.test(icon)) {
|
||||
// return icon;
|
||||
// }
|
||||
switch (suggestionType) {
|
||||
case "arg":
|
||||
return SuggestionIcons.Argument;
|
||||
case "file":
|
||||
return SuggestionIcons.File;
|
||||
case "folder":
|
||||
return SuggestionIcons.Folder;
|
||||
case "option":
|
||||
return SuggestionIcons.Option;
|
||||
case "subcommand":
|
||||
return SuggestionIcons.Subcommand;
|
||||
case "mixin":
|
||||
return SuggestionIcons.Mixin;
|
||||
case "shortcut":
|
||||
return SuggestionIcons.Shortcut;
|
||||
case "special":
|
||||
return SuggestionIcons.Special;
|
||||
default:
|
||||
return SuggestionIcons.Default;
|
||||
}
|
||||
};
|
||||
|
||||
export type FilterStrategy = "fuzzy" | "prefix" | "default";
|
110
src/autocomplete/runtime/template.ts
Normal file
110
src/autocomplete/runtime/template.ts
Normal file
@ -0,0 +1,110 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Modified from https://github.com/microsoft/inshellisense/blob/main/src/runtime/template.ts
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
import { GlobalModel } from "@/models";
|
||||
import log from "../utils/log";
|
||||
|
||||
/**
|
||||
* Retrieves the contents of the specified directory on the active remote machine.
|
||||
* @param cwd The directory whose contents should be returned.
|
||||
* @param tempType The template to use when returning the contents. If "folders" is passed, only the directories within the specified directory will be returned. Otherwise, all the contents will be returned.
|
||||
* @returns The contents of the directory formatted to the specified template.
|
||||
*/
|
||||
export const getFileCompletionSuggestions = async (
|
||||
cwd: string,
|
||||
tempType: "filepaths" | "folders"
|
||||
): Promise<Fig.TemplateSuggestion[]> => {
|
||||
const comptype = tempType === "filepaths" ? "file" : "directory";
|
||||
if (comptype == null) return [];
|
||||
const crtn = await GlobalModel.submitCommand("_compfiledir", null, [], { comptype, cwd }, false, false);
|
||||
if (Array.isArray(crtn?.update?.data)) {
|
||||
if (crtn.update.data.length === 0) return [];
|
||||
const firstData = crtn.update.data[0];
|
||||
if (firstData.info?.infocomps) {
|
||||
if (firstData.info.infocomps.length === 0) return [];
|
||||
if (firstData.info.infocomps[0] === "(no completions)") return [];
|
||||
return firstData.info.infocomps.map((comp: string) => {
|
||||
log.debug("getFileCompletionSuggestions", cwd, comp);
|
||||
return {
|
||||
name: comp,
|
||||
displayName: comp,
|
||||
priority: comp.startsWith(".") ? 1 : 55,
|
||||
context: { templateType: tempType },
|
||||
type: comp.endsWith("/") ? "folder" : "file",
|
||||
};
|
||||
});
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const historyTemplate = (): Fig.TemplateSuggestion[] => {
|
||||
const inputModel = GlobalModel.inputModel;
|
||||
const cmdLine = inputModel.curLine;
|
||||
inputModel.loadHistory(false, 0, "session");
|
||||
const hitems = GlobalModel.inputModel.filteredHistoryItems;
|
||||
if (hitems.length > 0) {
|
||||
const hmap: Map<string, Fig.TemplateSuggestion> = new Map();
|
||||
hitems.forEach((h) => {
|
||||
const cmdstr = h.cmdstr.trim();
|
||||
if (cmdstr.startsWith(cmdLine)) {
|
||||
if (hmap.has(cmdstr)) {
|
||||
hmap.get(cmdstr).priority += 1;
|
||||
} else {
|
||||
hmap.set(cmdstr, {
|
||||
name: cmdstr,
|
||||
priority: 90,
|
||||
context: {
|
||||
templateType: "history",
|
||||
},
|
||||
icon: "🕒",
|
||||
type: "special",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
const ret = Array.from(hmap.values());
|
||||
log.debug("historyTemplate ret", ret);
|
||||
return ret;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// TODO: implement help template
|
||||
const helpTemplate = (): Fig.TemplateSuggestion[] => {
|
||||
return [];
|
||||
};
|
||||
|
||||
export const runTemplates = async (
|
||||
template: Fig.TemplateStrings[] | Fig.Template,
|
||||
cwd: string
|
||||
): Promise<Fig.TemplateSuggestion[]> => {
|
||||
const templates = template instanceof Array ? template : [template];
|
||||
log.debug("runTemplates", templates, cwd);
|
||||
return (
|
||||
await Promise.all(
|
||||
templates.map(async (t) => {
|
||||
try {
|
||||
switch (t) {
|
||||
case "filepaths":
|
||||
return await getFileCompletionSuggestions(cwd, "filepaths");
|
||||
case "folders":
|
||||
return await getFileCompletionSuggestions(cwd, "folders");
|
||||
case "history":
|
||||
return historyTemplate();
|
||||
case "help":
|
||||
return helpTemplate();
|
||||
}
|
||||
} catch (e) {
|
||||
log.debug({ msg: "template failed", e, template: t, cwd });
|
||||
return [];
|
||||
}
|
||||
})
|
||||
)
|
||||
).flat();
|
||||
};
|
352
src/autocomplete/runtime/utils.ts
Normal file
352
src/autocomplete/runtime/utils.ts
Normal file
@ -0,0 +1,352 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Modified from https://github.com/microsoft/inshellisense/blob/main/src/runtime/utils.ts
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
import { Shell } from "../utils/shell";
|
||||
import { GlobalModel, getApi } from "@/models";
|
||||
import { MemCache } from "@/util/memcache";
|
||||
import log from "../utils/log";
|
||||
import { Token, TokenType } from "./model";
|
||||
|
||||
export type ExecuteShellCommandTTYResult = {
|
||||
code: number | null;
|
||||
};
|
||||
|
||||
const commandResultCache = new MemCache<Fig.ExecuteCommandInput, Fig.ExecuteCommandOutput>(1000 * 60 * 5);
|
||||
|
||||
export const buildExecuteShellCommand =
|
||||
(timeout: number): Fig.ExecuteCommandFunction =>
|
||||
async (input: Fig.ExecuteCommandInput): Promise<Fig.ExecuteCommandOutput> => {
|
||||
const cachedResult = commandResultCache.get(input);
|
||||
log.debug("cachedResult", cachedResult);
|
||||
if (cachedResult) {
|
||||
log.debug("Using cached result for", input);
|
||||
return cachedResult;
|
||||
}
|
||||
log.debug("Executing command", input);
|
||||
const { command, args, cwd, env } = input;
|
||||
const resp = await GlobalModel.submitEphemeralCommand(
|
||||
"eval",
|
||||
null,
|
||||
[[command, ...args].join(" ")],
|
||||
null,
|
||||
false,
|
||||
{
|
||||
expectsresponse: true,
|
||||
overridecwd: cwd,
|
||||
env: env,
|
||||
timeoutms: timeout,
|
||||
}
|
||||
);
|
||||
|
||||
const { stdout, stderr } = await GlobalModel.getEphemeralCommandOutput(resp);
|
||||
const output: Fig.ExecuteCommandOutput = { stdout, stderr, status: stderr?.length > 1 ? 1 : 0 };
|
||||
if (output.status !== 0) {
|
||||
log.debug("Command failed, skipping caching", output);
|
||||
} else {
|
||||
commandResultCache.put(input, output);
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
const pathSeps = new Map<Shell, string>();
|
||||
|
||||
/**
|
||||
* Get the path separator for the given shell.
|
||||
* @param shell The shell to get the path separator for.
|
||||
* @returns The path separator.
|
||||
*/
|
||||
export function getPathSep(shell: Shell): string {
|
||||
if (!pathSeps.has(shell)) {
|
||||
const pathSep = getApi().pathSep();
|
||||
pathSeps.set(shell, pathSep);
|
||||
return pathSep;
|
||||
}
|
||||
return pathSeps.get(shell) as string;
|
||||
}
|
||||
|
||||
export async function getEnvironmentVariables(cwd?: string): Promise<Record<string, string>> {
|
||||
const resp = await GlobalModel.submitEphemeralCommand("eval", null, ["env"], null, false, {
|
||||
expectsresponse: true,
|
||||
overridecwd: cwd,
|
||||
env: null,
|
||||
});
|
||||
const { stdout, stderr } = await GlobalModel.getEphemeralCommandOutput(resp);
|
||||
if (stderr) {
|
||||
log.debug({ msg: "failed to get environment variables", stderr });
|
||||
}
|
||||
|
||||
const env = {};
|
||||
stdout
|
||||
.split("\n")
|
||||
.filter((s) => s.length > 0)
|
||||
.forEach((line) => {
|
||||
const [key, value] = line.split("=");
|
||||
env[key] = value;
|
||||
});
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the current token is a path or not. If it is an incomplete path, return the base name of the path as the new cwd to be used in downstream parsing operations. Otherwise, return the current cwd.
|
||||
* @param token The token to check.
|
||||
* @param cwd The current working directory.
|
||||
* @param shell The shell being used.
|
||||
* @returns The new cwd, whether the token is a path, and whether the path is complete.
|
||||
*/
|
||||
export async function resolveCwdToken(
|
||||
token: Token,
|
||||
cwd: string,
|
||||
shell: Shell
|
||||
): Promise<{ cwd: string; pathy: boolean; complete: boolean }> {
|
||||
log.debug("resolveCwdToken start", { token, cwd });
|
||||
if (!token?.value) return { cwd, pathy: false, complete: false };
|
||||
log.debug("resolveCwdToken token not null");
|
||||
if (token.type != TokenType.PATH) return { cwd, pathy: false, complete: false };
|
||||
const sep = getPathSep(shell);
|
||||
const complete = token.value.endsWith(sep);
|
||||
const dirname = getApi().pathDirName(token.value);
|
||||
log.debug("resolveCwdToken dirname", dirname);
|
||||
|
||||
// This accounts for cases where the somewhat dumb path.dirname function parses a path out of a token that is not a path, like "git commit -m 'foo/bar'"
|
||||
if (dirname !== "." && !token.value.startsWith(dirname)) return { cwd, pathy: false, complete: false };
|
||||
|
||||
let respCwd = await resolvePathRemote(complete ? token.value : dirname);
|
||||
const exists = respCwd !== undefined;
|
||||
respCwd = respCwd ? (respCwd?.endsWith(sep) ? respCwd : respCwd + sep) : cwd;
|
||||
log.debug("resolveCwdToken", { token, cwd, complete, dirname, respCwd, exists });
|
||||
return { cwd: respCwd, pathy: exists, complete: complete && exists };
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the given path exists on the remote machine.
|
||||
* @param path The path to check.
|
||||
* @returns True if the path exists.
|
||||
*/
|
||||
export async function resolvePathRemote(path: string): Promise<string | undefined> {
|
||||
const resp = await GlobalModel.submitEphemeralCommand(
|
||||
"eval",
|
||||
null,
|
||||
[`if [ -d "${path}" ]; then cd "${path}" || return 1; pwd; else return 1; fi`],
|
||||
null,
|
||||
false,
|
||||
{
|
||||
expectsresponse: true,
|
||||
env: {},
|
||||
}
|
||||
);
|
||||
const output = await GlobalModel.getEphemeralCommandOutput(resp);
|
||||
log.debug("resolvePathRemote", path, output);
|
||||
return output.stderr?.length > 0 ? undefined : output.stdout.trimEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the comparator function on each value and returns true if any of them match
|
||||
* @param values The value(s) to check
|
||||
* @param comparator The function to use to compare the values
|
||||
* @returns True if any of the values match the comparator
|
||||
*/
|
||||
export function matchAny<T>(values: Fig.SingleOrArray<T>, comparator: (a: T) => boolean) {
|
||||
if (Array.isArray(values)) {
|
||||
for (const value of values) {
|
||||
if (comparator(value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
return comparator(values);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any of the values start with the input string
|
||||
* @param values The value(s) to check
|
||||
* @param input The input to check against
|
||||
* @returns True if any of the values start with the input
|
||||
*/
|
||||
export function startsWithAny(values: Fig.SingleOrArray<string>, input: string) {
|
||||
return matchAny(values, (a) => a.startsWith(input));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any of the values are not equal to the input
|
||||
* @param values The value(s) to check
|
||||
* @param input The input to check against
|
||||
* @returns True if any of the values are not equal to the input
|
||||
*/
|
||||
export function equalsAny<T>(values: Fig.SingleOrArray<T>, input: T) {
|
||||
return matchAny(values, (a) => a == input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any of the values of the SingleOrArray are not in the array
|
||||
* @param values The value(s) to check
|
||||
* @param arr The array to check against
|
||||
* @returns True if any of the values are not in the array
|
||||
*/
|
||||
export function notInAny<T>(values: Fig.SingleOrArray<T>, arr: T[]) {
|
||||
return matchAny(values, (a) => !arr.includes(a));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first element of a Fig.SingleOrArray<T>.
|
||||
* @param values Either a single value or an array of values of the specified type.
|
||||
* @returns The first element of the array, or the value.
|
||||
*/
|
||||
export function getFirst<T>(values: Fig.SingleOrArray<T>): T {
|
||||
if (Array.isArray(values)) {
|
||||
return values[0];
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all elements of a Fig.SingleOrArray<T>
|
||||
* @param values Either a single value or an array of values of the specified type.
|
||||
* @returns The array of values, or an empty array
|
||||
*/
|
||||
export function getAll<T>(values: Fig.SingleOrArray<T> | undefined): T[] {
|
||||
if (Array.isArray(values)) {
|
||||
return values;
|
||||
}
|
||||
return [values as T];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string is an option, i.e. starts with "--".
|
||||
* @param value The string to check.
|
||||
* @returns True if the string is an option.
|
||||
*/
|
||||
export function isOption(value: string): boolean {
|
||||
return value.startsWith("--");
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string is a flag, i.e. starts with "-" but not "--".
|
||||
* @param value The string to check.
|
||||
* @returns True if the string is a flag.
|
||||
*/
|
||||
export function isFlag(value: string): boolean {
|
||||
return value.startsWith("-") && !isOption(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string is either a flag or an option, i.e. starts with "-".
|
||||
* @param value The string to check.
|
||||
* @returns True if the string is a flag or an option.
|
||||
*/
|
||||
export function isFlagOrOption(value: string): boolean {
|
||||
return value.startsWith("-");
|
||||
}
|
||||
|
||||
export function isPath(value: string, shell: Shell): boolean {
|
||||
return value.includes(getPathSep(shell));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the flag of a Fig.SingleOrArray<string>.
|
||||
* @param values Either a string or an array of strings.
|
||||
* @returns The flag, or undefined if none is found.
|
||||
*/
|
||||
export function getFlag(values: Fig.SingleOrArray<string>): string | undefined {
|
||||
if (Array.isArray(values)) {
|
||||
for (const value of values) {
|
||||
if (isFlag(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
} else if (isFlag(values)) {
|
||||
return values;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function determineTokenType(value: string, shell: Shell): TokenType {
|
||||
if (isOption(value)) {
|
||||
return TokenType.OPTION;
|
||||
} else if (isFlag(value)) {
|
||||
return TokenType.FLAG;
|
||||
} else if (isPath(value, shell)) {
|
||||
return TokenType.PATH;
|
||||
} else {
|
||||
return TokenType.ARGUMENT;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an option suggestion contains a flag. If so, modifies the name to include the preceding flags.
|
||||
* @param option The option to modify.
|
||||
* @param precedingFlags The preceding flags to prepend to the suggestion name.
|
||||
* @returns The modified option suggestion.
|
||||
*/
|
||||
export function modifyPosixFlags(option: Fig.Option, precedingFlags: string): Fig.Option {
|
||||
// We only want to modify the name if the option is a flag
|
||||
if (option.name) {
|
||||
// Get the name of the flag without the preceding "-"
|
||||
const name = getFlag(option.name)?.slice(1);
|
||||
|
||||
if (name) {
|
||||
// Shallow copy the option so we can modify the name without modifying the original spec.
|
||||
option = { ...option };
|
||||
|
||||
// We want to prepend the existing flags to the name, except for the suggestion of the last flag (i.e. the `c` of an input `-abc`), which we want to replace with the existing flags.
|
||||
// The end result is that we will suggest -abc instead of -a -b -c. We do not want -abb. The case of -aba should already be covered by filterFlags.
|
||||
if (name === precedingFlags?.at(-1)) {
|
||||
option.name = "-" + precedingFlags;
|
||||
} else {
|
||||
option.name = "-" + precedingFlags + name;
|
||||
}
|
||||
}
|
||||
}
|
||||
return option;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort suggestions in-place by priority, then by name.
|
||||
* @param suggestions The suggestions to sort.
|
||||
*/
|
||||
export function sortSuggestions(suggestions: Fig.Suggestion[]) {
|
||||
suggestions.sort((a, b) => {
|
||||
if (a.priority == b.priority) {
|
||||
if (a.name) {
|
||||
if (b.name) {
|
||||
return getFirst(a.name).trim().localeCompare(getFirst(b.name));
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
} else if (b.name) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return (b.priority ?? 0) - (a.priority ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two subcommand objects, with the second subcommand taking precedence in case of conflicts.
|
||||
* @param subcommand1 The first subcommand.
|
||||
* @param subcommand2 The second subcommand.
|
||||
* @returns The merged subcommand.
|
||||
*/
|
||||
export function mergeSubcomands(subcommand1: Fig.Subcommand, subcommand2: Fig.Subcommand): Fig.Subcommand {
|
||||
log.debug("merging two subcommands", subcommand1, subcommand2);
|
||||
const newCommand: Fig.Subcommand = { ...subcommand1 };
|
||||
|
||||
// Merge the generated spec with the existing spec
|
||||
for (const key in subcommand2) {
|
||||
if (Array.isArray(subcommand2[key])) {
|
||||
newCommand[key] = [...subcommand2[key], ...(newCommand[key] ?? [])];
|
||||
continue;
|
||||
} else if (typeof subcommand2[key] === "object") {
|
||||
newCommand[key] = { ...subcommand2[key], ...(newCommand[key] ?? {}) };
|
||||
} else {
|
||||
newCommand[key] = subcommand2[key];
|
||||
}
|
||||
}
|
||||
log.debug("merged subcommand:", newCommand);
|
||||
return newCommand;
|
||||
}
|
17
src/autocomplete/utils/log.ts
Normal file
17
src/autocomplete/utils/log.ts
Normal file
@ -0,0 +1,17 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Modified from https://github.com/microsoft/inshellisense/blob/main/src/utils/log.ts
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
import { GlobalModel } from "@/models";
|
||||
|
||||
export const debug = (...content) => {
|
||||
if (!GlobalModel.autocompleteModel.loggingEnabled) {
|
||||
return;
|
||||
}
|
||||
console.log("[autocomplete]", ...content);
|
||||
};
|
||||
|
||||
export default { debug };
|
16
src/autocomplete/utils/shell.ts
Normal file
16
src/autocomplete/utils/shell.ts
Normal file
@ -0,0 +1,16 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Modified from https://github.com/microsoft/inshellisense/blob/main/src/utils/shell.ts
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
export enum Shell {
|
||||
Bash = "bash",
|
||||
Powershell = "powershell",
|
||||
Pwsh = "pwsh",
|
||||
Zsh = "zsh",
|
||||
Fish = "fish",
|
||||
Cmd = "cmd",
|
||||
Xonsh = "xonsh",
|
||||
}
|
@ -598,6 +598,18 @@ electron.nativeTheme.on("updated", () => {
|
||||
});
|
||||
});
|
||||
|
||||
electron.ipcMain.on("path-basename", (event, p) => {
|
||||
event.returnValue = path.basename(p);
|
||||
});
|
||||
|
||||
electron.ipcMain.on("path-dirname", (event, p) => {
|
||||
event.returnValue = path.dirname(p);
|
||||
});
|
||||
|
||||
electron.ipcMain.on("path-sep", (event) => {
|
||||
event.returnValue = path.sep;
|
||||
});
|
||||
|
||||
function readLastLinesOfFile(filePath: string, lineCount: number) {
|
||||
return new Promise((resolve, reject) => {
|
||||
child_process.exec(`tail -n ${lineCount} "${filePath}"`, (err, stdout, stderr) => {
|
||||
|
@ -30,6 +30,9 @@ contextBridge.exposeInMainWorld("api", {
|
||||
contextEditMenu: (position, opts) => ipcRenderer.send("context-editmenu", position, opts),
|
||||
onWaveSrvStatusChange: (callback) => ipcRenderer.on("wavesrv-status-change", callback),
|
||||
onToggleDevUI: (callback) => ipcRenderer.on("toggle-devui", callback),
|
||||
pathBaseName: (path) => ipcRenderer.sendSync("path-basename", path),
|
||||
pathDirName: (path) => ipcRenderer.sendSync("path-dirname", path),
|
||||
pathSep: () => ipcRenderer.sendSync("path-sep"),
|
||||
showContextMenu: (menu, position) => ipcRenderer.send("contextmenu-show", menu, position),
|
||||
onContextMenuClick: (callback) => ipcRenderer.on("contextmenu-click", callback),
|
||||
});
|
||||
|
234
src/models/autocomplete.ts
Normal file
234
src/models/autocomplete.ts
Normal file
@ -0,0 +1,234 @@
|
||||
import { Shell, getSuggestions } from "@/autocomplete";
|
||||
import log from "@/autocomplete/utils/log";
|
||||
import { Model } from "./model";
|
||||
import * as mobx from "mobx";
|
||||
|
||||
/**
|
||||
* Gets the length of the token at the end of the line.
|
||||
* @param line the line
|
||||
* @returns the length of the token at the end of the line
|
||||
*/
|
||||
function getEndTokenLength(line: string): number {
|
||||
if (!line) {
|
||||
return 0;
|
||||
}
|
||||
const lastSpaceIndex = line.lastIndexOf(" ");
|
||||
if (lastSpaceIndex < line.length) {
|
||||
return line.length - line.lastIndexOf(" ") - 1;
|
||||
}
|
||||
return line.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* The autocomplete model.
|
||||
*/
|
||||
export class AutocompleteModel {
|
||||
globalModel: Model;
|
||||
@mobx.observable suggestions: Fig.Suggestion[] = null;
|
||||
@mobx.observable primarySuggestionIndex: number = 0;
|
||||
charsToDrop: number = 0;
|
||||
@mobx.observable loggingEnabled: boolean;
|
||||
|
||||
constructor(globalModel: Model) {
|
||||
mobx.makeObservable(this);
|
||||
this.globalModel = globalModel;
|
||||
|
||||
this.loggingEnabled = globalModel.isDev;
|
||||
|
||||
// This is a hack to get the suggestions to update after the history is loaded the first time
|
||||
mobx.reaction(
|
||||
() => this.globalModel.inputModel.historyItems.get() != null,
|
||||
() => {
|
||||
log.debug("history loaded, reloading suggestions");
|
||||
this.loadSuggestions();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the autocomplete feature is enabled.
|
||||
* @returns whether the autocomplete feature is enabled
|
||||
*/
|
||||
@mobx.computed
|
||||
get isEnabled(): boolean {
|
||||
const clientData: ClientDataType = this.globalModel.clientData.get();
|
||||
return clientData?.clientopts.autocompleteenabled ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily loads suggestions for the current input line.
|
||||
*/
|
||||
loadSuggestions = mobx.flow(function* (this: AutocompleteModel) {
|
||||
if (!this.isEnabled) {
|
||||
this.suggestions = null;
|
||||
return;
|
||||
}
|
||||
log.debug("get suggestions");
|
||||
try {
|
||||
const festate = this.globalModel.getCurRemoteInstance().festate;
|
||||
const suggestions: Fig.Suggestion[] = yield getSuggestions(
|
||||
this.globalModel.inputModel.curLine,
|
||||
festate.cwd,
|
||||
festate.shell as Shell
|
||||
);
|
||||
this.suggestions = suggestions;
|
||||
} catch (error) {
|
||||
console.error("error getting suggestions: ", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the current suggestions.
|
||||
* @returns the current suggestions
|
||||
*/
|
||||
getSuggestions(): Fig.Suggestion[] {
|
||||
if (!this.isEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the current suggestions.
|
||||
*/
|
||||
clearSuggestions(): void {
|
||||
if (!this.isEnabled) {
|
||||
return;
|
||||
}
|
||||
mobx.action(() => {
|
||||
this.suggestions = null;
|
||||
this.primarySuggestionIndex = 0;
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the primary suggestion.
|
||||
* @returns the index of the primary suggestion
|
||||
*/
|
||||
getPrimarySuggestionIndex(): number {
|
||||
return this.primarySuggestionIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the index of the primary suggestion.
|
||||
* @param index the index of the primary suggestion
|
||||
*/
|
||||
setPrimarySuggestionIndex(index: number): void {
|
||||
if (!this.isEnabled) {
|
||||
return;
|
||||
}
|
||||
mobx.action(() => {
|
||||
this.primarySuggestionIndex = index;
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the additional text required to add to the current input line in order to apply the suggestion at the given index.
|
||||
* @param index the index of the suggestion to apply
|
||||
* @returns the additional text required to add to the current input line in order to apply the suggestion at the given index
|
||||
*/
|
||||
getSuggestionCompletion(index: number): string {
|
||||
log.debug("getSuggestionCompletion", index);
|
||||
const autocompleteSuggestions: Fig.Suggestion[] = this.getSuggestions();
|
||||
|
||||
// Build the ghost prompt with the primary suggestion if available
|
||||
let retVal = "";
|
||||
log.debug("autocompleteSuggestions", autocompleteSuggestions);
|
||||
if (autocompleteSuggestions != null && autocompleteSuggestions.length > index) {
|
||||
const suggestion = autocompleteSuggestions[index];
|
||||
log.debug("suggestion", suggestion);
|
||||
|
||||
if (!suggestion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (suggestion.insertValue) {
|
||||
retVal = suggestion.insertValue;
|
||||
} else if (typeof suggestion.name === "string") {
|
||||
retVal = suggestion.name;
|
||||
} else if (suggestion.name.length > 0) {
|
||||
retVal = suggestion.name[0];
|
||||
}
|
||||
const curLine = this.globalModel.inputModel.curLine;
|
||||
|
||||
if (retVal.startsWith(curLine.trim())) {
|
||||
// This accounts for if the first suggestion is a history item, since this will be the full command string.
|
||||
retVal = retVal.substring(curLine.length);
|
||||
} else {
|
||||
log.debug("retVal", retVal);
|
||||
|
||||
// The following is a workaround for slow responses from underlying commands. It assumes that the primary suggestion will be a continuation of the current token.
|
||||
// The runtime will provide a number of chars to drop, but it will return after the render has already completed, meaning we will end up with a flicker. This is a workaround to prevent the flicker.
|
||||
// As we add more characters to the current token, we assume we need to drop the same number of characters from the primary suggestion, even if the runtime has not yet provided the updated characters to drop.
|
||||
const curEndTokenLen = getEndTokenLength(curLine);
|
||||
const lastEndTokenLen = getEndTokenLength(this.globalModel.inputModel.lastCurLine);
|
||||
log.debug("curEndTokenLen", curEndTokenLen, "lastEndTokenLen", lastEndTokenLen);
|
||||
if (curEndTokenLen > lastEndTokenLen) {
|
||||
this.charsToDrop = Math.max(curEndTokenLen, this.charsToDrop ?? 0);
|
||||
} else {
|
||||
this.charsToDrop = Math.min(curEndTokenLen, this.charsToDrop ?? 0);
|
||||
}
|
||||
|
||||
if (this.charsToDrop > 0) {
|
||||
retVal = retVal.substring(this.charsToDrop);
|
||||
}
|
||||
log.debug("charsToDrop", this.charsToDrop, "retVal", retVal);
|
||||
}
|
||||
log.debug("ghost prompt", curLine + retVal);
|
||||
}
|
||||
return retVal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the additional text required to add to the current input line in order to apply the primary suggestion.
|
||||
* @returns the additional text required to add to the current input line in order to apply the primary suggestion
|
||||
* @see getSuggestionCompletion
|
||||
* @see getPrimarySuggestionIndex
|
||||
*/
|
||||
getPrimarySuggestionCompletion(): string {
|
||||
if (!this.isEnabled) {
|
||||
return null;
|
||||
}
|
||||
const suggestionIndex = this.getPrimarySuggestionIndex();
|
||||
const retVal = this.getSuggestionCompletion(suggestionIndex);
|
||||
if (retVal) {
|
||||
return retVal;
|
||||
} else if (suggestionIndex > 0) {
|
||||
this.setPrimarySuggestionIndex(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the suggestion at the given index to the current input line.
|
||||
* @param index the index of the suggestion to apply
|
||||
*/
|
||||
applySuggestion(index: number): void {
|
||||
if (!this.isEnabled) {
|
||||
return;
|
||||
}
|
||||
let suggestionCompletion = this.getSuggestionCompletion(index);
|
||||
log.debug("applying suggestion: ", suggestionCompletion);
|
||||
if (suggestionCompletion) {
|
||||
let pos: number;
|
||||
const curLine = this.globalModel.inputModel.curLine;
|
||||
if (suggestionCompletion.includes("{cursor}")) {
|
||||
pos = curLine.length + suggestionCompletion.indexOf("{cursor}");
|
||||
suggestionCompletion = suggestionCompletion.replace("{cursor}", "");
|
||||
}
|
||||
const newLine = curLine + suggestionCompletion;
|
||||
pos = pos ?? newLine.length;
|
||||
log.debug("new line", `"${newLine}"`, "pos", pos);
|
||||
this.globalModel.inputModel.updateCmdLine({ str: newLine, pos: pos });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the primary suggestion to the current input line.
|
||||
* @see applySuggestion
|
||||
* @see getPrimarySuggestionIndex
|
||||
*/
|
||||
applyPrimarySuggestion(): void {
|
||||
this.applySuggestion(this.getPrimarySuggestionIndex());
|
||||
}
|
||||
}
|
@ -548,6 +548,10 @@ class CommandRunner {
|
||||
setGlobalShortcut(shortcut: string): Promise<CommandRtnType> {
|
||||
return GlobalModel.submitCommand("client", "setglobalshortcut", [shortcut], { nohist: "1" }, false);
|
||||
}
|
||||
|
||||
setAutocompleteEnabled(enabled: boolean): Promise<CommandRtnType> {
|
||||
return GlobalModel.submitCommand("autocomplete", enabled ? "on" : "off", null, { nohist: "1" }, false);
|
||||
}
|
||||
}
|
||||
|
||||
export { CommandRunner };
|
||||
|
@ -73,8 +73,8 @@ class InputModel {
|
||||
lastCurLine: string = "";
|
||||
|
||||
constructor(globalModel: Model) {
|
||||
this.globalModel = globalModel;
|
||||
mobx.makeObservable(this);
|
||||
this.globalModel = globalModel;
|
||||
mobx.action(() => {
|
||||
this.codeSelectSelectedIndex.set(-1);
|
||||
this.codeSelectBlockRefArray = [];
|
||||
@ -492,6 +492,7 @@ class InputModel {
|
||||
this.scrollHistoryItemIntoView(hitem.historynum);
|
||||
}
|
||||
}
|
||||
this.globalModel.autocompleteModel.clearSuggestions();
|
||||
}
|
||||
|
||||
moveHistorySelection(amt: number): void {
|
||||
@ -777,11 +778,22 @@ class InputModel {
|
||||
set curLine(val: string) {
|
||||
this.lastCurLine = this.curLine;
|
||||
const hidx = this.historyIndex.get();
|
||||
const runGetSuggestions = this.curLine != val;
|
||||
mobx.action(() => {
|
||||
if (this.modHistory.length <= hidx) {
|
||||
this.modHistory.length = hidx + 1;
|
||||
}
|
||||
this.modHistory[hidx] = val;
|
||||
|
||||
// Whenever curLine changes, we should fetch the suggestions
|
||||
if (val.trim() == "") {
|
||||
this.globalModel.autocompleteModel.clearSuggestions();
|
||||
return;
|
||||
}
|
||||
if (runGetSuggestions) {
|
||||
this.globalModel.autocompleteModel.loadSuggestions();
|
||||
}
|
||||
this.modHistory[hidx] = val;
|
||||
})();
|
||||
}
|
||||
|
||||
@ -808,6 +820,7 @@ class InputModel {
|
||||
this.historyQueryOpts.set(getDefaultHistoryQueryOpts());
|
||||
this.historyAfterLoadIndex = 0;
|
||||
this.dropModHistory(true);
|
||||
this.globalModel.autocompleteModel.clearSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -37,6 +37,7 @@ import { GlobalCommandRunner } from "./global";
|
||||
import { clearMonoFontCache, getMonoFontSize } from "@/util/textmeasure";
|
||||
import type { TermWrap } from "@/plugins/terminal/term";
|
||||
import * as util from "@/util/util";
|
||||
import { AutocompleteModel } from "./autocomplete";
|
||||
|
||||
type SWLinePtr = {
|
||||
line: LineType;
|
||||
@ -113,6 +114,7 @@ class Model {
|
||||
|
||||
keybindManager: KeybindManager;
|
||||
inputModel: InputModel;
|
||||
autocompleteModel: AutocompleteModel;
|
||||
pluginsModel: PluginsModel;
|
||||
bookmarksModel: BookmarksModel;
|
||||
historyViewModel: HistoryViewModel;
|
||||
@ -162,6 +164,7 @@ class Model {
|
||||
this.initSystemKeybindings();
|
||||
this.initAppKeybindings();
|
||||
this.inputModel = new InputModel(this);
|
||||
this.autocompleteModel = new AutocompleteModel(this);
|
||||
this.pluginsModel = new PluginsModel(this);
|
||||
this.bookmarksModel = new BookmarksModel(this);
|
||||
this.historyViewModel = new HistoryViewModel(this);
|
||||
|
7
src/types/custom.d.ts
vendored
7
src/types/custom.d.ts
vendored
@ -15,7 +15,7 @@ declare global {
|
||||
type LineContainerStrs = "main" | "sidebar" | "history";
|
||||
type AppUpdateStatusType = "unavailable" | "ready";
|
||||
type NativeThemeSource = "system" | "light" | "dark";
|
||||
type InputAuxViewType = null | "history" | "info" | "aichat";
|
||||
type InputAuxViewType = null | "history" | "info" | "aichat" | "suggestions";
|
||||
|
||||
type OV<V> = mobx.IObservableValue<V>;
|
||||
type OArr<V> = mobx.IObservableArray<V>;
|
||||
@ -445,6 +445,7 @@ declare global {
|
||||
type InfoType = {
|
||||
infotitle?: string;
|
||||
infomsg?: string;
|
||||
infoelement?: React.ReactNode;
|
||||
infomsghtml?: boolean;
|
||||
websharelink?: boolean;
|
||||
infoerror?: string;
|
||||
@ -623,6 +624,7 @@ declare global {
|
||||
globalshortcut: string;
|
||||
globalshortcutenabled: boolean;
|
||||
webgl: boolean;
|
||||
autocompleteenabled: boolean = true;
|
||||
};
|
||||
|
||||
type ReleaseInfoType = {
|
||||
@ -968,6 +970,9 @@ declare global {
|
||||
onToggleDevUI: (callback: () => void) => void;
|
||||
showContextMenu: (menu: ElectronContextMenuItem[], position: { x: number; y: number }) => void;
|
||||
onContextMenuClick: (callback: (id: string) => void) => void;
|
||||
pathBaseName: (path: string) => string;
|
||||
pathDirName: (path: string) => string;
|
||||
pathSep: () => string;
|
||||
};
|
||||
|
||||
type ElectronContextMenuItem = {
|
||||
|
76
src/util/memcache.ts
Normal file
76
src/util/memcache.ts
Normal file
@ -0,0 +1,76 @@
|
||||
// Copyright 2024, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Inspired by https://github.com/sleeplessinc/cache/index.js
|
||||
// Copyright 2017 Sleepless Software Inc. All rights reserved.
|
||||
// SPDX-License-Identifier: ISC
|
||||
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import duration, { Duration } from "dayjs/plugin/duration";
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
interface MemCacheItem<V = any> {
|
||||
expires: Dayjs;
|
||||
val: V;
|
||||
}
|
||||
|
||||
export class MemCache<K, V> {
|
||||
ttl: Duration;
|
||||
data: Map<string, MemCacheItem<V>>;
|
||||
_timeout: NodeJS.Timeout;
|
||||
|
||||
constructor(ttl = 0) {
|
||||
this.ttl = dayjs.duration(ttl, "ms");
|
||||
this.data = new Map();
|
||||
}
|
||||
|
||||
hash(key: K) {
|
||||
return JSON.stringify(key);
|
||||
}
|
||||
|
||||
get(key: K) {
|
||||
const hashKey = this.hash(key);
|
||||
let val = null;
|
||||
const obj = this.data.get(hashKey);
|
||||
if (obj) {
|
||||
if (dayjs() < obj.expires) {
|
||||
val = obj.val;
|
||||
} else {
|
||||
val = null;
|
||||
this.data.delete(hashKey);
|
||||
}
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
put(key: K, val: V = null, ttl = 0) {
|
||||
const ttlToUse = ttl == 0 ? this.ttl : dayjs.duration(ttl, "ms");
|
||||
const expires = dayjs().add(ttlToUse);
|
||||
if (val !== null) {
|
||||
this.data.set(this.hash(key), {
|
||||
expires,
|
||||
val,
|
||||
});
|
||||
this.schedulePurge();
|
||||
}
|
||||
}
|
||||
|
||||
schedulePurge() {
|
||||
if (!this._timeout) {
|
||||
this._timeout = setTimeout(() => {
|
||||
this.purge();
|
||||
this._timeout = null;
|
||||
}, this.ttl.asMilliseconds());
|
||||
}
|
||||
}
|
||||
|
||||
purge() {
|
||||
const now = dayjs();
|
||||
this.data.forEach((v, k) => {
|
||||
if (now >= v.expires) {
|
||||
this.data.delete(k);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -1,9 +1,10 @@
|
||||
{
|
||||
"include": ["src/**/*", "types/**/*"],
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"target": "es6",
|
||||
"module": "commonjs",
|
||||
"jsx": "preserve",
|
||||
"lib": ["ES2022"],
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
@ -14,18 +15,21 @@
|
||||
"experimentalDecorators": true,
|
||||
"downlevelIteration": true,
|
||||
"baseUrl": "./",
|
||||
"types": ["@withfig/autocomplete-types"],
|
||||
"paths": {
|
||||
"@/app/*": ["src/app/*"], // Points to the src folder
|
||||
"@/util/*": ["src/util/*"], // Points to the src folder
|
||||
"@/models": ["src/models/index"], // Points directly to the index file
|
||||
"@/app/*": ["src/app/*"], // Points to the app folder
|
||||
"@/util/*": ["src/util/*"], // Points to the util folder
|
||||
"@/models": ["src/models/index"], // Points directly to the models index file
|
||||
"@/models/*": ["src/models/*"], // For everything else inside models
|
||||
"@/common/*": ["src/app/common/*"], // For everything else inside models
|
||||
"@/elements": ["src/app/common/elements/index"], // Points directly to the index file
|
||||
"@/elements/*": ["src/app/common/elements/*"], // For everything else inside models
|
||||
"@/modals": ["src/app/common/modals/index"], // Points directly to the index file
|
||||
"@/modals/*": ["src/app/common/modals/*"], // For everything else inside models
|
||||
"@/assets/*": ["src/app/assets/*"], // For everything else inside models
|
||||
"@/plugins/*": ["src/plugins/*"], // For everything else inside models
|
||||
"@/common/*": ["src/app/common/*"], // For everything else inside common
|
||||
"@/elements": ["src/app/common/elements/index"], // Points directly to the elements index file
|
||||
"@/elements/*": ["src/app/common/elements/*"], // For everything else inside elements
|
||||
"@/modals": ["src/app/common/modals/index"], // Points directly to the modals index file
|
||||
"@/modals/*": ["src/app/common/modals/*"], // For everything else inside modals
|
||||
"@/assets/*": ["src/app/assets/*"], // For everything else inside assets
|
||||
"@/plugins/*": ["src/plugins/*"], // For everything else inside plugins
|
||||
"@/autocomplete": ["src/autocomplete/index"], // Points directly to the autocomplete index file
|
||||
"@/autocomplete/*": ["src/autocomplete/*"], // For everything else inside autocomplete
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -173,6 +173,7 @@ func init() {
|
||||
registerCmdFn("cr", CrCommand)
|
||||
registerCmdFn("connect", CrCommand)
|
||||
registerCmdFn("_compgen", CompGenCommand)
|
||||
registerCmdFn("_compfiledir", CompFileDirCommand)
|
||||
registerCmdFn("clear", ClearCommand)
|
||||
registerCmdFn("reset", RemoteResetCommand)
|
||||
registerCmdFn("reset:cwd", ResetCwdCommand)
|
||||
@ -298,6 +299,9 @@ func init() {
|
||||
registerCmdFn("_debug:ri", DebugRemoteInstanceCommand)
|
||||
|
||||
registerCmdFn("sudo:clear", ClearSudoCache)
|
||||
|
||||
registerCmdFn("autocomplete:on", AutocompleteOnCommand)
|
||||
registerCmdFn("autocomplete:off", AutocompleteOffCommand)
|
||||
}
|
||||
|
||||
func getValidCommands() []string {
|
||||
@ -3310,6 +3314,40 @@ func doCompGen(ctx context.Context, pk *scpacket.FeCommandPacketType, prefix str
|
||||
return comps, hasMore, nil
|
||||
}
|
||||
|
||||
func CompFileDirCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.UpdatePacket, error) {
|
||||
ids, err := resolveUiIds(ctx, pk, 0) // best-effort
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("/_compfiledir error: %w", err)
|
||||
}
|
||||
|
||||
comptype := pk.Kwargs["comptype"]
|
||||
|
||||
if comptype != comp.CGTypeFile && comptype != comp.CGTypeDir {
|
||||
return nil, fmt.Errorf("/_compfiledir invalid comptype '%s'", comptype)
|
||||
}
|
||||
|
||||
compCtx := comp.CompContext{}
|
||||
if ids.Remote != nil {
|
||||
rptr := ids.Remote.RemotePtr
|
||||
compCtx.RemotePtr = &rptr
|
||||
if pk.Kwargs["cwd"] != "" {
|
||||
compCtx.Cwd = pk.Kwargs["cwd"]
|
||||
} else if ids.Remote.FeState != nil {
|
||||
compCtx.Cwd = ids.Remote.FeState["cwd"]
|
||||
}
|
||||
}
|
||||
|
||||
crtn, err := comp.DoSimpleComp(ctx, comptype, "", compCtx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if crtn == nil {
|
||||
return nil, nil
|
||||
}
|
||||
compStrs := crtn.GetCompDisplayStrs()
|
||||
return makeInfoFromComps(crtn.CompType, compStrs, crtn.HasMore), nil
|
||||
}
|
||||
|
||||
func CompGenCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.UpdatePacket, error) {
|
||||
ids, err := resolveUiIds(ctx, pk, 0) // best-effort
|
||||
if err != nil {
|
||||
@ -6321,6 +6359,59 @@ func ReleaseCheckOffCommand(ctx context.Context, pk *scpacket.FeCommandPacketTyp
|
||||
return update, nil
|
||||
}
|
||||
|
||||
func setAutocompleteEnabled(ctx context.Context, clientData *sstore.ClientData, autocompleteEnabledValue bool) error {
|
||||
clientOpts := clientData.ClientOpts
|
||||
clientOpts.AutocompleteEnabled = autocompleteEnabledValue
|
||||
err := sstore.SetClientOpts(ctx, clientOpts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error trying to update client autocomplete setting: %v", err)
|
||||
}
|
||||
log.Printf("client autocomplete setting updated to %v\n", autocompleteEnabledValue)
|
||||
return nil
|
||||
}
|
||||
|
||||
func AutocompleteOnCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.UpdatePacket, error) {
|
||||
clientData, err := sstore.EnsureClientData(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot retrieve client data: %v", err)
|
||||
}
|
||||
if clientData.ClientOpts.AutocompleteEnabled {
|
||||
return sstore.InfoMsgUpdate("autocomplete is already on"), nil
|
||||
}
|
||||
err = setAutocompleteEnabled(ctx, clientData, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientData, err = sstore.EnsureClientData(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot retrieve updated client data: %v", err)
|
||||
}
|
||||
update := sstore.InfoMsgUpdate("autocomplete is now on")
|
||||
update.AddUpdate(*clientData)
|
||||
return update, nil
|
||||
}
|
||||
|
||||
func AutocompleteOffCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.UpdatePacket, error) {
|
||||
clientData, err := sstore.EnsureClientData(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot retrieve client data: %v", err)
|
||||
}
|
||||
if !clientData.ClientOpts.AutocompleteEnabled {
|
||||
return sstore.InfoMsgUpdate("autocomplete is already off"), nil
|
||||
}
|
||||
err = setAutocompleteEnabled(ctx, clientData, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientData, err = sstore.EnsureClientData(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot retrieve updated client data: %v", err)
|
||||
}
|
||||
update := sstore.InfoMsgUpdate("autocomplete is now off")
|
||||
update.AddUpdate(*clientData)
|
||||
return update, nil
|
||||
}
|
||||
|
||||
func ReleaseCheckCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.UpdatePacket, error) {
|
||||
err := runReleaseCheck(ctx, true)
|
||||
if err != nil {
|
||||
|
@ -507,6 +507,12 @@ func getCompType(compPos shparse.CompletionPos) string {
|
||||
case shparse.CompTypeArg, shparse.CompTypeBasic, shparse.CompTypeAssignment:
|
||||
return CGTypeFile
|
||||
|
||||
case shparse.CompTypeDir:
|
||||
return CGTypeDir
|
||||
|
||||
case shparse.CompTypeFile:
|
||||
return CGTypeFile
|
||||
|
||||
default:
|
||||
return CGTypeFile
|
||||
}
|
||||
@ -515,11 +521,9 @@ func getCompType(compPos shparse.CompletionPos) string {
|
||||
func fixupVarPrefix(varPrefix string) string {
|
||||
if strings.HasPrefix(varPrefix, "${") {
|
||||
varPrefix = varPrefix[2:]
|
||||
if strings.HasSuffix(varPrefix, "}") {
|
||||
varPrefix = varPrefix[:len(varPrefix)-1]
|
||||
}
|
||||
} else if strings.HasPrefix(varPrefix, "$") {
|
||||
varPrefix = varPrefix[1:]
|
||||
varPrefix = strings.TrimSuffix(varPrefix, "}")
|
||||
} else {
|
||||
varPrefix = strings.TrimPrefix(varPrefix, "$")
|
||||
}
|
||||
return varPrefix
|
||||
}
|
||||
|
@ -17,6 +17,8 @@ const (
|
||||
CompTypeVar = "var"
|
||||
CompTypeAssignment = "assignment"
|
||||
CompTypeBasic = "basic"
|
||||
CompTypeFile = "file"
|
||||
CompTypeDir = "dir"
|
||||
)
|
||||
|
||||
type CompletionPos struct {
|
||||
|
@ -252,6 +252,7 @@ type ClientOptsType struct {
|
||||
GlobalShortcut string `json:"globalshortcut,omitempty"`
|
||||
GlobalShortcutEnabled bool `json:"globalshortcutenabled,omitempty"`
|
||||
WebGL bool `json:"webgl,omitempty"`
|
||||
AutocompleteEnabled bool `json:"autocompleteenabled,omitempty"`
|
||||
}
|
||||
|
||||
type FeOptsType struct {
|
||||
|
@ -67,6 +67,7 @@ var electronCommon = {
|
||||
"@/modals": path.resolve(__dirname, "../src/app/common/modals/"),
|
||||
"@/assets": path.resolve(__dirname, "../src/app/assets/"),
|
||||
"@/plugins": path.resolve(__dirname, "../src/plugins/"),
|
||||
"@/autocomplete": path.resolve(__dirname, "../src/autocomplete/"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -88,6 +88,7 @@ var webCommon = {
|
||||
"@/modals": path.resolve(__dirname, "../src/app/common/modals/"),
|
||||
"@/assets": path.resolve(__dirname, "../src/app/assets/"),
|
||||
"@/plugins": path.resolve(__dirname, "../src/plugins/"),
|
||||
"@/autocomplete": path.resolve(__dirname, "../src/autocomplete/"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
77
yarn.lock
77
yarn.lock
@ -2784,6 +2784,23 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@fig/autocomplete-generators@npm:^2.4.0":
|
||||
version: 2.4.0
|
||||
resolution: "@fig/autocomplete-generators@npm:2.4.0"
|
||||
checksum: 10c0/34436e8ce20e9dfe703b3e1d41a5c8685d591e5c685849bf5d296a0667bd93127e11e8680204f3752a25ea667c5b27251a128b73737fea0f104ba378597cee88
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@fig/autocomplete-helpers@npm:^1.0.7":
|
||||
version: 1.0.7
|
||||
resolution: "@fig/autocomplete-helpers@npm:1.0.7"
|
||||
dependencies:
|
||||
semver: "npm:^7.3.5"
|
||||
typescript: "npm:^4.6.3"
|
||||
checksum: 10c0/2372399d34d5da849057afdfcbcd0423ee896a8d5e75cb3fc1c1f1e818db146b622c3685af107e601a432af3ed3428f22c5faf5e555871602b9d2b25a5d5041d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@gar/promisify@npm:^1.1.3":
|
||||
version: 1.1.3
|
||||
resolution: "@gar/promisify@npm:1.1.3"
|
||||
@ -4124,6 +4141,26 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@withfig/autocomplete-types@npm:^1.30.0":
|
||||
version: 1.30.0
|
||||
resolution: "@withfig/autocomplete-types@npm:1.30.0"
|
||||
checksum: 10c0/8cb44c2de317fc7ab424254ba7c98addd05073009ec70de8168ae9b671874d0481138e862ffeb28132861520f532f0114a2b0aadbfa8219fec4ae8b9de21f30b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@withfig/autocomplete@npm:^2.652.3":
|
||||
version: 2.655.3
|
||||
resolution: "@withfig/autocomplete@npm:2.655.3"
|
||||
dependencies:
|
||||
"@fig/autocomplete-generators": "npm:^2.4.0"
|
||||
"@fig/autocomplete-helpers": "npm:^1.0.7"
|
||||
semver: "npm:^7.6.0"
|
||||
strip-json-comments: "npm:^5.0.1"
|
||||
yaml: "npm:^2.4.1"
|
||||
checksum: 10c0/d0c604f6b236b558d36e430086a36ad7ca2dbbea6373fc49214b543477cf74b019e3b57af4a9bf7f4fd024ac748a8c579a42b870160ac3b6ee41dfc99cdf5950
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@xmldom/xmldom@npm:^0.8.8":
|
||||
version: 0.8.10
|
||||
resolution: "@xmldom/xmldom@npm:0.8.10"
|
||||
@ -10697,7 +10734,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4":
|
||||
"semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0":
|
||||
version: 7.6.0
|
||||
resolution: "semver@npm:7.6.0"
|
||||
dependencies:
|
||||
@ -11178,6 +11215,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"strip-json-comments@npm:^5.0.1":
|
||||
version: 5.0.1
|
||||
resolution: "strip-json-comments@npm:5.0.1"
|
||||
checksum: 10c0/c9d9d55a0167c57aa688df3aa20628cf6f46f0344038f189eaa9d159978e80b2bfa6da541a40d83f7bde8a3554596259bf6b70578b2172356536a0e3fa5a0982
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"style-loader@npm:4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "style-loader@npm:4.0.0"
|
||||
@ -11481,6 +11525,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"typescript@npm:^4.6.3":
|
||||
version: 4.9.5
|
||||
resolution: "typescript@npm:4.9.5"
|
||||
bin:
|
||||
tsc: bin/tsc
|
||||
tsserver: bin/tsserver
|
||||
checksum: 10c0/5f6cad2e728a8a063521328e612d7876e12f0d8a8390d3b3aaa452a6a65e24e9ac8ea22beb72a924fd96ea0a49ea63bb4e251fb922b12eedfb7f7a26475e5c56
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"typescript@npm:^5.0.0":
|
||||
version: 5.4.5
|
||||
resolution: "typescript@npm:5.4.5"
|
||||
@ -11501,6 +11555,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"typescript@patch:typescript@npm%3A^4.6.3#optional!builtin<compat/typescript>":
|
||||
version: 4.9.5
|
||||
resolution: "typescript@patch:typescript@npm%3A4.9.5#optional!builtin<compat/typescript>::version=4.9.5&hash=289587"
|
||||
bin:
|
||||
tsc: bin/tsc
|
||||
tsserver: bin/tsserver
|
||||
checksum: 10c0/e3333f887c6829dfe0ab6c1dbe0dd1e3e2aeb56c66460cb85c5440c566f900c833d370ca34eb47558c0c69e78ced4bfe09b8f4f98b6de7afed9b84b8d1dd06a1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"typescript@patch:typescript@npm%3A^5.0.0#optional!builtin<compat/typescript>":
|
||||
version: 5.4.5
|
||||
resolution: "typescript@patch:typescript@npm%3A5.4.5#optional!builtin<compat/typescript>::version=5.4.5&hash=5adc0c"
|
||||
@ -11855,6 +11919,8 @@ __metadata:
|
||||
"@types/throttle-debounce": "npm:^5.0.1"
|
||||
"@types/uuid": "npm:^9.0.7"
|
||||
"@types/webpack-env": "npm:^1.18.3"
|
||||
"@withfig/autocomplete": "npm:^2.652.3"
|
||||
"@withfig/autocomplete-types": "npm:^1.30.0"
|
||||
autobind-decorator: "npm:^2.4.0"
|
||||
babel-loader: "npm:^9.1.3"
|
||||
babel-plugin-jsx-control-statements: "npm:^4.1.2"
|
||||
@ -12348,6 +12414,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"yaml@npm:^2.4.1":
|
||||
version: 2.4.2
|
||||
resolution: "yaml@npm:2.4.2"
|
||||
bin:
|
||||
yaml: bin.mjs
|
||||
checksum: 10c0/280ddb2e43ffa7d91a95738e80c8f33e860749cdc25aa6d9e4d350a28e174fd7e494e4aa023108aaee41388e451e3dc1292261d8f022aabcf90df9c63d647549
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"yargs-parser@npm:^21.1.1":
|
||||
version: 21.1.1
|
||||
resolution: "yargs-parser@npm:21.1.1"
|
||||
|
Loading…
Reference in New Issue
Block a user