diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 9c45f2868..17d4f2e47 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -448,6 +448,10 @@ const ChangeConnectionBlockModal = React.memo( const changeConnModalOpen = jotai.useAtomValue(changeConnModalAtom); const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); 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( async (connName: string) => { if (connName == "") { @@ -476,6 +480,109 @@ const ChangeConnectionBlockModal = React.memo( }, [blockId, blockData] ); + React.useEffect(() => { + const loadFromBackend = async () => { + let connList: Array; + + 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 = []; + 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 = []; + 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 = []; + 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( (waveEvent: WaveKeyboardEvent): boolean => { if (keyutil.checkKeyPressed(waveEvent, "Enter")) { @@ -493,6 +600,9 @@ const ChangeConnectionBlockModal = React.memo( }, [changeConnModalAtom, viewModel, blockId, connSelected] ); + React.useEffect(() => { + console.log("connSelected is: ", connSelected); + }, [connSelected]); if (!changeConnModalOpen) { return null; } @@ -500,7 +610,7 @@ const ChangeConnectionBlockModal = React.memo( { changeConnection(selected); globalStore.set(changeConnModalAtom, false); @@ -509,7 +619,7 @@ const ChangeConnectionBlockModal = React.memo( onKeyDown={(e) => keyutil.keydownWrapper(handleTypeAheadKeyDown)(e)} onChange={(current: string) => setConnSelected(current)} value={connSelected} - label="Switch connection" + label="username@host" onClickBackdrop={() => globalStore.set(changeConnModalAtom, false)} /> ); diff --git a/frontend/app/block/blockutil.tsx b/frontend/app/block/blockutil.tsx index a5f22b9ea..a194e8737 100644 --- a/frontend/app/block/blockutil.tsx +++ b/frontend/app/block/blockutil.tsx @@ -208,7 +208,7 @@ export const ConnectionButton = React.memo( const connStatus = jotai.useAtomValue(connStatusAtom); let showDisconnectedSlash = false; let connIconElem: React.ReactNode = null; - let color = "#53b4ea"; + let color = "var(--conn-icon-color)"; const clickHandler = function () { setConnModalOpen(true); }; diff --git a/frontend/app/modals/typeaheadmodal.tsx b/frontend/app/modals/typeaheadmodal.tsx index 6865aefb4..ad62bee95 100644 --- a/frontend/app/modals/typeaheadmodal.tsx +++ b/frontend/app/modals/typeaheadmodal.tsx @@ -8,45 +8,35 @@ import ReactDOM from "react-dom"; 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 { suggestions?: SuggestionsType[]; onSelect?: (_: string) => void; } const Suggestions = forwardRef(({ suggestions, onSelect }: SuggestionsProps, ref) => { - const renderIcon = (icon: string | React.ReactNode) => { + const renderIcon = (icon: string | React.ReactNode, color: string) => { if (typeof icon === "string") { - return ; + return ; } return icon; }; - const renderItem = (item: BaseItem | ConnectionItem, index: number) => ( -
onSelect(item.label)} className="suggestion-item"> + const renderItem = (item: SuggestionBaseItem | SuggestionConnectionItem, index: number) => ( +
{ + if ("onSelect" in item && item.onSelect) { + item.onSelect(item.label); + } else { + onSelect(item.label); + } + }} + className="suggestion-item" + >
- {item.icon && renderIcon(item.icon)} + {item.icon && renderIcon(item.icon, "iconColor" in item && item.iconColor ? item.iconColor : "inherit")} {item.label}
- {"status" in item && item.status == "connected" && }
); @@ -61,7 +51,7 @@ const Suggestions = forwardRef(({ suggestions,
); } - return renderItem(item as BaseItem, index); + return renderItem(item as SuggestionBaseItem, index); })} ); @@ -249,4 +239,3 @@ const TypeAheadModal = ({ }; export { TypeAheadModal }; -export type { SuggestionsType }; diff --git a/frontend/app/store/wshserver.ts b/frontend/app/store/wshserver.ts index a1b4d960a..489320d77 100644 --- a/frontend/app/store/wshserver.ts +++ b/frontend/app/store/wshserver.ts @@ -32,6 +32,11 @@ class WshServerType { return wshServerRpcHelper_call("connensure", data, opts); } + // command "connlist" [call] + ConnListCommand(opts?: RpcOpts): Promise { + return wshServerRpcHelper_call("connlist", null, opts); + } + // command "connreinstallwsh" [call] ConnReinstallWshCommand(data: string, opts?: RpcOpts): Promise { return wshServerRpcHelper_call("connreinstallwsh", data, opts); diff --git a/frontend/app/theme.less b/frontend/app/theme.less index e5201a751..96773babf 100644 --- a/frontend/app/theme.less +++ b/frontend/app/theme.less @@ -91,4 +91,7 @@ --form-element-primary-color: var(--accent-color); --form-element-secondary-color: rgba(255, 255, 255, 0.2); --form-element-error-color: var(--error-color); + + /* temporary conn icon color - will be replaced with individual colors */ + --conn-icon-color: #53b4ea; } diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index efc58280e..a0ceea084 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -245,6 +245,27 @@ declare global { openSwitchConnection?: () => void; 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 {}; diff --git a/pkg/remote/conncontroller/conncontroller.go b/pkg/remote/conncontroller/conncontroller.go index d35109112..515706993 100644 --- a/pkg/remote/conncontroller/conncontroller.go +++ b/pkg/remote/conncontroller/conncontroller.go @@ -540,16 +540,17 @@ func resolveSshConfigPatterns(configFiles []string) ([]string, error) { // for each host, find the first good alias for _, hostPattern := range host.Patterns { hostPatternStr := hostPattern.String() - if !strings.Contains(hostPatternStr, "*") || alreadyUsed[hostPatternStr] { - discoveredPatterns = append(discoveredPatterns, hostPatternStr) - alreadyUsed[hostPatternStr] = true + normalized := remote.NormalizeConfigPattern(hostPatternStr) + if (!strings.Contains(hostPatternStr, "*") && !strings.Contains(hostPatternStr, "?") && !strings.Contains(hostPatternStr, "!")) || alreadyUsed[normalized] { + discoveredPatterns = append(discoveredPatterns, normalized) + alreadyUsed[normalized] = true break } } } } 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...) } if len(discoveredPatterns) == 0 { @@ -559,6 +560,42 @@ func resolveSshConfigPatterns(configFiles []string) ([]string, error) { 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) { home := wavebase.GetHomeDir() localConfig := filepath.Join(home, ".ssh", "config") diff --git a/pkg/remote/connutil.go b/pkg/remote/connutil.go index 1cdf59ed4..2dcd64a9c 100644 --- a/pkg/remote/connutil.go +++ b/pkg/remote/connutil.go @@ -7,11 +7,14 @@ import ( "io" "log" "os" + "os/user" "path/filepath" "regexp" "strconv" "strings" + "github.com/kevinburke/ssh_config" + "github.com/skeema/knownhosts" "golang.org/x/crypto/ssh" ) @@ -330,3 +333,27 @@ func IsPowershell(shellPath string) bool { shellBase := filepath.Base(shellPath) 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) +} diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index e77652ba9..8a8b09151 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -42,6 +42,12 @@ func ConnEnsureCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) 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 func ConnReinstallWshCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "connreinstallwsh", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index dea31b4ba..6304ef584 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -70,6 +70,7 @@ const ( Command_ConnReinstallWsh = "connreinstallwsh" Command_ConnConnect = "connconnect" Command_ConnDisconnect = "conndisconnect" + Command_ConnList = "connlist" ) type RespOrErrorUnion[T any] struct { @@ -112,6 +113,7 @@ type WshRpcInterface interface { ConnReinstallWshCommand(ctx context.Context, connName string) error ConnConnectCommand(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 EventRecvCommand(ctx context.Context, data WaveEvent) error diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index c496d6136..5f464c339 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -522,3 +522,7 @@ func (ws *WshServer) ConnReinstallWshCommand(ctx context.Context, connName strin } return conn.CheckAndInstallWsh(ctx, connName, &conncontroller.WshInstallOpts{Force: true, NoUserPrompt: true}) } + +func (ws *WshServer) ConnListCommand(ctx context.Context) ([]string, error) { + return conncontroller.GetConnectionsList() +}