From bf1b556537b9f76a05c844427805db771618b92c Mon Sep 17 00:00:00 2001 From: Red J Adaya Date: Wed, 7 Feb 2024 05:23:56 +0800 Subject: [PATCH 1/3] Split components in common (#276) * split settings modals * init * init * remove styles not related to checkbox * remove styles not related to CmdStrCode * renderCmdText doesn't need styles * remove styles not related to dropdown * IconButton doesn't need styles because it extends Button * remove old connections * InfoMessage conmponent no longer needed * fix import error * remove styles not related to InlineSettingsTextEdit * remove styles not related to InputDecoration * LinkButton doesn't need styles cos it's extends Button component * remove styles not related to markdown * remove styles not related to modal * NumberField doesn't need styles cos it extends TextField * remove styles not related to PasswordField * RemoteStatusLight no longer used. It's replaced by Status component. * remove styles not related to ResizableSidebar * SettingsError doesn't need styles cos it uses classnames in app.less * remove styles not related to Status * remove styles not related to TextField * remove styles not related to Toggle * remove styles not related to Tooltip --- src/app/bookmarks/bookmarks.tsx | 2 +- src/app/clientsettings/clientsettings.tsx | 2 +- src/app/common/common.less | 1153 ------------- src/app/common/common.tsx | 1452 ----------------- src/app/common/elements/button.less | 123 ++ src/app/common/elements/button.tsx | 63 + src/app/common/elements/checkbox.less | 68 + src/app/common/elements/checkbox.tsx | 70 + src/app/common/elements/cmdstrcode.less | 102 ++ src/app/common/elements/cmdstrcode.tsx | 66 + src/app/common/elements/cmdtext.tsx | 10 + src/app/common/elements/dropdown.less | 127 ++ src/app/common/elements/dropdown.tsx | 259 +++ src/app/common/elements/iconbutton.tsx | 21 + src/app/common/elements/index.tsx | 20 + .../elements/inlinesettingstextedit.less | 40 + .../elements/inlinesettingstextedit.tsx | 149 ++ src/app/common/elements/inputdecoration.less | 19 + src/app/common/elements/inputdecoration.tsx | 32 + src/app/common/elements/linkbutton.tsx | 28 + src/app/common/elements/markdown.less | 91 ++ src/app/common/elements/markdown.tsx | 105 ++ src/app/common/elements/modal.less | 79 + src/app/common/elements/modal.tsx | 81 + src/app/common/elements/numberfield.tsx | 39 + src/app/common/elements/passwordfield.less | 30 + src/app/common/elements/passwordfield.tsx | 100 ++ src/app/common/elements/resizablesidebar.less | 9 + src/app/common/elements/resizablesidebar.tsx | 171 ++ src/app/common/elements/settingserror.tsx | 36 + .../elements/showwaveshellinstallprompt.tsx | 28 + src/app/common/elements/status.less | 30 + src/app/common/elements/status.tsx | 34 + src/app/common/elements/textfield.less | 82 + src/app/common/elements/textfield.tsx | 173 ++ src/app/common/elements/toggle.less | 47 + src/app/common/elements/toggle.tsx | 28 + src/app/common/elements/tooltip.less | 23 + src/app/common/elements/tooltip.tsx | 84 + src/app/common/modals/about.tsx | 2 +- src/app/common/modals/alert.tsx | 2 +- src/app/common/modals/clientstop.tsx | 2 +- src/app/common/modals/createremoteconn.tsx | 12 +- src/app/common/modals/disconnected.tsx | 2 +- src/app/common/modals/editremoteconn.tsx | 2 +- src/app/common/modals/linesettings.tsx | 2 +- src/app/common/modals/screensettings.tsx | 2 +- src/app/common/modals/sessionsettings.less | 9 + src/app/common/modals/sessionsettings.tsx | 24 +- src/app/common/modals/tabswitcher.tsx | 2 +- src/app/common/modals/tos.tsx | 2 +- .../common/modals/viewremoteconndetail.tsx | 2 +- src/app/connections/connections.tsx | 2 +- src/app/history/history.tsx | 2 +- src/app/line/linecomps.tsx | 2 +- src/app/pluginsview/pluginsview.tsx | 4 +- src/app/sidebar/sidebar.tsx | 2 +- src/app/workspace/cmdinput/aichat.tsx | 2 +- src/app/workspace/cmdinput/cmdinput.tsx | 2 +- src/app/workspace/screen/screenview.tsx | 14 +- src/app/workspace/screen/tab.tsx | 2 +- src/plugins/code/code.tsx | 2 +- src/plugins/markdown/markdown.tsx | 15 +- src/plugins/openai/openai.tsx | 18 +- src/types/types.ts | 4 + 65 files changed, 2560 insertions(+), 2652 deletions(-) delete mode 100644 src/app/common/common.less delete mode 100644 src/app/common/common.tsx create mode 100644 src/app/common/elements/button.less create mode 100644 src/app/common/elements/button.tsx create mode 100644 src/app/common/elements/checkbox.less create mode 100644 src/app/common/elements/checkbox.tsx create mode 100644 src/app/common/elements/cmdstrcode.less create mode 100644 src/app/common/elements/cmdstrcode.tsx create mode 100644 src/app/common/elements/cmdtext.tsx create mode 100644 src/app/common/elements/dropdown.less create mode 100644 src/app/common/elements/dropdown.tsx create mode 100644 src/app/common/elements/iconbutton.tsx create mode 100644 src/app/common/elements/index.tsx create mode 100644 src/app/common/elements/inlinesettingstextedit.less create mode 100644 src/app/common/elements/inlinesettingstextedit.tsx create mode 100644 src/app/common/elements/inputdecoration.less create mode 100644 src/app/common/elements/inputdecoration.tsx create mode 100644 src/app/common/elements/linkbutton.tsx create mode 100644 src/app/common/elements/markdown.less create mode 100644 src/app/common/elements/markdown.tsx create mode 100644 src/app/common/elements/modal.less create mode 100644 src/app/common/elements/modal.tsx create mode 100644 src/app/common/elements/numberfield.tsx create mode 100644 src/app/common/elements/passwordfield.less create mode 100644 src/app/common/elements/passwordfield.tsx create mode 100644 src/app/common/elements/resizablesidebar.less create mode 100644 src/app/common/elements/resizablesidebar.tsx create mode 100644 src/app/common/elements/settingserror.tsx create mode 100644 src/app/common/elements/showwaveshellinstallprompt.tsx create mode 100644 src/app/common/elements/status.less create mode 100644 src/app/common/elements/status.tsx create mode 100644 src/app/common/elements/textfield.less create mode 100644 src/app/common/elements/textfield.tsx create mode 100644 src/app/common/elements/toggle.less create mode 100644 src/app/common/elements/toggle.tsx create mode 100644 src/app/common/elements/tooltip.less create mode 100644 src/app/common/elements/tooltip.tsx diff --git a/src/app/bookmarks/bookmarks.tsx b/src/app/bookmarks/bookmarks.tsx index dd7edaa69..9dd75e1b4 100644 --- a/src/app/bookmarks/bookmarks.tsx +++ b/src/app/bookmarks/bookmarks.tsx @@ -9,7 +9,7 @@ import { If, For } from "tsx-control-statements/components"; import cn from "classnames"; import type { BookmarkType } from "../../types/types"; import { GlobalModel } from "../../model/model"; -import { CmdStrCode, Markdown } from "../common/common"; +import { CmdStrCode, Markdown } from "../common/elements"; import { ReactComponent as XmarkIcon } from "../assets/icons/line/xmark.svg"; import { ReactComponent as CopyIcon } from "../assets/icons/favourites/copy.svg"; diff --git a/src/app/clientsettings/clientsettings.tsx b/src/app/clientsettings/clientsettings.tsx index 3f873962d..21807c0ee 100644 --- a/src/app/clientsettings/clientsettings.tsx +++ b/src/app/clientsettings/clientsettings.tsx @@ -7,7 +7,7 @@ import * as mobx from "mobx"; import { boundMethod } from "autobind-decorator"; import cn from "classnames"; import { GlobalModel, GlobalCommandRunner, MinFontSize, MaxFontSize, RemotesModel } from "../../model/model"; -import { Toggle, InlineSettingsTextEdit, SettingsError, Dropdown } from "../common/common"; +import { Toggle, InlineSettingsTextEdit, SettingsError, Dropdown } from "../common/elements"; import { CommandRtnType, ClientDataType } from "../../types/types"; import { commandRtnHandler, isBlank } from "../../util/util"; diff --git a/src/app/common/common.less b/src/app/common/common.less deleted file mode 100644 index 628694b94..000000000 --- a/src/app/common/common.less +++ /dev/null @@ -1,1153 +0,0 @@ -@import "../../app/common/themes/themes.less"; - -.info-message { - position: relative; - font-weight: normal; - - color: @term-white; - - .message-content { - position: absolute; - display: none; - flex-direction: row; - align-items: flex-start; - top: -6px; - left: -6px; - padding: 5px; - border: 1px solid #777; - background-color: #444; - border-radius: 5px; - z-index: 5; - overflow: hidden; - - .icon { - display: inline; - width: 1em; - height: 1em; - fill: @base-color; - padding-top: 0.2em; - } - - .info-icon { - margin-right: 5px; - flex-shrink: 0; - } - - .info-children { - flex: 1 0 0; - overflow: hidden; - } - } - - &:hover { - .message-content { - display: flex; - } - } -} - -.cmdstr-code { - position: relative; - display: flex; - flex-direction: row; - padding: 0px 10px 0px 0; - - &.is-large { - .use-button { - height: 28px; - width: 28px; - } - - .code-div code { - } - } - - &.limit-height .code-div { - max-height: 58px; - } - - &.limit-height.is-large .code-div { - max-height: 68px; - } - - .use-button { - flex-grow: 0; - padding: 3px; - border-radius: 3px 0 0 3px; - height: 22px; - width: 22px; - display: flex; - align-items: center; - justify-content: center; - align-self: flex-start; - cursor: pointer; - } - - .code-div { - background-color: @term-black; - display: flex; - flex-direction: row; - min-width: 100px; - overflow: auto; - border-left: 1px solid #777; - - code { - flex-shrink: 0; - min-width: 100px; - color: @term-white; - white-space: pre; - padding: 2px 8px 2px 8px; - background-color: @term-black; - font-size: 1em; - font-family: @fixed-font; - } - } - - .copy-control { - width: 0; - position: relative; - display: block; - visibility: hidden; - - .inner-copy { - position: absolute; - bottom: -1px; - right: -20px; - - padding: 2px; - padding-left: 4px; - cursor: pointer; - width: 20px; - - &:hover { - color: @term-white; - } - } - } - - &:hover .copy-control { - visibility: visible !important; - } -} - -.checkbox-toggle { - position: relative; - display: inline-block; - width: 40px; - height: 22px; - - input { - opacity: 0; - width: 0; - height: 0; - } - - .slider { - position: absolute; - content: ""; - cursor: pointer; - top: 0; - bottom: 0; - left: 0; - right: 0; - background-color: #333; - transition: 0.5s; - border-radius: 33px; - } - - .slider:before { - position: absolute; - content: ""; - height: 18px; - width: 18px; - left: 2px; - bottom: 2px; - background-color: @term-white; - transition: 0.5s; - border-radius: 50%; - } - - input:checked + .slider { - background-color: @term-green; - } - - input:checked + .slider:before { - transform: translateX(18px); - } -} - -.checkbox { - display: flex; - - input[type="checkbox"] { - height: 0; - width: 0; - } - - input[type="checkbox"] + label { - position: relative; - display: flex; - align-items: center; - color: @term-bright-white; - transition: color 250ms cubic-bezier(0.4, 0, 0.23, 1); - } - input[type="checkbox"] + label > span { - display: flex; - justify-content: center; - align-items: center; - margin-right: 10px; - width: 20px; - height: 20px; - background: transparent; - border: 2px solid #9e9e9e; - border-radius: 2px; - cursor: pointer; - transition: all 250ms cubic-bezier(0.4, 0, 0.23, 1); - } - - input[type="checkbox"] + label:hover > span, - input[type="checkbox"]:focus + label > span { - background: rgba(255, 255, 255, 0.1); - } - input[type="checkbox"]:checked + label > ins { - height: 100%; - } - - input[type="checkbox"]:checked + label > span { - border: 10px solid @term-green; - } - input[type="checkbox"]:checked + label > span:before { - content: ""; - position: absolute; - top: -2px; - left: 3px; - width: 7px; - height: 12px; - border-right: 2px solid #fff; - border-bottom: 2px solid #fff; - transform: rotate(45deg); - transform-origin: 0% 100%; - animation: checkbox-check 500ms cubic-bezier(0.4, 0, 0.23, 1); - } - - @keyframes checkbox-check { - 0% { - opacity: 0; - } - 33% { - opacity: 0.5; - } - 100% { - opacity: 1; - } - } -} - -.button.is-wave-green { - background-color: #222; - color: @term-white; - - &:hover { - background-color: @term-green; - color: @term-bright-white; - } -} - -.button.is-plain, -.button.is-prompt-cancel { - background-color: #222; - color: @term-white; - - &:hover { - background-color: #666; - color: @term-bright-white; - } -} - -.button.is-prompt-danger { - background-color: #222; - color: @term-white; - - &:hover { - background-color: @tab-red; - color: @term-bright-white; - } -} - -.button.is-inline-height { - height: 22px; -} - -.button input.confirm-checkbox { - margin-right: 5px; -} - -.cmd-hints { - display: flex; - flex-direction: row; - - .hint-item { - padding: 0px 5px 0px 5px; - border-radius: 0 0 3px 3px; - cursor: pointer; - } - - .hint-item:not(:last-child) { - margin-right: 8px; - } - - .hint-item.color-green { - color: @term-black; - background-color: @tab-green; - - &:hover { - color: @term-white; - } - } - - .hint-item.color-nohover-green { - color: @term-black; - background-color: @tab-green; - cursor: default; - } - - .hint-item.color-white { - color: @term-black; - background-color: @term-white; - - &:hover { - background-color: @term-bright-white; - } - } - - .hint-item.color-nohover-white { - color: @term-black; - background-color: @term-white; - cursor: default; - } - - .hint-item.color-blue { - color: @term-black; - background-color: @tab-blue; - - &:hover { - color: @term-white; - } - } - - .hint-item.color-nohover-blue { - color: @term-black; - background-color: @tab-blue; - cursor: default; - } -} - -.markdown { - color: @term-white; - margin-bottom: 10px; - font-family: @markdown-font; - font-size: 14px; - - code { - background-color: @markdown-highlight; - color: @term-white; - font-family: @terminal-font; - border-radius: 4px; - } - - code.inline { - padding-top: 0; - padding-bottom: 0; - font-family: @terminal-font; - } - - .title { - color: @term-white; - margin-top: 16px; - margin-bottom: 8px; - } - - strong { - color: @term-white; - } - - a { - color: #32afff; - } - - table { - tr th { - color: @term-white; - } - } - - ul { - list-style-type: disc; - list-style-position: outside; - margin-left: 16px; - } - - ol { - list-style-position: outside; - margin-left: 19px; - } - - blockquote { - margin: 4px 10px 4px 10px; - border-radius: 3px; - background-color: @markdown-highlight; - padding: 2px 4px 2px 6px; - } - - pre { - background-color: @markdown-highlight; - margin: 4px 10px 4px 10px; - padding: 6px 6px 6px 10px; - border-radius: 4px; - } - - pre.selected { - outline: 2px solid @term-green; - } - - .title.is-1 { - border-bottom: 1px solid #777; - padding-bottom: 6px; - } - .title.is-2 { - border-bottom: 1px solid #777; - padding-bottom: 6px; - } - .title.is-3 { - } - .title.is-4 { - } - .title.is-5 { - } - .title.is-6 { - } -} - -.markdown > *:first-child { - margin-top: 0 !important; -} - -.copied-indicator { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: @term-white; - opacity: 0; - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - pointer-events: none; - animation-name: fade-in-out; - animation-duration: 0.3s; -} - -.loading-spinner { - display: inline-block; - position: absolute; - top: calc(40% - 8px); - left: 30px; - width: 20px; - height: 20px; - - div { - box-sizing: border-box; - display: block; - position: absolute; - width: 16px; - height: 16px; - margin: 2px; - border: 2px solid #777; - border-radius: 50%; - animation: loader-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; - border-color: #777 transparent transparent transparent; - } - - div:nth-child(1) { - animation-delay: -0.45s; - } - - div:nth-child(2) { - animation-delay: -0.3s; - } - - div:nth-child(3) { - animation-delay: -0.15s; - } -} - -#measure { - position: absolute; - z-index: -1; - top: -5000px; - - .pre { - white-space: pre; - } -} - -.text-button { - color: @term-white; - cursor: pointer; - background-color: #171717; - outline: 2px solid #171717; - - &:hover, - &:focus { - color: @term-white; - background-color: #333; - outline: 2px solid #333; - } - - &.connect-button { - color: @term-green; - &:hover { - color: @term-green; - } - } - - &.disconnect-button { - color: @term-red; - &:hover { - color: @term-red; - } - } - - &.success-button { - color: @term-green; - &:hover { - color: @term-green; - } - } - - &.error-button { - color: @term-red; - &:hover { - color: @term-red; - } - } - - &.grey-button { - color: #666; - &:hover { - color: #666; - } - } - - &.disabled-button { - &:hover, - &:focus { - outline: none; - background-color: #171717; - } - cursor: default; - } -} - -.focus-indicator { - position: absolute; - display: none; - width: 5px; - border-radius: 3px; - height: calc(100% - 20px); - top: 10px; - left: 0; - z-index: 8; - - &.selected { - display: block; - background-color: #666 !important; - } - - &.active, - &.active.selected { - display: block; - background-color: @tab-blue !important; - } - - &.active.selected.fg-focus { - display: block; - background-color: @tab-green !important; - } -} - -.focus-parent:hover .focus-indicator { - display: block; - background-color: #222; -} - -.remote-status { - width: 1em; - height: 1em; - display: inline; - fill: #c4a000; - - &.status-init, - &.status-disconnected { - fill: #c4a000; - } - - &.status-connecting { - fill: #c4a000; - } - - &.status-connected { - fill: #4e9a06; - } - - &.status-error { - fill: #cc0000; - } -} - -.wave-dropdown { - position: relative; - height: 44px; - min-width: 150px; - width: 100%; - border: 1px solid var(--element-separator, rgba(241, 246, 243, 0.15)); - border-radius: 6px; - background: var(--element-hover-2, rgba(255, 255, 255, 0.06)); - box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.4), 0px 0px 0.5px 0px rgba(0, 0, 0, 0.5), - 0px 0px 0.5px 0px rgba(255, 255, 255, 0.5) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.2) inset; - - &.no-label { - height: 34px; - } - - &-label { - position: absolute; - left: 16px; - top: 16px; - font-size: 12.5px; - transition: all 0.3s; - color: @term-white; - line-height: 10px; - - &.float { - font-size: 10px; - top: 5px; - } - - &.offset-left { - left: 42px; - } - } - - &-display { - position: absolute; - left: 16px; - bottom: 5px; - - &.offset-left { - left: 42px; - } - } - - &-arrow { - position: absolute; - right: 16px; - top: 50%; - transform: translateY(-50%); - transition: transform 0.3s; - pointer-events: none; - - i { - font-size: 14px; - } - } - - &-arrow-rotate { - transform: translateY(-50%) rotate(180deg); // Rotate the arrow when dropdown is open - } - - &-item { - display: flex; - min-width: 120px; - padding: 5px 8px; - justify-content: space-between; - align-items: center; - align-self: stretch; - border-radius: 6px; - - &-highlighted, - &:hover { - background: var(--element-active, rgba(241, 246, 243, 0.08)); - box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.4), 0px 0px 0.5px 0px rgba(0, 0, 0, 0.5), - 0px 0px 0.5px 0px rgba(255, 255, 255, 0.5) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.2) inset; - } - } - - .wave-input-decoration { - position: absolute; - top: 0; - height: 100%; - } - - .wave-input-decoration.end-position { - margin-right: 44px; - right: 0; - } - - .wave-input-decoration.start-position { - left: 0; - } - - &-error { - border-color: @term-red; - } - - &:focus { - border-color: @term-green; - } -} - -.wave-dropdown-menu { - position: absolute; - top: 100%; - left: 0; - right: 0; - margin-top: 2px; - max-height: 200px; - overflow-y: auto; - padding: 6px; - flex-direction: column; - align-items: flex-start; - gap: 4px; - border-radius: 6px; - background: var(--olive-dark-1, #151715); - box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.5), 0px 3px 8px 0px rgba(0, 0, 0, 0.35), 0px 0px 0.5px 0px #fff inset, - 0px 0.5px 0px 0px rgba(255, 255, 255, 0.2) inset; - animation-fill-mode: forwards; - z-index: 1000; -} - -.wave-dropdown-menu-close { - z-index: 0; - animation: waveDropdownMenuFadeOut 0.3s ease-out; -} - -.wave-textfield.wave-password { - .wave-textfield-inner-eye { - position: absolute; - right: 16px; - top: 52%; - transform: translateY(-50%); - transition: transform 0.3s; - - i { - font-size: 14px; - } - } - - .wave-input-decoration { - position: absolute; - top: 0; - height: 100%; - } - - .wave-input-decoration.end-position { - margin-right: 47px; - right: 0; - } - - .wave-input-decoration.start-position { - left: 0; - } -} - -.wave-textfield { - display: flex; - align-items: center; - border-radius: 6px; - position: relative; - height: 44px; - min-width: 412px; - gap: 6px; - border: 1px solid var(--element-separator, rgba(241, 246, 243, 0.15)); - background: var(--element-hover-2, rgba(255, 255, 255, 0.06)); - box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.4), 0px 0px 0.5px 0px rgba(0, 0, 0, 0.5), - 0px 0px 0.5px 0px rgba(255, 255, 255, 0.5) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.2) inset; - - &:hover { - cursor: text; - } - - &.focused { - border-color: @term-green; - } - - &.disabled { - opacity: 0.75; - } - - &.error { - border-color: @term-red; - } - - &-inner { - display: flex; - align-items: flex-end; - height: 100%; - position: relative; - flex-grow: 1; - - &-label { - position: absolute; - left: 16px; - top: 16px; - font-size: 12.5px; - transition: all 0.3s; - color: @text-secondary; - line-height: 10px; - - &.float { - font-size: 10px; - top: 5px; - } - - &.offset-left { - left: 0; - } - } - - &-input { - width: 100%; - height: 30px; - border: none; - padding: 5px 0 5px 16px; - font-size: 16px; - outline: none; - background-color: transparent; - color: @term-bright-white; - line-height: 20px; - - &.offset-left { - padding: 5px 16px 5px 0; - } - } - } - - &.no-label { - height: 34px; - - input { - height: 32px; - } - } -} - -.wave-input-decoration { - display: flex; - align-items: center; - justify-content: center; - - i { - font-size: 13px; - } -} - -.wave-input-decoration.start-position { - margin: 0 4px 0 16px; -} - -.wave-input-decoration.end-position { - margin: 0 16px 0 8px; -} - -.wave-tooltip { - display: flex; - position: absolute; - z-index: 1000; - flex-direction: row; - align-items: flex-start; - gap: 10px; - padding: 10px; - border: 1px solid #777; - background-color: #444; - border-radius: 5px; - overflow: hidden; - width: 300px; - - i { - display: inline; - font-size: 13px; - fill: @base-color; - padding-top: 0.2em; - } -} - -.inline-edit { - .icon { - display: inline; - width: 12px; - height: 12px; - margin-left: 1em; - vertical-align: middle; - font-size: 14px; - } - - .button { - padding-top: 0; - } - - &.edit-not-active { - cursor: pointer; - - i.fa-pen { - margin-left: 5px; - } - - &:hover { - text-decoration: underline; - text-decoration-style: dotted; - } - } - - &.edit-active { - input.input { - padding: 0; - height: 20px; - } - - .button { - height: 20px; - } - } -} - -.wave-button { - background: none; - color: inherit; - border: none; - font: inherit; - cursor: pointer; - outline: inherit; - - display: flex; - padding: 6px 16px; - align-items: center; - gap: 4px; - border-radius: 6px; - height: auto; - - &:hover { - color: @term-white; - } - - i { - fill: rgba(255, 255, 255, 0.12); - } - - &.primary { - color: @term-green; - background: none; - - i { - fill: @term-green; - } - - &.solid { - color: @term-bright-white; - background: @term-green; - box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.4), 0px 0px 0.5px 0px rgba(0, 0, 0, 0.5), - 0px 0px 0.5px 0px rgba(255, 255, 255, 0.8) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.6) inset; - - i { - fill: @term-white; - } - } - - &.outlined { - border: 1px solid @term-green; - } - - &.ghost { - // Styles for .ghost are already defined above - } - - &:hover { - color: @term-bright-white; - } - } - - &.secondary { - color: @term-white; - background: none; - - &.solid { - background: rgba(255, 255, 255, 0.09); - box-shadow: none; - } - - &.outlined { - border: 1px solid rgba(255, 255, 255, 0.09); - } - - &.ghost { - padding: 6px 10px; - - i { - fill: @term-green; - } - } - } - - &.color-yellow { - &.solid { - border-color: @warning-yellow; - background-color: mix(@warning-yellow, @term-white, 50%); - box-shadow: none; - } - - &.outlined { - color: @warning-yellow; - border-color: @warning-yellow; - &:hover { - color: @term-white; - border-color: @term-white; - } - } - - &.ghost { - } - } - - &.color-red { - &.solid { - border-color: @term-red; - background-color: mix(@term-red, @term-white, 50%); - box-shadow: none; - } - - &.outlined { - color: @term-red; - border-color: @term-red; - } - - &.ghost { - } - } - - &.disabled { - opacity: 0.5; - } - - &.link-button { - cursor: pointer; - } -} - -.wave-status-container { - display: flex; - align-items: center; - - .dot { - height: 6px; - width: 6px; - border-radius: 50%; - display: inline-block; - margin-right: 8px; - } - - .dot.green { - background-color: @status-connected; - } - - .dot.red { - background-color: @status-error; - } - - .dot.gray { - background-color: @status-disconnected; - } - - .dot.yellow { - background-color: @status-connecting; - } -} - -.wave-modal-container { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: flex; - justify-content: center; - align-items: center; - z-index: 500; - - .wave-modal-backdrop { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(21, 23, 21, 0.7); - z-index: 1; - } -} - -.wave-modal { - z-index: 2; - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 16px; - border-radius: 10px; - background: #151715; - box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.35), 0px 10px 24px 0px rgba(0, 0, 0, 0.45), - 0px 0px 0.5px 0px rgba(255, 255, 255, 0.5) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.2) inset; - - .wave-modal-content { - display: flex; - flex-direction: column; - align-items: center; - width: 100%; - - .wave-modal-header { - width: 100%; - display: flex; - align-items: center; - padding: 12px 14px 12px 20px; - justify-content: space-between; - line-height: 20px; - border-bottom: 1px solid rgba(250, 250, 250, 0.1); - - .wave-modal-title { - color: #eceeec; - font-size: 15px; - } - - button { - i { - font-size: 18px; - } - } - } - - .wave-modal-body { - width: 100%; - padding: 0px 20px; - } - - .wave-modal-footer { - display: flex; - justify-content: flex-end; - width: 100%; - padding: 0 20px 20px; - - button:last-child { - margin-left: 8px; - } - } - } -} diff --git a/src/app/common/common.tsx b/src/app/common/common.tsx deleted file mode 100644 index 578eb6090..000000000 --- a/src/app/common/common.tsx +++ /dev/null @@ -1,1452 +0,0 @@ -// Copyright 2023, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import * as React from "react"; -import * as mobxReact from "mobx-react"; -import * as mobx from "mobx"; -import { boundMethod } from "autobind-decorator"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import cn from "classnames"; -import { If } from "tsx-control-statements/components"; -import { RemoteType } from "../../types/types"; -import ReactDOM from "react-dom"; -import { GlobalModel, GlobalCommandRunner } from "../../model/model"; -import * as appconst from "../appconst"; -import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "../../util/keyutil"; -import { MagicLayout } from "../magiclayout"; - -import { ReactComponent as CheckIcon } from "../assets/icons/line/check.svg"; -import { ReactComponent as CopyIcon } from "../assets/icons/history/copy.svg"; -import { ReactComponent as CircleIcon } from "../assets/icons/circle.svg"; -import { ReactComponent as KeyIcon } from "../assets/icons/key.svg"; -import { ReactComponent as RotateIcon } from "../assets/icons/rotate_left.svg"; -import { ReactComponent as CircleInfoIcon } from "../assets/icons/circle_info.svg"; - -import "./common.less"; - -type OV = mobx.IObservableValue; - -function renderCmdText(text: string): any { - return ⌘{text}; -} - -class CmdStrCode extends React.Component< - { - cmdstr: string; - onUse: () => void; - onCopy: () => void; - isCopied: boolean; - fontSize: "normal" | "large"; - limitHeight: boolean; - }, - {} -> { - @boundMethod - handleUse(e: any) { - e.stopPropagation(); - if (this.props.onUse != null) { - this.props.onUse(); - } - } - - @boundMethod - handleCopy(e: any) { - e.stopPropagation(); - if (this.props.onCopy != null) { - this.props.onCopy(); - } - } - - render() { - let { isCopied, cmdstr, fontSize, limitHeight } = this.props; - return ( -
- -
-
copied
-
-
-
- -
-
- {cmdstr} -
-
-
- -
-
-
- ); - } -} - -class Toggle extends React.Component<{ checked: boolean; onChange: (value: boolean) => void }, {}> { - @boundMethod - handleChange(e: any): void { - let { onChange } = this.props; - if (onChange != null) { - onChange(e.target.checked); - } - } - - render() { - return ( - - ); - } -} - -class Checkbox extends React.Component< - { - checked?: boolean; - defaultChecked?: boolean; - onChange: (value: boolean) => void; - label: React.ReactNode; - className?: string; - id?: string; - }, - { checkedInternal: boolean } -> { - generatedId; - static idCounter = 0; - - constructor(props) { - super(props); - this.state = { - checkedInternal: this.props.checked ?? Boolean(this.props.defaultChecked), - }; - this.generatedId = `checkbox-${Checkbox.idCounter++}`; - } - - componentDidUpdate(prevProps) { - if (this.props.checked !== undefined && this.props.checked !== prevProps.checked) { - this.setState({ checkedInternal: this.props.checked }); - } - } - - handleChange = (e) => { - const newChecked = e.target.checked; - if (this.props.checked === undefined) { - this.setState({ checkedInternal: newChecked }); - } - this.props.onChange(newChecked); - }; - - render() { - const { label, className, id } = this.props; - const { checkedInternal } = this.state; - const checkboxId = id || this.generatedId; - - return ( -
- - -
- ); - } -} - -interface InputDecorationProps { - position?: "start" | "end"; - children: React.ReactNode; -} - -@mobxReact.observer -class InputDecoration extends React.Component { - render() { - const { children, position = "end" } = this.props; - return ( -
- {children} -
- ); - } -} - -interface TooltipProps { - message: React.ReactNode; - icon?: React.ReactNode; // Optional icon property - children: React.ReactNode; - className?: string; -} - -interface TooltipState { - isVisible: boolean; -} - -@mobxReact.observer -class Tooltip extends React.Component { - iconRef: React.RefObject; - - constructor(props: TooltipProps) { - super(props); - this.state = { - isVisible: false, - }; - this.iconRef = React.createRef(); - } - - @boundMethod - showBubble() { - this.setState({ isVisible: true }); - } - - @boundMethod - hideBubble() { - this.setState({ isVisible: false }); - } - - @boundMethod - calculatePosition() { - // Get the position of the icon element - const iconElement = this.iconRef.current; - if (iconElement) { - const rect = iconElement.getBoundingClientRect(); - return { - top: `${rect.bottom + window.scrollY - 29}px`, - left: `${rect.left + window.scrollX + rect.width / 2 - 17.5}px`, - }; - } - return {}; - } - - @boundMethod - renderBubble() { - if (!this.state.isVisible) return null; - - const style = this.calculatePosition(); - - return ReactDOM.createPortal( -
- {this.props.icon &&
{this.props.icon}
} -
{this.props.message}
-
, - document.getElementById("app")! - ); - } - - render() { - return ( -
- {this.props.children} - {this.renderBubble()} -
- ); - } -} - -type ButtonVariantType = "outlined" | "solid" | "ghost"; -type ButtonThemeType = "primary" | "secondary"; - -interface ButtonProps { - theme?: ButtonThemeType; - children: React.ReactNode; - onClick?: () => void; - disabled?: boolean; - variant?: ButtonVariantType; - leftIcon?: React.ReactNode; - rightIcon?: React.ReactNode; - color?: string; - style?: React.CSSProperties; - autoFocus?: boolean; - className?: string; -} - -class Button extends React.Component { - static defaultProps = { - theme: "primary", - variant: "solid", - color: "", - style: {}, - }; - - @boundMethod - handleClick() { - if (this.props.onClick && !this.props.disabled) { - this.props.onClick(); - } - } - - render() { - const { leftIcon, rightIcon, theme, children, disabled, variant, color, style, autoFocus, className } = - this.props; - - return ( - - ); - } -} - -class IconButton extends Button { - render() { - const { children, theme, variant = "solid", ...rest } = this.props; - const className = `wave-button icon-button ${theme} ${variant}`; - - return ( - - ); - } -} - -export default IconButton; - -interface LinkButtonProps extends ButtonProps { - href: string; - rel?: string; - target?: string; -} - -class LinkButton extends React.Component { - render() { - const { leftIcon, rightIcon, children, className, ...rest } = this.props; - - return ( - - {leftIcon && {leftIcon}} - {children} - {rightIcon && {rightIcon}} - - ); - } -} - -interface StatusProps { - status: "green" | "red" | "gray" | "yellow"; - text: string; -} - -class Status extends React.Component { - @boundMethod - renderDot() { - const { status } = this.props; - - return
; - } - - render() { - const { text } = this.props; - - return ( -
- {this.renderDot()} - {text} -
- ); - } -} - -interface TextFieldDecorationProps { - startDecoration?: React.ReactNode; - endDecoration?: React.ReactNode; -} -interface TextFieldProps { - label?: string; - value?: string; - className?: string; - onChange?: (value: string) => void; - placeholder?: string; - defaultValue?: string; - decoration?: TextFieldDecorationProps; - required?: boolean; - maxLength?: number; - autoFocus?: boolean; - disabled?: boolean; -} - -interface TextFieldState { - focused: boolean; - internalValue: string; - error: boolean; - showHelpText: boolean; - hasContent: boolean; -} - -class TextField extends React.Component { - inputRef: React.RefObject; - state: TextFieldState; - - constructor(props: TextFieldProps) { - super(props); - const hasInitialContent = Boolean(props.value || props.defaultValue); - this.state = { - focused: false, - hasContent: hasInitialContent, - internalValue: props.defaultValue || "", - error: false, - showHelpText: false, - }; - this.inputRef = React.createRef(); - } - - componentDidUpdate(prevProps: TextFieldProps) { - // Only update the focus state if using as controlled - if (this.props.value !== undefined && this.props.value !== prevProps.value) { - this.setState({ focused: Boolean(this.props.value) }); - } - } - - // Method to handle focus at the component level - @boundMethod - handleComponentFocus() { - if (this.inputRef.current && !this.inputRef.current.contains(document.activeElement)) { - this.inputRef.current.focus(); - } - } - - // Method to handle blur at the component level - @boundMethod - handleComponentBlur() { - if (this.inputRef.current?.contains(document.activeElement)) { - this.inputRef.current.blur(); - } - } - - @boundMethod - handleFocus() { - this.setState({ focused: true }); - } - - @boundMethod - handleBlur() { - const { required } = this.props; - if (this.inputRef.current) { - const value = this.inputRef.current.value; - if (required && !value) { - this.setState({ error: true, focused: false }); - } else { - this.setState({ error: false, focused: false }); - } - } - } - - @boundMethod - handleHelpTextClick() { - this.setState((prevState) => ({ showHelpText: !prevState.showHelpText })); - } - - @boundMethod - handleInputChange(e: React.ChangeEvent) { - const { required, onChange } = this.props; - const inputValue = e.target.value; - - // Check if value is empty and the field is required - if (required && !inputValue) { - this.setState({ error: true, hasContent: false }); - } else { - this.setState({ error: false, hasContent: Boolean(inputValue) }); - } - - // Update the internal state for uncontrolled version - if (this.props.value === undefined) { - this.setState({ internalValue: inputValue }); - } - - onChange && onChange(inputValue); - } - - render() { - const { label, value, placeholder, decoration, className, maxLength, autoFocus, disabled } = this.props; - const { focused, internalValue, error } = this.state; - - // Decide if the input should behave as controlled or uncontrolled - const inputValue = value ?? internalValue; - - return ( -
- {decoration?.startDecoration && <>{decoration.startDecoration}} -
- - - - -
- {decoration?.endDecoration && <>{decoration.endDecoration}} -
- ); - } -} - -class NumberField extends TextField { - @boundMethod - handleInputChange(e: React.ChangeEvent) { - const { required, onChange } = this.props; - const inputValue = e.target.value; - - // Allow only numeric input - if (inputValue === "" || /^\d*$/.test(inputValue)) { - // Update the internal state only if the component is not controlled. - if (this.props.value === undefined) { - const isError = required ? inputValue.trim() === "" : false; - - this.setState({ - internalValue: inputValue, - error: isError, - hasContent: Boolean(inputValue), - }); - } - - onChange && onChange(inputValue); - } - } - - render() { - // Use the render method from TextField but add the onKeyDown handler - const renderedTextField = super.render(); - return React.cloneElement(renderedTextField); - } -} - -interface PasswordFieldState extends TextFieldState { - passwordVisible: boolean; -} - -@mobxReact.observer -class PasswordField extends TextField { - state: PasswordFieldState; - - constructor(props) { - super(props); - this.state = { - ...this.state, - passwordVisible: false, - }; - } - - @boundMethod - togglePasswordVisibility() { - //@ts-ignore - this.setState((prevState) => ({ - //@ts-ignore - passwordVisible: !prevState.passwordVisible, - })); - } - - @boundMethod - handleInputChange(e: React.ChangeEvent) { - // Call the parent handleInputChange method - super.handleInputChange(e); - } - - render() { - const { decoration, className, placeholder, maxLength, label } = this.props; - const { focused, internalValue, error, passwordVisible } = this.state; - const inputValue = this.props.value ?? internalValue; - - // The input should always receive the real value - const inputProps = { - className: cn("wave-textfield-inner-input", { "offset-left": decoration?.startDecoration }), - ref: this.inputRef, - id: label, - value: inputValue, // Always use the real value here - onChange: this.handleInputChange, - onFocus: this.handleFocus, - onBlur: this.handleBlur, - placeholder: placeholder, - maxLength: maxLength, - }; - - return ( -
- {decoration?.startDecoration && <>{decoration.startDecoration}} -
- - - - - - - -
- - - - - - -
-
- {decoration?.endDecoration && <>{decoration.endDecoration}} -
- ); - } -} - -@mobxReact.observer -class RemoteStatusLight extends React.Component<{ remote: RemoteType }, {}> { - render() { - let remote = this.props.remote; - let status = "error"; - let wfp = false; - if (remote != null) { - status = remote.status; - wfp = remote.waitingforpassword; - } - if (status == "connecting") { - if (wfp) return ; - else return ; - } - return ; - } -} - -@mobxReact.observer -class InlineSettingsTextEdit extends React.Component< - { - text: string; - value: string; - onChange: (val: string) => void; - maxLength: number; - placeholder: string; - showIcon?: boolean; - }, - {} -> { - isEditing: OV = mobx.observable.box(false, { name: "inlineedit-isEditing" }); - tempText: OV; - shouldFocus: boolean = false; - inputRef: React.RefObject = React.createRef(); - - componentDidUpdate(): void { - if (this.shouldFocus) { - this.shouldFocus = false; - if (this.inputRef.current != null) { - this.inputRef.current.focus(); - } - } - } - - @boundMethod - handleChangeText(e: any): void { - mobx.action(() => { - this.tempText.set(e.target.value); - })(); - } - - @boundMethod - confirmChange(): void { - mobx.action(() => { - let newText = this.tempText.get(); - this.isEditing.set(false); - this.tempText = null; - this.props.onChange(newText); - })(); - } - - @boundMethod - cancelChange(): void { - mobx.action(() => { - this.isEditing.set(false); - this.tempText = null; - })(); - } - - @boundMethod - handleKeyDown(e: any): void { - let waveEvent = adaptFromReactOrNativeKeyEvent(e); - if (checkKeyPressed(waveEvent, "Enter")) { - e.preventDefault(); - e.stopPropagation(); - this.confirmChange(); - return; - } - if (checkKeyPressed(waveEvent, "Escape")) { - e.preventDefault(); - e.stopPropagation(); - this.cancelChange(); - return; - } - return; - } - - @boundMethod - clickEdit(): void { - mobx.action(() => { - this.isEditing.set(true); - this.shouldFocus = true; - this.tempText = mobx.observable.box(this.props.value, { name: "inlineedit-tempText" }); - })(); - } - - render() { - if (this.isEditing.get()) { - return ( -
-
-
- -
-
-
- - - -
-
-
-
- - - -
-
-
-
- ); - } else { - return ( -
- {this.props.text} - - - -
- ); - } - } -} - -@mobxReact.observer -class InfoMessage extends React.Component<{ width: number; children: React.ReactNode }> { - render() { - return ( -
-
- -
-
-
- -
-
{this.props.children}
-
-
- ); - } -} - -function LinkRenderer(props: any): any { - let newUrl = "https://extern?" + encodeURIComponent(props.href); - return ( - - {props.children} - - ); -} - -function HeaderRenderer(props: any, hnum: number): any { - return
{props.children}
; -} - -function CodeRenderer(props: any): any { - return {props.children}; -} - -@mobxReact.observer -class CodeBlockMarkdown extends React.Component<{ children: React.ReactNode; codeSelectSelectedIndex?: number }, {}> { - blockIndex: number; - blockRef: React.RefObject; - - constructor(props) { - super(props); - this.blockRef = React.createRef(); - this.blockIndex = GlobalModel.inputModel.addCodeBlockToCodeSelect(this.blockRef); - } - - render() { - let clickHandler: (e: React.MouseEvent, blockIndex: number) => void; - let inputModel = GlobalModel.inputModel; - clickHandler = (e: React.MouseEvent, blockIndex: number) => { - inputModel.setCodeSelectSelectedCodeBlock(blockIndex); - }; - let selected = this.blockIndex == this.props.codeSelectSelectedIndex; - return ( -
 clickHandler(event, this.blockIndex)}
-            >
-                {this.props.children}
-            
- ); - } -} - -@mobxReact.observer -class Markdown extends React.Component< - { text: string; style?: any; extraClassName?: string; codeSelect?: boolean }, - {} -> { - CodeBlockRenderer(props: any, codeSelect: boolean, codeSelectIndex: number): any { - if (codeSelect) { - return {props.children}; - } else { - const clickHandler = (e: React.MouseEvent) => { - let blockText = (e.target as HTMLElement).innerText; - if (blockText) { - blockText = blockText.replace(/\n$/, ""); // remove trailing newline - navigator.clipboard.writeText(blockText); - } - }; - return
 clickHandler(event)}>{props.children}
; - } - } - - render() { - let text = this.props.text; - let codeSelect = this.props.codeSelect; - let curCodeSelectIndex = GlobalModel.inputModel.getCodeSelectSelectedIndex(); - let markdownComponents = { - a: LinkRenderer, - h1: (props) => HeaderRenderer(props, 1), - h2: (props) => HeaderRenderer(props, 2), - h3: (props) => HeaderRenderer(props, 3), - h4: (props) => HeaderRenderer(props, 4), - h5: (props) => HeaderRenderer(props, 5), - h6: (props) => HeaderRenderer(props, 6), - code: (props) => CodeRenderer(props), - pre: (props) => this.CodeBlockRenderer(props, codeSelect, curCodeSelectIndex), - }; - return ( -
- - {text} - -
- ); - } -} - -@mobxReact.observer -class SettingsError extends React.Component<{ errorMessage: OV }, {}> { - @boundMethod - dismissError(): void { - mobx.action(() => { - this.props.errorMessage.set(null); - })(); - } - - render() { - if (this.props.errorMessage.get() == null) { - return null; - } - return ( -
-
Error: {this.props.errorMessage.get()}
-
-
- -
-
- ); - } -} - -interface DropdownDecorationProps { - startDecoration?: React.ReactNode; - endDecoration?: React.ReactNode; -} - -interface DropdownProps { - label?: string; - options: { value: string; label: string }[]; - value?: string; - className?: string; - onChange: (value: string) => void; - placeholder?: string; - decoration?: DropdownDecorationProps; - defaultValue?: string; - required?: boolean; -} - -interface DropdownState { - isOpen: boolean; - internalValue: string; - highlightedIndex: number; - isTouched: boolean; -} - -@mobxReact.observer -class Dropdown extends React.Component { - wrapperRef: React.RefObject; - menuRef: React.RefObject; - timeoutId: any; - - constructor(props: DropdownProps) { - super(props); - this.state = { - isOpen: false, - internalValue: props.defaultValue || "", - highlightedIndex: -1, - isTouched: false, - }; - this.wrapperRef = React.createRef(); - this.menuRef = React.createRef(); - } - - componentDidMount() { - document.addEventListener("mousedown", this.handleClickOutside); - } - - componentWillUnmount() { - document.removeEventListener("mousedown", this.handleClickOutside); - } - - componentDidUpdate(prevProps: Readonly, prevState: Readonly, snapshot?: any): void { - // If the dropdown was open but now is closed, start the timeout - if (prevState.isOpen && !this.state.isOpen) { - this.timeoutId = setTimeout(() => { - if (this.menuRef.current) { - this.menuRef.current.style.display = "none"; - } - }, 300); // Time is equal to the animation duration - } - // If the dropdown is now open, cancel any existing timeout and show the menu - else if (!prevState.isOpen && this.state.isOpen) { - if (this.timeoutId !== null) { - clearTimeout(this.timeoutId); // Cancel any existing timeout - this.timeoutId = null; - } - if (this.menuRef.current) { - this.menuRef.current.style.display = "inline-flex"; - } - } - } - - @boundMethod - handleClickOutside(event: MouseEvent) { - // Check if the click is outside both the wrapper and the menu - if ( - this.wrapperRef.current && - !this.wrapperRef.current.contains(event.target as Node) && - this.menuRef.current && - !this.menuRef.current.contains(event.target as Node) - ) { - this.setState({ isOpen: false }); - } - } - - @boundMethod - handleClick() { - this.toggleDropdown(); - } - - @boundMethod - handleFocus() { - this.setState({ isTouched: true }); - } - - @boundMethod - handleKeyDown(event: React.KeyboardEvent) { - const { options } = this.props; - const { isOpen, highlightedIndex } = this.state; - - switch (event.key) { - case "Enter": - case " ": - if (isOpen) { - const option = options[highlightedIndex]; - if (option) { - this.handleSelect(option.value, undefined); - } - } else { - this.toggleDropdown(); - } - break; - case "Escape": - this.setState({ isOpen: false }); - break; - case "ArrowUp": - if (isOpen) { - this.setState((prevState) => ({ - highlightedIndex: - prevState.highlightedIndex > 0 ? prevState.highlightedIndex - 1 : options.length - 1, - })); - } - break; - case "ArrowDown": - if (isOpen) { - this.setState((prevState) => ({ - highlightedIndex: - prevState.highlightedIndex < options.length - 1 ? prevState.highlightedIndex + 1 : 0, - })); - } - break; - case "Tab": - this.setState({ isOpen: false }); - break; - } - } - - @boundMethod - handleSelect(value: string, event?: React.MouseEvent | React.KeyboardEvent) { - const { onChange } = this.props; - if (event) { - event.stopPropagation(); // This stops the event from bubbling up to the wrapper - } - - if (!("value" in this.props)) { - this.setState({ internalValue: value }); - } - onChange(value); - this.setState({ isOpen: false, isTouched: true }); - } - - @boundMethod - toggleDropdown() { - this.setState((prevState) => ({ isOpen: !prevState.isOpen, isTouched: true })); - } - - @boundMethod - calculatePosition(): React.CSSProperties { - if (this.wrapperRef.current) { - const rect = this.wrapperRef.current.getBoundingClientRect(); - return { - position: "absolute", - top: `${rect.bottom + window.scrollY}px`, - left: `${rect.left + window.scrollX}px`, - width: `${rect.width}px`, - }; - } - return {}; - } - - render() { - const { label, options, value, placeholder, decoration, className, required } = this.props; - const { isOpen, internalValue, highlightedIndex, isTouched } = this.state; - - const currentValue = value ?? internalValue; - const selectedOptionLabel = - options.find((option) => option.value === currentValue)?.label || placeholder || internalValue; - - // Determine if the dropdown should be marked as having an error - const isError = - required && - (value === undefined || value === "") && - (internalValue === undefined || internalValue === "") && - isTouched; - - // Determine if the label should float - const shouldLabelFloat = !!value || !!internalValue || !!placeholder || isOpen; - - const dropdownMenu = isOpen - ? ReactDOM.createPortal( -
- {options.map((option, index) => ( -
this.handleSelect(option.value, e)} - onMouseEnter={() => this.setState({ highlightedIndex: index })} - onMouseLeave={() => this.setState({ highlightedIndex: -1 })} - > - {option.label} -
- ))} -
, - document.getElementById("app")! - ) - : null; - - return ( -
- {decoration?.startDecoration && <>{decoration.startDecoration}} - -
- {label} -
-
-
- {selectedOptionLabel} -
-
- -
- {dropdownMenu} - {decoration?.endDecoration && <>{decoration.endDecoration}} -
- ); - } -} - -interface ModalHeaderProps { - onClose?: () => void; - title: string; -} - -const ModalHeader: React.FC = ({ onClose, title }) => ( -
- {
{title}
} - - - - - -
-); - -interface ModalFooterProps { - onCancel?: () => void; - onOk?: () => void; - cancelLabel?: string; - okLabel?: string; -} - -const ModalFooter: React.FC = ({ onCancel, onOk, cancelLabel = "Cancel", okLabel = "Ok" }) => ( -
- {onCancel && ( - - )} - {onOk && } -
-); - -interface ModalProps { - className?: string; - children?: React.ReactNode; - onClickBackdrop?: () => void; -} - -class Modal extends React.Component { - static Header = ModalHeader; - static Footer = ModalFooter; - - renderBackdrop(onClick: (() => void) | undefined) { - return
; - } - - renderModal() { - const { className, children } = this.props; - - return ( -
- {this.renderBackdrop(this.props.onClickBackdrop)} -
-
{children}
-
-
- ); - } - - render() { - return ReactDOM.createPortal(this.renderModal(), document.getElementById("app")); - } -} - -function ShowWaveShellInstallPrompt(callbackFn: () => void) { - let message: string = ` -In order to use Wave's advanced features like unified history and persistent sessions, Wave installs a small, open-source helper program called WaveShell on your remote machine. WaveShell does not open any external ports and only communicates with your *local* Wave terminal instance over ssh. For more information please see [the docs](https://docs.waveterm.dev/reference/waveshell). - `; - message = message.trim(); - let prtn = GlobalModel.showAlert({ - message: message, - confirm: true, - markdown: true, - confirmflag: appconst.ConfirmKey_HideShellPrompt, - }); - prtn.then((confirm) => { - if (!confirm) { - return; - } - if (callbackFn) { - callbackFn(); - } - }); -} - -interface ResizableSidebarProps { - parentRef: React.RefObject; - position: "left" | "right"; - enableSnap?: boolean; - className?: string; - children?: (toggleCollapsed: () => void) => React.ReactNode; - toggleCollapse?: () => void; -} - -@mobxReact.observer -class ResizableSidebar extends React.Component { - resizeStartWidth: number = 0; - startX: number = 0; - prevDelta: number = 0; - prevDragDirection: string = null; - disposeReaction: any; - - @boundMethod - startResizing(event: React.MouseEvent) { - event.preventDefault(); - - const { parentRef, position } = this.props; - const parentRect = parentRef.current?.getBoundingClientRect(); - - if (!parentRect) return; - - if (position === "right") { - this.startX = parentRect.right - event.clientX; - } else { - this.startX = event.clientX - parentRect.left; - } - - const mainSidebarModel = GlobalModel.mainSidebarModel; - const collapsed = mainSidebarModel.getCollapsed(); - - this.resizeStartWidth = mainSidebarModel.getWidth(); - document.addEventListener("mousemove", this.onMouseMove); - document.addEventListener("mouseup", this.stopResizing); - - document.body.style.cursor = "col-resize"; - mobx.action(() => { - mainSidebarModel.setTempWidthAndTempCollapsed(this.resizeStartWidth, collapsed); - mainSidebarModel.isDragging.set(true); - })(); - } - - @boundMethod - onMouseMove(event: MouseEvent) { - event.preventDefault(); - - const { parentRef, enableSnap, position } = this.props; - const parentRect = parentRef.current?.getBoundingClientRect(); - const mainSidebarModel = GlobalModel.mainSidebarModel; - - if (!mainSidebarModel.isDragging.get() || !parentRect) return; - - let delta: number, newWidth: number; - - if (position === "right") { - delta = parentRect.right - event.clientX - this.startX; - } else { - delta = event.clientX - parentRect.left - this.startX; - } - - newWidth = this.resizeStartWidth + delta; - - if (enableSnap) { - const minWidth = MagicLayout.MainSidebarMinWidth; - const snapPoint = minWidth + MagicLayout.MainSidebarSnapThreshold; - const dragResistance = MagicLayout.MainSidebarDragResistance; - let dragDirection: string; - - if (delta - this.prevDelta > 0) { - dragDirection = "+"; - } else if (delta - this.prevDelta == 0) { - if (this.prevDragDirection == "+") { - dragDirection = "+"; - } else { - dragDirection = "-"; - } - } else { - dragDirection = "-"; - } - - this.prevDelta = delta; - this.prevDragDirection = dragDirection; - - if (newWidth - dragResistance > minWidth && newWidth < snapPoint && dragDirection == "+") { - newWidth = snapPoint; - mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, false); - } else if (newWidth + dragResistance < snapPoint && dragDirection == "-") { - newWidth = minWidth; - mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, true); - } else if (newWidth > snapPoint) { - mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, false); - } - } else { - if (newWidth <= MagicLayout.MainSidebarMinWidth) { - mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, true); - } else { - mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, false); - } - } - } - - @boundMethod - stopResizing() { - let mainSidebarModel = GlobalModel.mainSidebarModel; - - GlobalCommandRunner.clientSetSidebar( - mainSidebarModel.tempWidth.get(), - mainSidebarModel.tempCollapsed.get() - ).finally(() => { - mobx.action(() => { - mainSidebarModel.isDragging.set(false); - })(); - }); - - document.removeEventListener("mousemove", this.onMouseMove); - document.removeEventListener("mouseup", this.stopResizing); - document.body.style.cursor = ""; - } - - @boundMethod - toggleCollapsed() { - const mainSidebarModel = GlobalModel.mainSidebarModel; - - const tempCollapsed = mainSidebarModel.getCollapsed(); - const width = mainSidebarModel.getWidth(true); - mainSidebarModel.setTempWidthAndTempCollapsed(width, !tempCollapsed); - GlobalCommandRunner.clientSetSidebar(width, !tempCollapsed); - } - - render() { - const { className, children } = this.props; - const mainSidebarModel = GlobalModel.mainSidebarModel; - const width = mainSidebarModel.getWidth(); - const isCollapsed = mainSidebarModel.getCollapsed(); - - return ( -
-
{children(this.toggleCollapsed)}
-
-
- ); - } -} - -export { - CmdStrCode, - Toggle, - Checkbox, - renderCmdText, - RemoteStatusLight, - InlineSettingsTextEdit, - InfoMessage, - Markdown, - SettingsError, - Dropdown, - TextField, - InputDecoration, - NumberField, - PasswordField, - Tooltip, - Button, - IconButton, - LinkButton, - Status, - Modal, - ResizableSidebar, - ShowWaveShellInstallPrompt, -}; diff --git a/src/app/common/elements/button.less b/src/app/common/elements/button.less new file mode 100644 index 000000000..ac10dbdbe --- /dev/null +++ b/src/app/common/elements/button.less @@ -0,0 +1,123 @@ +@import "../../../app/common/themes/themes.less"; + +.wave-button { + background: none; + color: inherit; + border: none; + font: inherit; + cursor: pointer; + outline: inherit; + + display: flex; + padding: 6px 16px; + align-items: center; + gap: 4px; + border-radius: 6px; + height: auto; + + &:hover { + color: @term-white; + } + + i { + fill: rgba(255, 255, 255, 0.12); + } + + &.primary { + color: @term-green; + background: none; + + i { + fill: @term-green; + } + + &.solid { + color: @term-bright-white; + background: @term-green; + box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.4), 0px 0px 0.5px 0px rgba(0, 0, 0, 0.5), + 0px 0px 0.5px 0px rgba(255, 255, 255, 0.8) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.6) inset; + + i { + fill: @term-white; + } + } + + &.outlined { + border: 1px solid @term-green; + } + + &.ghost { + // Styles for .ghost are already defined above + } + + &:hover { + color: @term-bright-white; + } + } + + &.secondary { + color: @term-white; + background: none; + + &.solid { + background: rgba(255, 255, 255, 0.09); + box-shadow: none; + } + + &.outlined { + border: 1px solid rgba(255, 255, 255, 0.09); + } + + &.ghost { + padding: 6px 10px; + + i { + fill: @term-green; + } + } + } + + &.color-yellow { + &.solid { + border-color: @warning-yellow; + background-color: mix(@warning-yellow, @term-white, 50%); + box-shadow: none; + } + + &.outlined { + color: @warning-yellow; + border-color: @warning-yellow; + &:hover { + color: @term-white; + border-color: @term-white; + } + } + + &.ghost { + } + } + + &.color-red { + &.solid { + border-color: @term-red; + background-color: mix(@term-red, @term-white, 50%); + box-shadow: none; + } + + &.outlined { + color: @term-red; + border-color: @term-red; + } + + &.ghost { + } + } + + &.disabled { + opacity: 0.5; + } + + &.link-button { + cursor: pointer; + } +} diff --git a/src/app/common/elements/button.tsx b/src/app/common/elements/button.tsx new file mode 100644 index 000000000..cdd218250 --- /dev/null +++ b/src/app/common/elements/button.tsx @@ -0,0 +1,63 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import { boundMethod } from "autobind-decorator"; +import cn from "classnames"; + +import "./button.less"; + +type ButtonVariantType = "outlined" | "solid" | "ghost"; +type ButtonThemeType = "primary" | "secondary"; + +interface ButtonProps { + theme?: ButtonThemeType; + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + variant?: ButtonVariantType; + leftIcon?: React.ReactNode; + rightIcon?: React.ReactNode; + color?: string; + style?: React.CSSProperties; + autoFocus?: boolean; + className?: string; +} + +class Button extends React.Component { + static defaultProps = { + theme: "primary", + variant: "solid", + color: "", + style: {}, + }; + + @boundMethod + handleClick() { + if (this.props.onClick && !this.props.disabled) { + this.props.onClick(); + } + } + + render() { + const { leftIcon, rightIcon, theme, children, disabled, variant, color, style, autoFocus, className } = + this.props; + + return ( + + ); + } +} + +export { Button }; +export type { ButtonProps }; diff --git a/src/app/common/elements/checkbox.less b/src/app/common/elements/checkbox.less new file mode 100644 index 000000000..f0c52877e --- /dev/null +++ b/src/app/common/elements/checkbox.less @@ -0,0 +1,68 @@ +@import "../../../app/common/themes/themes.less"; + +.checkbox { + display: flex; + + input[type="checkbox"] { + height: 0; + width: 0; + } + + input[type="checkbox"] + label { + position: relative; + display: flex; + align-items: center; + color: @term-bright-white; + transition: color 250ms cubic-bezier(0.4, 0, 0.23, 1); + } + input[type="checkbox"] + label > span { + display: flex; + justify-content: center; + align-items: center; + margin-right: 10px; + width: 20px; + height: 20px; + background: transparent; + border: 2px solid #9e9e9e; + border-radius: 2px; + cursor: pointer; + transition: all 250ms cubic-bezier(0.4, 0, 0.23, 1); + } + + input[type="checkbox"] + label:hover > span, + input[type="checkbox"]:focus + label > span { + background: rgba(255, 255, 255, 0.1); + } + input[type="checkbox"]:checked + label > ins { + height: 100%; + } + + input[type="checkbox"]:checked + label > span { + border: 10px solid @term-green; + } + input[type="checkbox"]:checked + label > span:before { + content: ""; + position: absolute; + top: -2px; + left: 3px; + width: 7px; + height: 12px; + border-right: 2px solid #fff; + border-bottom: 2px solid #fff; + transform: rotate(45deg); + transform-origin: 0% 100%; + animation: checkbox-check 500ms cubic-bezier(0.4, 0, 0.23, 1); + } + + @keyframes checkbox-check { + 0% { + opacity: 0; + } + 33% { + opacity: 0.5; + } + 100% { + opacity: 1; + } + } +} diff --git a/src/app/common/elements/checkbox.tsx b/src/app/common/elements/checkbox.tsx new file mode 100644 index 000000000..57ece9c5c --- /dev/null +++ b/src/app/common/elements/checkbox.tsx @@ -0,0 +1,70 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobx from "mobx"; +import cn from "classnames"; + +import "./checkbox.less"; + +class Checkbox extends React.Component< + { + checked?: boolean; + defaultChecked?: boolean; + onChange: (value: boolean) => void; + label: React.ReactNode; + className?: string; + id?: string; + }, + { checkedInternal: boolean } +> { + generatedId; + static idCounter = 0; + + constructor(props) { + super(props); + this.state = { + checkedInternal: this.props.checked ?? Boolean(this.props.defaultChecked), + }; + this.generatedId = `checkbox-${Checkbox.idCounter++}`; + } + + componentDidUpdate(prevProps) { + if (this.props.checked !== undefined && this.props.checked !== prevProps.checked) { + this.setState({ checkedInternal: this.props.checked }); + } + } + + handleChange = (e) => { + const newChecked = e.target.checked; + if (this.props.checked === undefined) { + this.setState({ checkedInternal: newChecked }); + } + this.props.onChange(newChecked); + }; + + render() { + const { label, className, id } = this.props; + const { checkedInternal } = this.state; + const checkboxId = id || this.generatedId; + + return ( +
+ + +
+ ); + } +} + +export { Checkbox }; diff --git a/src/app/common/elements/cmdstrcode.less b/src/app/common/elements/cmdstrcode.less new file mode 100644 index 000000000..83bb17fc4 --- /dev/null +++ b/src/app/common/elements/cmdstrcode.less @@ -0,0 +1,102 @@ +@import "../../../app/common/themes/themes.less"; + +.cmdstr-code { + position: relative; + display: flex; + flex-direction: row; + padding: 0px 10px 0px 0; + + &.is-large { + .use-button { + height: 28px; + width: 28px; + } + + .code-div code { + } + } + + &.limit-height .code-div { + max-height: 58px; + } + + &.limit-height.is-large .code-div { + max-height: 68px; + } + + .use-button { + flex-grow: 0; + padding: 3px; + border-radius: 3px 0 0 3px; + height: 22px; + width: 22px; + display: flex; + align-items: center; + justify-content: center; + align-self: flex-start; + cursor: pointer; + } + + .code-div { + background-color: @term-black; + display: flex; + flex-direction: row; + min-width: 100px; + overflow: auto; + border-left: 1px solid #777; + + code { + flex-shrink: 0; + min-width: 100px; + color: @term-white; + white-space: pre; + padding: 2px 8px 2px 8px; + background-color: @term-black; + font-size: 1em; + font-family: @fixed-font; + } + } + + .copy-control { + width: 0; + position: relative; + display: block; + visibility: hidden; + + .inner-copy { + position: absolute; + bottom: -1px; + right: -20px; + + padding: 2px; + padding-left: 4px; + cursor: pointer; + width: 20px; + + &:hover { + color: @term-white; + } + } + } + + &:hover .copy-control { + visibility: visible !important; + } +} + +.copied-indicator { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: @term-white; + opacity: 0; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + pointer-events: none; + animation-name: fade-in-out; + animation-duration: 0.3s; +} diff --git a/src/app/common/elements/cmdstrcode.tsx b/src/app/common/elements/cmdstrcode.tsx new file mode 100644 index 000000000..8063d8732 --- /dev/null +++ b/src/app/common/elements/cmdstrcode.tsx @@ -0,0 +1,66 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import { boundMethod } from "autobind-decorator"; +import cn from "classnames"; +import { If } from "tsx-control-statements/components"; + +import { ReactComponent as CheckIcon } from "../../assets/icons/line/check.svg"; +import { ReactComponent as CopyIcon } from "../../assets/icons/history/copy.svg"; + +import "./cmdstrcode.less"; + +class CmdStrCode extends React.Component< + { + cmdstr: string; + onUse: () => void; + onCopy: () => void; + isCopied: boolean; + fontSize: "normal" | "large"; + limitHeight: boolean; + }, + {} +> { + @boundMethod + handleUse(e: any) { + e.stopPropagation(); + if (this.props.onUse != null) { + this.props.onUse(); + } + } + + @boundMethod + handleCopy(e: any) { + e.stopPropagation(); + if (this.props.onCopy != null) { + this.props.onCopy(); + } + } + + render() { + let { isCopied, cmdstr, fontSize, limitHeight } = this.props; + return ( +
+ +
+
copied
+
+
+
+ +
+
+ {cmdstr} +
+
+
+ +
+
+
+ ); + } +} + +export { CmdStrCode }; diff --git a/src/app/common/elements/cmdtext.tsx b/src/app/common/elements/cmdtext.tsx new file mode 100644 index 000000000..2a3cb3988 --- /dev/null +++ b/src/app/common/elements/cmdtext.tsx @@ -0,0 +1,10 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; + +function renderCmdText(text: string): any { + return ⌘{text}; +} + +export { renderCmdText }; diff --git a/src/app/common/elements/dropdown.less b/src/app/common/elements/dropdown.less new file mode 100644 index 000000000..267fbffcf --- /dev/null +++ b/src/app/common/elements/dropdown.less @@ -0,0 +1,127 @@ +@import "../../../app/common/themes/themes.less"; + +.wave-dropdown { + position: relative; + height: 44px; + min-width: 150px; + width: 100%; + border: 1px solid rgba(241, 246, 243, 0.15); + border-radius: 6px; + background: rgba(255, 255, 255, 0.06); + box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.4), 0px 0px 0.5px 0px rgba(0, 0, 0, 0.5), + 0px 0px 0.5px 0px rgba(255, 255, 255, 0.5) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.2) inset; + + &.no-label { + height: 34px; + } + + &-label { + position: absolute; + left: 16px; + top: 16px; + font-size: 12.5px; + transition: all 0.3s; + color: @term-white; + line-height: 10px; + + &.float { + font-size: 10px; + top: 5px; + } + + &.offset-left { + left: 42px; + } + } + + &-display { + position: absolute; + left: 16px; + bottom: 5px; + + &.offset-left { + left: 42px; + } + } + + &-arrow { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + transition: transform 0.3s; + pointer-events: none; + + i { + font-size: 14px; + } + } + + &-arrow-rotate { + transform: translateY(-50%) rotate(180deg); // Rotate the arrow when dropdown is open + } + + &-item { + display: flex; + min-width: 120px; + padding: 5px 8px; + justify-content: space-between; + align-items: center; + align-self: stretch; + border-radius: 6px; + + &-highlighted, + &:hover { + background: rgba(241, 246, 243, 0.08); + box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.4), 0px 0px 0.5px 0px rgba(0, 0, 0, 0.5), + 0px 0px 0.5px 0px rgba(255, 255, 255, 0.5) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.2) inset; + } + } + + .wave-input-decoration { + position: absolute; + top: 0; + height: 100%; + } + + .wave-input-decoration.end-position { + margin-right: 44px; + right: 0; + } + + .wave-input-decoration.start-position { + left: 0; + } + + &-error { + border-color: @term-red; + } + + &:focus { + border-color: @term-green; + } +} + +.wave-dropdown-menu { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 2px; + max-height: 200px; + overflow-y: auto; + padding: 6px; + flex-direction: column; + align-items: flex-start; + gap: 4px; + border-radius: 6px; + background: #151715; + box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.5), 0px 3px 8px 0px rgba(0, 0, 0, 0.35), 0px 0px 0.5px 0px #fff inset, + 0px 0.5px 0px 0px rgba(255, 255, 255, 0.2) inset; + animation-fill-mode: forwards; + z-index: 1000; +} + +.wave-dropdown-menu-close { + z-index: 0; +} diff --git a/src/app/common/elements/dropdown.tsx b/src/app/common/elements/dropdown.tsx new file mode 100644 index 000000000..0b9ea4c86 --- /dev/null +++ b/src/app/common/elements/dropdown.tsx @@ -0,0 +1,259 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import { boundMethod } from "autobind-decorator"; +import cn from "classnames"; +import { If } from "tsx-control-statements/components"; +import ReactDOM from "react-dom"; + +import "./dropdown.less"; + +interface DropdownDecorationProps { + startDecoration?: React.ReactNode; + endDecoration?: React.ReactNode; +} + +interface DropdownProps { + label?: string; + options: { value: string; label: string }[]; + value?: string; + className?: string; + onChange: (value: string) => void; + placeholder?: string; + decoration?: DropdownDecorationProps; + defaultValue?: string; + required?: boolean; +} + +interface DropdownState { + isOpen: boolean; + internalValue: string; + highlightedIndex: number; + isTouched: boolean; +} + +@mobxReact.observer +class Dropdown extends React.Component { + wrapperRef: React.RefObject; + menuRef: React.RefObject; + timeoutId: any; + + constructor(props: DropdownProps) { + super(props); + this.state = { + isOpen: false, + internalValue: props.defaultValue || "", + highlightedIndex: -1, + isTouched: false, + }; + this.wrapperRef = React.createRef(); + this.menuRef = React.createRef(); + } + + componentDidMount() { + document.addEventListener("mousedown", this.handleClickOutside); + } + + componentWillUnmount() { + document.removeEventListener("mousedown", this.handleClickOutside); + } + + componentDidUpdate(prevProps: Readonly, prevState: Readonly, snapshot?: any): void { + // If the dropdown was open but now is closed, start the timeout + if (prevState.isOpen && !this.state.isOpen) { + this.timeoutId = setTimeout(() => { + if (this.menuRef.current) { + this.menuRef.current.style.display = "none"; + } + }, 300); // Time is equal to the animation duration + } + // If the dropdown is now open, cancel any existing timeout and show the menu + else if (!prevState.isOpen && this.state.isOpen) { + if (this.timeoutId !== null) { + clearTimeout(this.timeoutId); // Cancel any existing timeout + this.timeoutId = null; + } + if (this.menuRef.current) { + this.menuRef.current.style.display = "inline-flex"; + } + } + } + + @boundMethod + handleClickOutside(event: MouseEvent) { + // Check if the click is outside both the wrapper and the menu + if ( + this.wrapperRef.current && + !this.wrapperRef.current.contains(event.target as Node) && + this.menuRef.current && + !this.menuRef.current.contains(event.target as Node) + ) { + this.setState({ isOpen: false }); + } + } + + @boundMethod + handleClick() { + this.toggleDropdown(); + } + + @boundMethod + handleFocus() { + this.setState({ isTouched: true }); + } + + @boundMethod + handleKeyDown(event: React.KeyboardEvent) { + const { options } = this.props; + const { isOpen, highlightedIndex } = this.state; + + switch (event.key) { + case "Enter": + case " ": + if (isOpen) { + const option = options[highlightedIndex]; + if (option) { + this.handleSelect(option.value, undefined); + } + } else { + this.toggleDropdown(); + } + break; + case "Escape": + this.setState({ isOpen: false }); + break; + case "ArrowUp": + if (isOpen) { + this.setState((prevState) => ({ + highlightedIndex: + prevState.highlightedIndex > 0 ? prevState.highlightedIndex - 1 : options.length - 1, + })); + } + break; + case "ArrowDown": + if (isOpen) { + this.setState((prevState) => ({ + highlightedIndex: + prevState.highlightedIndex < options.length - 1 ? prevState.highlightedIndex + 1 : 0, + })); + } + break; + case "Tab": + this.setState({ isOpen: false }); + break; + } + } + + @boundMethod + handleSelect(value: string, event?: React.MouseEvent | React.KeyboardEvent) { + const { onChange } = this.props; + if (event) { + event.stopPropagation(); // This stops the event from bubbling up to the wrapper + } + + if (!("value" in this.props)) { + this.setState({ internalValue: value }); + } + onChange(value); + this.setState({ isOpen: false, isTouched: true }); + } + + @boundMethod + toggleDropdown() { + this.setState((prevState) => ({ isOpen: !prevState.isOpen, isTouched: true })); + } + + @boundMethod + calculatePosition(): React.CSSProperties { + if (this.wrapperRef.current) { + const rect = this.wrapperRef.current.getBoundingClientRect(); + return { + position: "absolute", + top: `${rect.bottom + window.scrollY}px`, + left: `${rect.left + window.scrollX}px`, + width: `${rect.width}px`, + }; + } + return {}; + } + + render() { + const { label, options, value, placeholder, decoration, className, required } = this.props; + const { isOpen, internalValue, highlightedIndex, isTouched } = this.state; + + const currentValue = value ?? internalValue; + const selectedOptionLabel = + options.find((option) => option.value === currentValue)?.label || placeholder || internalValue; + + // Determine if the dropdown should be marked as having an error + const isError = + required && + (value === undefined || value === "") && + (internalValue === undefined || internalValue === "") && + isTouched; + + // Determine if the label should float + const shouldLabelFloat = !!value || !!internalValue || !!placeholder || isOpen; + + const dropdownMenu = isOpen + ? ReactDOM.createPortal( +
+ {options.map((option, index) => ( +
this.handleSelect(option.value, e)} + onMouseEnter={() => this.setState({ highlightedIndex: index })} + onMouseLeave={() => this.setState({ highlightedIndex: -1 })} + > + {option.label} +
+ ))} +
, + document.getElementById("app")! + ) + : null; + + return ( +
+ {decoration?.startDecoration && <>{decoration.startDecoration}} + +
+ {label} +
+
+
+ {selectedOptionLabel} +
+
+ +
+ {dropdownMenu} + {decoration?.endDecoration && <>{decoration.endDecoration}} +
+ ); + } +} + +export { Dropdown }; diff --git a/src/app/common/elements/iconbutton.tsx b/src/app/common/elements/iconbutton.tsx new file mode 100644 index 000000000..e843b522b --- /dev/null +++ b/src/app/common/elements/iconbutton.tsx @@ -0,0 +1,21 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import { Button } from "./button"; +class IconButton extends Button { + render() { + const { children, theme, variant = "solid", ...rest } = this.props; + const className = `wave-button icon-button ${theme} ${variant}`; + + return ( + + ); + } +} + +export default IconButton; + +export { IconButton }; diff --git a/src/app/common/elements/index.tsx b/src/app/common/elements/index.tsx new file mode 100644 index 000000000..fbdcad7e6 --- /dev/null +++ b/src/app/common/elements/index.tsx @@ -0,0 +1,20 @@ +export { Button } from "./button"; +export { Checkbox } from "./checkbox"; +export { CmdStrCode } from "./cmdstrcode"; +export { renderCmdText } from "./cmdtext"; +export { Dropdown } from "./dropdown"; +export { IconButton } from "./iconbutton"; +export { InlineSettingsTextEdit } from "./inlinesettingstextedit"; +export { InputDecoration } from "./inputdecoration"; +export { LinkButton } from "./linkbutton"; +export { Markdown } from "./markdown"; +export { Modal } from "./modal"; +export { NumberField } from "./numberfield"; +export { PasswordField } from "./passwordfield"; +export { ResizableSidebar } from "./resizablesidebar"; +export { SettingsError } from "./settingserror"; +export { ShowWaveShellInstallPrompt } from "./showwaveshellinstallprompt"; +export { Status } from "./status"; +export { TextField } from "./textfield"; +export { Toggle } from "./toggle"; +export { Tooltip } from "./tooltip"; diff --git a/src/app/common/elements/inlinesettingstextedit.less b/src/app/common/elements/inlinesettingstextedit.less new file mode 100644 index 000000000..bab9243bc --- /dev/null +++ b/src/app/common/elements/inlinesettingstextedit.less @@ -0,0 +1,40 @@ +@import "../../../app/common/themes/themes.less"; + +.inline-edit { + .icon { + display: inline; + width: 12px; + height: 12px; + margin-left: 1em; + vertical-align: middle; + font-size: 14px; + } + + .button { + padding-top: 0; + } + + &.edit-not-active { + cursor: pointer; + + i.fa-pen { + margin-left: 5px; + } + + &:hover { + text-decoration: underline; + text-decoration-style: dotted; + } + } + + &.edit-active { + input.input { + padding: 0; + height: 20px; + } + + .button { + height: 20px; + } + } +} diff --git a/src/app/common/elements/inlinesettingstextedit.tsx b/src/app/common/elements/inlinesettingstextedit.tsx new file mode 100644 index 000000000..194eeead3 --- /dev/null +++ b/src/app/common/elements/inlinesettingstextedit.tsx @@ -0,0 +1,149 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import * as mobx from "mobx"; +import { boundMethod } from "autobind-decorator"; +import cn from "classnames"; +import { If } from "tsx-control-statements/components"; +import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "../../../util/keyutil"; + +import "./inlinesettingstextedit.less"; + +type OV = mobx.IObservableValue; + +@mobxReact.observer +class InlineSettingsTextEdit extends React.Component< + { + text: string; + value: string; + onChange: (val: string) => void; + maxLength: number; + placeholder: string; + showIcon?: boolean; + }, + {} +> { + isEditing: OV = mobx.observable.box(false, { name: "inlineedit-isEditing" }); + tempText: OV; + shouldFocus: boolean = false; + inputRef: React.RefObject = React.createRef(); + + componentDidUpdate(): void { + if (this.shouldFocus) { + this.shouldFocus = false; + if (this.inputRef.current != null) { + this.inputRef.current.focus(); + } + } + } + + @boundMethod + handleChangeText(e: any): void { + mobx.action(() => { + this.tempText.set(e.target.value); + })(); + } + + @boundMethod + confirmChange(): void { + mobx.action(() => { + let newText = this.tempText.get(); + this.isEditing.set(false); + this.tempText = null; + this.props.onChange(newText); + })(); + } + + @boundMethod + cancelChange(): void { + mobx.action(() => { + this.isEditing.set(false); + this.tempText = null; + })(); + } + + @boundMethod + handleKeyDown(e: any): void { + let waveEvent = adaptFromReactOrNativeKeyEvent(e); + if (checkKeyPressed(waveEvent, "Enter")) { + e.preventDefault(); + e.stopPropagation(); + this.confirmChange(); + return; + } + if (checkKeyPressed(waveEvent, "Escape")) { + e.preventDefault(); + e.stopPropagation(); + this.cancelChange(); + return; + } + return; + } + + @boundMethod + clickEdit(): void { + mobx.action(() => { + this.isEditing.set(true); + this.shouldFocus = true; + this.tempText = mobx.observable.box(this.props.value, { name: "inlineedit-tempText" }); + })(); + } + + render() { + if (this.isEditing.get()) { + return ( +
+
+
+ +
+
+
+ + + +
+
+
+
+ + + +
+
+
+
+ ); + } else { + return ( +
+ {this.props.text} + + + +
+ ); + } + } +} + +export { InlineSettingsTextEdit }; diff --git a/src/app/common/elements/inputdecoration.less b/src/app/common/elements/inputdecoration.less new file mode 100644 index 000000000..0ae5f74b5 --- /dev/null +++ b/src/app/common/elements/inputdecoration.less @@ -0,0 +1,19 @@ +@import "../../../app/common/themes/themes.less"; + +.wave-input-decoration { + display: flex; + align-items: center; + justify-content: center; + + i { + font-size: 13px; + } +} + +.wave-input-decoration.start-position { + margin: 0 4px 0 16px; +} + +.wave-input-decoration.end-position { + margin: 0 16px 0 8px; +} diff --git a/src/app/common/elements/inputdecoration.tsx b/src/app/common/elements/inputdecoration.tsx new file mode 100644 index 000000000..d74b1a417 --- /dev/null +++ b/src/app/common/elements/inputdecoration.tsx @@ -0,0 +1,32 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import cn from "classnames"; + +import "./inputdecoration.less"; + +interface InputDecorationProps { + position?: "start" | "end"; + children: React.ReactNode; +} + +@mobxReact.observer +class InputDecoration extends React.Component { + render() { + const { children, position = "end" } = this.props; + return ( +
+ {children} +
+ ); + } +} + +export { InputDecoration }; diff --git a/src/app/common/elements/linkbutton.tsx b/src/app/common/elements/linkbutton.tsx new file mode 100644 index 000000000..5b639c50c --- /dev/null +++ b/src/app/common/elements/linkbutton.tsx @@ -0,0 +1,28 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import cn from "classnames"; +import { ButtonProps } from "./button"; + +interface LinkButtonProps extends ButtonProps { + href: string; + rel?: string; + target?: string; +} + +class LinkButton extends React.Component { + render() { + const { leftIcon, rightIcon, children, className, ...rest } = this.props; + + return ( + + {leftIcon && {leftIcon}} + {children} + {rightIcon && {rightIcon}} + + ); + } +} + +export { LinkButton }; diff --git a/src/app/common/elements/markdown.less b/src/app/common/elements/markdown.less new file mode 100644 index 000000000..ef8943132 --- /dev/null +++ b/src/app/common/elements/markdown.less @@ -0,0 +1,91 @@ +@import "../../../app/common/themes/themes.less"; + +.markdown { + color: @term-white; + margin-bottom: 10px; + font-family: @markdown-font; + font-size: 14px; + + code { + background-color: @markdown-highlight; + color: @term-white; + font-family: @terminal-font; + border-radius: 4px; + } + + code.inline { + padding-top: 0; + padding-bottom: 0; + font-family: @terminal-font; + } + + .title { + color: @term-white; + margin-top: 16px; + margin-bottom: 8px; + } + + strong { + color: @term-white; + } + + a { + color: #32afff; + } + + table { + tr th { + color: @term-white; + } + } + + ul { + list-style-type: disc; + list-style-position: outside; + margin-left: 16px; + } + + ol { + list-style-position: outside; + margin-left: 19px; + } + + blockquote { + margin: 4px 10px 4px 10px; + border-radius: 3px; + background-color: @markdown-highlight; + padding: 2px 4px 2px 6px; + } + + pre { + background-color: @markdown-highlight; + margin: 4px 10px 4px 10px; + padding: 6px 6px 6px 10px; + border-radius: 4px; + } + + pre.selected { + outline: 2px solid @term-green; + } + + .title.is-1 { + border-bottom: 1px solid #777; + padding-bottom: 6px; + } + .title.is-2 { + border-bottom: 1px solid #777; + padding-bottom: 6px; + } + .title.is-3 { + } + .title.is-4 { + } + .title.is-5 { + } + .title.is-6 { + } +} + +.markdown > *:first-child { + margin-top: 0 !important; +} diff --git a/src/app/common/elements/markdown.tsx b/src/app/common/elements/markdown.tsx new file mode 100644 index 000000000..66804d2c6 --- /dev/null +++ b/src/app/common/elements/markdown.tsx @@ -0,0 +1,105 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import cn from "classnames"; +import { GlobalModel } from "../../../model/model"; + +import "./markdown.less"; + +function LinkRenderer(props: any): any { + let newUrl = "https://extern?" + encodeURIComponent(props.href); + return ( + + {props.children} + + ); +} + +function HeaderRenderer(props: any, hnum: number): any { + return
{props.children}
; +} + +function CodeRenderer(props: any): any { + return {props.children}; +} + +@mobxReact.observer +class CodeBlockMarkdown extends React.Component<{ children: React.ReactNode; codeSelectSelectedIndex?: number }, {}> { + blockIndex: number; + blockRef: React.RefObject; + + constructor(props) { + super(props); + this.blockRef = React.createRef(); + this.blockIndex = GlobalModel.inputModel.addCodeBlockToCodeSelect(this.blockRef); + } + + render() { + let clickHandler: (e: React.MouseEvent, blockIndex: number) => void; + let inputModel = GlobalModel.inputModel; + clickHandler = (e: React.MouseEvent, blockIndex: number) => { + inputModel.setCodeSelectSelectedCodeBlock(blockIndex); + }; + let selected = this.blockIndex == this.props.codeSelectSelectedIndex; + return ( +
 clickHandler(event, this.blockIndex)}
+            >
+                {this.props.children}
+            
+ ); + } +} + +@mobxReact.observer +class Markdown extends React.Component< + { text: string; style?: any; extraClassName?: string; codeSelect?: boolean }, + {} +> { + CodeBlockRenderer(props: any, codeSelect: boolean, codeSelectIndex: number): any { + if (codeSelect) { + return {props.children}; + } else { + const clickHandler = (e: React.MouseEvent) => { + let blockText = (e.target as HTMLElement).innerText; + if (blockText) { + blockText = blockText.replace(/\n$/, ""); // remove trailing newline + navigator.clipboard.writeText(blockText); + } + }; + return
 clickHandler(event)}>{props.children}
; + } + } + + render() { + let text = this.props.text; + let codeSelect = this.props.codeSelect; + let curCodeSelectIndex = GlobalModel.inputModel.getCodeSelectSelectedIndex(); + let markdownComponents = { + a: LinkRenderer, + h1: (props) => HeaderRenderer(props, 1), + h2: (props) => HeaderRenderer(props, 2), + h3: (props) => HeaderRenderer(props, 3), + h4: (props) => HeaderRenderer(props, 4), + h5: (props) => HeaderRenderer(props, 5), + h6: (props) => HeaderRenderer(props, 6), + code: (props) => CodeRenderer(props), + pre: (props) => this.CodeBlockRenderer(props, codeSelect, curCodeSelectIndex), + }; + return ( +
+ + {text} + +
+ ); + } +} + +export { Markdown }; diff --git a/src/app/common/elements/modal.less b/src/app/common/elements/modal.less new file mode 100644 index 000000000..7ac04104f --- /dev/null +++ b/src/app/common/elements/modal.less @@ -0,0 +1,79 @@ +@import "../../../app/common/themes/themes.less"; + +.wave-modal-container { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + justify-content: center; + align-items: center; + z-index: 500; + + .wave-modal-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(21, 23, 21, 0.7); + z-index: 1; + } +} + +.wave-modal { + z-index: 2; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; + border-radius: 10px; + background: #151715; + box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.35), 0px 10px 24px 0px rgba(0, 0, 0, 0.45), + 0px 0px 0.5px 0px rgba(255, 255, 255, 0.5) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.2) inset; + + .wave-modal-content { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + + .wave-modal-header { + width: 100%; + display: flex; + align-items: center; + padding: 12px 14px 12px 20px; + justify-content: space-between; + line-height: 20px; + border-bottom: 1px solid rgba(250, 250, 250, 0.1); + + .wave-modal-title { + color: #eceeec; + font-size: 15px; + } + + button { + i { + font-size: 18px; + } + } + } + + .wave-modal-body { + width: 100%; + padding: 0px 20px; + } + + .wave-modal-footer { + display: flex; + justify-content: flex-end; + width: 100%; + padding: 0 20px 20px; + + button:last-child { + margin-left: 8px; + } + } + } +} diff --git a/src/app/common/elements/modal.tsx b/src/app/common/elements/modal.tsx new file mode 100644 index 000000000..cc8c1ff80 --- /dev/null +++ b/src/app/common/elements/modal.tsx @@ -0,0 +1,81 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobx from "mobx"; +import { If } from "tsx-control-statements/components"; +import ReactDOM from "react-dom"; +import { Button } from "./button"; +import { IconButton } from "./iconbutton"; + +import "./modal.less"; + +type OV = mobx.IObservableValue; + +interface ModalHeaderProps { + onClose?: () => void; + title: string; +} + +const ModalHeader: React.FC = ({ onClose, title }) => ( +
+ {
{title}
} + + + + + +
+); + +interface ModalFooterProps { + onCancel?: () => void; + onOk?: () => void; + cancelLabel?: string; + okLabel?: string; +} + +const ModalFooter: React.FC = ({ onCancel, onOk, cancelLabel = "Cancel", okLabel = "Ok" }) => ( +
+ {onCancel && ( + + )} + {onOk && } +
+); + +interface ModalProps { + className?: string; + children?: React.ReactNode; + onClickBackdrop?: () => void; +} + +class Modal extends React.Component { + static Header = ModalHeader; + static Footer = ModalFooter; + + renderBackdrop(onClick: (() => void) | undefined) { + return
; + } + + renderModal() { + const { className, children } = this.props; + + return ( +
+ {this.renderBackdrop(this.props.onClickBackdrop)} +
+
{children}
+
+
+ ); + } + + render() { + return ReactDOM.createPortal(this.renderModal(), document.getElementById("app")); + } +} + +export { Modal }; diff --git a/src/app/common/elements/numberfield.tsx b/src/app/common/elements/numberfield.tsx new file mode 100644 index 000000000..1a40d6847 --- /dev/null +++ b/src/app/common/elements/numberfield.tsx @@ -0,0 +1,39 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import { boundMethod } from "autobind-decorator"; + +import { TextField } from "./textfield"; + +class NumberField extends TextField { + @boundMethod + handleInputChange(e: React.ChangeEvent) { + const { required, onChange } = this.props; + const inputValue = e.target.value; + + // Allow only numeric input + if (inputValue === "" || /^\d*$/.test(inputValue)) { + // Update the internal state only if the component is not controlled. + if (this.props.value === undefined) { + const isError = required ? inputValue.trim() === "" : false; + + this.setState({ + internalValue: inputValue, + error: isError, + hasContent: Boolean(inputValue), + }); + } + + onChange && onChange(inputValue); + } + } + + render() { + // Use the render method from TextField but add the onKeyDown handler + const renderedTextField = super.render(); + return React.cloneElement(renderedTextField); + } +} + +export { NumberField }; diff --git a/src/app/common/elements/passwordfield.less b/src/app/common/elements/passwordfield.less new file mode 100644 index 000000000..88c6e948d --- /dev/null +++ b/src/app/common/elements/passwordfield.less @@ -0,0 +1,30 @@ +@import "../../../app/common/themes/themes.less"; + +.wave-password { + .wave-textfield-inner-eye { + position: absolute; + right: 16px; + top: 52%; + transform: translateY(-50%); + transition: transform 0.3s; + + i { + font-size: 14px; + } + } + + .wave-input-decoration { + position: absolute; + top: 0; + height: 100%; + } + + .wave-input-decoration.end-position { + margin-right: 47px; + right: 0; + } + + .wave-input-decoration.start-position { + left: 0; + } +} diff --git a/src/app/common/elements/passwordfield.tsx b/src/app/common/elements/passwordfield.tsx new file mode 100644 index 000000000..b2781e03b --- /dev/null +++ b/src/app/common/elements/passwordfield.tsx @@ -0,0 +1,100 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import { boundMethod } from "autobind-decorator"; +import cn from "classnames"; +import { If } from "tsx-control-statements/components"; +import { TextFieldState, TextField } from "./textfield"; + +import "./passwordfield.less"; + +interface PasswordFieldState extends TextFieldState { + passwordVisible: boolean; +} + +@mobxReact.observer +class PasswordField extends TextField { + state: PasswordFieldState; + + constructor(props) { + super(props); + this.state = { + ...this.state, + passwordVisible: false, + }; + } + + @boundMethod + togglePasswordVisibility() { + //@ts-ignore + this.setState((prevState) => ({ + //@ts-ignore + passwordVisible: !prevState.passwordVisible, + })); + } + + @boundMethod + handleInputChange(e: React.ChangeEvent) { + // Call the parent handleInputChange method + super.handleInputChange(e); + } + + render() { + const { decoration, className, placeholder, maxLength, label } = this.props; + const { focused, internalValue, error, passwordVisible } = this.state; + const inputValue = this.props.value ?? internalValue; + + // The input should always receive the real value + const inputProps = { + className: cn("wave-textfield-inner-input", { "offset-left": decoration?.startDecoration }), + ref: this.inputRef, + id: label, + value: inputValue, // Always use the real value here + onChange: this.handleInputChange, + onFocus: this.handleFocus, + onBlur: this.handleBlur, + placeholder: placeholder, + maxLength: maxLength, + }; + + return ( +
+ {decoration?.startDecoration && <>{decoration.startDecoration}} +
+ + + + + + + +
+ + + + + + +
+
+ {decoration?.endDecoration && <>{decoration.endDecoration}} +
+ ); + } +} + +export { PasswordField }; diff --git a/src/app/common/elements/resizablesidebar.less b/src/app/common/elements/resizablesidebar.less new file mode 100644 index 000000000..819c05ad1 --- /dev/null +++ b/src/app/common/elements/resizablesidebar.less @@ -0,0 +1,9 @@ +@import "../../../app/common/themes/themes.less"; + +.sidebar-handle { + position: absolute; + top: 0; + bottom: 0; + width: 5px; + cursor: col-resize; +} diff --git a/src/app/common/elements/resizablesidebar.tsx b/src/app/common/elements/resizablesidebar.tsx new file mode 100644 index 000000000..5617f9c55 --- /dev/null +++ b/src/app/common/elements/resizablesidebar.tsx @@ -0,0 +1,171 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import * as mobx from "mobx"; +import { boundMethod } from "autobind-decorator"; +import cn from "classnames"; +import { GlobalModel, GlobalCommandRunner } from "../../../model/model"; +import { MagicLayout } from "../../magiclayout"; + +import "./resizablesidebar.less"; + +type OV = mobx.IObservableValue; + +interface ResizableSidebarProps { + parentRef: React.RefObject; + position: "left" | "right"; + enableSnap?: boolean; + className?: string; + children?: (toggleCollapsed: () => void) => React.ReactNode; + toggleCollapse?: () => void; +} + +@mobxReact.observer +class ResizableSidebar extends React.Component { + resizeStartWidth: number = 0; + startX: number = 0; + prevDelta: number = 0; + prevDragDirection: string = null; + disposeReaction: any; + + @boundMethod + startResizing(event: React.MouseEvent) { + event.preventDefault(); + + const { parentRef, position } = this.props; + const parentRect = parentRef.current?.getBoundingClientRect(); + + if (!parentRect) return; + + if (position === "right") { + this.startX = parentRect.right - event.clientX; + } else { + this.startX = event.clientX - parentRect.left; + } + + const mainSidebarModel = GlobalModel.mainSidebarModel; + const collapsed = mainSidebarModel.getCollapsed(); + + this.resizeStartWidth = mainSidebarModel.getWidth(); + document.addEventListener("mousemove", this.onMouseMove); + document.addEventListener("mouseup", this.stopResizing); + + document.body.style.cursor = "col-resize"; + mobx.action(() => { + mainSidebarModel.setTempWidthAndTempCollapsed(this.resizeStartWidth, collapsed); + mainSidebarModel.isDragging.set(true); + })(); + } + + @boundMethod + onMouseMove(event: MouseEvent) { + event.preventDefault(); + + const { parentRef, enableSnap, position } = this.props; + const parentRect = parentRef.current?.getBoundingClientRect(); + const mainSidebarModel = GlobalModel.mainSidebarModel; + + if (!mainSidebarModel.isDragging.get() || !parentRect) return; + + let delta: number, newWidth: number; + + if (position === "right") { + delta = parentRect.right - event.clientX - this.startX; + } else { + delta = event.clientX - parentRect.left - this.startX; + } + + newWidth = this.resizeStartWidth + delta; + + if (enableSnap) { + const minWidth = MagicLayout.MainSidebarMinWidth; + const snapPoint = minWidth + MagicLayout.MainSidebarSnapThreshold; + const dragResistance = MagicLayout.MainSidebarDragResistance; + let dragDirection: string; + + if (delta - this.prevDelta > 0) { + dragDirection = "+"; + } else if (delta - this.prevDelta == 0) { + if (this.prevDragDirection == "+") { + dragDirection = "+"; + } else { + dragDirection = "-"; + } + } else { + dragDirection = "-"; + } + + this.prevDelta = delta; + this.prevDragDirection = dragDirection; + + if (newWidth - dragResistance > minWidth && newWidth < snapPoint && dragDirection == "+") { + newWidth = snapPoint; + mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, false); + } else if (newWidth + dragResistance < snapPoint && dragDirection == "-") { + newWidth = minWidth; + mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, true); + } else if (newWidth > snapPoint) { + mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, false); + } + } else { + if (newWidth <= MagicLayout.MainSidebarMinWidth) { + mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, true); + } else { + mainSidebarModel.setTempWidthAndTempCollapsed(newWidth, false); + } + } + } + + @boundMethod + stopResizing() { + let mainSidebarModel = GlobalModel.mainSidebarModel; + + GlobalCommandRunner.clientSetSidebar( + mainSidebarModel.tempWidth.get(), + mainSidebarModel.tempCollapsed.get() + ).finally(() => { + mobx.action(() => { + mainSidebarModel.isDragging.set(false); + })(); + }); + + document.removeEventListener("mousemove", this.onMouseMove); + document.removeEventListener("mouseup", this.stopResizing); + document.body.style.cursor = ""; + } + + @boundMethod + toggleCollapsed() { + const mainSidebarModel = GlobalModel.mainSidebarModel; + + const tempCollapsed = mainSidebarModel.getCollapsed(); + const width = mainSidebarModel.getWidth(true); + mainSidebarModel.setTempWidthAndTempCollapsed(width, !tempCollapsed); + GlobalCommandRunner.clientSetSidebar(width, !tempCollapsed); + } + + render() { + const { className, children } = this.props; + const mainSidebarModel = GlobalModel.mainSidebarModel; + const width = mainSidebarModel.getWidth(); + const isCollapsed = mainSidebarModel.getCollapsed(); + + return ( +
+
{children(this.toggleCollapsed)}
+
+
+ ); + } +} + +export { ResizableSidebar }; diff --git a/src/app/common/elements/settingserror.tsx b/src/app/common/elements/settingserror.tsx new file mode 100644 index 000000000..aa72b836a --- /dev/null +++ b/src/app/common/elements/settingserror.tsx @@ -0,0 +1,36 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import * as mobx from "mobx"; +import { boundMethod } from "autobind-decorator"; + +type OV = mobx.IObservableValue; + +@mobxReact.observer +class SettingsError extends React.Component<{ errorMessage: OV }, {}> { + @boundMethod + dismissError(): void { + mobx.action(() => { + this.props.errorMessage.set(null); + })(); + } + + render() { + if (this.props.errorMessage.get() == null) { + return null; + } + return ( +
+
Error: {this.props.errorMessage.get()}
+
+
+ +
+
+ ); + } +} + +export { SettingsError }; diff --git a/src/app/common/elements/showwaveshellinstallprompt.tsx b/src/app/common/elements/showwaveshellinstallprompt.tsx new file mode 100644 index 000000000..34fbb0e10 --- /dev/null +++ b/src/app/common/elements/showwaveshellinstallprompt.tsx @@ -0,0 +1,28 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { GlobalModel } from "../../../model/model"; +import * as appconst from "../../appconst"; + +function ShowWaveShellInstallPrompt(callbackFn: () => void) { + let message: string = ` +In order to use Wave's advanced features like unified history and persistent sessions, Wave installs a small, open-source helper program called WaveShell on your remote machine. WaveShell does not open any external ports and only communicates with your *local* Wave terminal instance over ssh. For more information please see [the docs](https://docs.waveterm.dev/reference/waveshell). + `; + message = message.trim(); + let prtn = GlobalModel.showAlert({ + message: message, + confirm: true, + markdown: true, + confirmflag: appconst.ConfirmKey_HideShellPrompt, + }); + prtn.then((confirm) => { + if (!confirm) { + return; + } + if (callbackFn) { + callbackFn(); + } + }); +} + +export { ShowWaveShellInstallPrompt }; diff --git a/src/app/common/elements/status.less b/src/app/common/elements/status.less new file mode 100644 index 000000000..9c1028b83 --- /dev/null +++ b/src/app/common/elements/status.less @@ -0,0 +1,30 @@ +@import "../../../app/common/themes/themes.less"; + +.wave-status-container { + display: flex; + align-items: center; + + .dot { + height: 6px; + width: 6px; + border-radius: 50%; + display: inline-block; + margin-right: 8px; + } + + .dot.green { + background-color: @status-connected; + } + + .dot.red { + background-color: @status-error; + } + + .dot.gray { + background-color: @status-disconnected; + } + + .dot.yellow { + background-color: @status-connecting; + } +} diff --git a/src/app/common/elements/status.tsx b/src/app/common/elements/status.tsx new file mode 100644 index 000000000..58005423d --- /dev/null +++ b/src/app/common/elements/status.tsx @@ -0,0 +1,34 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import { boundMethod } from "autobind-decorator"; + +import "./status.less"; + +interface StatusProps { + status: "green" | "red" | "gray" | "yellow"; + text: string; +} + +class Status extends React.Component { + @boundMethod + renderDot() { + const { status } = this.props; + + return
; + } + + render() { + const { text } = this.props; + + return ( +
+ {this.renderDot()} + {text} +
+ ); + } +} + +export { Status }; diff --git a/src/app/common/elements/textfield.less b/src/app/common/elements/textfield.less new file mode 100644 index 000000000..12261ce69 --- /dev/null +++ b/src/app/common/elements/textfield.less @@ -0,0 +1,82 @@ +@import "../../../app/common/themes/themes.less"; + +.wave-textfield { + display: flex; + align-items: center; + border-radius: 6px; + position: relative; + height: 44px; + min-width: 412px; + gap: 6px; + border: 1px solid rgba(241, 246, 243, 0.15); + background: rgba(255, 255, 255, 0.06); + box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.4), 0px 0px 0.5px 0px rgba(0, 0, 0, 0.5), + 0px 0px 0.5px 0px rgba(255, 255, 255, 0.5) inset, 0px 0.5px 0px 0px rgba(255, 255, 255, 0.2) inset; + + &:hover { + cursor: text; + } + + &.focused { + border-color: @term-green; + } + + &.disabled { + opacity: 0.75; + } + + &.error { + border-color: @term-red; + } + + &-inner { + display: flex; + align-items: flex-end; + height: 100%; + position: relative; + flex-grow: 1; + + &-label { + position: absolute; + left: 16px; + top: 16px; + font-size: 12.5px; + transition: all 0.3s; + color: @text-secondary; + line-height: 10px; + + &.float { + font-size: 10px; + top: 5px; + } + + &.offset-left { + left: 0; + } + } + + &-input { + width: 100%; + height: 30px; + border: none; + padding: 5px 0 5px 16px; + font-size: 16px; + outline: none; + background-color: transparent; + color: @term-bright-white; + line-height: 20px; + + &.offset-left { + padding: 5px 16px 5px 0; + } + } + } + + &.no-label { + height: 34px; + + input { + height: 32px; + } + } +} diff --git a/src/app/common/elements/textfield.tsx b/src/app/common/elements/textfield.tsx new file mode 100644 index 000000000..a129eb22e --- /dev/null +++ b/src/app/common/elements/textfield.tsx @@ -0,0 +1,173 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import { boundMethod } from "autobind-decorator"; +import cn from "classnames"; +import { If } from "tsx-control-statements/components"; + +import "./textfield.less"; + +interface TextFieldDecorationProps { + startDecoration?: React.ReactNode; + endDecoration?: React.ReactNode; +} +interface TextFieldProps { + label?: string; + value?: string; + className?: string; + onChange?: (value: string) => void; + placeholder?: string; + defaultValue?: string; + decoration?: TextFieldDecorationProps; + required?: boolean; + maxLength?: number; + autoFocus?: boolean; + disabled?: boolean; +} + +interface TextFieldState { + focused: boolean; + internalValue: string; + error: boolean; + showHelpText: boolean; + hasContent: boolean; +} + +class TextField extends React.Component { + inputRef: React.RefObject; + state: TextFieldState; + + constructor(props: TextFieldProps) { + super(props); + const hasInitialContent = Boolean(props.value || props.defaultValue); + this.state = { + focused: false, + hasContent: hasInitialContent, + internalValue: props.defaultValue || "", + error: false, + showHelpText: false, + }; + this.inputRef = React.createRef(); + } + + componentDidUpdate(prevProps: TextFieldProps) { + // Only update the focus state if using as controlled + if (this.props.value !== undefined && this.props.value !== prevProps.value) { + this.setState({ focused: Boolean(this.props.value) }); + } + } + + // Method to handle focus at the component level + @boundMethod + handleComponentFocus() { + if (this.inputRef.current && !this.inputRef.current.contains(document.activeElement)) { + this.inputRef.current.focus(); + } + } + + // Method to handle blur at the component level + @boundMethod + handleComponentBlur() { + if (this.inputRef.current?.contains(document.activeElement)) { + this.inputRef.current.blur(); + } + } + + @boundMethod + handleFocus() { + this.setState({ focused: true }); + } + + @boundMethod + handleBlur() { + const { required } = this.props; + if (this.inputRef.current) { + const value = this.inputRef.current.value; + if (required && !value) { + this.setState({ error: true, focused: false }); + } else { + this.setState({ error: false, focused: false }); + } + } + } + + @boundMethod + handleHelpTextClick() { + this.setState((prevState) => ({ showHelpText: !prevState.showHelpText })); + } + + @boundMethod + handleInputChange(e: React.ChangeEvent) { + const { required, onChange } = this.props; + const inputValue = e.target.value; + + // Check if value is empty and the field is required + if (required && !inputValue) { + this.setState({ error: true, hasContent: false }); + } else { + this.setState({ error: false, hasContent: Boolean(inputValue) }); + } + + // Update the internal state for uncontrolled version + if (this.props.value === undefined) { + this.setState({ internalValue: inputValue }); + } + + onChange && onChange(inputValue); + } + + render() { + const { label, value, placeholder, decoration, className, maxLength, autoFocus, disabled } = this.props; + const { focused, internalValue, error } = this.state; + + // Decide if the input should behave as controlled or uncontrolled + const inputValue = value ?? internalValue; + + return ( +
+ {decoration?.startDecoration && <>{decoration.startDecoration}} +
+ + + + +
+ {decoration?.endDecoration && <>{decoration.endDecoration}} +
+ ); + } +} + +export { TextField }; +export type { TextFieldProps, TextFieldDecorationProps, TextFieldState }; diff --git a/src/app/common/elements/toggle.less b/src/app/common/elements/toggle.less new file mode 100644 index 000000000..d920b9b0b --- /dev/null +++ b/src/app/common/elements/toggle.less @@ -0,0 +1,47 @@ +@import "../../../app/common/themes/themes.less"; + +.checkbox-toggle { + position: relative; + display: inline-block; + width: 40px; + height: 22px; + + input { + opacity: 0; + width: 0; + height: 0; + } + + .slider { + position: absolute; + content: ""; + cursor: pointer; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-color: #333; + transition: 0.5s; + border-radius: 33px; + } + + .slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 2px; + bottom: 2px; + background-color: @term-white; + transition: 0.5s; + border-radius: 50%; + } + + input:checked + .slider { + background-color: @term-green; + } + + input:checked + .slider:before { + transform: translateX(18px); + } +} diff --git a/src/app/common/elements/toggle.tsx b/src/app/common/elements/toggle.tsx new file mode 100644 index 000000000..b155e9464 --- /dev/null +++ b/src/app/common/elements/toggle.tsx @@ -0,0 +1,28 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import { boundMethod } from "autobind-decorator"; + +import "./toggle.less"; + +class Toggle extends React.Component<{ checked: boolean; onChange: (value: boolean) => void }, {}> { + @boundMethod + handleChange(e: any): void { + let { onChange } = this.props; + if (onChange != null) { + onChange(e.target.checked); + } + } + + render() { + return ( + + ); + } +} + +export { Toggle }; diff --git a/src/app/common/elements/tooltip.less b/src/app/common/elements/tooltip.less new file mode 100644 index 000000000..e51b85152 --- /dev/null +++ b/src/app/common/elements/tooltip.less @@ -0,0 +1,23 @@ +@import "../../../app/common/themes/themes.less"; + +.wave-tooltip { + display: flex; + position: absolute; + z-index: 1000; + flex-direction: row; + align-items: flex-start; + gap: 10px; + padding: 10px; + border: 1px solid #777; + background-color: #444; + border-radius: 5px; + overflow: hidden; + width: 300px; + + i { + display: inline; + font-size: 13px; + fill: @base-color; + padding-top: 0.2em; + } +} diff --git a/src/app/common/elements/tooltip.tsx b/src/app/common/elements/tooltip.tsx new file mode 100644 index 000000000..e746392a8 --- /dev/null +++ b/src/app/common/elements/tooltip.tsx @@ -0,0 +1,84 @@ +// Copyright 2023, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from "react"; +import * as mobxReact from "mobx-react"; +import { boundMethod } from "autobind-decorator"; +import cn from "classnames"; +import ReactDOM from "react-dom"; + +import "./tooltip.less"; + +interface TooltipProps { + message: React.ReactNode; + icon?: React.ReactNode; // Optional icon property + children: React.ReactNode; + className?: string; +} + +interface TooltipState { + isVisible: boolean; +} + +@mobxReact.observer +class Tooltip extends React.Component { + iconRef: React.RefObject; + + constructor(props: TooltipProps) { + super(props); + this.state = { + isVisible: false, + }; + this.iconRef = React.createRef(); + } + + @boundMethod + showBubble() { + this.setState({ isVisible: true }); + } + + @boundMethod + hideBubble() { + this.setState({ isVisible: false }); + } + + @boundMethod + calculatePosition() { + // Get the position of the icon element + const iconElement = this.iconRef.current; + if (iconElement) { + const rect = iconElement.getBoundingClientRect(); + return { + top: `${rect.bottom + window.scrollY - 29}px`, + left: `${rect.left + window.scrollX + rect.width / 2 - 17.5}px`, + }; + } + return {}; + } + + @boundMethod + renderBubble() { + if (!this.state.isVisible) return null; + + const style = this.calculatePosition(); + + return ReactDOM.createPortal( +
+ {this.props.icon &&
{this.props.icon}
} +
{this.props.message}
+
, + document.getElementById("app")! + ); + } + + render() { + return ( +
+ {this.props.children} + {this.renderBubble()} +
+ ); + } +} + +export { Tooltip }; diff --git a/src/app/common/modals/about.tsx b/src/app/common/modals/about.tsx index 7d46cccba..c74477b9e 100644 --- a/src/app/common/modals/about.tsx +++ b/src/app/common/modals/about.tsx @@ -6,7 +6,7 @@ import * as mobxReact from "mobx-react"; import * as mobx from "mobx"; import { boundMethod } from "autobind-decorator"; import { GlobalModel } from "../../../model/model"; -import { Modal, LinkButton } from "../common"; +import { Modal, LinkButton } from "../elements"; import * as util from "../../../util/util"; import logo from "../../assets/waveterm-logo-with-bg.svg"; diff --git a/src/app/common/modals/alert.tsx b/src/app/common/modals/alert.tsx index 4ce56fc12..39a41896d 100644 --- a/src/app/common/modals/alert.tsx +++ b/src/app/common/modals/alert.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import * as mobxReact from "mobx-react"; import { boundMethod } from "autobind-decorator"; import { If } from "tsx-control-statements/components"; -import { Markdown, Modal, Button, Checkbox } from "../common"; +import { Markdown, Modal, Button, Checkbox } from "../elements"; import { GlobalModel, GlobalCommandRunner } from "../../../model/model"; import "./alert.less"; diff --git a/src/app/common/modals/clientstop.tsx b/src/app/common/modals/clientstop.tsx index b4d4f91e0..7b8aca6f7 100644 --- a/src/app/common/modals/clientstop.tsx +++ b/src/app/common/modals/clientstop.tsx @@ -6,7 +6,7 @@ import * as mobxReact from "mobx-react"; import { boundMethod } from "autobind-decorator"; import { If } from "tsx-control-statements/components"; import { GlobalModel } from "../../../model/model"; -import { Modal, Button } from "../common"; +import { Modal, Button } from "../elements"; import "./clientstop.less"; diff --git a/src/app/common/modals/createremoteconn.tsx b/src/app/common/modals/createremoteconn.tsx index 69c0b3e06..3cb3d81e1 100644 --- a/src/app/common/modals/createremoteconn.tsx +++ b/src/app/common/modals/createremoteconn.tsx @@ -8,9 +8,17 @@ import { boundMethod } from "autobind-decorator"; import { If } from "tsx-control-statements/components"; import { GlobalModel, GlobalCommandRunner, RemotesModel } from "../../../model/model"; import * as T from "../../../types/types"; -import { Modal, TextField, NumberField, InputDecoration, Dropdown, PasswordField, Tooltip, ShowWaveShellInstallPrompt } from "../common"; +import { + Modal, + TextField, + NumberField, + InputDecoration, + Dropdown, + PasswordField, + Tooltip, + ShowWaveShellInstallPrompt, +} from "../elements"; import * as util from "../../../util/util"; -import * as appconst from "../../appconst"; import "./createremoteconn.less"; diff --git a/src/app/common/modals/disconnected.tsx b/src/app/common/modals/disconnected.tsx index 96dd92517..204f8e309 100644 --- a/src/app/common/modals/disconnected.tsx +++ b/src/app/common/modals/disconnected.tsx @@ -6,7 +6,7 @@ import * as mobxReact from "mobx-react"; import * as mobx from "mobx"; import { boundMethod } from "autobind-decorator"; import { GlobalModel } from "../../../model/model"; -import { Modal, Button } from "../common"; +import { Modal, Button } from "../elements"; import "./disconnected.less"; diff --git a/src/app/common/modals/editremoteconn.tsx b/src/app/common/modals/editremoteconn.tsx index 930a306ad..1a9bec921 100644 --- a/src/app/common/modals/editremoteconn.tsx +++ b/src/app/common/modals/editremoteconn.tsx @@ -8,7 +8,7 @@ import { If } from "tsx-control-statements/components"; import { boundMethod } from "autobind-decorator"; import { GlobalModel, GlobalCommandRunner, RemotesModel } from "../../../model/model"; import * as T from "../../../types/types"; -import { Modal, TextField, InputDecoration, Dropdown, PasswordField, Tooltip } from "../common"; +import { Modal, TextField, InputDecoration, Dropdown, PasswordField, Tooltip } from "../elements"; import * as util from "../../../util/util"; import "./editremoteconn.less"; diff --git a/src/app/common/modals/linesettings.tsx b/src/app/common/modals/linesettings.tsx index b7c3d7dda..4b2676b3f 100644 --- a/src/app/common/modals/linesettings.tsx +++ b/src/app/common/modals/linesettings.tsx @@ -6,7 +6,7 @@ import * as mobxReact from "mobx-react"; import * as mobx from "mobx"; import { boundMethod } from "autobind-decorator"; import { GlobalModel, GlobalCommandRunner } from "../../../model/model"; -import { SettingsError, Modal, Dropdown } from "../common"; +import { SettingsError, Modal, Dropdown } from "../elements"; import { LineType, RendererPluginType } from "../../../types/types"; import { PluginModel } from "../../../plugins/plugins"; import { commandRtnHandler } from "../../../util/util"; diff --git a/src/app/common/modals/screensettings.tsx b/src/app/common/modals/screensettings.tsx index 2034ca575..34c2a2aa8 100644 --- a/src/app/common/modals/screensettings.tsx +++ b/src/app/common/modals/screensettings.tsx @@ -8,7 +8,7 @@ import { boundMethod } from "autobind-decorator"; import { If, For } from "tsx-control-statements/components"; import cn from "classnames"; import { GlobalModel, GlobalCommandRunner, TabColors, TabIcons, Screen } from "../../../model/model"; -import { Toggle, InlineSettingsTextEdit, SettingsError, Modal, Dropdown, Tooltip } from "../common"; +import { Toggle, InlineSettingsTextEdit, SettingsError, Modal, Dropdown, Tooltip } from "../elements"; import { RemoteType } from "../../../types/types"; import * as util from "../../../util/util"; import { commandRtnHandler } from "../../../util/util"; diff --git a/src/app/common/modals/sessionsettings.less b/src/app/common/modals/sessionsettings.less index 67e7a7e48..a28564cc4 100644 --- a/src/app/common/modals/sessionsettings.less +++ b/src/app/common/modals/sessionsettings.less @@ -14,6 +14,15 @@ gap: 4px; align-self: stretch; width: 100%; + + .settings-label > div:first-child { + margin-right: 5px; + } } } } + +.session-settings-tooltip i { + font-size: 12px; + margin-left: 0.5px; +} diff --git a/src/app/common/modals/sessionsettings.tsx b/src/app/common/modals/sessionsettings.tsx index f3ed4604f..94827c965 100644 --- a/src/app/common/modals/sessionsettings.tsx +++ b/src/app/common/modals/sessionsettings.tsx @@ -6,7 +6,7 @@ import * as mobxReact from "mobx-react"; import * as mobx from "mobx"; import { boundMethod } from "autobind-decorator"; import { GlobalModel, GlobalCommandRunner, Session } from "../../../model/model"; -import { Toggle, InlineSettingsTextEdit, SettingsError, InfoMessage, Modal } from "../common"; +import { Toggle, InlineSettingsTextEdit, SettingsError, Modal, Tooltip } from "../elements"; import * as util from "../../../util/util"; import { commandRtnHandler } from "../../../util/util"; @@ -111,10 +111,14 @@ class SessionSettingsModal extends React.Component<{}, {}> {
Archived
- - Archive will hide the workspace from the active menu. Commands and output will be - retained in history. - + } + > + {} +
@@ -123,9 +127,13 @@ class SessionSettingsModal extends React.Component<{}, {}> {
Actions
- - Delete will remove the workspace, removing all commands and output from history. - + } + > + {} +
{ renderPluginIcon(plugin): any { let Comp = plugin.iconComp; - return ; + return ; } render() { diff --git a/src/app/sidebar/sidebar.tsx b/src/app/sidebar/sidebar.tsx index 7f2cf5d6b..26fe72196 100644 --- a/src/app/sidebar/sidebar.tsx +++ b/src/app/sidebar/sidebar.tsx @@ -19,7 +19,7 @@ import { ReactComponent as SettingsIcon } from "../assets/icons/settings.svg"; import localizedFormat from "dayjs/plugin/localizedFormat"; import { GlobalModel, GlobalCommandRunner, Session, VERSION } from "../../model/model"; import { isBlank, openLink } from "../../util/util"; -import { ResizableSidebar } from "../common/common"; +import { ResizableSidebar } from "../common/elements"; import * as constants from "../appconst"; import "./sidebar.less"; diff --git a/src/app/workspace/cmdinput/aichat.tsx b/src/app/workspace/cmdinput/aichat.tsx index ef65ef900..6692cd11b 100644 --- a/src/app/workspace/cmdinput/aichat.tsx +++ b/src/app/workspace/cmdinput/aichat.tsx @@ -12,7 +12,7 @@ import { Prompt } from "../../common/prompt/prompt"; import { TextAreaInput } from "./textareainput"; import { If, For } from "tsx-control-statements/components"; import type { OpenAICmdInfoChatMessageType } from "../../../types/types"; -import { Markdown } from "../../common/common"; +import { Markdown } from "../../common/elements"; import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "../../../util/keyutil"; @mobxReact.observer diff --git a/src/app/workspace/cmdinput/cmdinput.tsx b/src/app/workspace/cmdinput/cmdinput.tsx index 397027f8f..a183d3f48 100644 --- a/src/app/workspace/cmdinput/cmdinput.tsx +++ b/src/app/workspace/cmdinput/cmdinput.tsx @@ -11,7 +11,7 @@ import dayjs from "dayjs"; import type { RemoteType, RemoteInstanceType, RemotePtrType } from "../../../types/types"; import localizedFormat from "dayjs/plugin/localizedFormat"; import { GlobalModel, GlobalCommandRunner, Screen } from "../../../model/model"; -import { renderCmdText } from "../../common/common"; +import { renderCmdText } from "../../common/elements"; import { TextAreaInput } from "./textareainput"; import { InfoMsg } from "./infomsg"; import { HistoryInfo } from "./historyinfo"; diff --git a/src/app/workspace/screen/screenview.tsx b/src/app/workspace/screen/screenview.tsx index 15251f3e7..19c7cf6a1 100644 --- a/src/app/workspace/screen/screenview.tsx +++ b/src/app/workspace/screen/screenview.tsx @@ -10,16 +10,24 @@ import { If, For } from "tsx-control-statements/components"; import cn from "classnames"; import { debounce } from "throttle-debounce"; import dayjs from "dayjs"; -import { GlobalCommandRunner, TabColors, TabIcons, ForwardLineContainer, GlobalModel, ScreenLines, Screen, Session } from "../../../model/model"; +import { + GlobalCommandRunner, + TabColors, + TabIcons, + ForwardLineContainer, + GlobalModel, + ScreenLines, + Screen, + Session, +} from "../../../model/model"; import type { LineType, RenderModeType, LineFactoryProps } from "../../../types/types"; import * as T from "../../../types/types"; import localizedFormat from "dayjs/plugin/localizedFormat"; -import { Button } from "../../common/common"; +import { Button, TextField, Dropdown } from "../../common/elements"; import { getRemoteStr } from "../../common/prompt/prompt"; import { Line } from "../../line/linecomps"; import { LinesView } from "../../line/linesview"; import * as util from "../../../util/util"; -import { TextField, Dropdown } from "../../common/common"; import { ReactComponent as EllipseIcon } from "../../assets/icons/ellipse.svg"; import { ReactComponent as Check12Icon } from "../../assets/icons/check12.svg"; import { ReactComponent as SquareIcon } from "../../assets/icons/tab/square.svg"; diff --git a/src/app/workspace/screen/tab.tsx b/src/app/workspace/screen/tab.tsx index b5f3f8e85..a48139ec5 100644 --- a/src/app/workspace/screen/tab.tsx +++ b/src/app/workspace/screen/tab.tsx @@ -8,7 +8,7 @@ import { boundMethod } from "autobind-decorator"; import cn from "classnames"; import { GlobalModel, GlobalCommandRunner, Screen } from "../../../model/model"; import { ActionsIcon, StatusIndicator, CenteredIcon } from "../../common/icons/icons"; -import { renderCmdText } from "../../common/common"; +import { renderCmdText } from "../../common/elements"; import { ReactComponent as SquareIcon } from "../../assets/icons/tab/square.svg"; import * as constants from "../../appconst"; import { Reorder } from "framer-motion"; diff --git a/src/plugins/code/code.tsx b/src/plugins/code/code.tsx index 00c8ffaa9..c7d9d37d1 100644 --- a/src/plugins/code/code.tsx +++ b/src/plugins/code/code.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import * as T from "../../types/types"; import Editor, { Monaco } from "@monaco-editor/react"; import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api"; -import { Markdown } from "../../app/common/common"; +import { Markdown } from "../../app/common/elements"; import { GlobalModel, GlobalCommandRunner } from "../../model/model"; import Split from "react-split-it"; import loader from "@monaco-editor/loader"; diff --git a/src/plugins/markdown/markdown.tsx b/src/plugins/markdown/markdown.tsx index ea393765b..e293e25bb 100644 --- a/src/plugins/markdown/markdown.tsx +++ b/src/plugins/markdown/markdown.tsx @@ -6,7 +6,7 @@ import * as mobx from "mobx"; import * as mobxReact from "mobx-react"; import * as T from "../../types/types"; import { sprintf } from "sprintf-js"; -import { Markdown } from "../../app/common/common"; +import { Markdown } from "../../app/common/elements"; import "./markdown.less"; @@ -17,7 +17,13 @@ const DefaultMaxMarkdownWidth = 1000; @mobxReact.observer class SimpleMarkdownRenderer extends React.Component< - { data: T.ExtBlob; context: T.RendererContext; opts: T.RendererOpts; savedHeight: number, lineState: T.LineStateType }, + { + data: T.ExtBlob; + context: T.RendererContext; + opts: T.RendererOpts; + savedHeight: number; + lineState: T.LineStateType; + }, {} > { markdownText: OV = mobx.observable.box(null, { name: "markdownText" }); @@ -74,7 +80,10 @@ class SimpleMarkdownRenderer extends React.Component< maxHeight: opts.maxSize.height, }} > - +
); diff --git a/src/plugins/openai/openai.tsx b/src/plugins/openai/openai.tsx index 6bdc53ca9..ac2a505b7 100644 --- a/src/plugins/openai/openai.tsx +++ b/src/plugins/openai/openai.tsx @@ -8,7 +8,7 @@ import * as T from "../../types/types"; import { debounce } from "throttle-debounce"; import { boundMethod } from "autobind-decorator"; import { PacketDataBuffer } from "../core/ptydata"; -import { Markdown } from "../../app/common/common"; +import { Markdown } from "../../app/common/elements"; import "./openai.less"; @@ -207,7 +207,7 @@ class OpenAIRenderer extends React.Component<{ model: OpenAIRendererModel }> {
@@ -236,18 +236,18 @@ class OpenAIRenderer extends React.Component<{ model: OpenAIRendererModel }> { let cmd = model.rawCmd; let styleVal: Record = null; if (model.loading.get() && model.savedHeight >= 0 && model.isDone) { - styleVal = { + styleVal = { height: model.savedHeight, - maxHeight: model.opts.maxSize.height + maxHeight: model.opts.maxSize.height, }; } else { - let maxWidth = model.opts.maxSize.width - if(maxWidth > 1000) { - maxWidth = 1000 + let maxWidth = model.opts.maxSize.width; + if (maxWidth > 1000) { + maxWidth = 1000; } - styleVal = { + styleVal = { maxWidth: maxWidth, - maxHeight: model.opts.maxSize.height + maxHeight: model.opts.maxSize.height, }; } let version = model.version.get(); diff --git a/src/types/types.ts b/src/types/types.ts index e1bd72766..b06d8c4bc 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -11,6 +11,9 @@ type RemoteStatusTypeStrs = "connected" | "connecting" | "disconnected" | "error type LineContainerStrs = "main" | "sidebar" | "history"; type OV = mobx.IObservableValue; +type OArr = mobx.IObservableArray; +type OMap = mobx.ObservableMap; +type CV = mobx.IComputedValue; type SessionDataType = { sessionid: string; @@ -811,6 +814,7 @@ export type { CmdInputTextPacketType, OpenAICmdInfoChatMessageType, ScreenStatusIndicatorUpdateType, + OV, }; export { StatusIndicatorLevel }; From ff855cf308b4df2596ddc7a05c45a6a72eefe5a9 Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 7 Feb 2024 17:20:28 -0300 Subject: [PATCH 2/3] add AI and the blog --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ee154ad07..87e1e0edb 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ # Wave Terminal -A open-source, cross-platform, modern terminal for seamless workflows. +A open-source, cross-platform, AI-integrated, modern terminal for seamless workflows. Wave isn't just another terminal emulator; it's a rethink on how terminals are built. Wave combines command line with the power of the open web to help veteran CLI users and new developers alike. @@ -18,6 +18,7 @@ Wave isn't just another terminal emulator; it's a rethink on how terminals are b * Persistent sessions that can restore state across network disconnections and reboots * Searchable contextual command history across all remote sessions (saved locally) * Workspaces, tabs, and command blocks to keep you organized +* AI Integration with ChatGPT (or ChatGPT compatible APIs) to help write commands and get answers inline ## Installation @@ -35,6 +36,7 @@ brew install --cask wave * Homepage — https://www.waveterm.dev * Download Page — https://www.waveterm.dev/download * Documentation — https://docs.waveterm.dev/ +* Blog — https://blog.waveterm.dev/ * Quick Start Guide — https://docs.waveterm.dev/quickstart/ * Discord Community — https://discord.gg/XfvZ334gwU From b37f7f722e5d386e434341fbda8be800b598f8f4 Mon Sep 17 00:00:00 2001 From: Cole Lashley Date: Thu, 8 Feb 2024 12:37:23 -0800 Subject: [PATCH 3/3] Command to copy file from remote to local (#231) * first pass of copy file * first pass fixing up function * fleshed out copy function, still working on display and parameters * implemented scp like syntax * finished implemententation of copy file - there are still issues * more bug fixes, still running into error * pushing waveshell concurrency and channel fixes - still need to do some qol fixes before merge * aesthetic fixes and removed logs * fixed bug in GetRemoteRuntimeState * formatting small fix * fixed pretty print bytes * added local to local command * small fix removing workaround * added workaround back * added some logs for debug * added some more logs * quick bug fix for update cmd race condition * added fix for race condition * added some more logs for debugging * fixed up logs * added proper fe state for dest parameter * implemented setting status indicator output * first pass at updating status indicators * removed logs and small fix ups * removed whitespace * addressed review comments --- waveshell/pkg/packet/parser.go | 8 +- wavesrv/pkg/cmdrunner/cmdrunner.go | 555 +++++++++++++++++++++++++++++ wavesrv/pkg/cmdrunner/resolver.go | 5 + wavesrv/pkg/remote/remote.go | 17 +- wavesrv/pkg/sstore/dbops.go | 1 + wavesrv/pkg/sstore/sstore.go | 4 + 6 files changed, 579 insertions(+), 11 deletions(-) diff --git a/waveshell/pkg/packet/parser.go b/waveshell/pkg/packet/parser.go index 72631a4ae..a41608663 100644 --- a/waveshell/pkg/packet/parser.go +++ b/waveshell/pkg/packet/parser.go @@ -156,16 +156,12 @@ func (p *PacketParser) trySendRpcResponse(pk PacketType) bool { return false } p.Lock.Lock() - defer p.Lock.Unlock() entry := p.RpcMap[respId] + p.Lock.Unlock() if entry == nil { return false } - // nonblocking send - select { - case entry.RespCh <- respPk: - default: - } + entry.RespCh <- respPk return true } diff --git a/wavesrv/pkg/cmdrunner/cmdrunner.go b/wavesrv/pkg/cmdrunner/cmdrunner.go index 718c83fdc..e4c4d9df9 100644 --- a/wavesrv/pkg/cmdrunner/cmdrunner.go +++ b/wavesrv/pkg/cmdrunner/cmdrunner.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/fs" "log" "net/url" @@ -27,6 +28,7 @@ import ( "github.com/kevinburke/ssh_config" "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/shellenv" "github.com/wavetermdev/waveterm/waveshell/pkg/shellutil" "github.com/wavetermdev/waveterm/waveshell/pkg/shexec" @@ -199,6 +201,8 @@ func init() { registerCmdFn("remote:reset", RemoteResetCommand) registerCmdFn("remote:parse", RemoteConfigParseCommand) + registerCmdFn("copyfile", CopyFileCommand) + registerCmdFn("screen:resize", ScreenResizeCommand) registerCmdFn("line", LineCommand) @@ -697,6 +701,8 @@ func EvalCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore. newPk, rtnErr := EvalMetaCommand(ctxWithHistory, pk) if rtnErr == nil { update, rtnErr = HandleCommand(ctxWithHistory, newPk) + } else { + return nil, fmt.Errorf("error in Eval Meta Command: %v", rtnErr) } if !resolveBool(pk.Kwargs["nohist"], false) { // TODO should this be "pk" or "newPk" (2nd arg) @@ -1102,6 +1108,553 @@ func SidebarRemoveCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) return &sstore.ModelUpdate{Screens: []*sstore.ScreenType{screen}}, nil } +func prettyPrintByteSize(size int64) string { + gbSize := float64(size) / float64(1073741824) + if gbSize > 1 { + return fmt.Sprintf("%.2f Gigabytes", gbSize) + } + mbSize := float64(size) / float64(1048576) + if mbSize > 1 { + return fmt.Sprintf("%.2f Megabytes", mbSize) + } + kbSize := float64(size) / float64(1024) + if kbSize > 1 { + return fmt.Sprintf("%.2f Kilobytes", kbSize) + } + return fmt.Sprintf("%v Bytes", size) +} + +// this can only be called in a defer func, because recover() only works inside of a defe +func deferWriteCmdStatus(ctx context.Context, cmd *sstore.CmdType, startTime time.Time, exitSuccess bool, outputPos int64) { + r := recover() + if r != nil { + panicMsg := fmt.Sprintf("panic: %v", r) + log.Printf("panic: %v\n", panicMsg) + writeStringToPty(ctx, cmd, panicMsg, &outputPos) + } + duration := time.Since(startTime) + cmdStatus := sstore.CmdStatusDone + var exitCode int + if !exitSuccess { + cmdStatus = sstore.CmdStatusError + exitCode = 1 + } + ck := base.MakeCommandKey(cmd.ScreenId, cmd.LineId) + donePk := packet.MakeCmdDonePacket(ck) + donePk.Ts = time.Now().UnixMilli() + donePk.ExitCode = exitCode + donePk.DurationMs = duration.Milliseconds() + update, err := sstore.UpdateCmdDoneInfo(context.Background(), ck, donePk, cmdStatus) + if err != nil { + // nothing to do + log.Printf("error updating cmddoneinfo (in openai): %v\n", err) + return + } + sstore.MainBus.SendScreenUpdate(cmd.ScreenId, update) +} + +func checkForWriteReady(ctx context.Context, iter *packet.RpcResponseIter) (string, error) { + readyIf, err := iter.Next(ctx) + if err != nil { + return "", fmt.Errorf("error getting write ready response: %v\r\n", err) + } + readyPk, ok := readyIf.(*packet.WriteFileReadyPacketType) + if !ok { + return "", fmt.Errorf("bad write ready packet received %v", readyIf) + } + if readyPk.Error != "" { + return "", fmt.Errorf("ready error: %v", readyPk.Error) + } + return readyPk.RespId, nil +} + +func checkForWriteFinished(ctx context.Context, iter *packet.RpcResponseIter) error { + doneIf, err := iter.Next(ctx) + if err != nil { + return fmt.Errorf("error while getting done response: %v", err) + } + writeDonePk, ok := doneIf.(*packet.WriteFileDonePacketType) + if !ok { + return fmt.Errorf("bad done packet received: %T", doneIf) + } + if writeDonePk.Error != "" { + return fmt.Errorf("done error: %v", writeDonePk.Error) + } + return nil +} + +func doCopyLocalFileToRemote(ctx context.Context, cmd *sstore.CmdType, remote_msh *remote.MShellProc, localPath string, destPath string, outputPos int64) { + var exitSuccess bool + startTime := time.Now() + defer func() { + deferWriteCmdStatus(ctx, cmd, startTime, exitSuccess, outputPos) + }() + localFile, err := os.Open(localPath) + if err != nil { + writeStringToPty(ctx, cmd, fmt.Sprintf("Error, unable to open file %v: %v\r\n", localFile, localPath), &outputPos) + return + } + defer localFile.Close() + writePk := packet.MakeWriteFilePacket() + writePk.ReqId = uuid.New().String() + writePk.Path = destPath + iter, err := remote_msh.WriteFile(ctx, writePk) + if err != nil { + writeStringToPty(ctx, cmd, fmt.Sprintf("Error starting file write: %v\r\n", err), &outputPos) + return + } + defer iter.Close() + _, err = checkForWriteReady(ctx, iter) + if err != nil { + writeStringToPty(ctx, cmd, fmt.Sprintf("Write ready packet error: %v\r\n", err), &outputPos) + return + } + fileStat, err := localFile.Stat() + if err != nil { + writeStringToPty(ctx, cmd, fmt.Sprintf("error: could not get file stat: %v", err), &outputPos) + return + } + fileSizeBytes := fileStat.Size() + bytesWritten := int64(0) + lastFileTransferPercentage := float64(0) + fileTransferPercentage := float64(0) + writeStringToPty(ctx, cmd, fmt.Sprintf("Source File Size: %s\r\n", prettyPrintByteSize(fileSizeBytes)), &outputPos) + writeStringToPty(ctx, cmd, "[", &outputPos) + var buffer [server.MaxFileDataPacketSize]byte + bufSlice := buffer[:] + for { + dataPk := packet.MakeFileDataPacket(writePk.ReqId) + bytesRead, err := io.ReadFull(localFile, bufSlice) + if err == io.ErrUnexpectedEOF || err == io.EOF { + dataPk.Eof = true + } else if err != nil { + dataErr := fmt.Sprintf("error reading file data: %v", err) + dataPk.Error = dataErr + remote_msh.SendFileData(dataPk) + writeStringToPty(ctx, cmd, dataErr, &outputPos) + return + } + if bytesRead > 0 { + dataPk.Data = make([]byte, bytesRead) + copy(dataPk.Data, bufSlice[0:bytesRead]) + bytesWritten += int64(len(dataPk.Data)) + fileTransferPercentage = float64(bytesWritten) / float64(fileSizeBytes) + + if fileTransferPercentage-lastFileTransferPercentage > float64(0.05) { + writeStringToPty(ctx, cmd, "-", &outputPos) + lastFileTransferPercentage = fileTransferPercentage + } + } + remote_msh.SendFileData(dataPk) + if dataPk.Eof { + break + } + } + err = checkForWriteFinished(ctx, iter) + if err != nil { + writeStringToPty(ctx, cmd, fmt.Sprintf("Write finished packet error %v", err), &outputPos) + return + } + writeStringToPty(ctx, cmd, "] done. \r\n", &outputPos) + writeStringToPty(ctx, cmd, fmt.Sprintf("Finished transferring. Transferred %v bytes\r\n", fileSizeBytes), &outputPos) + exitSuccess = true +} + +func getStatusBarString(filePercentageInt int) string { + statusBarString := "\x1b[2k\r[" + for count := 0; count < 20; count++ { + if (filePercentageInt - count*5) > 0 { + statusBarString += "-" + } else { + statusBarString += " " + } + } + if filePercentageInt < 100 { + statusBarString += fmt.Sprintf("] %v%%", filePercentageInt) + } else { + statusBarString += "]" + } + return statusBarString +} + +func doCopyRemoteFileToRemote(ctx context.Context, cmd *sstore.CmdType, sourceMsh *remote.MShellProc, destMsh *remote.MShellProc, sourcePath string, destPath string, outputPos int64) { + var exitSuccess bool + startTime := time.Now() + defer func() { + deferWriteCmdStatus(ctx, cmd, startTime, exitSuccess, outputPos) + }() + streamPk := packet.MakeStreamFilePacket() + streamPk.ReqId = uuid.New().String() + streamPk.Path = sourcePath + sourceStreamIter, err := sourceMsh.StreamFile(ctx, streamPk) + if err != nil { + writeStringToPty(ctx, cmd, fmt.Sprintf("Error getting file data packet: %v\r\n", err), &outputPos) + return + } + defer sourceStreamIter.Close() + respIf, err := sourceStreamIter.Next(ctx) + if err != nil { + writeStringToPty(ctx, cmd, fmt.Sprintf("Error getting next packet: %v\r\n", err), &outputPos) + return + } + resp, ok := respIf.(*packet.StreamFileResponseType) + if !ok { + writeStringToPty(ctx, cmd, fmt.Sprintf("Error in getting packet response: %v\r\n", err), &outputPos) + return + } + if resp == nil || resp.Error != "" { + writeStringToPty(ctx, cmd, fmt.Sprintf("Response packet has error: %v\r\n", err), &outputPos) + return + } + fileSizeBytes := resp.Info.Size + if fileSizeBytes == 0 { + writeStringToPty(ctx, cmd, "Source file does not exist or is empty - exiting\r\n", &outputPos) + return + } + writeStringToPty(ctx, cmd, fmt.Sprintf("Source File Size: %v\r\n", prettyPrintByteSize(fileSizeBytes)), &outputPos) + writePk := packet.MakeWriteFilePacket() + writePk.ReqId = uuid.New().String() + writePk.Path = destPath + destWriteIter, err := destMsh.WriteFile(ctx, writePk) + if err != nil { + writeStringToPty(ctx, cmd, fmt.Sprintf("Error starting file write: %v\r\n", err), &outputPos) + return + } + defer destWriteIter.Close() + _, err = checkForWriteReady(ctx, destWriteIter) + if err != nil { + writeStringToPty(ctx, cmd, fmt.Sprintf("Write ready packet error: %v\r\n", err), &outputPos) + return + } + bytesWritten := int64(0) + lastFilePercentageInt := int(0) + fileTransferPercentage := float64(0) + writeStringToPty(ctx, cmd, "[", &outputPos) + for { + dataPkIf, err := sourceStreamIter.Next(ctx) + if err != nil { + log.Printf("error in read-file while getting data: %v\n", err) + return + } + if dataPkIf == nil { + break + } + dataPk, ok := dataPkIf.(*packet.FileDataPacketType) + if !ok { + writeStringToPty(ctx, cmd, fmt.Sprintf("error in read-file, invalid data packet type: %T\r\n", dataPkIf), &outputPos) + return + } + if dataPk.Error != "" { + writeStringToPty(ctx, cmd, fmt.Sprintf("in read-file, data packet error: %s\r\n", dataPk.Error), &outputPos) + return + } + writeDataPk := packet.MakeFileDataPacket(writePk.ReqId) + writeDataPk.Eof = dataPk.Eof + writeDataPk.Error = dataPk.Error + writeDataPk.Type = dataPk.Type + writeDataPk.Data = make([]byte, int64(len(dataPk.Data))) + copy(writeDataPk.Data, dataPk.Data) + err = destMsh.SendFileData(writeDataPk) + if err != nil { + writeStringToPty(ctx, cmd, fmt.Sprintf("error sending file to dest: %v\r\n", err), &outputPos) + return + } + bytesWritten += int64(len(dataPk.Data)) + fileTransferPercentage = float64(bytesWritten) / float64(fileSizeBytes) + filePercentageInt := int(fileTransferPercentage * 100) + if filePercentageInt-lastFilePercentageInt > 5 { + statusBarString := getStatusBarString(filePercentageInt) + writeStringToPty(ctx, cmd, statusBarString, &outputPos) + lastFilePercentageInt = filePercentageInt + } + } + err = checkForWriteFinished(ctx, destWriteIter) + if err != nil { + writeStringToPty(ctx, cmd, fmt.Sprintf("\r\nWrite finished packet error %v", err), &outputPos) + return + } + writeStringToPty(ctx, cmd, getStatusBarString(100), &outputPos) + writeStringToPty(ctx, cmd, " done. \r\n", &outputPos) + writeStringToPty(ctx, cmd, fmt.Sprintf("Finished transferring. Transferred %v bytes\r\n", bytesWritten), &outputPos) + exitSuccess = true +} + +func doCopyLocalFileToLocal(ctx context.Context, cmd *sstore.CmdType, sourcePath string, destPath string, outputPos int64) { + var exitSuccess bool + var bytesWritten int64 + startTime := time.Now() + defer func() { + deferWriteCmdStatus(ctx, cmd, startTime, exitSuccess, outputPos) + }() + sourceFile, err := os.Open(sourcePath) + if err != nil { + writeStringToPty(ctx, cmd, fmt.Sprintf("error opening source file %v", err), &outputPos) + return + } + defer sourceFile.Close() + sourceFileStat, err := sourceFile.Stat() + if err != nil { + writeStringToPty(ctx, cmd, fmt.Sprintf("error getting filestat %v", err), &outputPos) + return + } + fileSizeBytes := sourceFileStat.Size() + writeStringToPty(ctx, cmd, fmt.Sprintf("Source File Size: %v\r\n", prettyPrintByteSize(fileSizeBytes)), &outputPos) + destFile, err := os.Create(destPath) + if err != nil { + writeStringToPty(ctx, cmd, fmt.Sprintf("error creating dest file %v", err), &outputPos) + return + } + defer destFile.Close() + bytesWritten, err = io.Copy(destFile, sourceFile) + if err != nil { + writeStringToPty(ctx, cmd, fmt.Sprintf("error copying files %v", err), &outputPos) + return + } + writeStringToPty(ctx, cmd, fmt.Sprintf("Finished transferring. Transferred %v bytes\r\n", bytesWritten), &outputPos) + exitSuccess = true +} + +func doCopyRemoteFileToLocal(ctx context.Context, cmd *sstore.CmdType, remote_msh *remote.MShellProc, sourcePath string, localPath string, outputPos int64) { + var exitSuccess bool + startTime := time.Now() + defer func() { + deferWriteCmdStatus(ctx, cmd, startTime, exitSuccess, outputPos) + }() + streamPk := packet.MakeStreamFilePacket() + streamPk.ReqId = uuid.New().String() + streamPk.Path = sourcePath + iter, err := remote_msh.StreamFile(ctx, streamPk) + if err != nil { + writeStringToPty(ctx, cmd, fmt.Sprintf("Error getting file data packet: %v\r\n", err), &outputPos) + return + } + defer iter.Close() + respIf, err := iter.Next(ctx) + if err != nil { + writeStringToPty(ctx, cmd, fmt.Sprintf("Error getting next packet: %v\r\n", err), &outputPos) + return + } + resp, ok := respIf.(*packet.StreamFileResponseType) + if !ok { + writeStringToPty(ctx, cmd, fmt.Sprintf("Error in getting packet response: %v\r\n", err), &outputPos) + return + } + if resp == nil || resp.Error != "" { + writeStringToPty(ctx, cmd, fmt.Sprintf("Response packet has error: %v\r\n", err), &outputPos) + return + } + fileSizeBytes := resp.Info.Size + if fileSizeBytes == 0 { + writeStringToPty(ctx, cmd, "Source file doesn't exist or file is empty - exiting\r\n", &outputPos) + return + } + writeStringToPty(ctx, cmd, fmt.Sprintf("Source File Size: %s\r\n", prettyPrintByteSize(fileSizeBytes)), &outputPos) + localFile, err := os.Create(localPath) + if err != nil { + writeStringToPty(ctx, cmd, fmt.Sprintf("Error creating file on local %v\r\n", err), &outputPos) + return + } + defer localFile.Close() + bytesWritten := int64(0) + lastFileTransferPercentage := float64(0) + fileTransferPercentage := float64(0) + writeStringToPty(ctx, cmd, "[", &outputPos) + for { + dataPkIf, err := iter.Next(ctx) + if err != nil { + log.Printf("error in read-file while getting data: %v\n", err) + return + } + if dataPkIf == nil { + break + } + dataPk, ok := dataPkIf.(*packet.FileDataPacketType) + if !ok { + writeStringToPty(ctx, cmd, fmt.Sprintf("error in read-file, invalid data packet type: %T\r\n", dataPkIf), &outputPos) + return + } + if dataPk.Error != "" { + writeStringToPty(ctx, cmd, fmt.Sprintf("in read-file, data packet error: %s", dataPk.Error), &outputPos) + return + } + localFile.Write(dataPk.Data) + bytesWritten += int64(len(dataPk.Data)) + fileTransferPercentage = float64(bytesWritten) / float64(fileSizeBytes) + + if fileTransferPercentage-lastFileTransferPercentage > float64(0.05) { + writeStringToPty(ctx, cmd, "-", &outputPos) + lastFileTransferPercentage = fileTransferPercentage + } + } + writeStringToPty(ctx, cmd, "] done. \r\n", &outputPos) + writeStringToPty(ctx, cmd, fmt.Sprintf("Finished transferring. Transferred %v bytes\n", fileSizeBytes), &outputPos) + exitSuccess = true +} + +func writeStringToPty(ctx context.Context, cmd *sstore.CmdType, outputString string, outputPos *int64) { + outBytes := []byte(outputString) + update, err := sstore.AppendToCmdPtyBlob(ctx, cmd.ScreenId, cmd.LineId, outBytes, *outputPos) + *outputPos += int64(len(outBytes)) + if err != nil { + log.Printf("error writing to pty: %v", err) + } + sstore.MainBus.SendScreenUpdate(cmd.ScreenId, update) + err = sstore.SetStatusIndicatorLevel(ctx, cmd.ScreenId, sstore.StatusIndicatorLevel_Output, false) + if err != nil { + // This is not a fatal error, so just log it + log.Printf("error setting status indicator level to output in writeStringToPty: %v\n", err) + } +} + +func parseCopyFileParam(info string) (remote string, path string, err error) { + stringsList := strings.Split(info, ":") + if len(stringsList) == 1 { + // use cur remote + return "", stringsList[0], nil + } else if len(stringsList) == 2 { + remote := strings.Trim(stringsList[0], "[] ") + return remote, stringsList[1], nil + } else { + return "error", "error", fmt.Errorf("malformed arguments") + } +} + +func CopyFileCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) { + if len(pk.Args) == 0 { + return nil, fmt.Errorf("usage: /copyfile [file to copy] local=[path to copy to on local]") + } + ids, err := resolveUiIds(ctx, pk, R_Screen|R_Session|R_RemoteConnected) + if err != nil { + return nil, fmt.Errorf("failed to resolve connected remote id: %v", err) + } + sourceInfo := pk.Args[0] + sourceRemote, sourcePath, err := parseCopyFileParam(sourceInfo) + var sourceRemoteId *ResolvedRemote + var destRemoteId *ResolvedRemote + if err != nil { + return nil, fmt.Errorf("error: malformed arguments - usage: [remote]:path ") + } else if sourceRemote == "" { + // use cur remote + sourceRemote = ConnectedRemote + sourceRemoteId = ids.Remote + if ids.Remote.RemoteCopy.IsLocal() { + sourceRemote = LocalRemote + } + } else { + pk.Kwargs["remote"] = sourceRemote + sourceIds, err := resolveUiIds(ctx, pk, R_Remote) + if err != nil { + return nil, fmt.Errorf("error resolving remote id %v", err) + } + sourceRemoteId = sourceIds.Remote + } + destInfo := pk.Args[1] + destRemote, destPath, err := parseCopyFileParam(destInfo) + if err != nil { + return nil, fmt.Errorf("error: malformed arguments - usage: [remote]:path ") + } else if destRemote == "" { + destRemote = ConnectedRemote + destRemoteId = ids.Remote + if ids.Remote.RemoteCopy.IsLocal() { + destRemote = LocalRemote + } + } else { + pk.Kwargs["remote"] = destRemote + destIds, err := resolveUiIds(ctx, pk, R_Remote) + if err != nil { + return nil, fmt.Errorf("error resolving remote id %v", err) + } + destRemoteId = destIds.Remote + } + if destPath == "" { + return nil, fmt.Errorf("error: malformed arguments - usage: [remote]:path ") + } + + var sourceFullPath string + var destFullPath string + sourceMsh := sourceRemoteId.MShell + if sourceMsh == nil { + return nil, fmt.Errorf("failure getting source remote mshell") + } + sourceRRState := sourceMsh.GetRemoteRuntimeState() + sourcePathWithHome, err := sourceRRState.ExpandHomeDir(sourcePath) + if err != nil { + return nil, fmt.Errorf("expand home dir err: %v", err) + } + sourceFullPath = sourcePathWithHome + if (sourceRemote == ConnectedRemote || sourceRemote == LocalRemote) && !filepath.IsAbs(sourcePathWithHome) && sourceRemoteId.FeState != nil { + sourceCwd := sourceRemoteId.FeState["cwd"] + if sourceCwd != "" { + sourceFullPath = filepath.Join(sourceCwd, sourcePathWithHome) + } + } + if destPath[len(destPath)-1:] == "/" { + sourceFileName := filepath.Base(sourceFullPath) + destPath = filepath.Join(destPath, sourceFileName) + } + destMsh := destRemoteId.MShell + if destMsh == nil { + return nil, fmt.Errorf("failure getting dest remote mshell") + } + destRRState := destMsh.GetRemoteRuntimeState() + destPathWithHome, err := destRRState.ExpandHomeDir(destPath) + if err != nil { + return nil, fmt.Errorf("expand home dir err: %v", err) + } + destFullPath = destPathWithHome + if (destRemote == ConnectedRemote || destRemote == LocalRemote) && !filepath.IsAbs(destPathWithHome) && destRemoteId.FeState != nil { + destCwd := destRemoteId.FeState["cwd"] + if destCwd != "" { + destFullPath = filepath.Join(destCwd, destPathWithHome) + } + } + var outputPos int64 + outputStr := fmt.Sprintf("Copying [%v]:%v to [%v]:%v\r\n", sourceRemoteId.DisplayName, sourceFullPath, destRemoteId.DisplayName, destFullPath) + termopts := sstore.TermOpts{Rows: shellutil.DefaultTermRows, Cols: shellutil.DefaultTermCols, FlexRows: true, MaxPtySize: remote.DefaultMaxPtySize} + cmd, err := makeDynCmd(ctx, "copy file", ids, pk.GetRawStr(), termopts) + writeStringToPty(ctx, cmd, outputStr, &outputPos) + 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, "/copy file", 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.Interactive = pk.Interactive + if destRemote != ConnectedRemote && destRemoteId != nil && !destRemoteId.RState.IsConnected() { + writeStringToPty(ctx, cmd, fmt.Sprintf("Attempting to autoconnect to remote %v\r\n", destRemote), &outputPos) + err = destRemoteId.MShell.TryAutoConnect() + if err != nil { + writeStringToPty(ctx, cmd, fmt.Sprintf("Couldn't connect to remote %v\r\n", sourceRemote), &outputPos) + } else { + writeStringToPty(ctx, cmd, "Auto connect successful\r\n", &outputPos) + } + } + if sourceRemote != LocalRemote && sourceRemoteId != nil && !sourceRemoteId.RState.IsConnected() { + writeStringToPty(ctx, cmd, fmt.Sprintf("Attempting to autoconnect to remote %v\r\n", sourceRemote), &outputPos) + err = sourceRemoteId.MShell.TryAutoConnect() + if err != nil { + writeStringToPty(ctx, cmd, fmt.Sprintf("Couldn't connect to remote %v\r\n", sourceRemote), &outputPos) + } else { + writeStringToPty(ctx, cmd, "Auto connect successful\r\n", &outputPos) + } + } + sstore.MainBus.SendScreenUpdate(cmd.ScreenId, update) + update = &sstore.ModelUpdate{} + if destRemote == LocalRemote && sourceRemote == LocalRemote { + go doCopyLocalFileToLocal(context.Background(), cmd, sourceFullPath, destFullPath, outputPos) + } else if destRemote == LocalRemote && sourceRemote != LocalRemote { + go doCopyRemoteFileToLocal(context.Background(), cmd, sourceMsh, sourceFullPath, destFullPath, outputPos) + } else if destRemote != LocalRemote && sourceRemote == LocalRemote { + go doCopyLocalFileToRemote(context.Background(), cmd, destMsh, sourceFullPath, destFullPath, outputPos) + } else if destRemote != LocalRemote && sourceRemote != LocalRemote { + go doCopyRemoteFileToRemote(context.Background(), cmd, sourceMsh, destMsh, sourceFullPath, destFullPath, outputPos) + } + return update, nil +} + func RemoteInstallCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) { ids, err := resolveUiIds(ctx, pk, R_Session|R_Screen|R_Remote) if err != nil { @@ -2463,6 +3016,7 @@ func addLineForCmd(ctx context.Context, metaCmd string, shouldFocus bool, ids re Cmd: cmd, Screens: []*sstore.ScreenType{screen}, } + sstore.IncrementNumRunningCmds_Update(update, cmd.ScreenId, 1) updateHistoryContext(ctx, rtnLine, cmd, cmd.FeState) return update, nil } @@ -3422,6 +3976,7 @@ func LineRestartCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) ( NoCreateCmdPtyFile: true, } cmd, callback, err := remote.RunCommand(ctx, rcOpts, runPacket) + sstore.IncrementNumRunningCmds(cmd.ScreenId, 1) if callback != nil { defer callback() } diff --git a/wavesrv/pkg/cmdrunner/resolver.go b/wavesrv/pkg/cmdrunner/resolver.go index ee62436b2..265cf1936 100644 --- a/wavesrv/pkg/cmdrunner/resolver.go +++ b/wavesrv/pkg/cmdrunner/resolver.go @@ -24,6 +24,11 @@ const ( R_RemoteConnected = 16 ) +const ( + ConnectedRemote = "connected" + LocalRemote = "local" +) + type resolvedIds struct { SessionId string ScreenId string diff --git a/wavesrv/pkg/remote/remote.go b/wavesrv/pkg/remote/remote.go index 3e5bf06d2..2860f8de8 100644 --- a/wavesrv/pkg/remote/remote.go +++ b/wavesrv/pkg/remote/remote.go @@ -50,6 +50,7 @@ const RemoteTermRows = 8 const RemoteTermCols = 80 const PtyReadBufSize = 100 const RemoteConnectTimeout = 15 * time.Second +const RpcIterChannelSize = 100 var envVarsToStrip map[string]bool = map[string]bool{ "PROMPT": true, @@ -665,7 +666,12 @@ func (msh *MShellProc) GetRemoteRuntimeState() RemoteRuntimeState { if vars["remoteuser"] == "root" || vars["sudo"] == "1" { vars["isroot"] = "1" } - state.RemoteVars = vars + varsCopy := make(map[string]string) + // deep copy so that concurrent calls don't collide on this data + for key, value := range vars { + varsCopy[key] = value + } + state.RemoteVars = varsCopy state.ActiveShells = msh.StateMap.GetShells() return state } @@ -1203,6 +1209,10 @@ func (msh *MShellProc) ReInit(ctx context.Context, shellType string) (*packet.Sh return ssPk, nil } +func (msh *MShellProc) WriteFile(ctx context.Context, writePk *packet.WriteFilePacketType) (*packet.RpcResponseIter, error) { + return msh.PacketRpcIter(ctx, writePk) +} + func (msh *MShellProc) StreamFile(ctx context.Context, streamPk *packet.StreamFilePacketType) (*packet.RpcResponseIter, error) { return msh.PacketRpcIter(ctx, streamPk) } @@ -1886,7 +1896,6 @@ func RunCommand(ctx context.Context, rcOpts RunCommandOpts, runPacket *packet.Ru RunPacket: runPacket, }) - go pushNumRunningCmdsUpdate(&runPacket.CK, 1) return cmd, func() { removeCmdWait(runPacket.CK) }, nil } @@ -1925,7 +1934,7 @@ func (msh *MShellProc) PacketRpcIter(ctx context.Context, pk packet.RpcPacketTyp return nil, fmt.Errorf("PacketRpc passed nil packet") } reqId := pk.GetReqId() - msh.ServerProc.Output.RegisterRpc(reqId) + msh.ServerProc.Output.RegisterRpcSz(reqId, RpcIterChannelSize) err := msh.ServerProc.Input.SendPacketCtx(ctx, pk) if err != nil { return nil, err @@ -2064,8 +2073,6 @@ func (msh *MShellProc) handleCmdDonePacket(donePk *packet.CmdDonePacketType) { // fall-through (nothing to do) } } - - go pushNumRunningCmdsUpdate(&donePk.CK, -1) sstore.MainBus.SendUpdate(update) return } diff --git a/wavesrv/pkg/sstore/dbops.go b/wavesrv/pkg/sstore/dbops.go index 5e394275c..0b8ca3d79 100644 --- a/wavesrv/pkg/sstore/dbops.go +++ b/wavesrv/pkg/sstore/dbops.go @@ -950,6 +950,7 @@ func UpdateCmdDoneInfo(ctx context.Context, ck base.CommandKey, donePk *packet.C // This is not a fatal error, so just log it log.Printf("error setting status indicator level after done packet: %v\n", err) } + IncrementNumRunningCmds_Update(update, screenId, -1) return update, nil } diff --git a/wavesrv/pkg/sstore/sstore.go b/wavesrv/pkg/sstore/sstore.go index 9cd62e50a..b9f2c5cf9 100644 --- a/wavesrv/pkg/sstore/sstore.go +++ b/wavesrv/pkg/sstore/sstore.go @@ -1100,6 +1100,10 @@ type RemoteType struct { OpenAIOpts *OpenAIOptsType `json:"openaiopts,omitempty"` } +func (r *RemoteType) IsLocal() bool { + return r.Local && !r.IsSudo() +} + func (r *RemoteType) IsSudo() bool { return r.SSHOpts != nil && r.SSHOpts.IsSudo }