Connection Typeahead/Suggestions (#332)

Adds a list of potential remotes to add and filters it as you type. It
also provides options for reconnecting on a disconnection and
specifically connecting to a local connection
This commit is contained in:
Sylvie Crowe 2024-09-05 17:02:44 -07:00 committed by GitHub
parent 1706131a80
commit fc0b1929ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 238 additions and 34 deletions

View File

@ -448,6 +448,10 @@ const ChangeConnectionBlockModal = React.memo(
const changeConnModalOpen = jotai.useAtomValue(changeConnModalAtom); const changeConnModalOpen = jotai.useAtomValue(changeConnModalAtom);
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId)); const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
const isNodeFocused = jotai.useAtomValue(nodeModel.isFocused); const isNodeFocused = jotai.useAtomValue(nodeModel.isFocused);
const connection = blockData?.meta?.connection ?? "local";
const connStatusAtom = getConnStatusAtom(connection);
const connStatus = jotai.useAtomValue(connStatusAtom);
const [suggestions, setSuggestions] = React.useState([]);
const changeConnection = React.useCallback( const changeConnection = React.useCallback(
async (connName: string) => { async (connName: string) => {
if (connName == "") { if (connName == "") {
@ -476,6 +480,109 @@ const ChangeConnectionBlockModal = React.memo(
}, },
[blockId, blockData] [blockId, blockData]
); );
React.useEffect(() => {
const loadFromBackend = async () => {
let connList: Array<string>;
try {
connList = await WshServer.ConnListCommand({ timeout: 2000 });
} catch (e) {
console.log("unable to load conn list from backend. using blank list: ", e);
}
let createNew: boolean = true;
if (connSelected == "") {
createNew = false;
}
const filteredList: Array<string> = [];
for (const conn of connList) {
if (conn === connSelected) {
createNew = false;
}
if (conn.includes(connSelected)) {
filteredList.push(conn);
}
}
// priority handles special suggestions when necessary
// for instance, when reconnecting
const newConnectionSuggestion: SuggestionConnectionItem = {
status: "connected",
icon: "arrow-right-arrow-left",
iconColor: "var(--conn-icon-color)",
label: `(+) ${connSelected}`,
value: "",
onSelect: (_: string) => {
changeConnection(connSelected);
globalStore.set(changeConnModalAtom, false);
},
};
const reconnectSuggestion: SuggestionConnectionItem = {
status: "connected",
icon: "arrow-right-arrow-left",
iconColor: "var(--conn-icon-color)",
label: `Reconnect to ${connStatus.connection}`,
value: "",
onSelect: async (_: string) => {
console.log("unimplemented: reconnect");
},
};
const priorityItems: Array<SuggestionConnectionItem> = [];
if (createNew) {
console.log("added to priority items");
priorityItems.push(newConnectionSuggestion);
}
if (connStatus.status == "disconnected" || connStatus.status == "error") {
priorityItems.push(reconnectSuggestion);
}
const prioritySuggestions: SuggestionConnectionScope = {
headerText: "",
items: priorityItems,
};
const localSuggestion: SuggestionConnectionScope = {
headerText: "Local",
items: [
{
status: "connected",
icon: "laptop",
iconColor: "var(--grey-text-color)",
value: "",
label: "Switch to Local Connection",
// TODO: need to specify user name and host name
onSelect: (_: string) => {
changeConnection("");
globalStore.set(changeConnModalAtom, false);
},
},
],
};
const remoteItems = filteredList.map((connName) => {
const item: SuggestionConnectionItem = {
status: "connected",
icon: "arrow-right-arrow-left",
iconColor: "var(--conn-icon-color)",
value: connName,
label: connName,
};
return item;
});
const remoteSuggestions: SuggestionConnectionScope = {
headerText: "Remote",
items: remoteItems,
};
let out: Array<SuggestionsType> = [];
if (prioritySuggestions.items.length > 0) {
out.push(prioritySuggestions);
}
if (localSuggestion.items.length > 0) {
out.push(localSuggestion);
}
if (remoteSuggestions.items.length > 0) {
out.push(remoteSuggestions);
}
setSuggestions(out);
};
loadFromBackend();
}, [connStatus, setSuggestions, connSelected, changeConnection]);
const handleTypeAheadKeyDown = React.useCallback( const handleTypeAheadKeyDown = React.useCallback(
(waveEvent: WaveKeyboardEvent): boolean => { (waveEvent: WaveKeyboardEvent): boolean => {
if (keyutil.checkKeyPressed(waveEvent, "Enter")) { if (keyutil.checkKeyPressed(waveEvent, "Enter")) {
@ -493,6 +600,9 @@ const ChangeConnectionBlockModal = React.memo(
}, },
[changeConnModalAtom, viewModel, blockId, connSelected] [changeConnModalAtom, viewModel, blockId, connSelected]
); );
React.useEffect(() => {
console.log("connSelected is: ", connSelected);
}, [connSelected]);
if (!changeConnModalOpen) { if (!changeConnModalOpen) {
return null; return null;
} }
@ -500,7 +610,7 @@ const ChangeConnectionBlockModal = React.memo(
<TypeAheadModal <TypeAheadModal
blockRef={blockRef} blockRef={blockRef}
anchorRef={connBtnRef} anchorRef={connBtnRef}
// suggestions={[]} suggestions={suggestions}
onSelect={(selected: string) => { onSelect={(selected: string) => {
changeConnection(selected); changeConnection(selected);
globalStore.set(changeConnModalAtom, false); globalStore.set(changeConnModalAtom, false);
@ -509,7 +619,7 @@ const ChangeConnectionBlockModal = React.memo(
onKeyDown={(e) => keyutil.keydownWrapper(handleTypeAheadKeyDown)(e)} onKeyDown={(e) => keyutil.keydownWrapper(handleTypeAheadKeyDown)(e)}
onChange={(current: string) => setConnSelected(current)} onChange={(current: string) => setConnSelected(current)}
value={connSelected} value={connSelected}
label="Switch connection" label="username@host"
onClickBackdrop={() => globalStore.set(changeConnModalAtom, false)} onClickBackdrop={() => globalStore.set(changeConnModalAtom, false)}
/> />
); );

View File

@ -208,7 +208,7 @@ export const ConnectionButton = React.memo(
const connStatus = jotai.useAtomValue(connStatusAtom); const connStatus = jotai.useAtomValue(connStatusAtom);
let showDisconnectedSlash = false; let showDisconnectedSlash = false;
let connIconElem: React.ReactNode = null; let connIconElem: React.ReactNode = null;
let color = "#53b4ea"; let color = "var(--conn-icon-color)";
const clickHandler = function () { const clickHandler = function () {
setConnModalOpen(true); setConnModalOpen(true);
}; };

View File

@ -8,45 +8,35 @@ import ReactDOM from "react-dom";
import "./typeaheadmodal.less"; import "./typeaheadmodal.less";
type ConnStatus = "connected" | "connecting" | "disconnected" | "error";
interface BaseItem {
label: string;
value: string;
icon?: string | React.ReactNode;
}
interface ConnectionItem extends BaseItem {
status: ConnStatus;
iconColor: string;
}
interface ConnectionScope {
headerText?: string;
items: ConnectionItem[];
}
type SuggestionsType = ConnectionItem | ConnectionScope;
interface SuggestionsProps { interface SuggestionsProps {
suggestions?: SuggestionsType[]; suggestions?: SuggestionsType[];
onSelect?: (_: string) => void; onSelect?: (_: string) => void;
} }
const Suggestions = forwardRef<HTMLDivElement, SuggestionsProps>(({ suggestions, onSelect }: SuggestionsProps, ref) => { const Suggestions = forwardRef<HTMLDivElement, SuggestionsProps>(({ suggestions, onSelect }: SuggestionsProps, ref) => {
const renderIcon = (icon: string | React.ReactNode) => { const renderIcon = (icon: string | React.ReactNode, color: string) => {
if (typeof icon === "string") { if (typeof icon === "string") {
return <i className={makeIconClass(icon, false)}></i>; return <i className={makeIconClass(icon, false)} style={{ color: color }}></i>;
} }
return icon; return icon;
}; };
const renderItem = (item: BaseItem | ConnectionItem, index: number) => ( const renderItem = (item: SuggestionBaseItem | SuggestionConnectionItem, index: number) => (
<div key={index} onClick={() => onSelect(item.label)} className="suggestion-item"> <div
key={index}
onClick={() => {
if ("onSelect" in item && item.onSelect) {
item.onSelect(item.label);
} else {
onSelect(item.label);
}
}}
className="suggestion-item"
>
<div className="name"> <div className="name">
{item.icon && renderIcon(item.icon)} {item.icon && renderIcon(item.icon, "iconColor" in item && item.iconColor ? item.iconColor : "inherit")}
{item.label} {item.label}
</div> </div>
{"status" in item && item.status == "connected" && <i className={makeIconClass("fa-check", false)}></i>}
</div> </div>
); );
@ -61,7 +51,7 @@ const Suggestions = forwardRef<HTMLDivElement, SuggestionsProps>(({ suggestions,
</div> </div>
); );
} }
return renderItem(item as BaseItem, index); return renderItem(item as SuggestionBaseItem, index);
})} })}
</div> </div>
); );
@ -249,4 +239,3 @@ const TypeAheadModal = ({
}; };
export { TypeAheadModal }; export { TypeAheadModal };
export type { SuggestionsType };

View File

@ -32,6 +32,11 @@ class WshServerType {
return wshServerRpcHelper_call("connensure", data, opts); return wshServerRpcHelper_call("connensure", data, opts);
} }
// command "connlist" [call]
ConnListCommand(opts?: RpcOpts): Promise<string[]> {
return wshServerRpcHelper_call("connlist", null, opts);
}
// command "connreinstallwsh" [call] // command "connreinstallwsh" [call]
ConnReinstallWshCommand(data: string, opts?: RpcOpts): Promise<void> { ConnReinstallWshCommand(data: string, opts?: RpcOpts): Promise<void> {
return wshServerRpcHelper_call("connreinstallwsh", data, opts); return wshServerRpcHelper_call("connreinstallwsh", data, opts);

View File

@ -91,4 +91,7 @@
--form-element-primary-color: var(--accent-color); --form-element-primary-color: var(--accent-color);
--form-element-secondary-color: rgba(255, 255, 255, 0.2); --form-element-secondary-color: rgba(255, 255, 255, 0.2);
--form-element-error-color: var(--error-color); --form-element-error-color: var(--error-color);
/* temporary conn icon color - will be replaced with individual colors */
--conn-icon-color: #53b4ea;
} }

View File

@ -245,6 +245,27 @@ declare global {
openSwitchConnection?: () => void; openSwitchConnection?: () => void;
viewModel: ViewModel; viewModel: ViewModel;
}; };
type ConnStatusType = "connected" | "connecting" | "disconnected" | "error" | "init";
interface SuggestionBaseItem {
label: string;
value: string;
icon?: string | React.ReactNode;
}
interface SuggestionConnectionItem extends SuggestionBaseItem {
status: ConnStatusType;
iconColor: string;
onSelect?: (_: string) => void;
}
interface SuggestionConnectionScope {
headerText?: string;
items: SuggestionConnectionItem[];
}
type SuggestionsType = SuggestionConnectionItem | SuggestionConnectionScope;
} }
export {}; export {};

View File

@ -540,16 +540,17 @@ func resolveSshConfigPatterns(configFiles []string) ([]string, error) {
// for each host, find the first good alias // for each host, find the first good alias
for _, hostPattern := range host.Patterns { for _, hostPattern := range host.Patterns {
hostPatternStr := hostPattern.String() hostPatternStr := hostPattern.String()
if !strings.Contains(hostPatternStr, "*") || alreadyUsed[hostPatternStr] { normalized := remote.NormalizeConfigPattern(hostPatternStr)
discoveredPatterns = append(discoveredPatterns, hostPatternStr) if (!strings.Contains(hostPatternStr, "*") && !strings.Contains(hostPatternStr, "?") && !strings.Contains(hostPatternStr, "!")) || alreadyUsed[normalized] {
alreadyUsed[hostPatternStr] = true discoveredPatterns = append(discoveredPatterns, normalized)
alreadyUsed[normalized] = true
break break
} }
} }
} }
} }
if len(errs) == len(configFiles) { if len(errs) == len(configFiles) {
errs = append([]error{fmt.Errorf("no ssh config files could be opened:\n")}, errs...) errs = append([]error{fmt.Errorf("no ssh config files could be opened: ")}, errs...)
return nil, errors.Join(errs...) return nil, errors.Join(errs...)
} }
if len(discoveredPatterns) == 0 { if len(discoveredPatterns) == 0 {
@ -559,6 +560,42 @@ func resolveSshConfigPatterns(configFiles []string) ([]string, error) {
return discoveredPatterns, nil return discoveredPatterns, nil
} }
func GetConnectionsList() ([]string, error) {
existing := GetAllConnStatus()
var currentlyRunning []string
var hasConnected []string
// populate all lists
for _, stat := range existing {
if stat.Connected {
currentlyRunning = append(currentlyRunning, stat.Connection)
}
if stat.HasConnected {
hasConnected = append(hasConnected, stat.Connection)
}
}
fromConfig, err := GetConnectionsFromConfig()
if err != nil {
return nil, err
}
// sort into one final list and remove duplicates
alreadyUsed := make(map[string]struct{})
var connList []string
for _, subList := range [][]string{currentlyRunning, hasConnected, fromConfig} {
for _, pattern := range subList {
if _, used := alreadyUsed[pattern]; !used {
connList = append(connList, pattern)
alreadyUsed[pattern] = struct{}{}
}
}
}
return connList, nil
}
func GetConnectionsFromConfig() ([]string, error) { func GetConnectionsFromConfig() ([]string, error) {
home := wavebase.GetHomeDir() home := wavebase.GetHomeDir()
localConfig := filepath.Join(home, ".ssh", "config") localConfig := filepath.Join(home, ".ssh", "config")

View File

@ -7,11 +7,14 @@ import (
"io" "io"
"log" "log"
"os" "os"
"os/user"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"github.com/kevinburke/ssh_config"
"github.com/skeema/knownhosts"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@ -330,3 +333,27 @@ func IsPowershell(shellPath string) bool {
shellBase := filepath.Base(shellPath) shellBase := filepath.Base(shellPath)
return strings.Contains(shellBase, "powershell") || strings.Contains(shellBase, "pwsh") return strings.Contains(shellBase, "powershell") || strings.Contains(shellBase, "pwsh")
} }
func NormalizeConfigPattern(pattern string) string {
userName, err := ssh_config.GetStrict(pattern, "User")
if err != nil {
localUser, err := user.Current()
if err == nil {
userName = localUser.Username
}
}
port, err := ssh_config.GetStrict(pattern, "Port")
if err != nil {
port = "22"
}
if userName != "" {
userName += "@"
}
if port == "22" {
port = ""
} else {
port = ":" + port
}
unnormalized := fmt.Sprintf("%s%s%s", userName, pattern, port)
return knownhosts.Normalize(unnormalized)
}

View File

@ -42,6 +42,12 @@ func ConnEnsureCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) err
return err return err
} }
// command "connlist", wshserver.ConnListCommand
func ConnListCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]string, error) {
resp, err := sendRpcRequestCallHelper[[]string](w, "connlist", nil, opts)
return resp, err
}
// command "connreinstallwsh", wshserver.ConnReinstallWshCommand // command "connreinstallwsh", wshserver.ConnReinstallWshCommand
func ConnReinstallWshCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { func ConnReinstallWshCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {
_, err := sendRpcRequestCallHelper[any](w, "connreinstallwsh", data, opts) _, err := sendRpcRequestCallHelper[any](w, "connreinstallwsh", data, opts)

View File

@ -70,6 +70,7 @@ const (
Command_ConnReinstallWsh = "connreinstallwsh" Command_ConnReinstallWsh = "connreinstallwsh"
Command_ConnConnect = "connconnect" Command_ConnConnect = "connconnect"
Command_ConnDisconnect = "conndisconnect" Command_ConnDisconnect = "conndisconnect"
Command_ConnList = "connlist"
) )
type RespOrErrorUnion[T any] struct { type RespOrErrorUnion[T any] struct {
@ -112,6 +113,7 @@ type WshRpcInterface interface {
ConnReinstallWshCommand(ctx context.Context, connName string) error ConnReinstallWshCommand(ctx context.Context, connName string) error
ConnConnectCommand(ctx context.Context, connName string) error ConnConnectCommand(ctx context.Context, connName string) error
ConnDisconnectCommand(ctx context.Context, connName string) error ConnDisconnectCommand(ctx context.Context, connName string) error
ConnListCommand(ctx context.Context) ([]string, error)
// eventrecv is special, it's handled internally by WshRpc with EventListener // eventrecv is special, it's handled internally by WshRpc with EventListener
EventRecvCommand(ctx context.Context, data WaveEvent) error EventRecvCommand(ctx context.Context, data WaveEvent) error

View File

@ -522,3 +522,7 @@ func (ws *WshServer) ConnReinstallWshCommand(ctx context.Context, connName strin
} }
return conn.CheckAndInstallWsh(ctx, connName, &conncontroller.WshInstallOpts{Force: true, NoUserPrompt: true}) return conn.CheckAndInstallWsh(ctx, connName, &conncontroller.WshInstallOpts{Force: true, NoUserPrompt: true})
} }
func (ws *WshServer) ConnListCommand(ctx context.Context) ([]string, error) {
return conncontroller.GetConnectionsList()
}