diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 949108f0c..843ae7fe9 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -15,6 +15,8 @@ import { TypeAheadModal } from "@/app/modals/typeaheadmodal"; import { ContextMenuModel } from "@/app/store/contextmenu"; import { atoms, + createBlock, + getApi, getBlockComponentModel, getConnStatusAtom, getHostName, @@ -182,6 +184,9 @@ const BlockFrame_Header = ({ const prevMagifiedState = React.useRef(magnified); const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection); const dragHandleRef = preview ? null : nodeModel.dragHandleRef; + const connName = blockData?.meta?.connection; + const allSettings = jotai.useAtomValue(atoms.fullConfigAtom); + const wshEnabled = allSettings?.connections?.[connName]?.["conn:wshenabled"] ?? true; React.useEffect(() => { if (!magnified || preview || prevMagifiedState.current) { @@ -239,6 +244,11 @@ const BlockFrame_Header = ({ ); } + const wshInstallButton: IconButtonDecl = { + elemtype: "iconbutton", + icon: "link-slash", + title: "wsh is not installed for this connection", + }; return (
@@ -256,6 +266,9 @@ const BlockFrame_Header = ({ changeConnModalAtom={changeConnModalAtom} /> )} + {manageConnection && !wshEnabled && ( + + )}
{headerTextElems}
{endIconsElem}
@@ -568,6 +581,10 @@ const ChangeConnectionBlockModal = React.memo( const allConnStatus = jotai.useAtomValue(atoms.allConnStatus); const [rowIndex, setRowIndex] = React.useState(0); const connStatusMap = new Map(); + const fullConfig = jotai.useAtomValue(atoms.fullConfigAtom); + const connectionsConfig = fullConfig.connections; + let filterOutNowsh = util.useAtomValueSafe(viewModel.filterOutNowsh) || true; + let maxActiveConnNum = 1; for (const conn of allConnStatus) { if (conn.activeconnnum > maxActiveConnNum) { @@ -638,7 +655,12 @@ const ChangeConnectionBlockModal = React.memo( if (conn === connSelected) { createNew = false; } - if (conn.includes(connSelected)) { + if ( + conn.includes(connSelected) && + connectionsConfig[conn]?.["display:hidden"] != true && + (connectionsConfig[conn]?.["conn:wshenabled"] != false || !filterOutNowsh) + // != false is necessary because of defaults + ) { filteredList.push(conn); } } @@ -647,7 +669,12 @@ const ChangeConnectionBlockModal = React.memo( if (conn === connSelected) { createNew = false; } - if (conn.includes(connSelected)) { + if ( + conn.includes(connSelected) && + connectionsConfig[conn]?.["display:hidden"] != true && + (connectionsConfig[conn]?.["conn:wshenabled"] != false || !filterOutNowsh) + // != false is necessary because of defaults + ) { filteredWslList.push(conn); } } @@ -734,9 +761,38 @@ const ChangeConnectionBlockModal = React.memo( }; return item; }); + const connectionsEditItem: SuggestionConnectionItem = { + status: "disconnected", + icon: "gear", + iconColor: "var(--grey-text-color", + value: "Edit Connections", + label: "Edit Connections", + onSelect: () => { + util.fireAndForget(async () => { + globalStore.set(changeConnModalAtom, false); + const path = `${getApi().getConfigDir()}/connections.json`; + const blockDef: BlockDef = { + meta: { + view: "preview", + file: path, + }, + }; + await createBlock(blockDef, false, true); + }); + }, + }; + const sortedRemoteItems = remoteItems.sort( + (itemA: SuggestionConnectionItem, itemB: SuggestionConnectionItem) => { + const connNameA = itemA.value; + const connNameB = itemB.value; + const valueA = connectionsConfig[connNameA]?.["display:order"] ?? 0; + const valueB = connectionsConfig[connNameB]?.["display:order"] ?? 0; + return valueA - valueB; + } + ); const remoteSuggestions: SuggestionConnectionScope = { headerText: "Remote", - items: remoteItems, + items: [...sortedRemoteItems, connectionsEditItem], }; let suggestions: Array = []; diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 74ee256e4..21791b391 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -246,6 +246,21 @@ function useBlockMetaKeyAtom(blockId: string, key: T): return useAtomValue(getBlockMetaKeyAtom(blockId, key)); } +function getConnConfigKeyAtom(connName: string, key: T): Atom { + let connCache = getSingleConnAtomCache(connName); + const keyAtomName = "#conn-" + key; + let keyAtom = connCache.get(keyAtomName); + if (keyAtom != null) { + return keyAtom; + } + keyAtom = atom((get) => { + let fullConfig = get(atoms.fullConfigAtom); + return fullConfig.connections[connName]?.[key]; + }); + connCache.set(keyAtomName, keyAtom); + return keyAtom; +} + const settingsAtomCache = new Map>(); function getOverrideConfigAtom(blockId: string, key: T): Atom { @@ -261,6 +276,13 @@ function getOverrideConfigAtom(blockId: string, ke if (metaKeyVal != null) { return metaKeyVal; } + const connNameAtom = getBlockMetaKeyAtom(blockId, "connection"); + const connName = get(connNameAtom); + const connConfigKeyAtom = getConnConfigKeyAtom(connName, key as any); + const connConfigKeyVal = get(connConfigKeyAtom); + if (connConfigKeyVal != null) { + return connConfigKeyVal; + } const settingsKeyAtom = getSettingsKeyAtom(key); const settingsVal = get(settingsKeyAtom); if (settingsVal != null) { @@ -322,6 +344,15 @@ function getSingleBlockAtomCache(blockId: string): Map> { return blockCache; } +function getSingleConnAtomCache(connName: string): Map> { + let blockCache = blockAtomCache.get(connName); + if (blockCache == null) { + blockCache = new Map>(); + blockAtomCache.set(connName, blockCache); + } + return blockCache; +} + function useBlockAtom(blockId: string, name: string, makeFn: () => Atom): Atom { const blockCache = getSingleBlockAtomCache(blockId); let atom = blockCache.get(name); diff --git a/frontend/app/view/preview/preview.tsx b/frontend/app/view/preview/preview.tsx index 3cb55316c..77ebd5898 100644 --- a/frontend/app/view/preview/preview.tsx +++ b/frontend/app/view/preview/preview.tsx @@ -121,6 +121,7 @@ export class PreviewModel implements ViewModel { loadableSpecializedView: Atom>; manageConnection: Atom; connStatus: Atom; + filterOutNowsh?: Atom; metaFilePath: Atom; statFilePath: Atom>; @@ -164,6 +165,7 @@ export class PreviewModel implements ViewModel { this.manageConnection = atom(true); this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); this.markdownShowToc = atom(false); + this.filterOutNowsh = atom(true); this.monacoRef = createRef(); this.viewIcon = atom((get) => { const blockData = get(this.blockAtom); diff --git a/frontend/app/view/sysinfo/sysinfo.tsx b/frontend/app/view/sysinfo/sysinfo.tsx index 787be2ff0..ec2ba93be 100644 --- a/frontend/app/view/sysinfo/sysinfo.tsx +++ b/frontend/app/view/sysinfo/sysinfo.tsx @@ -91,7 +91,7 @@ function convertWaveEventToDataItem(event: WaveEvent): DataItem { return dataItem; } -class SysinfoViewModel { +class SysinfoViewModel implements ViewModel { viewType: string; blockAtom: jotai.Atom; termMode: jotai.Atom; @@ -109,6 +109,7 @@ class SysinfoViewModel { metrics: jotai.Atom; connection: jotai.Atom; manageConnection: jotai.Atom; + filterOutNowsh: jotai.Atom; connStatus: jotai.Atom; plotMetaAtom: jotai.PrimitiveAtom>; endIconButtons: jotai.Atom; @@ -176,6 +177,7 @@ class SysinfoViewModel { }); this.plotMetaAtom = jotai.atom(new Map(Object.entries(DefaultPlotMeta))); this.manageConnection = jotai.atom(true); + this.filterOutNowsh = jotai.atom(true); this.loadingAtom = jotai.atom(true); this.numPoints = jotai.atom((get) => { const blockData = get(this.blockAtom); diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index d4236c92d..fb429f7b7 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -41,7 +41,7 @@ type InitialLoadDataType = { heldData: Uint8Array[]; }; -class TermViewModel { +class TermViewModel implements ViewModel { viewType: string; nodeModel: BlockNodeModel; connected: boolean; @@ -54,6 +54,7 @@ class TermViewModel { viewText: jotai.Atom; blockBg: jotai.Atom; manageConnection: jotai.Atom; + filterOutNowsh?: jotai.Atom; connStatus: jotai.Atom; termWshClient: TermWshClient; vdomBlockId: jotai.Atom; @@ -196,6 +197,7 @@ class TermViewModel { } return true; }); + this.filterOutNowsh = jotai.atom(false); this.termThemeNameAtom = useBlockAtom(blockId, "termthemeatom", () => { return jotai.atom((get) => { return get(getOverrideConfigAtom(this.blockId, "term:theme")) ?? DefaultTermTheme; @@ -221,7 +223,10 @@ class TermViewModel { const blockData = get(this.blockAtom); const fsSettingsAtom = getSettingsKeyAtom("term:fontsize"); const settingsFontSize = get(fsSettingsAtom); - const rtnFontSize = blockData?.meta?.["term:fontsize"] ?? settingsFontSize ?? 12; + const connName = blockData?.meta?.connection; + const fullConfig = get(atoms.fullConfigAtom); + const connFontSize = fullConfig?.connections?.[connName]?.["term:fontsize"]; + const rtnFontSize = blockData?.meta?.["term:fontsize"] ?? connFontSize ?? settingsFontSize ?? 12; if (typeof rtnFontSize != "number" || isNaN(rtnFontSize) || rtnFontSize < 4 || rtnFontSize > 64) { return 12; } @@ -725,6 +730,8 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { const termModeRef = React.useRef(termMode); const termFontSize = jotai.useAtomValue(model.fontSizeAtom); + const fullConfig = globalStore.get(atoms.fullConfigAtom); + const connFontFamily = fullConfig.connections?.[blockData?.meta?.connection]?.["term:fontfamily"]; React.useEffect(() => { const fullConfig = globalStore.get(atoms.fullConfigAtom); @@ -750,7 +757,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { { theme: termTheme, fontSize: termFontSize, - fontFamily: termSettings?.["term:fontfamily"] ?? "Hack", + fontFamily: termSettings?.["term:fontfamily"] ?? connFontFamily ?? "Hack", drawBoldTextInBrightColors: false, fontWeight: "normal", fontWeightBold: "bold", @@ -784,7 +791,7 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { termWrap.dispose(); rszObs.disconnect(); }; - }, [blockId, termSettings, termFontSize]); + }, [blockId, termSettings, termFontSize, connFontFamily]); React.useEffect(() => { if (termModeRef.current == "vdom" && termMode == "term") { diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index cf532fb86..49dd49803 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -237,6 +237,7 @@ declare global { blockBg?: jotai.Atom; manageConnection?: jotai.Atom; noPadding?: jotai.Atom; + filterOutNowsh?: jotai.Atom; onBack?: () => void; onForward?: () => void; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index e059da2d5..f72be05b3 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -277,8 +277,14 @@ declare global { // wshrpc.ConnKeywords type ConnKeywords = { - wshenabled?: boolean; - askbeforewshinstall?: boolean; + "conn:wshenabled"?: boolean; + "conn:askbeforewshinstall"?: boolean; + "display:hidden"?: boolean; + "display:order"?: number; + "term:*"?: boolean; + "term:fontsize"?: number; + "term:fontfamily"?: string; + "term:theme"?: string; "ssh:user"?: string; "ssh:hostname"?: string; "ssh:port"?: string; diff --git a/pkg/remote/conncontroller/conncontroller.go b/pkg/remote/conncontroller/conncontroller.go index 32d2d02c5..182180f0c 100644 --- a/pkg/remote/conncontroller/conncontroller.go +++ b/pkg/remote/conncontroller/conncontroller.go @@ -342,7 +342,7 @@ func (conn *SSHConn) CheckAndInstallWsh(ctx context.Context, clientDisplayName s } if !response.Confirm { meta := make(map[string]any) - meta["wshenabled"] = false + meta["conn:wshenabled"] = false err = wconfig.SetConnectionsConfigValue(conn.GetName(), meta) if err != nil { log.Printf("warning: error writing to connections file: %v", err) @@ -454,7 +454,39 @@ func (conn *SSHConn) Connect(ctx context.Context, connFlags *wshrpc.ConnKeywords } }) conn.FireConnChangeEvent() - return err + if err != nil { + return err + } + + // logic for saving connection and potential flags (we only save once a connection has been made successfully) + // at the moment, identity files is the only saved flag + var identityFiles []string + existingConfig := wconfig.ReadFullConfig() + existingConnection, ok := existingConfig.Connections[conn.GetName()] + if ok { + identityFiles = existingConnection.SshIdentityFile + } + if err != nil { + // i do not consider this a critical failure + log.Printf("config read error: unable to save connection %s: %v", conn.GetName(), err) + } + + meta := make(map[string]any) + if connFlags.SshIdentityFile != nil { + for _, identityFile := range connFlags.SshIdentityFile { + if utilfn.ContainsStr(identityFiles, identityFile) { + continue + } + identityFiles = append(identityFiles, connFlags.SshIdentityFile...) + } + meta["ssh:identityfile"] = identityFiles + } + err = wconfig.SetConnectionsConfigValue(conn.GetName(), meta) + if err != nil { + // i do not consider this a critical failure + log.Printf("config write error: unable to save connection %s: %v", conn.GetName(), err) + } + return nil } func (conn *SSHConn) WithLock(fn func()) { @@ -484,11 +516,11 @@ func (conn *SSHConn) connectInternal(ctx context.Context, connFlags *wshrpc.Conn askBeforeInstall := config.Settings.ConnAskBeforeWshInstall connSettings, ok := config.Connections[conn.GetName()] if ok { - if connSettings.WshEnabled != nil { - enableWsh = *connSettings.WshEnabled + if connSettings.ConnWshEnabled != nil { + enableWsh = *connSettings.ConnWshEnabled } - if connSettings.AskBeforeWshInstall != nil { - askBeforeInstall = *connSettings.AskBeforeWshInstall + if connSettings.ConnAskBeforeWshInstall != nil { + askBeforeInstall = *connSettings.ConnAskBeforeWshInstall } } if enableWsh { @@ -661,6 +693,9 @@ func GetConnectionsList() ([]string, error) { hasConnected = append(hasConnected, stat.Connection) } } + + fromInternal := GetConnectionsFromInternalConfig() + fromConfig, err := GetConnectionsFromConfig() if err != nil { return nil, err @@ -670,7 +705,7 @@ func GetConnectionsList() ([]string, error) { alreadyUsed := make(map[string]struct{}) var connList []string - for _, subList := range [][]string{currentlyRunning, hasConnected, fromConfig} { + for _, subList := range [][]string{currentlyRunning, hasConnected, fromInternal, fromConfig} { for _, pattern := range subList { if _, used := alreadyUsed[pattern]; !used { connList = append(connList, pattern) @@ -682,6 +717,15 @@ func GetConnectionsList() ([]string, error) { return connList, nil } +func GetConnectionsFromInternalConfig() []string { + var internalNames []string + config := wconfig.ReadFullConfig() + for internalName := range config.Connections { + internalNames = append(internalNames, internalName) + } + return internalNames +} + func GetConnectionsFromConfig() ([]string, error) { home := wavebase.GetHomeDir() localConfig := filepath.Join(home, ".ssh", "config") diff --git a/pkg/remote/sshclient.go b/pkg/remote/sshclient.go index 134873073..bcc4291c2 100644 --- a/pkg/remote/sshclient.go +++ b/pkg/remote/sshclient.go @@ -27,6 +27,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/userinput" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wshrpc" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/agent" @@ -649,7 +650,13 @@ func ConnectToClient(connCtx context.Context, opts *SSHOpts, currentClient *ssh. connFlags.SshHostName = opts.SSHHost connFlags.SshPort = fmt.Sprintf("%d", opts.SSHPort) - sshKeywords, err := combineSshKeywords(connFlags, sshConfigKeywords) + rawName := opts.String() + savedKeywords, ok := wconfig.ReadFullConfig().Connections[rawName] + if !ok { + savedKeywords = wshrpc.ConnKeywords{} + } + + sshKeywords, err := combineSshKeywords(connFlags, sshConfigKeywords, &savedKeywords) if err != nil { return nil, debugInfo.JumpNum, ConnectionError{ConnectionDebugInfo: debugInfo, Err: err} } @@ -685,7 +692,7 @@ func ConnectToClient(connCtx context.Context, opts *SSHOpts, currentClient *ssh. return client, debugInfo.JumpNum, nil } -func combineSshKeywords(userProvidedOpts *wshrpc.ConnKeywords, configKeywords *wshrpc.ConnKeywords) (*wshrpc.ConnKeywords, error) { +func combineSshKeywords(userProvidedOpts *wshrpc.ConnKeywords, configKeywords *wshrpc.ConnKeywords, savedKeywords *wshrpc.ConnKeywords) (*wshrpc.ConnKeywords, error) { sshKeywords := &wshrpc.ConnKeywords{} if userProvidedOpts.SshUser != "" { @@ -716,7 +723,13 @@ func combineSshKeywords(userProvidedOpts *wshrpc.ConnKeywords, configKeywords *w sshKeywords.SshPort = "22" } - sshKeywords.SshIdentityFile = append(userProvidedOpts.SshIdentityFile, configKeywords.SshIdentityFile...) + // use internal config ones + if savedKeywords != nil { + sshKeywords.SshIdentityFile = append(sshKeywords.SshIdentityFile, savedKeywords.SshIdentityFile...) + } + + sshKeywords.SshIdentityFile = append(sshKeywords.SshIdentityFile, userProvidedOpts.SshIdentityFile...) + sshKeywords.SshIdentityFile = append(sshKeywords.SshIdentityFile, configKeywords.SshIdentityFile...) // these are not officially supported in the waveterm frontend but can be configured // in ssh config files diff --git a/pkg/wconfig/defaultconfig/connections.json b/pkg/wconfig/defaultconfig/connections.json deleted file mode 100644 index 5165dbae8..000000000 --- a/pkg/wconfig/defaultconfig/connections.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "askbeforewshinstall": true -} diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 5733c2a9e..61cddbd2d 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -452,8 +452,16 @@ type CommandRemoteWriteFileData struct { } type ConnKeywords struct { - WshEnabled *bool `json:"wshenabled,omitempty"` - AskBeforeWshInstall *bool `json:"askbeforewshinstall,omitempty"` + ConnWshEnabled *bool `json:"conn:wshenabled,omitempty"` + ConnAskBeforeWshInstall *bool `json:"conn:askbeforewshinstall,omitempty"` + + DisplayHidden *bool `json:"display:hidden,omitempty"` + DisplayOrder float32 `json:"display:order,omitempty"` + + TermClear bool `json:"term:*,omitempty"` + TermFontSize float64 `json:"term:fontsize,omitempty"` + TermFontFamily string `json:"term:fontfamily,omitempty"` + TermTheme string `json:"term:theme,omitempty"` SshUser string `json:"ssh:user,omitempty"` SshHostName string `json:"ssh:hostname,omitempty"`