diff --git a/src/app/app.less b/src/app/app.less index 8f7d71e33..7ada6b379 100644 --- a/src/app/app.less +++ b/src/app/app.less @@ -251,6 +251,13 @@ input[type="checkbox"] { justify-content: center; } +.flex-centered-column { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + a.a-block { display: block; } @@ -786,6 +793,75 @@ a.a-block { margin-top: 6px; } +.conn-dropdown { + width: 412px; + + .lefticon { + position: absolute; + top: 50%; + left: 16px; + transform: translateY(-50%); + + .globe-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + fill: var(--app-text-secondary-color); + } + + .status-icon { + position: absolute; + left: 7px; + top: 8px; + circle { + stroke: var(--app-bg-color); + } + } + } + + .wave-dropdown-display { + bottom: 7px; + } +} + +.tab-colors, +.tab-icons { + display: flex; + flex-direction: row; + align-items: center; + + .tab-color-sep, + .tab-icon-sep { + padding-left: 10px; + padding-right: 10px; + font-weight: bold; + user-select: none; + } + + .tab-color-icon, + .tab-icon-icon { + width: 1.1em; + vertical-align: middle; + } + + .tab-color-name, + .tab-icon-name { + display: inline-block; + margin-left: 1em; + min-width: 70px; + user-select: none; + } + + .tab-color-select, + .tab-icon-select { + cursor: pointer; + margin: 3px; + &:hover { + outline: 2px solid white; + } + } +} + .settings-field { display: flex; flex-direction: row; @@ -847,7 +923,7 @@ a.a-block { } } - input { + input:not(.wave-input) { padding: 4px; border-radius: 3px; } @@ -858,44 +934,6 @@ a.a-block { } } - .tab-colors, - .tab-icons { - display: flex; - flex-direction: row; - align-items: center; - - .tab-color-sep, - .tab-icon-sep { - padding-left: 10px; - padding-right: 10px; - font-weight: bold; - user-select: none; - } - - .tab-color-icon, - .tab-icon-icon { - width: 1.1em; - vertical-align: middle; - } - - .tab-color-name, - .tab-icon-name { - display: inline-block; - margin-left: 1em; - min-width: 70px; - user-select: none; - } - - .tab-color-select, - .tab-icon-select { - cursor: pointer; - margin: 3px; - &:hover { - outline: 2px solid white; - } - } - } - .action-text { margin-left: 20px; diff --git a/src/app/common/elements/textfield.tsx b/src/app/common/elements/textfield.tsx index 8b6e7edaf..960611e84 100644 --- a/src/app/common/elements/textfield.tsx +++ b/src/app/common/elements/textfield.tsx @@ -159,7 +159,9 @@ class TextField extends React.Component { { this.model.setRecentConnAdded(true); this.model.closeModal(); - let crRtn = GlobalCommandRunner.screenSetRemote(cname, true, false); - crRtn.then((crcrtn) => { - if (crcrtn.success) { - return; - } - mobx.action(() => { - this.errorStr.set(crcrtn.error); - })(); - }); + if (GlobalModel.activeMainView.get() == "session") { + let crRtn = GlobalCommandRunner.screenSetRemote(cname, true, true); + crRtn.then((crcrtn) => { + if (crcrtn.success) { + return; + } + mobx.action(() => { + this.errorStr.set(crcrtn.error); + })(); + }); + } return; } mobx.action(() => { diff --git a/src/app/common/modals/screensettings.tsx b/src/app/common/modals/screensettings.tsx index 164009524..aafc2d186 100644 --- a/src/app/common/modals/screensettings.tsx +++ b/src/app/common/modals/screensettings.tsx @@ -8,19 +8,22 @@ import { boundMethod } from "autobind-decorator"; import { For } from "tsx-control-statements/components"; import cn from "classnames"; import { GlobalModel, GlobalCommandRunner, Screen } from "@/models"; -import { Toggle, InlineSettingsTextEdit, SettingsError, Modal, Dropdown, Tooltip } from "@/elements"; +import { SettingsError, Modal, Dropdown, Tooltip } from "@/elements"; import * as util from "@/util/util"; -import { TabIcon, Button } from "@/elements"; +import { Button } from "@/elements"; import { ReactComponent as GlobeIcon } from "@/assets/icons/globe.svg"; import { ReactComponent as StatusCircleIcon } from "@/assets/icons/statuscircle.svg"; -import * as appconst from "@/app/appconst"; +import { + TabColorSelector, + TabIconSelector, + TabNameTextField, + TabRemoteSelector, +} from "@/app/workspace/screen/newtabsettings"; import "./screensettings.less"; const ScreenDeleteMessage = ` Are you sure you want to delete this tab? - -All commands and output will be deleted. To hide the tab, and retain the commands and output, use 'archive'. `.trim(); const WebShareConfirmMarkdown = ` @@ -82,39 +85,6 @@ class ScreenSettingsModal extends React.Component<{}, {}> { GlobalModel.modalsModel.popModal(); } - @boundMethod - selectTabColor(color: string): void { - if (this.screen == null) { - return; - } - if (this.screen.getTabColor() == color) { - return; - } - const prtn = GlobalCommandRunner.screenSetSettings(this.screenId, { tabcolor: color }, false); - util.commandRtnHandler(prtn, this.errorMessage); - } - - @boundMethod - selectTabIcon(icon: string): void { - if (this.screen.getTabIcon() == icon) { - return; - } - const prtn = GlobalCommandRunner.screenSetSettings(this.screen.screenId, { tabicon: icon }, false); - util.commandRtnHandler(prtn, this.errorMessage); - } - - @boundMethod - handleChangeArchived(val: boolean): void { - if (this.screen == null) { - return; - } - if (this.screen.archived.get() == val) { - return; - } - const prtn = GlobalCommandRunner.screenArchive(this.screenId, val); - util.commandRtnHandler(prtn, this.errorMessage); - } - @boundMethod handleChangeWebShare(val: boolean): void { if (this.screen == null) { @@ -154,30 +124,6 @@ class ScreenSettingsModal extends React.Component<{}, {}> { }, 600); } - @boundMethod - inlineUpdateName(val: string): void { - if (this.screen == null) { - return; - } - if (util.isStrEq(val, this.screen.name.get())) { - return; - } - const prtn = GlobalCommandRunner.screenSetSettings(this.screenId, { name: val }, false); - util.commandRtnHandler(prtn, this.errorMessage); - } - - @boundMethod - inlineUpdateShareName(val: string): void { - if (this.screen == null) { - return; - } - if (util.isStrEq(val, this.screen.getShareName())) { - return; - } - const prtn = GlobalCommandRunner.screenSetSettings(this.screenId, { sharename: val }, false); - util.commandRtnHandler(prtn, this.errorMessage); - } - @boundMethod dismissError(): void { mobx.action(() => { @@ -207,119 +153,37 @@ class ScreenSettingsModal extends React.Component<{}, {}> { }); } - @boundMethod - selectRemote(cname: string): void { - let prtn = GlobalCommandRunner.screenSetRemote(cname, true, false); - util.commandRtnHandler(prtn, this.errorMessage); - } - render() { const screen = this.screen; if (screen == null) { return null; } - let color: string = null; - let icon: string = null; - let index: number = 0; - const curRemote = GlobalModel.getRemote(GlobalModel.getActiveScreen().getCurRemoteInstance().remoteid); - return (
-
-
Tab Id
-
{screen.screenId}
-
Name
- +
Connection
- - - -
- ), - }} - /> +
Tab Color
-
-
- -
{screen.getTabColor()}
-
-
|
- -
this.selectTabColor(color)} - > - -
-
-
+
Tab Icon
-
-
- -
{screen.getTabIcon()}
-
-
|
- -
this.selectTabIcon(icon)} - > - -
-
-
-
-
-
-
-
Archived
- } - className="screen-settings-tooltip" - > - - -
-
- +
diff --git a/src/app/common/modals/sessionsettings.tsx b/src/app/common/modals/sessionsettings.tsx index d48fabf14..38d57c76a 100644 --- a/src/app/common/modals/sessionsettings.tsx +++ b/src/app/common/modals/sessionsettings.tsx @@ -14,8 +14,6 @@ import "./sessionsettings.less"; const SessionDeleteMessage = ` Are you sure you want to delete this workspace? - -All commands and output will be deleted. To hide the workspace, and retain the commands and output, use 'archive'. `.trim(); @mobxReact.observer diff --git a/src/app/common/modals/tos.less b/src/app/common/modals/tos.less index e510024c9..2560a17a6 100644 --- a/src/app/common/modals/tos.less +++ b/src/app/common/modals/tos.less @@ -86,7 +86,7 @@ justify-content: center; button { - font-size: 12.5px !important; + font-size: 15px; margin-top: 16px; } diff --git a/src/app/common/modals/tos.tsx b/src/app/common/modals/tos.tsx index 70fbc80fc..bd7beaf02 100644 --- a/src/app/common/modals/tos.tsx +++ b/src/app/common/modals/tos.tsx @@ -20,6 +20,7 @@ class TosModal extends React.Component<{}, {}> { acceptTos(): void { GlobalCommandRunner.clientAcceptTos(); GlobalModel.modalsModel.popModal(); + GlobalCommandRunner.ensureWorkspace(); } @boundMethod @@ -40,32 +41,28 @@ class TosModal extends React.Component<{}, {}> {
Welcome to Wave Terminal!
-
Lets set everything for you
- Privacy + + Github +
-
Telemetry
+
Support us on GitHub
- We only collect minimal anonymous + We're open source and committed to providing a free terminal for + individual users. Please show your support us by giving us a star on{" "} -  telemetry data  + Github (wavetermdev/waveterm) - to help us understand how many people are using Wave. -
-
- -
- Telemetry {cdata.clientopts.notelemetry ? "Disabled" : "Enabled"} -
@@ -94,25 +91,28 @@ class TosModal extends React.Component<{}, {}> {
- - Github - + Privacy
-
Support us on GitHub
+
Telemetry
- We're open source and committed to providing a free terminal for - individual users. Please show your support us by giving us a star on{" "} + We collect minimal anonymous - Github (wavetermdev/waveterm) +  telemetry data  + to help us understand how people are using Wave. +
+
+ +
+ Telemetry {cdata.clientopts.notelemetry ? "Disabled" : "Enabled"} +
@@ -123,7 +123,7 @@ class TosModal extends React.Component<{}, {}> { Terms of Service
- +
diff --git a/src/app/common/prompt/prompt.less b/src/app/common/prompt/prompt.less index 5678baaa1..a56cac4ed 100644 --- a/src/app/common/prompt/prompt.less +++ b/src/app/common/prompt/prompt.less @@ -21,6 +21,10 @@ color: var(--term-bright-green); } + .term-prompt-shellmsg { + color: var(--term-bright-green); + } + .term-prompt-cwd { color: var(--term-bright-green); } @@ -34,6 +38,10 @@ color: var(--term-black); } + .term-prompt-shellmsg { + color: var(--term-green); + } + .term-prompt-remote { color: var(--term-green); } diff --git a/src/app/common/prompt/prompt.tsx b/src/app/common/prompt/prompt.tsx index 04fdb276f..eb1bde89d 100644 --- a/src/app/common/prompt/prompt.tsx +++ b/src/app/common/prompt/prompt.tsx @@ -26,6 +26,20 @@ function makeFullRemoteRef(ownerName: string, remoteRef: string, name: string): return ownerName + ":" + remoteRef + ":" + name; } +function getRemoteStrWithAlias(rptr: RemotePtrType): string { + if (rptr == null || isBlank(rptr.remoteid)) { + return "(null)"; + } + let remote = GlobalModel.getRemote(rptr.remoteid); + if (remote == null) { + return "(invalid)"; + } + if (!isBlank(remote.remotealias)) { + return `${remote.remotealias} (${remote.remotecanonicalname})`; + } + return `${remote.remotecanonicalname}`; +} + function getRemoteStr(rptr: RemotePtrType): string { if (rptr == null || isBlank(rptr.remoteid)) { return "(invalid remote)"; @@ -69,17 +83,16 @@ function getCwdStr(remote: RemoteType, state: Record): string { } @mobxReact.observer -class Prompt extends React.Component<{ rptr: RemotePtrType; festate: Record; color: boolean }, {}> { - render() { +class Prompt extends React.Component< + { rptr: RemotePtrType; festate: Record; color: boolean; shellInitMsg?: string }, + {} +> { + getRemoteElem() { const rptr = this.props.rptr; - if (rptr == null || isBlank(rptr.remoteid)) { - return  ; - } - const remote = GlobalModel.getRemote(this.props.rptr.remoteid); const remoteStr = getRemoteStr(rptr); - const festate = this.props.festate ?? {}; - const cwd = getCwdStr(remote, festate); + let remoteTitle: string = null; let isRoot = false; + let remote = this.getRemote(); if (remote?.remotevars) { if (remote.remotevars["sudo"] || remote.remotevars["bestuser"] == "root") { isRoot = true; @@ -89,11 +102,9 @@ class Prompt extends React.Component<{ rptr: RemotePtrType; festate: Record{cwd}; let remoteElem = null; if (remoteStr != "local") { remoteElem = ( @@ -102,6 +113,36 @@ class Prompt extends React.Component<{ rptr: RemotePtrType; festate: Record ); } + return { remoteElem, isRoot }; + } + + getRemote(): RemoteType { + const remote = GlobalModel.getRemote(this.props.rptr.remoteid); + return remote; + } + + render() { + const rptr = this.props.rptr; + if (rptr == null || isBlank(rptr.remoteid)) { + return  ; + } + let { remoteElem, isRoot } = this.getRemoteElem(); + let termClassNames = cn( + "term-prompt", + { "term-prompt-color": this.props.color }, + { "term-prompt-isroot": isRoot } + ); + if (this.props.shellInitMsg != null) { + return ( + + {remoteElem} {this.props.shellInitMsg} + + ); + } + const festate = this.props.festate ?? {}; + const remote = this.getRemote(); + const cwd = getCwdStr(remote, festate); + const cwdElem = {cwd}; let branchElem = null; let pythonElem = null; let condaElem = null; @@ -131,17 +172,11 @@ class Prompt extends React.Component<{ rptr: RemotePtrType; festate: Record + {remoteElem} {cwdElem} {branchElem} {condaElem} {pythonElem} ); } } -export { Prompt, getRemoteStr }; +export { Prompt, getRemoteStr, getRemoteStrWithAlias }; diff --git a/src/app/workspace/cmdinput/cmdinput.less b/src/app/workspace/cmdinput/cmdinput.less index 8cc099787..973159f73 100644 --- a/src/app/workspace/cmdinput/cmdinput.less +++ b/src/app/workspace/cmdinput/cmdinput.less @@ -24,6 +24,25 @@ height: 31px; } + .cmdinput-conn { + position: absolute; + top: 0; + left: 0; + border-radius: 0 0 4px 0; + background-color: rgba(88, 193, 66, 0.3); + padding: 2px 10px 4px 10px; + font-size: calc(var(--termfontsize)); + cursor: pointer; + + i { + margin-left: 5px; + } + + &:hover { + background-color: rgba(88, 193, 66, 0.5); + } + } + .cmdinput-actions { position: absolute; border-radius: 4px; @@ -138,8 +157,10 @@ color: var(--app-warning-color); align-items: center; - .wave-button { + .wave-button, + .button { margin-left: 10px; + padding: 4px 10px; } } @@ -170,6 +191,7 @@ font-family: var(--termfontfamily); font-size: var(--termfontsize); line-height: var(--termlineheight); + margin-left: 2px; } .cmd-input-filter { diff --git a/src/app/workspace/cmdinput/cmdinput.tsx b/src/app/workspace/cmdinput/cmdinput.tsx index 6f0e40fa8..5588206ed 100644 --- a/src/app/workspace/cmdinput/cmdinput.tsx +++ b/src/app/workspace/cmdinput/cmdinput.tsx @@ -17,6 +17,7 @@ import { HistoryInfo } from "./historyinfo"; import { Prompt } from "@/common/prompt/prompt"; import { CenteredIcon, RotateIcon } from "@/common/icons/icons"; import { AIChat } from "./aichat"; +import * as util from "@/util/util"; import "./cmdinput.less"; @@ -107,6 +108,24 @@ class CmdInput extends React.Component<{}, {}> { GlobalCommandRunner.resetShellState(); } + getRemoteDisplayName(rptr: RemotePtrType): string { + if (rptr == null) { + return "(unknown)"; + } + const remote = GlobalModel.getRemote(rptr.remoteid); + if (remote == null) { + return "(invalid)"; + } + let remoteNamePart = ""; + if (!util.isBlank(rptr.name)) { + remoteNamePart = "#" + rptr.name; + } + if (remote.remotealias) { + return remote.remotealias + remoteNamePart; + } + return remote.remotecanonicalname + remoteNamePart; + } + render() { const model = GlobalModel; const inputModel = model.inputModel; @@ -123,6 +142,9 @@ class CmdInput extends React.Component<{}, {}> { remote = GlobalModel.getRemote(ri.remoteid); feState = ri.festate; } + if (remote == null && rptr != null) { + remote = GlobalModel.getRemote(rptr.remoteid); + } feState = feState || {}; const infoShow = inputModel.infoShow.get(); const historyShow = !infoShow && inputModel.historyShow.get(); @@ -136,6 +158,19 @@ class CmdInput extends React.Component<{}, {}> { if (win != null) { numRunningLines = mobx.computed(() => win.getRunningCmdLines().length).get(); } + let shellInitMsg: string = null; + let hidePrompt = false; + if (ri == null) { + let shellStr = "shell"; + if (!util.isBlank(remote?.defaultshelltype)) { + shellStr = remote.defaultshelltype; + } + if (numRunningLines > 0) { + shellInitMsg = `initializing ${shellStr}...`; + } else { + hidePrompt = true; + } + } return (
{  is {remote.status}
- WARNING:  The shell state for this tab is invalid ( + The shell state for this tab is invalid ( see FAQ ). Must reset to continue. -
- reset shell state -
+ +
+
+ +
+ Shell is not initialized, must reset to continue. +
-
-
- - - + +
+
+ + + +
-
+
{ + GlobalModel.closeTabSettings(); if (GlobalModel.inputModel.isEmpty()) { let activeWindow = GlobalModel.getScreenLinesForActiveScreen(); let activeScreen = GlobalModel.getActiveScreen(); @@ -144,6 +145,7 @@ class CmdInputKeybindings extends React.Component<{ inputObject: TextAreaInput } return true; }); keybindManager.registerKeybinding("pane", "cmdinput", "generic:cancel", (waveEvent) => { + GlobalModel.closeTabSettings(); inputModel.toggleInfoMsg(); if (inputModel.inputMode.get() != null) { inputModel.resetInputMode(); @@ -614,6 +616,15 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: () if (ri != null && ri.shelltype != null) { shellType = ri.shelltype; } + if (shellType == "") { + let rptr = screen.curRemote.get(); + if (rptr != null) { + let remote = GlobalModel.getRemote(rptr.remoteid); + if (remote != null) { + shellType = remote.defaultshelltype; + } + } + } } let isMainInputFocused = this.mainInputFocused.get(); let isHistoryFocused = this.historyFocused.get(); diff --git a/src/app/workspace/screen/newtabsettings.tsx b/src/app/workspace/screen/newtabsettings.tsx new file mode 100644 index 000000000..31c4c3bdc --- /dev/null +++ b/src/app/workspace/screen/newtabsettings.tsx @@ -0,0 +1,230 @@ +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import * as mobx from "mobx"; +import { boundMethod } from "autobind-decorator"; +import { If, For } from "tsx-control-statements/components"; +import cn from "classnames"; +import { GlobalCommandRunner, GlobalModel, Screen } from "@/models"; +import { Button, TextField, Dropdown } from "@/elements"; +import { getRemoteStr, getRemoteStrWithAlias } from "@/common/prompt/prompt"; +import * as util from "@/util/util"; +import { TabIcon } from "@/elements/tabicon"; +import { ReactComponent as EllipseIcon } from "@/assets/icons/ellipse.svg"; +import { ReactComponent as Check12Icon } from "@/assets/icons/check12.svg"; +import { ReactComponent as GlobeIcon } from "@/assets/icons/globe.svg"; +import { ReactComponent as StatusCircleIcon } from "@/assets/icons/statuscircle.svg"; +import * as appconst from "@/app/appconst"; + +import "./screenview.less"; +import "./tabs.less"; + +@mobxReact.observer +class NewTabSettings extends React.Component<{ screen: Screen }, {}> { + errorMessage: OV = mobx.observable.box(null, { name: "NewTabSettings-errorMessage" }); + + constructor(props) { + super(props); + } + + @boundMethod + clickNewConnection(): void { + GlobalModel.remotesModel.openAddModal({ remoteedit: true }); + } + + render() { + let { screen } = this.props; + let rptr = screen.curRemote.get(); + return ( +
+
+ +
+
+
+
+ You're connected to [{getRemoteStrWithAlias(rptr)}]. Do you want to change it? +
+
+ +
+
+ To change connection from the command line use `/connect [alias|user@host]` +
+
+
+
+ +
+
+
+ +
+
+ ); + } +} + +@mobxReact.observer +class TabNameTextField extends React.Component<{ screen: Screen; errorMessage?: OV }, {}> { + @boundMethod + updateName(val: string): void { + let { screen } = this.props; + if (util.isStrEq(val, screen.name.get())) { + return; + } + let prtn = GlobalCommandRunner.screenSetSettings(screen.screenId, { name: val }, false); + util.commandRtnHandler(prtn, this.props.errorMessage); + } + + render() { + let { screen } = this.props; + return ( + + ); + } +} + +@mobxReact.observer +class TabColorSelector extends React.Component<{ screen: Screen; errorMessage?: OV }, {}> { + @boundMethod + selectTabColor(color: string): void { + let { screen } = this.props; + if (screen.getTabColor() == color) { + return; + } + let prtn = GlobalCommandRunner.screenSetSettings(screen.screenId, { tabcolor: color }, false); + util.commandRtnHandler(prtn, this.props.errorMessage); + } + + render() { + let { screen } = this.props; + let curColor = screen.getTabColor(); + if (util.isBlank(curColor) || curColor == "default") { + curColor = "green"; + } + let color: string | null = null; + return ( +
+
+ +
{screen.getTabColor()}
+
+
|
+ +
this.selectTabColor(color)}> + +
+
+
+ ); + } +} + +@mobxReact.observer +class TabIconSelector extends React.Component<{ screen: Screen; errorMessage?: OV }, {}> { + @boundMethod + selectTabIcon(icon: string): void { + let { screen } = this.props; + if (screen.getTabIcon() == icon) { + return; + } + let prtn = GlobalCommandRunner.screenSetSettings(screen.screenId, { tabicon: icon }, false); + util.commandRtnHandler(prtn, this.props.errorMessage); + } + + render() { + let { screen } = this.props; + let curIcon = screen.getTabIcon(); + if (util.isBlank(curIcon) || curIcon == "default") { + curIcon = "square"; + } + let icon: string | null = null; + let curColor = screen.getTabColor(); + return ( +
+
+ +
{screen.getTabIcon()}
+
+
|
+ +
this.selectTabIcon(icon)}> + +
+
+
+ ); + } +} + +@mobxReact.observer +class TabRemoteSelector extends React.Component<{ screen: Screen; errorMessage?: OV }, {}> { + selectedRemoteCN: OV = mobx.observable.box(null, { name: "TabRemoteSelector-selectedRemoteCN" }); + + @boundMethod + selectRemote(cname: string): void { + mobx.action(() => { + this.selectedRemoteCN.set(cname); + })(); + let prtn = GlobalCommandRunner.screenSetRemote(cname, true, true); + util.commandRtnHandler(prtn, this.props.errorMessage); + prtn.then((crtn) => { + GlobalModel.inputModel.giveFocus(); + }); + } + + @boundMethod + getOptions(): DropdownItem[] { + const remotes = GlobalModel.remotes; + const options = remotes + .filter((r) => !r.archived) + .map((remote) => ({ + ...remote, + label: getRemoteStrWithAlias(remote), + value: remote.remotecanonicalname, + })) + .sort((a, b) => { + let connValA = util.getRemoteConnVal(a); + let connValB = util.getRemoteConnVal(b); + if (connValA !== connValB) { + return connValA - connValB; + } + return a.remoteidx - b.remoteidx; + }); + return options; + } + + render() { + const { screen } = this.props; + let selectedRemote = this.selectedRemoteCN.get(); + if (selectedRemote == null) { + const curRI = screen.getCurRemoteInstance(); + if (curRI != null) { + const curRemote = GlobalModel.getRemote(curRI.remoteid); + selectedRemote = curRemote.remotecanonicalname; + } else { + const localRemote = GlobalModel.getLocalRemote(); + selectedRemote = localRemote.remotecanonicalname; + } + } + let curRemote = GlobalModel.getRemoteByName(selectedRemote); + return ( + + + +
+ ), + }} + /> + ); + } +} + +export { NewTabSettings, TabColorSelector, TabIconSelector, TabNameTextField, TabRemoteSelector }; diff --git a/src/app/workspace/screen/screenview.less b/src/app/workspace/screen/screenview.less index c8fdd3c31..c458532a7 100644 --- a/src/app/workspace/screen/screenview.less +++ b/src/app/workspace/screen/screenview.less @@ -171,37 +171,6 @@ gap: 8px; align-self: stretch; - .conn-dropdown { - width: 412px; - - .lefticon { - position: absolute; - top: 50%; - left: 16px; - transform: translateY(-50%); - - .globe-icon { - width: 16px; - height: 16px; - flex-shrink: 0; - fill: var(--app-text-secondary-color); - } - - .status-icon { - position: absolute; - left: 7px; - top: 8px; - circle { - stroke: var(--app-bg-color); - } - } - } - - .wave-dropdown-display { - bottom: 7px; - } - } - &.conn-section { gap: 8px; } diff --git a/src/app/workspace/screen/screenview.tsx b/src/app/workspace/screen/screenview.tsx index 67e7534b1..bd907ca62 100644 --- a/src/app/workspace/screen/screenview.tsx +++ b/src/app/workspace/screen/screenview.tsx @@ -12,18 +12,13 @@ import { debounce } from "throttle-debounce"; import dayjs from "dayjs"; import { GlobalCommandRunner, ForwardLineContainer, GlobalModel, ScreenLines, Screen, Session } from "@/models"; import localizedFormat from "dayjs/plugin/localizedFormat"; -import { Button, TextField, Dropdown } from "@/elements"; -import { getRemoteStr } from "@/common/prompt/prompt"; +import { Button } from "@/elements"; import { Line } from "@/app/line/linecomps"; import { LinesView } from "@/app/line/linesview"; import * as util from "@/util/util"; -import { TabIcon } from "@/elements/tabicon"; -import { ReactComponent as EllipseIcon } from "@/assets/icons/ellipse.svg"; -import { ReactComponent as Check12Icon } from "@/assets/icons/check12.svg"; -import { ReactComponent as GlobeIcon } from "@/assets/icons/globe.svg"; -import { ReactComponent as StatusCircleIcon } from "@/assets/icons/statuscircle.svg"; import * as appconst from "@/app/appconst"; import * as textmeasure from "@/util/textmeasure"; +import { NewTabSettings } from "./newtabsettings"; import "./screenview.less"; import "./tabs.less"; @@ -40,9 +35,16 @@ class ScreenView extends React.Component<{ session: Session; screen: Screen }, { sidebarShowing: OV = mobx.observable.box(false, { name: "screenview-sidebarShowing" }); sidebarShowingTimeoutId: any = null; - constructor(props: any) { + constructor(props: { session: Session; screen: Screen }) { super(props); this.handleResize_debounced = debounce(100, this.handleResize.bind(this)); + let screen = this.props.screen; + let hasSidebar = false; + if (screen != null) { + let viewOpts = screen.viewOpts.get(); + hasSidebar = viewOpts?.sidebar?.open; + } + this.sidebarShowing = mobx.observable.box(hasSidebar, { name: "screenview-sidebarShowing" }); } componentDidMount(): void { @@ -53,15 +55,13 @@ class ScreenView extends React.Component<{ session: Session; screen: Screen }, { this.rszObs.observe(elem); this.handleResize(); } - let viewOpts = screen.viewOpts.get(); - let hasSidebar = viewOpts?.sidebar?.open; - if (hasSidebar) { - mobx.action(() => this.sidebarShowing.set(true))(); - } } componentDidUpdate(): void { let { screen } = this.props; + if (screen == null) { + return; + } let viewOpts = screen.viewOpts.get(); let hasSidebar = viewOpts?.sidebar?.open; if (hasSidebar && !this.sidebarShowing.get()) { @@ -96,19 +96,62 @@ class ScreenView extends React.Component<{ session: Session; screen: Screen }, { })(); } + @boundMethod + createWorkspace() { + GlobalCommandRunner.createNewSession(); + } + + @boundMethod + createTab() { + GlobalCommandRunner.createNewScreen(); + } + render() { let { session, screen } = this.props; - if (screen == null) { - return ( -
- (no screen found) -
- ); - } let screenWidth = this.width.get(); if (screenWidth == null) { return
; } + if (session == null) { + let sessionCount = GlobalModel.sessionList.length; + return ( +
+
+
+
+
+ [no workspace] + + + +
+
+
+
+ ); + } + if (screen == null) { + let screens = GlobalModel.getSessionScreens(session.sessionId); + return ( +
+
+
+
+
+ [no active tab] + + + +
+
+
+
+ ); + } let fontSize = GlobalModel.getTermFontSize(); let dprStr = sprintf("%0.3f", GlobalModel.devicePixelRatio.get()); let viewOpts = screen.viewOpts.get(); @@ -339,193 +382,6 @@ class ScreenSidebar extends React.Component<{ screen: Screen; width: string }, { } } -@mobxReact.observer -class NewTabSettings extends React.Component<{ screen: Screen }, {}> { - connDropdownActive: OV = mobx.observable.box(false, { name: "NewTabSettings-connDropdownActive" }); - errorMessage: OV = mobx.observable.box(null, { name: "NewTabSettings-errorMessage" }); - remotes: RemoteType[]; - - constructor(props) { - super(props); - this.remotes = GlobalModel.remotes; - } - - @boundMethod - selectTabColor(color: string): void { - let { screen } = this.props; - if (screen.getTabColor() == color) { - return; - } - let prtn = GlobalCommandRunner.screenSetSettings(screen.screenId, { tabcolor: color }, false); - util.commandRtnHandler(prtn, this.errorMessage); - } - - @boundMethod - selectTabIcon(icon: string): void { - let { screen } = this.props; - if (screen.getTabIcon() == icon) { - return; - } - let prtn = GlobalCommandRunner.screenSetSettings(screen.screenId, { tabicon: icon }, false); - util.commandRtnHandler(prtn, this.errorMessage); - } - - @boundMethod - updateName(val: string): void { - let { screen } = this.props; - let prtn = GlobalCommandRunner.screenSetSettings(screen.screenId, { name: val }, false); - util.commandRtnHandler(prtn, this.errorMessage); - } - - @boundMethod - toggleConnDropdown(): void { - mobx.action(() => { - this.connDropdownActive.set(!this.connDropdownActive.get()); - })(); - } - - @boundMethod - selectRemote(cname: string): void { - let prtn = GlobalCommandRunner.screenSetRemote(cname, true, false); - util.commandRtnHandler(prtn, this.errorMessage); - } - - @boundMethod - clickNewConnection(): void { - GlobalModel.remotesModel.openAddModal({ remoteedit: true }); - } - - @boundMethod - getOptions(): { label: string; value: string }[] { - return this.remotes - .filter((r) => !r.archived) - .map((remote) => ({ - ...remote, - label: !util.isBlank(remote.remotealias) - ? `${remote.remotealias} - ${remote.remotecanonicalname}` - : remote.remotecanonicalname, - value: remote.remotecanonicalname, - })) - .sort((a, b) => { - let connValA = util.getRemoteConnVal(a); - let connValB = util.getRemoteConnVal(b); - if (connValA !== connValB) { - return connValA - connValB; - } - return a.remoteidx - b.remoteidx; - }); - } - - renderTabIconSelector(): React.ReactNode { - let { screen } = this.props; - let curIcon = screen.getTabIcon(); - if (util.isBlank(curIcon) || curIcon == "default") { - curIcon = "square"; - } - let icon: string | null = null; - let curColor = screen.getTabColor(); - return ( - <> -
Tab Icon:
-
- -
this.selectTabIcon(icon || "")} - > - -
-
-
- - ); - } - - renderTabColorSelector(): React.ReactNode { - let { screen } = this.props; - let curColor = screen.getTabColor(); - if (util.isBlank(curColor) || curColor == "default") { - curColor = "green"; - } - let color: string | null = null; - - return ( - <> -
Tab Color:
-
- -
this.selectTabColor(color || "")} - > - - - - -
-
-
- - ); - } - - render() { - let { screen } = this.props; - let rptr = screen.curRemote.get(); - let curRemote = GlobalModel.getRemote(GlobalModel.getActiveScreen().getCurRemoteInstance().remoteid); - - return ( -
-
- -
-
-
-
- You're connected to [{getRemoteStr(rptr)}]. Do you want to change it? -
-
- - - -
- ), - }} - /> -
-
- To change connection from the command line use `cr [alias|user@host]` -
-
-
-
-
{this.renderTabIconSelector()}
-
-
-
-
{this.renderTabColorSelector()}
-
-
- ); - } -} - // screen is not null @mobxReact.observer class ScreenWindowView extends React.Component<{ session: Session; screen: Screen; width: string }, {}> { @@ -561,6 +417,7 @@ class ScreenWindowView extends React.Component<{ session: Session; screen: Scree } componentDidMount() { + const { screen } = this.props; let wvElem = this.windowViewRef.current; if (wvElem != null) { let width = wvElem.offsetWidth; @@ -569,6 +426,12 @@ class ScreenWindowView extends React.Component<{ session: Session; screen: Scree this.rszObs = new ResizeObserver(this.handleResize.bind(this)); this.rszObs.observe(wvElem); } + if (screen.isNew) { + screen.isNew = false; + mobx.action(() => { + GlobalModel.tabSettingsOpen.set(true); + })(); + } } componentWillUnmount() { @@ -695,7 +558,7 @@ class ScreenWindowView extends React.Component<{ session: Session; screen: Scree return (
- + diff --git a/src/app/workspace/screen/tab.tsx b/src/app/workspace/screen/tab.tsx index ada8d266a..84f7c5be4 100644 --- a/src/app/workspace/screen/tab.tsx +++ b/src/app/workspace/screen/tab.tsx @@ -53,9 +53,8 @@ class ScreenTab extends React.Component< e.preventDefault(); e.stopPropagation(); mobx.action(() => { - GlobalModel.screenSettingsModal.set({ sessionId: screen.sessionId, screenId: screen.screenId }); + GlobalModel.tabSettingsOpen.set(!GlobalModel.tabSettingsOpen.get()); })(); - GlobalModel.modalsModel.pushModal(constants.SCREEN_SETTINGS); } render() { diff --git a/src/app/workspace/screen/tabs.less b/src/app/workspace/screen/tabs.less index 4785ee9fd..865141b2c 100644 --- a/src/app/workspace/screen/tabs.less +++ b/src/app/workspace/screen/tabs.less @@ -112,6 +112,27 @@ opacity: 1; font-weight: var(--screentabs-selected-font-weight); border-top: 2px solid var(--tab-color); + + .screen-tab-inner { + cursor: default; + } + } + + &:not(.is-active) .status-indicator { + .status-indicator-visible; + } + + &.is-active:not(:hover) .status-indicator { + .status-indicator-visible; + } + + &.is-active:hover .actions { + .positional-icon-visible; + + &:hover { + background-color: var(--app-selected-mask-color); + border-radius: 4px; + } } &.is-archived { @@ -148,20 +169,11 @@ } } } + .vertical-line { border-left: 1px solid var(--app-border-color); margin: 10px 0 8px 0; } - - &:not(:hover) .status-indicator { - .status-indicator-visible; - } - - &:hover { - .actions { - .positional-icon-visible; - } - } } } diff --git a/src/app/workspace/workspace.less b/src/app/workspace/workspace.less index c96d83697..d6c12e58e 100644 --- a/src/app/workspace/workspace.less +++ b/src/app/workspace/workspace.less @@ -23,4 +23,34 @@ width: 100%; color: var(--app-text-secondary-color); } + + .tab-settings-pulldown { + position: absolute; + top: var(--screentabs-height); + width: 100%; + height: 330px; + transition: height 0.2s ease-in-out; + overflow: hidden; + z-index: 11; + border-bottom: 3px solid var(--app-border-color); + background-color: var(--app-panel-bg-color); + border-radius: 0 0 5px 5px; + + &.closed { + height: 0; + border-bottom: none; + } + + .close-icon { + position: absolute; + right: 10px; + top: 10px; + cursor: pointer; + padding: 5px; + border-radius: 4px; + &:hover { + background-color: var(--app-selected-mask-color); + } + } + } } diff --git a/src/app/workspace/workspaceview.tsx b/src/app/workspace/workspaceview.tsx index c73b8eea9..78eddd80b 100644 --- a/src/app/workspace/workspaceview.tsx +++ b/src/app/workspace/workspaceview.tsx @@ -15,6 +15,10 @@ import { ScreenTabs } from "./screen/tabs"; import { ErrorBoundary } from "@/common/error/errorboundary"; import * as textmeasure from "@/util/textmeasure"; import "./workspace.less"; +import { boundMethod } from "autobind-decorator"; +import type { Screen } from "@/models"; +import { getRemoteStr, getRemoteStrWithAlias } from "@/common/prompt/prompt"; +import { TabColorSelector, TabIconSelector, TabNameTextField, TabRemoteSelector } from "./screen/newtabsettings"; dayjs.extend(localizedFormat); @@ -79,43 +83,115 @@ class SessionKeybindings extends React.Component<{}, {}> { } @mobxReact.observer -class WorkspaceView extends React.Component<{}, {}> { +class TabSettingsPulldownKeybindings extends React.Component<{}, {}> { + componentDidMount() { + let keybindManager = GlobalModel.keybindManager; + keybindManager.registerKeybinding("pane", "tabsettings", "generic:cancel", (waveEvent) => { + GlobalModel.closeTabSettings(); + return true; + }); + } + + componentWillUnmount() { + GlobalModel.keybindManager.unregisterDomain("tabsettings"); + } + render() { - let model = GlobalModel; - let session = model.getActiveSession(); - if (session == null) { - return ( -
-
-
(no active workspace)
+ return null; + } +} + +@mobxReact.observer +class TabSettings extends React.Component<{ screen: Screen }, {}> { + errorMessage: OV = mobx.observable.box(null, { name: "TabSettings-errorMessage" }); + + render() { + let { screen } = this.props; + let rptr = screen.curRemote.get(); + return ( +
+
+ +
+
+
+
+ You're connected to "{getRemoteStrWithAlias(rptr)}". Do you want to change it? +
+
+ +
+
+ To change connection from the command line use `cr [alias|user@host]`
- ); +
+
+ +
+
+
+ +
+
+ ); + } +} + +@mobxReact.observer +class WorkspaceView extends React.Component<{}, {}> { + @boundMethod + toggleTabSettings() { + mobx.action(() => { + GlobalModel.tabSettingsOpen.set(!GlobalModel.tabSettingsOpen.get()); + })(); + } + + render() { + const model = GlobalModel; + const session = model.getActiveSession(); + let activeScreen: Screen = null; + let sessionId: string = "none"; + if (session != null) { + sessionId = session.sessionId; + activeScreen = session.getActiveScreen(); } - let activeScreen = session.getActiveScreen(); let cmdInputHeight = model.inputModel.cmdInputHeight.get(); if (cmdInputHeight == 0) { cmdInputHeight = textmeasure.baseCmdInputHeight(GlobalModel.lineHeightEnv); // this is the base size of cmdInput (measured using devtools) } - let isHidden = GlobalModel.activeMainView.get() != "session"; - let mainSidebarModel = GlobalModel.mainSidebarModel; - + const isHidden = GlobalModel.activeMainView.get() != "session"; + const mainSidebarModel = GlobalModel.mainSidebarModel; + const showTabSettings = GlobalModel.tabSettingsOpen.get(); return (
- + - - - + + +
+
+ +
+ + + + +
+
+ +
- + + +
); diff --git a/src/models/commandrunner.ts b/src/models/commandrunner.ts index 749bfaa92..d2fbdf7c9 100644 --- a/src/models/commandrunner.ts +++ b/src/models/commandrunner.ts @@ -94,6 +94,10 @@ class CommandRunner { return GlobalModel.submitCommand("line", "set", [lineArg], kwargs, false); } + ensureWorkspace() { + GlobalModel.submitCommand("session", "ensureone", null, { nohist: "1" }, true); + } + createNewSession() { GlobalModel.submitCommand("session", "open", null, { nohist: "1" }, false); } diff --git a/src/models/model.ts b/src/models/model.ts index 557a47fc7..b5f6d82fc 100644 --- a/src/models/model.ts +++ b/src/models/model.ts @@ -104,6 +104,9 @@ class Model { devicePixelRatio: OV = mobx.observable.box(window.devicePixelRatio, { name: "devicePixelRatio", }); + tabSettingsOpen: OV = mobx.observable.box(false, { + name: "tabSettingsOpen", + }); remotesModel: RemotesModel; lineHeightEnv: LineHeightEnv; @@ -251,6 +254,14 @@ class Model { return (window as any).GlobalModel; } + closeTabSettings() { + if (this.tabSettingsOpen.get()) { + mobx.action(() => { + this.tabSettingsOpen.set(false); + })(); + } + } + toggleDevUI(): void { document.body.classList.toggle("is-dev"); } @@ -519,7 +530,7 @@ class Model { return; } const rtnp = this.showAlert({ - message: "Are you sure you want to delete this screen?", + message: "Are you sure you want to delete this tab?", confirm: true, }); rtnp.then((result) => { @@ -586,7 +597,7 @@ class Model { getLocalRemote(): RemoteType { for (const remote of this.remotes) { - if (remote.local) { + if (remote.local && !remote.issudo) { return remote; } } @@ -811,6 +822,12 @@ class Model { } } + markScreensAsNotNew(): void { + for (const screen of this.screenMap.values()) { + screen.isNew = false; + } + } + updateSessions(sessions: SessionDataType[]): void { genMergeData( this.sessionList, @@ -864,6 +881,7 @@ class Model { if (update.connect.screens != null) { this.screenMap.clear(); this.updateScreens(update.connect.screens); + this.markScreensAsNotNew(); } if (update.connect.sessions != null) { this.sessionList.clear(); @@ -955,6 +973,8 @@ class Model { } else if (update.userinputrequest != null) { const userInputRequest: UserInputRequest = update.userinputrequest; this.modalsModel.pushModal(appconst.USER_INPUT, userInputRequest); + } else if (update.sessiontombstone != null || update.screentombstone != null) { + // nothing (ignore) } else { // interactive-only updates follow below // we check interactive *inside* of the conditions because of isDev console.log message @@ -995,9 +1015,13 @@ class Model { this.activeMainView.set("session"); this.deactivateScreenLines(); this.ws.watchScreen(newActiveSessionId, newActiveScreenId); - setTimeout(() => { - GlobalCommandRunner.syncShellState(); - }, 100); + this.closeTabSettings(); + const activeScreen = this.getActiveScreen(); + if (activeScreen != null && activeScreen.getCurRemoteInstance() != null) { + setTimeout(() => { + GlobalCommandRunner.syncShellState(); + }, 100); + } } } else { console.warn("unknown update", genUpdate); diff --git a/src/models/screen.ts b/src/models/screen.ts index bc2360a82..b1ab54527 100644 --- a/src/models/screen.ts +++ b/src/models/screen.ts @@ -44,6 +44,7 @@ class Screen { filterRunning: OV; statusIndicator: OV; numRunningCmds: OV; + isNew: boolean; // used for showing screen settings on initial screen creation constructor(sdata: ScreenDataType, globalModel: Model) { this.globalModel = globalModel; @@ -91,6 +92,7 @@ class Screen { this.numRunningCmds = mobx.observable.box(0, { name: "screen-num-running-cmds", }); + this.isNew = true; } dispose() {} @@ -469,7 +471,7 @@ class Screen { this.renderers[lineId] = renderer; } - setLineFocus(lineNum: number, lineid: string, focus: boolean): void { + setLineFocus(lineNum: number, focus: boolean): void { mobx.action(() => this.termLineNumFocus.set(focus ? lineNum : 0))(); if (focus && this.selectedLine.get() != lineNum) { GlobalCommandRunner.screenSelectLine(String(lineNum), "cmd"); @@ -525,7 +527,7 @@ class Screen { termOpts: cmd.getTermOpts(), winSize: { height: 0, width: width }, dataHandler: cmd.handleData.bind(cmd), - focusHandler: (focus: boolean) => this.setLineFocus(line.linenum, line.lineid, focus), + focusHandler: (focus: boolean) => this.setLineFocus(line.linenum, focus), isRunning: cmd.isRunning(), customKeyHandler: this.termCustomKeyHandler.bind(this), fontSize: this.globalModel.getTermFontSize(), diff --git a/src/models/session.ts b/src/models/session.ts index 6da101002..3dca33106 100644 --- a/src/models/session.ts +++ b/src/models/session.ts @@ -95,19 +95,6 @@ class Session { return rdata; } } - let remote = this.globalModel.getRemote(rptr.remoteid); - if (remote != null) { - return { - riid: "", - sessionid: this.sessionId, - screenid: screenId, - remoteownerid: rptr.ownerid, - remoteid: rptr.remoteid, - name: rptr.name, - festate: remote.defaultfestate, - shelltype: remote.defaultshelltype, - }; - } return null; } } diff --git a/src/plugins/code/readme.md b/src/plugins/code/readme.md index 8319f3381..b427f2ff7 100644 --- a/src/plugins/code/readme.md +++ b/src/plugins/code/readme.md @@ -1,8 +1 @@ -# Code Editor for Wave Terminal - -These instructions are for setting up the build on MacOS. -If you're developing on Linux please use the [Linux Build Instructions](./build-linux.md). - -## Running the Development Version of Wave - -If you install the production version of Wave, you'll see a semi-transparent sidebar, and the data for Wave is stored in the directory ~/prompt. The development version has a red/brown sidebar and stores its data in ~/prompt-dev. This allows the production and development versions to be run simultaneously with no conflicts. If the dev database is corrupted by development bugs, or the schema changes in development it will not affect the production copy. +# CodeEdit diff --git a/src/types/custom.d.ts b/src/types/custom.d.ts index acb927a2f..e013ea7a0 100644 --- a/src/types/custom.d.ts +++ b/src/types/custom.d.ts @@ -112,7 +112,6 @@ declare global { errorstr: string; installstatus: string; installerrorstr: string; - defaultfestate: Record; connectmode: string; autoinstall: boolean; remoteidx: number; @@ -126,6 +125,7 @@ declare global { waitingforpassword: boolean; remoteopts?: RemoteOptsType; local: boolean; + issudo: boolean; remove?: boolean; shellpref: string; defaultshelltype: string; @@ -366,6 +366,8 @@ declare global { screenstatusindicator?: ScreenStatusIndicatorUpdateType; screennumrunningcommands?: ScreenNumRunningCommandsUpdateType; userinputrequest?: UserInputRequest; + screentombstone?: any; + sessiontombstone?: any; }; type HistoryViewDataType = { diff --git a/src/util/util.ts b/src/util/util.ts index e91d47c73..d3822a6f4 100644 --- a/src/util/util.ts +++ b/src/util/util.ts @@ -365,9 +365,11 @@ function commandRtnHandler(prtn: Promise, errorMessage: OV { - errorMessage.set(crtn.error); - })(); + if (errorMessage != null) { + mobx.action(() => { + errorMessage.set(crtn.error); + })(); + } }); } diff --git a/waveshell/pkg/packet/packet.go b/waveshell/pkg/packet/packet.go index b081368f9..cf6e39bab 100644 --- a/waveshell/pkg/packet/packet.go +++ b/waveshell/pkg/packet/packet.go @@ -76,7 +76,8 @@ const ( ) const ( - EC_InvalidCwd = "ERRCWD" + EC_InvalidCwd = "ERRCWD" + EC_CmdNotRunning = "CMDNOTRUNNING" ) const PacketSenderQueueSize = 20 diff --git a/waveshell/pkg/server/server.go b/waveshell/pkg/server/server.go index 39ee26b4e..edb13b295 100644 --- a/waveshell/pkg/server/server.go +++ b/waveshell/pkg/server/server.go @@ -353,7 +353,9 @@ func (m *MServer) MakeShellStatePacket(reqId string, shellType string, stdinData return nil, err } rtnCh := make(chan shellapi.ShellStateOutput, 1) - go sapi.GetShellState(rtnCh, stdinDataCh) + ctx, cancelFn := context.WithCancel(context.Background()) + defer cancelFn() + go sapi.GetShellState(ctx, rtnCh, stdinDataCh) for ssOutput := range rtnCh { if ssOutput.Error != "" { return nil, errors.New(ssOutput.Error) @@ -746,11 +748,6 @@ func (m *MServer) runCommand(runPacket *packet.RunPacketType) { m.Sender.SendErrorResponse(runPacket.ReqId, fmt.Errorf("server run packets require shell type")) return } - _, curInitState := m.StateMap.GetCurrentState(runPacket.ShellType) - if curInitState == nil { - m.Sender.SendErrorResponse(runPacket.ReqId, fmt.Errorf("shell type %q is not initialized", runPacket.ShellType)) - return - } if runPacket.State == nil { m.Sender.SendErrorResponse(runPacket.ReqId, fmt.Errorf("server run packets require state")) return @@ -760,10 +757,6 @@ func (m *MServer) runCommand(runPacket *packet.RunPacketType) { m.Sender.SendErrorResponse(runPacket.ReqId, fmt.Errorf("invalid shellstate version: %w", err)) return } - if !packet.StateVersionsCompatible(runPacket.State.Version, curInitState.Version) { - m.Sender.SendErrorResponse(runPacket.ReqId, fmt.Errorf("shellstate version %q is not compatible with current shell version %q", runPacket.State.Version, curInitState.Version)) - return - } ecmd, err := shexec.MakeMShellSingleCmd() if err != nil { m.Sender.SendErrorResponse(runPacket.ReqId, fmt.Errorf("server run packets require valid ck: %s", err)) diff --git a/waveshell/pkg/shellapi/bashapi.go b/waveshell/pkg/shellapi/bashapi.go index 96efb7948..d2cbedfa0 100644 --- a/waveshell/pkg/shellapi/bashapi.go +++ b/waveshell/pkg/shellapi/bashapi.go @@ -80,8 +80,8 @@ func (b bashShellApi) MakeShExecCommand(cmdStr string, rcFileName string, usePty return MakeBashShExecCommand(cmdStr, rcFileName, usePty) } -func (b bashShellApi) GetShellState(outCh chan ShellStateOutput, stdinDataCh chan []byte) { - GetBashShellState(outCh, stdinDataCh) +func (b bashShellApi) GetShellState(ctx context.Context, outCh chan ShellStateOutput, stdinDataCh chan []byte) { + GetBashShellState(ctx, outCh, stdinDataCh) } func (b bashShellApi) GetBaseShellOpts() string { @@ -146,7 +146,7 @@ printf "[%ENDBYTES%]"; } func execGetLocalBashShellVersion() string { - ctx, cancelFn := context.WithTimeout(context.Background(), GetStateTimeout) + ctx, cancelFn := context.WithTimeout(context.Background(), GetVersionTimeout) defer cancelFn() ecmd := exec.CommandContext(ctx, "bash", "-c", BashShellVersionCmdStr) out, err := ecmd.Output() @@ -169,9 +169,7 @@ func GetLocalBashMajorVersion() string { return localBashMajorVersion } -func GetBashShellState(outCh chan ShellStateOutput, stdinDataCh chan []byte) { - ctx, cancelFn := context.WithTimeout(context.Background(), GetStateTimeout) - defer cancelFn() +func GetBashShellState(ctx context.Context, outCh chan ShellStateOutput, stdinDataCh chan []byte) { defer close(outCh) stateCmd, endBytes := GetBashShellStateCmd(StateOutputFdNum) cmdStr := BaseBashOpts + "; " + stateCmd diff --git a/waveshell/pkg/shellapi/shellapi.go b/waveshell/pkg/shellapi/shellapi.go index 3b28a7ee2..aff1ca1ab 100644 --- a/waveshell/pkg/shellapi/shellapi.go +++ b/waveshell/pkg/shellapi/shellapi.go @@ -28,8 +28,7 @@ import ( "github.com/wavetermdev/waveterm/waveshell/pkg/wlog" ) -const GetStateTimeout = 10 * time.Second -const ReInitTimeout = GetStateTimeout + 2*time.Second +const GetVersionTimeout = 5 * time.Second const GetGitBranchCmdStr = `printf "GITBRANCH %s\x00" "$(git rev-parse --abbrev-ref HEAD 2>/dev/null)"` const GetK8sContextCmdStr = `printf "K8SCONTEXT %s\x00" "$(kubectl config current-context 2>/dev/null)"` const GetK8sNamespaceCmdStr = `printf "K8SNAMESPACE %s\x00" "$(kubectl config view --minify --output 'jsonpath={..namespace}' 2>/dev/null)"` @@ -73,7 +72,7 @@ type ShellApi interface { GetRemoteShellPath() string MakeRunCommand(cmdStr string, opts RunCommandOpts) string MakeShExecCommand(cmdStr string, rcFileName string, usePty bool) *exec.Cmd - GetShellState(outCh chan ShellStateOutput, stdinDataCh chan []byte) + GetShellState(ctx context.Context, outCh chan ShellStateOutput, stdinDataCh chan []byte) GetBaseShellOpts() string ParseShellStateOutput(output []byte) (*packet.ShellState, *packet.ShellStateStats, error) MakeRcFileStr(pk *packet.RunPacketType) string diff --git a/waveshell/pkg/shellapi/zshapi.go b/waveshell/pkg/shellapi/zshapi.go index 43bfeed0f..71cbdf4b4 100644 --- a/waveshell/pkg/shellapi/zshapi.go +++ b/waveshell/pkg/shellapi/zshapi.go @@ -246,9 +246,7 @@ func (z zshShellApi) MakeShExecCommand(cmdStr string, rcFileName string, usePty return exec.Command(GetLocalZshPath(), "-l", "-i", "-c", cmdStr) } -func (z zshShellApi) GetShellState(outCh chan ShellStateOutput, stdinDataCh chan []byte) { - ctx, cancelFn := context.WithTimeout(context.Background(), GetStateTimeout) - defer cancelFn() +func (z zshShellApi) GetShellState(ctx context.Context, outCh chan ShellStateOutput, stdinDataCh chan []byte) { defer close(outCh) stateCmd, endBytes := GetZshShellStateCmd(StateOutputFdNum) cmdStr := BaseZshOpts + "; " + stateCmd @@ -547,7 +545,7 @@ zshexit () { } func execGetLocalZshShellVersion() string { - ctx, cancelFn := context.WithTimeout(context.Background(), GetStateTimeout) + ctx, cancelFn := context.WithTimeout(context.Background(), GetVersionTimeout) defer cancelFn() ecmd := exec.CommandContext(ctx, "zsh", "-c", ZshShellVersionCmdStr) out, err := ecmd.Output() diff --git a/waveshell/pkg/utilfn/ansi.go b/waveshell/pkg/utilfn/ansi.go new file mode 100644 index 000000000..276989816 --- /dev/null +++ b/waveshell/pkg/utilfn/ansi.go @@ -0,0 +1,12 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package utilfn + +func AnsiResetColor() string { + return "\033[0m" +} + +func AnsiGreenColor() string { + return "\033[32m" +} diff --git a/wavesrv/cmd/main-server.go b/wavesrv/cmd/main-server.go index 5abf25f2a..e2271aa4c 100644 --- a/wavesrv/cmd/main-server.go +++ b/wavesrv/cmd/main-server.go @@ -978,11 +978,6 @@ func main() { log.Printf("[error] ensuring local remote: %v\n", err) return } - err = sstore.EnsureOneSession(context.Background()) - if err != nil { - log.Printf("[error] ensuring default session: %v\n", err) - return - } err = remote.LoadRemotes(context.Background()) if err != nil { log.Printf("[error] loading remotes: %v\n", err) diff --git a/wavesrv/pkg/cmdrunner/cmdrunner.go b/wavesrv/pkg/cmdrunner/cmdrunner.go index d57a6e1f6..a6a364a7d 100644 --- a/wavesrv/pkg/cmdrunner/cmdrunner.go +++ b/wavesrv/pkg/cmdrunner/cmdrunner.go @@ -30,7 +30,6 @@ import ( "github.com/wavetermdev/waveterm/waveshell/pkg/base" "github.com/wavetermdev/waveterm/waveshell/pkg/packet" "github.com/wavetermdev/waveterm/waveshell/pkg/server" - "github.com/wavetermdev/waveterm/waveshell/pkg/shellapi" "github.com/wavetermdev/waveterm/waveshell/pkg/shellenv" "github.com/wavetermdev/waveterm/waveshell/pkg/shellutil" "github.com/wavetermdev/waveterm/waveshell/pkg/shexec" @@ -96,6 +95,7 @@ const ( KwArgTemplate = "template" KwArgLang = "lang" KwArgMinimap = "minimap" + KwArgNoHist = "nohist" ) var ColorNames = []string{"yellow", "blue", "pink", "mint", "cyan", "violet", "orange", "green", "red", "white"} @@ -190,6 +190,7 @@ func init() { registerCmdFn("session:showall", SessionShowAllCommand) registerCmdFn("session:show", SessionShowCommand) registerCmdFn("session:openshared", SessionOpenSharedCommand) + registerCmdFn("session:ensureone", SessionEnsureOneCommand) registerCmdFn("screen", ScreenCommand) registerCmdFn("screen:archive", ScreenArchiveCommand) @@ -364,6 +365,20 @@ func resolveCommaSepListToMap(arg string) map[string]bool { return rtn } +func resolveShellType(shellArg string, defaultShell string) (string, error) { + if shellArg == "" { + if defaultShell == "" { + shellArg = packet.ShellType_bash + } else { + shellArg = defaultShell + } + } + if shellArg != packet.ShellType_bash && shellArg != packet.ShellType_zsh { + return "", fmt.Errorf("invalid shell type %q", shellArg) + } + return shellArg, nil +} + func resolveBool(arg string, def bool) bool { if arg == "" { return def @@ -723,7 +738,7 @@ func EvalCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.U } else { return nil, fmt.Errorf("error in Eval Meta Command: %w", rtnErr) } - if !resolveBool(pk.Kwargs["nohist"], false) { + if !resolveBool(pk.Kwargs[KwArgNoHist], false) { // TODO should this be "pk" or "newPk" (2nd arg) err := addToHistory(ctx, pk, historyContext, (newPk.MetaCmd != "run"), (rtnErr != nil)) if err != nil { @@ -816,6 +831,14 @@ func ScreenDeleteCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) if screenId == "" { return nil, fmt.Errorf("/screen:delete no active screen or screen arg passed") } + runningCmds, err := sstore.GetRunningScreenCmds(ctx, screenId) + if err != nil { + return nil, fmt.Errorf("/screen:delete cannot get running cmds: %v", err) + } + for _, runningCmd := range runningCmds { + // send SIGHUP to all running commands in this screen + remote.SendSignalToCmd(ctx, runningCmd, "SIGHUP") + } update, err := sstore.DeleteScreen(ctx, screenId, false, nil) if err != nil { return nil, err @@ -826,7 +849,7 @@ func ScreenDeleteCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) func ScreenOpenCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.UpdatePacket, error) { ids, err := resolveUiIds(ctx, pk, R_Session) if err != nil { - return nil, fmt.Errorf("/screen:open cannot open screen: %w", err) + return nil, err } activate := resolveBool(pk.Kwargs["activate"], true) newName := pk.Kwargs["name"] @@ -836,13 +859,37 @@ func ScreenOpenCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (s return nil, err } } - update, err := sstore.InsertScreen(ctx, ids.SessionId, newName, sstore.ScreenCreateOpts{}, activate) + sco := sstore.ScreenCreateOpts{RtnScreenId: new(string)} + update, err := sstore.InsertScreen(ctx, ids.SessionId, newName, sco, activate) if err != nil { return nil, err } + if sco.RtnScreenId == nil { + return nil, fmt.Errorf("error creating tab, no tab id returned") + } + uiContextCopy := *pk.UIContext + uiContextCopy.ScreenId = *sco.RtnScreenId + crUpdate, err := doNewTabConnectLocal(ctx, *sco.RtnScreenId, &uiContextCopy) + if err != nil { + return nil, err + } + update.Merge(crUpdate) return update, nil } +func doNewTabConnectLocal(ctx context.Context, screenId string, uiContext *scpacket.UIContextType) (scbus.UpdatePacket, error) { + crPk := scpacket.MakeFeCommandPacket() + crPk.MetaCmd = "connect" + crPk.Args = []string{"local"} + crPk.RawStr = "/connect local" + crPk.UIContext = uiContext + crUpdate, err := CrCommand(ctx, crPk) + if err != nil { + return nil, fmt.Errorf("error creating tab, cannot connect to remote: %w", err) + } + return crUpdate, nil +} + func ScreenReorderCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.UpdatePacket, error) { // Resolve the UI IDs for the session and screen ids, err := resolveUiIds(ctx, pk, R_Session|R_Screen) @@ -1666,7 +1713,7 @@ func CopyFileCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scb return nil, fmt.Errorf("cannot make termopts: %w", err) } pkTermOpts := convertTermOpts(termOpts) - cmd, err := makeDynCmd(ctx, "copy file", ids, pk.GetRawStr(), *pkTermOpts) + cmd, err := makeDynCmd(ctx, "copy file", ids, pk.GetRawStr(), *pkTermOpts, nil) writeStringToPty(ctx, cmd, outputStr, &outputPos) if err != nil { // TODO tricky error since the command was a success, but we can't show the output @@ -2462,10 +2509,16 @@ func crShowCommand(ctx context.Context, pk *scpacket.FeCommandPacketType, ids re if err != nil { return nil, fmt.Errorf("cannot get remote instances: %w", err) } - rmap := remote.GetRemoteMap() + if len(riArr) == 0 { + update := scbus.MakeUpdatePacket() + update.AddUpdate(sstore.InfoMsgType{ + InfoMsg: "this tab has no shell states", + }) + return update, nil + } for _, ri := range riArr { rptr := sstore.RemotePtrType{RemoteId: ri.RemoteId, Name: ri.Name} - msh := rmap[ri.RemoteId] + msh := remote.GetRemoteById(ri.RemoteId) if msh == nil { continue } @@ -2477,28 +2530,9 @@ func crShowCommand(ctx context.Context, pk *scpacket.FeCommandPacketType, ids re } buf.WriteString(fmt.Sprintf("%-30s %-50s\n", displayName, cwdStr)) } - riBaseMap := make(map[string]bool) - for _, ri := range riArr { - if ri.Name == "" { - riBaseMap[ri.RemoteId] = true - } - } - for remoteId, msh := range rmap { - if riBaseMap[remoteId] { - continue - } - feState := msh.GetDefaultFeState(msh.GetShellPref()) - if feState == nil { - continue - } - cwdStr := "-" - if feState["cwd"] != "" { - cwdStr = feState["cwd"] - } - buf.WriteString(fmt.Sprintf("%-30s %-50s (default)\n", msh.GetDisplayName(), cwdStr)) - } update := scbus.MakeUpdatePacket() update.AddUpdate(sstore.InfoMsgType{ + InfoTitle: "shell states for tab", InfoLines: splitLinesForInfo(buf.String()), }) return update, nil @@ -2854,7 +2888,7 @@ func OpenAICommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus return nil, fmt.Errorf("openai error, invalid 'pterm' value %q: %v", ptermVal, err) } termOpts := convertTermOpts(pkTermOpts) - cmd, err := makeDynCmd(ctx, GetCmdStr(pk), ids, pk.GetRawStr(), *termOpts) + cmd, err := makeDynCmd(ctx, GetCmdStr(pk), ids, pk.GetRawStr(), *termOpts, nil) if err != nil { return nil, fmt.Errorf("openai error, cannot make dyn cmd") } @@ -2936,50 +2970,104 @@ func CrCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.Upd if rstate.Archived { return nil, fmt.Errorf("/%s error: remote %q cannot switch to archived remote", GetCmdStr(pk), newRemote) } + newMsh := remote.GetRemoteById(rptr.RemoteId) + if newMsh == nil { + return nil, fmt.Errorf("/%s error: remote %q not found (msh)", GetCmdStr(pk), newRemote) + } + if !newMsh.IsConnected() { + err := newMsh.TryAutoConnect() + if err != nil { + return nil, fmt.Errorf("%q is disconnected, auto-connect failed: %w", rstate.GetBaseDisplayName(), err) + } + if !newMsh.IsConnected() { + if newMsh.GetRemoteCopy().ConnectMode == sstore.ConnectModeManual { + return nil, fmt.Errorf("%q is disconnected (must manually connect)", rstate.GetBaseDisplayName()) + } + return nil, fmt.Errorf("%q is disconnected", rstate.GetBaseDisplayName()) + } + } err = sstore.UpdateCurRemote(ctx, ids.ScreenId, *rptr) if err != nil { return nil, fmt.Errorf("/%s error: cannot update curremote: %w", GetCmdStr(pk), err) } - noHist := resolveBool(pk.Kwargs["nohist"], false) - if noHist { - screen, err := sstore.GetScreenById(ctx, ids.ScreenId) + ri, err := sstore.GetRemoteStatePtr(ctx, ids.SessionId, ids.ScreenId, *rptr) + if err != nil { + return nil, fmt.Errorf("/%s error looking up connection state: %w", GetCmdStr(pk), err) + } + if ri == nil { + // ok, if ri is nil we need to do a reinit + verbose := resolveBool(pk.Kwargs["verbose"], false) + shellType, err := resolveShellType(pk.Kwargs["shell"], rstate.DefaultShellType) if err != nil { - return nil, fmt.Errorf("/%s error: cannot resolve screen for update: %w", GetCmdStr(pk), err) + return nil, err } - update := scbus.MakeUpdatePacket() - update.AddUpdate(*screen, sstore.InteractiveUpdate(pk.Interactive)) + termOpts, err := GetUITermOpts(pk.UIContext.WinSize, DefaultPTERM) + if err != nil { + return nil, fmt.Errorf("cannot make termopts: %w", err) + } + pkTermOpts := convertTermOpts(termOpts) + cmd, err := makeDynCmd(ctx, "connect", ids, pk.GetRawStr(), *pkTermOpts, &makeDynCmdOpts{OverrideRPtr: rptr}) + if err != nil { + return nil, err + } + update, err := addLineForCmd(ctx, "connect", true, ids, cmd, "", nil) + if err != nil { + return nil, err + } + opts := connectOptsType{ + Verbose: verbose, + ShellType: shellType, + SessionId: ids.SessionId, + ScreenId: ids.ScreenId, + RPtr: *rptr, + } + go doAsyncResetCommand(newMsh, opts, cmd) + return update, nil + } else { + outputStr := fmt.Sprintf("reconnected to %s", GetFullRemoteDisplayName(rptr, rstate)) + cmd, err := makeStaticCmd(ctx, GetCmdStr(pk), ids, pk.GetRawStr(), []byte(outputStr)) + if err != nil { + // TODO tricky error since the command was a success, but we can't show the output + return nil, err + } + update, err := addLineForCmd(ctx, "/"+GetCmdStr(pk), false, ids, cmd, "", nil) + if err != nil { + // TODO tricky error since the command was a success, but we can't show the output + return nil, err + } + update.AddUpdate(sstore.InteractiveUpdate(pk.Interactive)) return update, nil } - outputStr := fmt.Sprintf("connected to %s", GetFullRemoteDisplayName(rptr, rstate)) - cmd, err := makeStaticCmd(ctx, GetCmdStr(pk), ids, pk.GetRawStr(), []byte(outputStr)) - if err != nil { - // TODO tricky error since the command was a success, but we can't show the output - return nil, err - } - update, err := addLineForCmd(ctx, "/"+GetCmdStr(pk), false, ids, cmd, "", nil) - if err != nil { - // TODO tricky error since the command was a success, but we can't show the output - return nil, err - } - update.AddUpdate(sstore.InteractiveUpdate(pk.Interactive)) - return update, nil } -func makeDynCmd(ctx context.Context, metaCmd string, ids resolvedIds, cmdStr string, termOpts sstore.TermOpts) (*sstore.CmdType, error) { +type makeDynCmdOpts struct { + OverrideRPtr *sstore.RemotePtrType +} + +func makeDynCmd(ctx context.Context, metaCmd string, ids resolvedIds, cmdStr string, termOpts sstore.TermOpts, opts *makeDynCmdOpts) (*sstore.CmdType, error) { + var rptr scpacket.RemotePtrType + if opts != nil && opts.OverrideRPtr != nil { + rptr = *opts.OverrideRPtr + } else if ids.Remote != nil { + rptr = ids.Remote.RemotePtr + } else { + local := remote.GetLocalRemote() + rptr = scpacket.RemotePtrType{RemoteId: local.RemoteId} + } cmd := &sstore.CmdType{ ScreenId: ids.ScreenId, LineId: scbase.GenWaveUUID(), CmdStr: cmdStr, RawCmdStr: cmdStr, - Remote: ids.Remote.RemotePtr, + Remote: rptr, TermOpts: termOpts, Status: sstore.CmdStatusRunning, RunOut: nil, } - if ids.Remote.StatePtr != nil { + if ids.Remote != nil && ids.Remote.StatePtr != nil { cmd.StatePtr = *ids.Remote.StatePtr } - if ids.Remote.FeState != nil { + if ids.Remote != nil && ids.Remote.FeState != nil { cmd.FeState = ids.Remote.FeState } err := sstore.CreateCmdPtyFile(ctx, cmd.ScreenId, cmd.LineId, cmd.TermOpts.MaxPtySize) @@ -3369,13 +3457,32 @@ func SessionOpenCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) ( return nil, err } } - update, err := sstore.InsertSessionWithName(ctx, newName, activate) + update, newSessionId, newScreenId, err := sstore.InsertSessionWithName(ctx, newName, activate) if err != nil { return nil, err } + uiContextCopy := *pk.UIContext + uiContextCopy.SessionId = newSessionId + uiContextCopy.ScreenId = newScreenId + crUpdate, err := doNewTabConnectLocal(ctx, newScreenId, &uiContextCopy) + if err != nil { + return nil, err + } + update.Merge(crUpdate) return update, nil } +func SessionEnsureOneCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.UpdatePacket, error) { + numSessions, err := sstore.GetSessionCount(ctx) + if err != nil { + return nil, fmt.Errorf("cannot get number of sessions: %v", err) + } + if numSessions > 0 { + return nil, nil + } + return SessionOpenCommand(ctx, pk) +} + func makeExternLink(urlStr string) string { return fmt.Sprintf(`https://extern?%s`, url.QueryEscape(urlStr)) } @@ -3471,7 +3578,7 @@ func ScreenShowCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (s if screen == nil { return nil, fmt.Errorf("screen not found") } - statePtr, err := remote.ResolveCurrentScreenStatePtr(ctx, ids.SessionId, ids.ScreenId, ids.Remote.RemotePtr) + statePtr, err := sstore.GetRemoteStatePtr(ctx, ids.SessionId, ids.ScreenId, ids.Remote.RemotePtr) if err != nil { return nil, fmt.Errorf("cannot resolve current screen stateptr: %v", err) } @@ -3483,8 +3590,10 @@ func ScreenShowCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (s buf.WriteString(fmt.Sprintf(" %-15s %s\n", "tabicon", screen.ScreenOpts.TabIcon)) buf.WriteString(fmt.Sprintf(" %-15s %d\n", "selectedline", screen.SelectedLine)) buf.WriteString(fmt.Sprintf(" %-15s %s\n", "curremote", GetFullRemoteDisplayName(&screen.CurRemote, &ids.Remote.RState))) - buf.WriteString(fmt.Sprintf(" %-15s %s\n", "stateptr-base", statePtr.BaseHash)) - buf.WriteString(fmt.Sprintf(" %-15s %v\n", "stateptr-diff", statePtr.DiffHashArr)) + if statePtr != nil { + buf.WriteString(fmt.Sprintf(" %-15s %s\n", "stateptr-base", statePtr.BaseHash)) + buf.WriteString(fmt.Sprintf(" %-15s %v\n", "stateptr-diff", statePtr.DiffHashArr)) + } update := scbus.MakeUpdatePacket() update.AddUpdate(sstore.InfoMsgType{ InfoTitle: "screen info", @@ -3682,21 +3791,17 @@ func RemoteResetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) ( if !ids.Remote.MShell.IsConnected() { return nil, fmt.Errorf("cannot reinit, remote is not connected") } - shellType := ids.Remote.ShellType - if pk.Kwargs["shell"] != "" { - shellArg := pk.Kwargs["shell"] - if shellArg != packet.ShellType_bash && shellArg != packet.ShellType_zsh { - return nil, fmt.Errorf("/reset invalid shell type %q", shellArg) - } - shellType = shellArg - } verbose := resolveBool(pk.Kwargs["verbose"], false) + shellType, err := resolveShellType(pk.Kwargs["shell"], ids.Remote.ShellType) + if err != nil { + return nil, err + } termOpts, err := GetUITermOpts(pk.UIContext.WinSize, DefaultPTERM) if err != nil { return nil, fmt.Errorf("cannot make termopts: %w", err) } pkTermOpts := convertTermOpts(termOpts) - cmd, err := makeDynCmd(ctx, "reset", ids, pk.GetRawStr(), *pkTermOpts) + cmd, err := makeDynCmd(ctx, "reset", ids, pk.GetRawStr(), *pkTermOpts, nil) if err != nil { return nil, err } @@ -3704,12 +3809,28 @@ func RemoteResetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) ( if err != nil { return nil, err } - go doResetCommand(ids, shellType, cmd, verbose) + opts := connectOptsType{ + Verbose: verbose, + ShellType: shellType, + SessionId: ids.SessionId, + ScreenId: ids.ScreenId, + RPtr: ids.Remote.RemotePtr, + } + go doAsyncResetCommand(ids.Remote.MShell, opts, cmd) return update, nil } -func doResetCommand(ids resolvedIds, shellType string, cmd *sstore.CmdType, verbose bool) { - ctx, cancelFn := context.WithTimeout(context.Background(), shellapi.ReInitTimeout) +type connectOptsType struct { + ShellType string // shell type to connect with + Verbose bool // extra output (show state changes, sizes, etc.) + SessionId string + ScreenId string + RPtr sstore.RemotePtrType +} + +// this does the asynchroneous part of the connection reset +func doAsyncResetCommand(msh *remote.MShellProc, opts connectOptsType, cmd *sstore.CmdType) { + ctx, cancelFn := context.WithCancel(context.Background()) defer cancelFn() startTime := time.Now() var outputPos int64 @@ -3725,28 +3846,30 @@ func doResetCommand(ids resolvedIds, shellType string, cmd *sstore.CmdType, verb dataFn := func(data []byte) { writeStringToPty(ctx, cmd, string(data), &outputPos) } - origStatePtr := ids.Remote.MShell.GetDefaultStatePtr(shellType) - ssPk, err := ids.Remote.MShell.ReInit(ctx, base.MakeCommandKey(cmd.ScreenId, cmd.LineId), shellType, dataFn, verbose) + origStatePtr, _ := sstore.GetRemoteStatePtr(ctx, opts.SessionId, opts.ScreenId, opts.RPtr) + ssPk, err := msh.ReInit(ctx, base.MakeCommandKey(cmd.ScreenId, cmd.LineId), opts.ShellType, dataFn, opts.Verbose) if err != nil { rtnErr = err return } if ssPk == nil || ssPk.State == nil { - rtnErr = fmt.Errorf("invalid initpk received from remote (no remote state)") + rtnErr = fmt.Errorf("no state received from connection (nil)") return } feState := sstore.FeStateFromShellState(ssPk.State) - remoteInst, err := sstore.UpdateRemoteState(ctx, ids.SessionId, ids.ScreenId, ids.Remote.RemotePtr, feState, ssPk.State, nil) + remoteInst, err := sstore.UpdateRemoteState(ctx, opts.SessionId, opts.ScreenId, opts.RPtr, feState, ssPk.State, nil) if err != nil { rtnErr = err return } - newStatePtr := ids.Remote.MShell.GetDefaultStatePtr(shellType) - if verbose && origStatePtr != nil && newStatePtr != nil { + newStatePtr := sstore.ShellStatePtr{ + BaseHash: ssPk.State.GetHashVal(false), + } + if opts.Verbose && origStatePtr != nil { statePtrDiff := fmt.Sprintf("oldstate: %v, newstate: %v\r\n", origStatePtr.BaseHash, newStatePtr.BaseHash) writeStringToPty(ctx, cmd, statePtrDiff, &outputPos) origFullState, _ := sstore.GetFullState(ctx, *origStatePtr) - newFullState, _ := sstore.GetFullState(ctx, *newStatePtr) + newFullState, _ := sstore.GetFullState(ctx, newStatePtr) if origFullState != nil && newFullState != nil { var diffBuf bytes.Buffer rtnstate.DisplayStateUpdateDiff(&diffBuf, *origFullState, *newFullState) @@ -3756,7 +3879,7 @@ func doResetCommand(ids resolvedIds, shellType string, cmd *sstore.CmdType, verb } } update := scbus.MakeUpdatePacket() - update.AddUpdate(sstore.MakeSessionUpdateForRemote(ids.SessionId, remoteInst)) + update.AddUpdate(sstore.MakeSessionUpdateForRemote(opts.SessionId, remoteInst)) scbus.MainUpdateBus.DoUpdate(update) } @@ -3765,10 +3888,13 @@ func ResetCwdCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scb if err != nil { return nil, err } - statePtr, err := remote.ResolveCurrentScreenStatePtr(ctx, ids.SessionId, ids.ScreenId, ids.Remote.RemotePtr) + statePtr, err := sstore.GetRemoteStatePtr(ctx, ids.SessionId, ids.ScreenId, ids.Remote.RemotePtr) if err != nil { return nil, err } + if statePtr == nil { + return nil, fmt.Errorf("no shell state found, cannot reset cwd (run /reset)") + } stateDiff, err := sstore.GetCurStateDiffFromPtr(ctx, statePtr) if err != nil { return nil, err diff --git a/wavesrv/pkg/cmdrunner/resolver.go b/wavesrv/pkg/cmdrunner/resolver.go index 15c8854f9..d87ca0372 100644 --- a/wavesrv/pkg/cmdrunner/resolver.go +++ b/wavesrv/pkg/cmdrunner/resolver.go @@ -41,7 +41,7 @@ type ResolvedRemote struct { MShell *remote.MShellProc RState remote.RemoteRuntimeState RemoteCopy *sstore.RemoteType - ShellType string + ShellType string // default remote shell preference StatePtr *sstore.ShellStatePtr FeState map[string]string } @@ -488,8 +488,8 @@ func ResolveRemoteFromPtr(ctx context.Context, rptr *sstore.RemotePtrType, sessi } else { if ri == nil { rtn.ShellType = msh.GetShellPref() - rtn.StatePtr = msh.GetDefaultStatePtr(rtn.ShellType) - rtn.FeState = msh.GetDefaultFeState(rtn.ShellType) + rtn.StatePtr = nil + rtn.FeState = nil } else { rtn.StatePtr = &sstore.ShellStatePtr{BaseHash: ri.StateBaseHash, DiffHashArr: ri.StateDiffHashArr} rtn.FeState = ri.FeState diff --git a/wavesrv/pkg/cmdrunner/shparse.go b/wavesrv/pkg/cmdrunner/shparse.go index 1aa816bfd..b0011b9d5 100644 --- a/wavesrv/pkg/cmdrunner/shparse.go +++ b/wavesrv/pkg/cmdrunner/shparse.go @@ -314,7 +314,7 @@ func IsReturnStateCommand(cmdStr string) bool { func EvalBracketArgs(origCmdStr string) (map[string]string, string, error) { rtn := make(map[string]string) if strings.HasPrefix(origCmdStr, " ") { - rtn["nohist"] = "1" + rtn[KwArgNoHist] = "1" } cmdStr := strings.TrimSpace(origCmdStr) if !strings.HasPrefix(cmdStr, "[") { diff --git a/wavesrv/pkg/remote/remote.go b/wavesrv/pkg/remote/remote.go index 8dac46722..f374df51b 100644 --- a/wavesrv/pkg/remote/remote.go +++ b/wavesrv/pkg/remote/remote.go @@ -214,38 +214,6 @@ func (msh *MShellProc) GetStatus() string { return msh.Status } -func (msh *MShellProc) GetDefaultState(shellType string) *packet.ShellState { - _, state := msh.StateMap.GetCurrentState(shellType) - return state -} - -func (msh *MShellProc) EnsureShellType(ctx context.Context, shellType string) error { - if msh.StateMap.HasShell(shellType) { - return nil - } - // try to reinit the shell - _, err := msh.ReInit(ctx, base.CommandKey(""), shellType, nil, false) - if err != nil { - return fmt.Errorf("error trying to initialize shell %q: %v", shellType, err) - } - return nil -} - -func (msh *MShellProc) GetDefaultStatePtr(shellType string) *sstore.ShellStatePtr { - msh.Lock.Lock() - defer msh.Lock.Unlock() - hash, _ := msh.StateMap.GetCurrentState(shellType) - if hash == "" { - return nil - } - return &sstore.ShellStatePtr{BaseHash: hash} -} - -func (msh *MShellProc) GetDefaultFeState(shellType string) map[string]string { - state := msh.GetDefaultState(shellType) - return sstore.FeStateFromShellState(state) -} - func (msh *MShellProc) GetRemoteId() string { msh.Lock.Lock() defer msh.Lock.Unlock() @@ -489,6 +457,26 @@ func ResolveRemoteRef(remoteRef string) *RemoteRuntimeState { return nil } +func SendSignalToCmd(ctx context.Context, cmd *sstore.CmdType, sig string) error { + msh := GetRemoteById(cmd.Remote.RemoteId) + if msh == nil { + return fmt.Errorf("no connection found") + } + if !msh.IsConnected() { + return fmt.Errorf("not connected") + } + cmdCk := base.MakeCommandKey(cmd.ScreenId, cmd.LineId) + if !msh.IsCmdRunning(cmdCk) { + // this could also return nil (depends on use case) + // settled on coded error so we can check for this error + return base.CodedErrorf(packet.EC_CmdNotRunning, "cmd not running") + } + sigPk := packet.MakeSpecialInputPacket() + sigPk.CK = cmdCk + sigPk.SigName = sig + return msh.ServerProc.Input.SendPacket(sigPk) +} + func unquoteDQBashString(str string) (string, bool) { if len(str) < 2 { return str, false @@ -588,6 +576,7 @@ func (msh *MShellProc) GetRemoteRuntimeState() RemoteRuntimeState { InstallStatus: msh.InstallStatus, NeedsMShellUpgrade: msh.NeedsMShellUpgrade, Local: msh.Remote.Local, + IsSudo: msh.Remote.IsSudo(), NoInitPk: msh.ErrNoInitPk, AuthType: sstore.RemoteAuthTypeNone, ShellPref: msh.Remote.ShellPref, @@ -661,11 +650,6 @@ func (msh *MShellProc) GetRemoteRuntimeState() RemoteRuntimeState { vars["besthost"] = vars["remotehost"] vars["bestshorthost"] = vars["remoteshorthost"] } - _, curState := msh.StateMap.GetCurrentState(shellPref) - if curState != nil { - state.DefaultFeState = sstore.FeStateFromShellState(curState) - vars["cwd"] = curState.Cwd - } if msh.Remote.Local && msh.Remote.IsSudo() { vars["bestuser"] = "sudo" } else if msh.Remote.IsSudo() { @@ -687,7 +671,6 @@ func (msh *MShellProc) GetRemoteRuntimeState() RemoteRuntimeState { varsCopy[key] = value } state.RemoteVars = varsCopy - state.ActiveShells = msh.StateMap.GetShells() return state } @@ -932,9 +915,9 @@ func (msh *MShellProc) writeToPtyBuffer_nolock(strFmt string, args ...interface{ realStr = realStr + "\r\n" } if strings.HasPrefix(realStr, "*") { - realStr = "\033[0m\033[31mprompt>\033[0m " + realStr[1:] + realStr = "\033[0m\033[31mwave>\033[0m " + realStr[1:] } else { - realStr = "\033[0m\033[32mprompt>\033[0m " + realStr + realStr = "\033[0m\033[32mwave>\033[0m " + realStr } barr := msh.PtyBuffer.Bytes() if len(barr) > 0 && barr[len(barr)-1] != '\n' { @@ -1509,18 +1492,18 @@ func (msh *MShellProc) ReInit(ctx context.Context, ck base.CommandKey, shellType } func makeShellInitOutputMsg(verbose bool, state *packet.ShellState, stats *packet.ShellStateStats, dur time.Duration, ptyMsg bool) string { + waveStr := fmt.Sprintf("%swave>%s", utilfn.AnsiGreenColor(), utilfn.AnsiResetColor()) if !verbose || ptyMsg { if ptyMsg { return fmt.Sprintf("initialized state shell:%s statehash:%s %dms\n", state.GetShellType(), state.GetHashVal(false), dur.Milliseconds()) } else { - return fmt.Sprintf("initialized connection state (shell:%s)\r\n", state.GetShellType()) + return fmt.Sprintf("%s initialized connection state (shell:%s)\r\n", waveStr, state.GetShellType()) } } var buf bytes.Buffer - buf.WriteString("-----\r\n") - buf.WriteString(fmt.Sprintf("initialized connection shell:%s statehash:%s %dms\r\n", state.GetShellType(), state.GetHashVal(false), dur.Milliseconds())) + buf.WriteString(fmt.Sprintf("%s initialized connection shell:%s statehash:%s %dms\r\n", waveStr, state.GetShellType(), state.GetHashVal(false), dur.Milliseconds())) if stats != nil { - buf.WriteString(fmt.Sprintf(" outsize:%s size:%s env:%d, vars:%d, aliases:%d, funcs:%d\r\n", scbase.NumFormatDec(stats.OutputSize), scbase.NumFormatDec(stats.StateSize), stats.EnvCount, stats.VarCount, stats.AliasCount, stats.FuncCount)) + buf.WriteString(fmt.Sprintf("%s outsize:%s size:%s env:%d, vars:%d, aliases:%d, funcs:%d\r\n", waveStr, scbase.NumFormatDec(stats.OutputSize), scbase.NumFormatDec(stats.StateSize), stats.EnvCount, stats.VarCount, stats.AliasCount, stats.FuncCount)) } return buf.String() } @@ -1762,7 +1745,7 @@ func (msh *MShellProc) Launch(interactive bool) { msh.WriteToPtyBuffer("*disconnected exitcode=%d\n", exitCode) }() go msh.ProcessPackets() - msh.initActiveShells() + // msh.initActiveShells() go msh.NotifyRemoteUpdate() } @@ -1780,7 +1763,7 @@ func (msh *MShellProc) initActiveShells() { wg.Add(1) go func(shellType string) { defer wg.Done() - reinitCtx, cancelFn := context.WithTimeout(context.Background(), shellapi.ReInitTimeout) + reinitCtx, cancelFn := context.WithTimeout(context.Background(), 12*time.Second) defer cancelFn() _, err = msh.ReInit(reinitCtx, base.CommandKey(""), shellType, nil, false) if err != nil { @@ -1894,25 +1877,6 @@ func (msh *MShellProc) removePendingStateCmd(screenId string, rptr sstore.Remote } } -func ResolveCurrentScreenStatePtr(ctx context.Context, sessionId string, screenId string, remotePtr sstore.RemotePtrType) (*sstore.ShellStatePtr, error) { - statePtr, err := sstore.GetRemoteStatePtr(ctx, sessionId, screenId, remotePtr) - if err != nil { - return nil, fmt.Errorf("cannot get current connection stateptr: %w", err) - } - if statePtr == nil { - msh := GetRemoteById(remotePtr.RemoteId) - err := msh.EnsureShellType(ctx, msh.GetShellPref()) // make sure shellType is initialized - if err != nil { - return nil, err - } - statePtr = msh.GetDefaultStatePtr(msh.GetShellPref()) - if statePtr == nil { - return nil, fmt.Errorf("no valid default connection stateptr") - } - } - return statePtr, nil -} - type RunCommandOpts struct { SessionId string ScreenId string @@ -1990,10 +1954,13 @@ func RunCommand(ctx context.Context, rcOpts RunCommandOpts, runPacket *packet.Ru statePtr = rcOpts.StatePtr } else { var err error - statePtr, err = ResolveCurrentScreenStatePtr(ctx, sessionId, screenId, remotePtr) + statePtr, err = sstore.GetRemoteStatePtr(ctx, sessionId, screenId, remotePtr) if err != nil { return nil, nil, fmt.Errorf("cannot run command: %w", err) } + if statePtr == nil { + return nil, nil, fmt.Errorf("cannot run command: no valid shell state found") + } } currentState, err := sstore.GetFullState(ctx, *statePtr) if err != nil || currentState == nil { @@ -2002,10 +1969,6 @@ func RunCommand(ctx context.Context, rcOpts RunCommandOpts, runPacket *packet.Ru runPacket.State = addScVarsToState(currentState) runPacket.StateComplete = true runPacket.ShellType = currentState.GetShellType() - err = msh.EnsureShellType(ctx, runPacket.ShellType) // make sure shellType is initialized - if err != nil { - return nil, nil, err - } // start cmdwait. must be started before sending the run packet // this ensures that we don't process output, or cmddone packets until we set up the line, cmd, and ptyout file @@ -2144,7 +2107,7 @@ func (msh *MShellProc) HandleFeInput(inputPk *scpacket.FeInputPacketType) error msh.Lock.Unlock() if sink == nil { // no sink and no running command - return fmt.Errorf("cannot send input, cmd is not running") + return fmt.Errorf("cannot send input, cmd is not running (%s)", inputPk.CK) } return sink.HandleInput(inputPk) } @@ -2461,7 +2424,7 @@ func (msh *MShellProc) processSinglePacket(pk packet.PacketType) { msh.WriteToPtyBuffer("stderr> [remote %s] %s\n", msh.GetRemoteName(), rawPk.Data) return } - msh.WriteToPtyBuffer("MSH> [remote %s] unhandled packet %s\n", msh.GetRemoteName(), packet.AsString(pk)) + msh.WriteToPtyBuffer("*[remote %s] unhandled packet %s\n", msh.GetRemoteName(), packet.AsString(pk)) } func (msh *MShellProc) ProcessPackets() { diff --git a/wavesrv/pkg/scbus/modelupdate.go b/wavesrv/pkg/scbus/modelupdate.go index 0917f1aaf..2d06eb741 100644 --- a/wavesrv/pkg/scbus/modelupdate.go +++ b/wavesrv/pkg/scbus/modelupdate.go @@ -5,6 +5,7 @@ package scbus import ( "encoding/json" + "fmt" "reflect" "github.com/wavetermdev/waveterm/waveshell/pkg/packet" @@ -96,6 +97,16 @@ func (upk *ModelUpdatePacketType) AddUpdate(items ...ModelUpdateItem) { *(upk.Data) = append(*(upk.Data), items...) } +// adds the items from p2 to the update (p2 must be ModelUpdatePacketType) +func (upk *ModelUpdatePacketType) Merge(p2Arg UpdatePacket) error { + p2, ok := p2Arg.(*ModelUpdatePacketType) + if !ok { + return fmt.Errorf("cannot merge ModelUpdatePacketType with %T", p2Arg) + } + *(upk.Data) = append(*(upk.Data), *(p2.Data)...) + return nil +} + // Create a new model update packet func MakeUpdatePacket() *ModelUpdatePacketType { return &ModelUpdatePacketType{ diff --git a/wavesrv/pkg/scpacket/scpacket.go b/wavesrv/pkg/scpacket/scpacket.go index 24dad7c22..e40b68cc8 100644 --- a/wavesrv/pkg/scpacket/scpacket.go +++ b/wavesrv/pkg/scpacket/scpacket.go @@ -106,6 +106,9 @@ func (pk *FeCommandPacketType) GetRawStr() string { } var args []string for k, v := range pk.Kwargs { + if k == "nohist" { + continue + } argStr := fmt.Sprintf("%s=%s", shellescape.Quote(k), shellescape.Quote(v)) args = append(args, argStr) } diff --git a/wavesrv/pkg/sstore/dbops.go b/wavesrv/pkg/sstore/dbops.go index cc38cad66..41669227a 100644 --- a/wavesrv/pkg/sstore/dbops.go +++ b/wavesrv/pkg/sstore/dbops.go @@ -386,9 +386,9 @@ func GetSessionByName(ctx context.Context, name string) (*SessionType, error) { return session, nil } -// returns sessionId +// returns (update, newSessionId, newScreenId, error) // if sessionName == "", it will be generated -func InsertSessionWithName(ctx context.Context, sessionName string, activate bool) (*scbus.ModelUpdatePacketType, error) { +func InsertSessionWithName(ctx context.Context, sessionName string, activate bool) (*scbus.ModelUpdatePacketType, string, string, error) { var newScreen *ScreenType newSessionId := scbase.GenWaveUUID() txErr := WithTx(ctx, func(tx *TxWrap) error { @@ -414,11 +414,11 @@ func InsertSessionWithName(ctx context.Context, sessionName string, activate boo return nil }) if txErr != nil { - return nil, txErr + return nil, "", "", txErr } session, err := GetSessionById(ctx, newSessionId) if err != nil { - return nil, err + return nil, "", "", err } update := scbus.MakeUpdatePacket() update.AddUpdate(*session) @@ -426,7 +426,7 @@ func InsertSessionWithName(ctx context.Context, sessionName string, activate boo if activate { update.AddUpdate(ActiveSessionIdUpdate(newSessionId)) } - return update, nil + return update, newSessionId, newScreen.ScreenId, nil } func SetActiveSessionId(ctx context.Context, sessionId string) error { @@ -569,6 +569,9 @@ func InsertScreen(ctx context.Context, sessionId string, origScreenName string, query = `UPDATE session SET activescreenid = ? WHERE sessionid = ?` tx.Exec(query, newScreenId, sessionId) } + if opts.RtnScreenId != nil { + *opts.RtnScreenId = newScreenId + } return nil }) if txErr != nil { @@ -1044,11 +1047,6 @@ func DeleteScreen(ctx context.Context, screenId string, sessionDel bool, update if sessionId == "" { return fmt.Errorf("cannot delete screen (no sessionid)") } - query = `SELECT count(*) FROM screen WHERE sessionid = ? AND NOT archived` - numScreens := tx.GetInt(query, sessionId) - if numScreens <= 1 { - return fmt.Errorf("cannot delete the last screen in a session") - } isActive = tx.Exists(`SELECT sessionid FROM session WHERE sessionid = ? AND activescreenid = ?`, sessionId, screenId) if isActive { screenIds := tx.SelectStrings(`SELECT screenid FROM screen WHERE sessionid = ? AND NOT archived ORDER BY screenidx`, sessionId) diff --git a/wavesrv/pkg/sstore/sstore.go b/wavesrv/pkg/sstore/sstore.go index e637782b5..00f074485 100644 --- a/wavesrv/pkg/sstore/sstore.go +++ b/wavesrv/pkg/sstore/sstore.go @@ -375,6 +375,7 @@ type ScreenCreateOpts struct { CopyRemote bool CopyCwd bool CopyEnv bool + RtnScreenId *string } func (sco ScreenCreateOpts) HasCopy() bool { @@ -760,7 +761,6 @@ type RemoteRuntimeState struct { RemoteAlias string `json:"remotealias,omitempty"` RemoteCanonicalName string `json:"remotecanonicalname"` RemoteVars map[string]string `json:"remotevars"` - DefaultFeState map[string]string `json:"defaultfestate"` Status string `json:"status"` ConnectTimeout int `json:"connecttimeout,omitempty"` CountdownActive bool `json:"countdownactive"` @@ -779,9 +779,9 @@ type RemoteRuntimeState struct { MShellVersion string `json:"mshellversion"` WaitingForPassword bool `json:"waitingforpassword,omitempty"` Local bool `json:"local,omitempty"` + IsSudo bool `json:"issudo,omitempty"` RemoteOpts *RemoteOptsType `json:"remoteopts,omitempty"` CanComplete bool `json:"cancomplete,omitempty"` - ActiveShells []string `json:"activeshells,omitempty"` ShellPref string `json:"shellpref,omitempty"` DefaultShellType string `json:"defaultshelltype,omitempty"` } @@ -1127,21 +1127,6 @@ func EnsureLocalRemote(ctx context.Context) error { return nil } -func EnsureOneSession(ctx context.Context) error { - numSessions, err := GetSessionCount(ctx) - if err != nil { - return err - } - if numSessions > 0 { - return nil - } - _, err = InsertSessionWithName(ctx, DefaultSessionName, true) - if err != nil { - return err - } - return nil -} - func createClientData(tx *TxWrap) error { curve := elliptic.P384() pkey, err := ecdsa.GenerateKey(curve, rand.Reader)