Merge branch 'main' of github.com:wavetermdev/waveterm into red/aichat-sidebar

This commit is contained in:
Red Adaya 2024-05-01 09:42:28 +08:00
commit 8ea22c4c1f
42 changed files with 2587 additions and 21888 deletions

View File

@ -1,24 +1,28 @@
name: TestDriver.ai Regression Testing (test)
name: TestDriver.ai Regression Testing
on:
push:
branches: ["main"]
branches:
- main
pull_request:
branches: ["main"]
branches:
- main
schedule:
- cron: "0 21 * * *" # every day at 9pm
workflow_dispatch:
- cron: 0 21 * * *
workflow_dispatch: null
permissions:
contents: read # To allow the action to read repository contents
pull-requests: write # To allow the action to create/update pull request comments
jobs:
test:
name: "TestDriver"
name: TestDriver
runs-on: ubuntu-latest
steps:
- uses: dashcamio/testdriver@main
id: testdriver
# note that .testdriver/prerun.sh runs before this, so the app has launched already
with:
version: v2.9.4
version: v2.10.2
prerun: |
rm ~/Desktop/WITH-LOVE-FROM-AMERICA.txt
cd ~/actions-runner/_work/testdriver/testdriver/
@ -38,8 +42,24 @@ jobs:
echo "Electron Done"
exit
prompt: |
2. click "Create new tab"
2. focus the Wave input with the keyboard shorcut Command + I
3. type 'ls' into the input
4. press return
5. validate Wave shows the result of 'ls'
1. wait 10 seconds
1. click "Continue"
1. click "Create new tab"
1. validate that overlapping text does not appear in the application
1. focus the Wave input with the keyboard shorcut Command + I
1. type 'ls' into the input
1. press return
1. validate Wave shows the result of 'ls'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: peter-evans/create-or-update-comment@v4
if: ${{always()}}
with:
issue-number: ${{ github.event.pull_request.number }}
body: |
## TestDriver Summary
${{ steps.testdriver.outputs.markdown }}
${{ steps.testdriver.outputs.summary }}
reactions: |
+1
-1

View File

@ -9,6 +9,8 @@
# Wave Terminal
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm?ref=badge_shield)
Wave is an open-source AI-native terminal built 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.
@ -56,3 +58,7 @@ Find more information in our [Contributions Guide](CONTRIBUTING.md), which inclu
- [Ways to contribute](CONTRIBUTING.md#contributing-to-wave-terminal)
- [Contribution guidelines](CONTRIBUTING.md#before-you-start)
## License
Wave Terminal is licensed under the Apache-2.0 License. For more information on our dependencies, see [here](./acknowledgements/README.md).

View File

@ -1,20 +1,5 @@
# Open-Source Acknowledgements
We make use of many amazing open-source projects to build Wave Terminal. Here are the links to the latest acknowledgements for each of our components, including license disclaimers for each dependency:
We make use of many amazing open-source projects to build Wave Terminal. We automatically generate license reports via FOSSA to comply with the license distribution requirements of our dependencies. Below is a summary of the licenses used by our product. Clicking on the image will take you to the full report on FOSSA's website.
- [Frontend](./disclaimers/frontend.md)
- [Backend](./disclaimers/backend.md)
## Generating license disclaimers
The license disclaimers for the backend are generated using the [go-licenses](https://github.com/google/go-licenses) tool. We supply a template file ([`go_licenses_report.tpl`](./go_licenses_report.tpl)) to generate a pretty print of the disclaimers for each dependency. This outputs to the file [`backend.md`](./disclaimers/backend.md).
The license disclaimers for the frontend are generated using the [`yarn licenses` tool](https://classic.yarnpkg.com/lang/en/docs/cli/licenses/). This outputs to the file [`frontend.md`](./disclaimers/frontend.md).
These disclaimer files linked above will be periodically regenerated to reflect new dependencies.
The [`scripthaus.md` file](../scripthaus.md) contains scripts to generate the disclaimers and package them. To manually generate the disclaimers, run the following from the repository root directory:
```bash
scripthaus run generate-license-disclaimers
```
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fwavetermdev%2Fwaveterm?ref=badge_large)

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -1,16 +0,0 @@
# Wave Terminal Backend Acknowledgements
The following sets forth attribution notices for third party software that may be contained in portions of the Wave Terminal product.
{{ range . }}
## {{ .Name }}
* Name: {{ .Name }}
* Version: {{ .Version }}
* License: [{{ .LicenseName }}]({{ .LicenseURL }})
```txt
{{ .LicenseText }}
```
-----
{{ end }}

View File

@ -156,7 +156,7 @@
--table-border-color: rgba(241, 246, 243, 0.15);
--table-thead-border-top-color: rgba(250, 250, 250, 0.1);
--table-thead-bright-border-color: rgb(204, 204, 204);
--table-thead-bg-color: rgba(250, 250, 250, 0.02);
--table-thead-bg-color: rgb(5, 5, 5);
--table-tr-border-bottom-color: rgba(241, 246, 243, 0.15);
--table-tr-hover-bg-color: rgba(255, 255, 255, 0.06);
--table-tr-selected-bg-color: rgb(34, 34, 34);

View File

@ -9,8 +9,8 @@
--app-accent-color: rgb(75, 166, 57);
--app-accent-bg-color: rgba(75, 166, 57, 0.2);
--app-text-color: rgb(0, 0, 0);
--app-text-primary-color: rgb(0, 0, 0, 0.9);
--app-text-secondary-color: rgb(0, 0, 0, 0.7);
--app-text-primary-color: rgb(23, 23, 23);
--app-text-secondary-color: rgb(76, 76, 76);
--app-border-color: rgb(139 145 138);
--app-panel-bg-color: rgb(224, 224, 224);
--app-panel-bg-color-dev: rgb(224, 224, 224);
@ -57,6 +57,10 @@
--scrollbar-thumb-hover-color: rgba(0, 0, 0, 0.4);
--scrollbar-thumb-active-color: rgba(0, 0, 0, 0.5);
/* table colors */
--table-thead-bg-color: rgb(253, 253, 253);
--table-tr-selected-bg-color: rgb(216, 216, 216);
/* line color */
--line-actions-bg-color: rgba(0, 0, 0, 0.1);
--line-actions-inactive-color: rgba(0, 0, 0, 0.3);

View File

@ -1,6 +1,7 @@
.clientsettings-view {
.content {
padding: 14px 18px 0 30px;
padding: 14px 18px 14px 30px;
overflow-y: scroll;
}
.wave-dropdown {

View File

@ -14,6 +14,7 @@ import * as appconst from "@/app/appconst";
import "./clientsettings.less";
import { MainView } from "../common/elements/mainview";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
class ClientSettingsKeybindings extends React.Component<{}, {}> {
componentDidMount() {
@ -251,7 +252,12 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove
const curSudoPwClearOnSleep = GlobalModel.getSudoPwClearOnSleep();
return (
<MainView className="clientsettings-view" title="Client Settings" onClose={this.handleClose}>
<MainView
className="clientsettings-view"
title="Client Settings"
onClose={this.handleClose}
scrollable={true}
>
<If condition={!isHidden}>
<ClientSettingsKeybindings></ClientSettingsKeybindings>
</If>

View File

@ -40,7 +40,8 @@
}
.mainview-content {
padding-top: 14px;
display: flex;
flex-direction: column;
}
}

View File

@ -7,6 +7,8 @@ import cn from "classnames";
import { GlobalModel } from "@/models";
import "./mainview.less";
import { Choose, If, Otherwise, When } from "tsx-control-statements/components";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
@mobxReact.observer
class MainView extends React.Component<{
@ -14,6 +16,8 @@ class MainView extends React.Component<{
onClose: () => void;
children: React.ReactNode;
className?: string;
scrollable?: boolean;
onScrollbarInitialized?: () => void;
}> {
render() {
const sidebarModel = GlobalModel.mainSidebarModel;
@ -31,7 +35,21 @@ class MainView extends React.Component<{
</div>
</header>
</div>
<Choose>
<When condition={this.props.scrollable}>
<OverlayScrollbarsComponent
className="mainview-content"
options={{ scrollbars: { autoHide: "leave" } }}
defer={true}
events={{ initialized: this.props.onScrollbarInitialized }}
>
{this.props.children}
</OverlayScrollbarsComponent>
</When>
<Otherwise>
<div className="mainview-content">{this.props.children}</div>
</Otherwise>
</Choose>
</div>
);
}

View File

@ -52,6 +52,7 @@
}
button {
padding-right: 4px !important;
i {
font-size: 18px;
}

View File

@ -24,7 +24,7 @@ const ModalHeader: React.FC<ModalHeaderProps> = ({ onClose, keybindings, title }
</If>
{<div className="wave-modal-title">{title}</div>}
<If condition={onClose}>
<Button className="secondary ghost" onClick={onClose}>
<Button className="secondary ghost" onClick={() => onClose()} title="Close (ESC)">
<i className="fa-sharp fa-solid fa-xmark"></i>
</Button>
</If>

View File

@ -1,25 +1,24 @@
.rconndetail-modal {
width: auto;
max-width: 80vw;
min-height: 565px;
.wave-modal-content {
display: flex;
padding-bottom: 0px;
flex-direction: column;
align-items: center;
gap: 20px;
flex-shrink: 0;
width: auto;
max-width: 80vw;
max-height: 90vh;
.wave-modal-body {
display: flex;
padding: 0px 20px;
padding: 20px;
align-items: flex-start;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: 16px;
align-self: stretch;
overflow-y: scroll;
.name-header-actions-wrapper {
display: flex;
@ -53,6 +52,7 @@
.remote-detail {
width: 100%;
padding-top: 16px;
.settings-field {
display: flex;

View File

@ -15,6 +15,7 @@ import * as appconst from "@/app/appconst";
import "./viewremoteconndetail.less";
import { ModalKeybindings } from "../elements/modal";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
@mobxReact.observer
class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
@ -299,15 +300,33 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
return (
<Modal className="rconndetail-modal">
<ModalKeybindings
onOk={() => {
if (selectedRemoteStatus == "connecting") {
return;
}
this.handleClose();
}}
onCancel={() => {
if (selectedRemoteStatus == "connecting") {
return;
}
this.handleClose();
}}
></ModalKeybindings>
<Modal.Header title="Connection" onClose={this.handleClose} />
<div className="wave-modal-body">
<OverlayScrollbarsComponent
className="wave-modal-body"
options={{ scrollbars: { autoHide: "leave" } }}
defer={true}
>
<div className="name-header-actions-wrapper">
<div className="name text-primary name-wrapper">
{util.getRemoteName(remote)}&nbsp; {getImportTooltip(remote)}
</div>
<div className="header-actions">{this.renderHeaderBtns(remote)}</div>
</div>
<div className="remote-detail" style={{ overflow: "hidden" }}>
<div className="remote-detail">
<div className="settings-field">
<div className="settings-label">Conn Id</div>
<div className="settings-input">{remote.remoteid}</div>
@ -381,33 +400,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
></div>
</div>
</div>
</div>
<div className="wave-modal-footer">
<ModalKeybindings
onOk={() => {
if (selectedRemoteStatus == "connecting") {
return;
}
this.handleClose();
}}
onCancel={() => {
if (selectedRemoteStatus == "connecting") {
return;
}
this.handleClose();
}}
></ModalKeybindings>
<Button
className="secondary"
disabled={selectedRemoteStatus == "connecting"}
onClick={this.handleClose}
>
Cancel
</Button>
<Button disabled={selectedRemoteStatus == "connecting"} onClick={this.handleClose}>
Done
</Button>
</div>
</OverlayScrollbarsComponent>
</Modal>
);
}

View File

@ -9,10 +9,20 @@
margin: 20px 50px 20px 20px;
}
.connections-table-container {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow-y: scroll;
max-height: 85vh;
width: fit-content;
max-width: 970px;
}
.connections-table {
margin: 0px 10px 10px 10px;
table-layout: fixed;
max-width: 970px;
position: relative;
colgroup {
.first-col {
@ -28,13 +38,16 @@
thead {
border-radius: var(--sizing-2-xs, 4px);
border-bottom: 2px solid var(--table-thead-bright-border-color);
user-select: none;
th {
position: sticky;
top: 0;
height: 32px;
padding: 5px 15px 5px 10px;
color: var(--app-text-color);
border-bottom: 2px solid var(--table-thead-bright-border-color);
background: var(--table-thead-bg-color);
}
}
@ -78,8 +91,10 @@
footer {
margin-left: 10px;
margin-top: 10px;
display: flex;
flex-direction: row;
flex-shrink: 0;
gap: 8px;
}

View File

@ -13,6 +13,7 @@ import * as util from "@/util/util";
import "./connections.less";
import { MainView } from "../common/elements/mainview";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
class ConnectionsKeybindings extends React.Component<{}, {}> {
componentDidMount() {
@ -149,6 +150,11 @@ class ConnectionsView extends React.Component<{ model: RemotesModel }, { hovered
<If condition={!isHidden}>
<ConnectionsKeybindings></ConnectionsKeybindings>
</If>
<OverlayScrollbarsComponent
className="connections-table-container"
options={{ scrollbars: { autoHide: "leave" } }}
defer={true}
>
<table
className="connections-table"
cellSpacing="0"
@ -200,6 +206,7 @@ class ConnectionsView extends React.Component<{ model: RemotesModel }, { hovered
</For>
</tbody>
</table>
</OverlayScrollbarsComponent>
<footer>
<Button
className="secondary"

View File

@ -260,8 +260,8 @@ class MainSideBar extends React.Component<MainSideBarProps, {}> {
<div className="logo">
<WaveLogo />
</div>
<div className="close-button">
<i className="fa-sharp fa-solid fa-xmark-large" onClick={toggleCollapse} />
<div className="close-button" onClick={toggleCollapse}>
<i className="fa-sharp fa-solid fa-xmark-large" />
</div>
</div>
<div className="contents">

View File

@ -61,9 +61,11 @@ class AIChat extends React.Component<{}, {}> {
constructor(props: any) {
super(props);
mobx.makeObservable(this);
this.chatWindowScrollRef = React.createRef();
this.textAreaRef = React.createRef();
}
componentDidMount() {
const inputModel = GlobalModel.inputModel;
if (this.chatWindowScrollRef?.current != null) {
@ -88,7 +90,7 @@ class AIChat extends React.Component<{}, {}> {
}
submitChatMessage(messageStr: string) {
const curLine = GlobalModel.inputModel.getCurLine();
const curLine = GlobalModel.inputModel.curLine;
const prtn = GlobalModel.submitChatInfoCommand(messageStr, curLine, false);
prtn.then((rtn) => {
if (!rtn.success) {
@ -103,15 +105,19 @@ class AIChat extends React.Component<{}, {}> {
return { numLines, linePos };
}
@mobx.action.bound
onTextAreaFocused(e: any) {
GlobalModel.inputModel.setAuxViewFocus(true);
this.onTextAreaChange(e);
}
@mobx.action.bound
onTextAreaBlur(e: any) {
GlobalModel.inputModel.setAuxViewFocus(false);
//GlobalModel.inputModel.setAuxViewFocus(false);
}
// Adjust the height of the textarea to fit the text
@boundMethod
onTextAreaChange(e: any) {
// Calculate the bounding height of the text area
const textAreaMaxLines = 4;
@ -126,6 +132,9 @@ class AIChat extends React.Component<{}, {}> {
// Set the new height of the text area, bounded by the min and max height.
const newHeight = Math.min(Math.max(scrollHeight, textAreaMinHeight), textAreaMaxHeight);
this.textAreaRef.current.style.height = newHeight + "px";
}
onTextAreaInput(e: any) {
GlobalModel.inputModel.codeSelectDeselectAll();
}
@ -140,8 +149,10 @@ class AIChat extends React.Component<{}, {}> {
this.submitChatMessage(messageStr);
currentRef.value = "";
} else {
mobx.action(() => {
inputModel.grabCodeSelectSelection();
inputModel.setAuxViewFocus(false);
})();
}
}
@ -182,7 +193,6 @@ class AIChat extends React.Component<{}, {}> {
return true;
}
@mobx.action
@boundMethod
onKeyDown(e: any) {}
@ -254,9 +264,10 @@ class AIChat extends React.Component<{}, {}> {
autoComplete="off"
autoCorrect="off"
id="chat-cmd-input"
onFocus={this.onTextAreaFocused.bind(this)}
onBlur={this.onTextAreaBlur.bind(this)}
onChange={this.onTextAreaChange.bind(this)}
onFocus={this.onTextAreaFocused}
onBlur={this.onTextAreaBlur}
onChange={this.onTextAreaChange}
onInput={this.onTextAreaInput}
onKeyDown={this.onKeyDown}
style={{ fontSize: this.termFontSize }}
className="chat-textarea"

View File

@ -193,8 +193,8 @@
}
// This aligns the icons with the prompt field.
// We don't need right padding because the whole input field is already padded.
padding: 2px 0 0 12px;
// We don't need right margin because the whole input field is already padded.
margin: 2px 0 0 12px;
cursor: pointer;
}

View File

@ -29,6 +29,11 @@ class CmdInput extends React.Component<{}, {}> {
cmdInputRef: React.RefObject<any> = React.createRef();
promptRef: React.RefObject<any> = React.createRef();
constructor(props) {
super(props);
mobx.makeObservable(this);
}
componentDidMount() {
this.updateCmdInputHeight();
}
@ -56,7 +61,7 @@ class CmdInput extends React.Component<{}, {}> {
this.updateCmdInputHeight();
}
@boundMethod
@mobx.action.bound
clickFocusInputHint(): void {
GlobalModel.inputModel.giveFocus();
}
@ -75,7 +80,7 @@ class CmdInput extends React.Component<{}, {}> {
GlobalModel.inputModel.setAuxViewFocus(false);
}
@boundMethod
@mobx.action.bound
clickAIAction(e: any): void {
e.preventDefault();
e.stopPropagation();
@ -87,7 +92,7 @@ class CmdInput extends React.Component<{}, {}> {
}
}
@boundMethod
@mobx.action.bound
clickHistoryAction(e: any): void {
e.preventDefault();
e.stopPropagation();
@ -105,11 +110,9 @@ class CmdInput extends React.Component<{}, {}> {
GlobalCommandRunner.connectRemote(remoteId);
}
@boundMethod
@mobx.action.bound
toggleFilter(screen: Screen) {
mobx.action(() => {
screen.filterRunning.set(!screen.filterRunning.get());
})();
}
@boundMethod

View File

@ -22,6 +22,7 @@
color: var(--app-text-color);
display: flex;
flex-direction: column-reverse;
min-height: 100%;
.history-item {
cursor: pointer;

View File

@ -42,6 +42,11 @@ class HItem extends React.Component<
},
{}
> {
constructor(props) {
super(props);
mobx.makeObservable(this);
}
renderRemote(hitem: HistoryItem): any {
if (hitem.remote == null || isBlank(hitem.remote.remoteid)) {
return sprintf("%-15s ", "");
@ -169,12 +174,12 @@ class HistoryInfo extends React.Component<{}, {}> {
}
}
@boundMethod
@mobx.action.bound
handleClose() {
GlobalModel.inputModel.closeAuxView();
}
@boundMethod
@mobx.action.bound
handleItemClick(hitem: HistoryItem) {
const inputModel = GlobalModel.inputModel;
const selItem = inputModel.getHistorySelectedItem();
@ -195,14 +200,14 @@ class HistoryInfo extends React.Component<{}, {}> {
}, 3000);
}
@boundMethod
@mobx.action.bound
handleClickType() {
const inputModel = GlobalModel.inputModel;
inputModel.setAuxViewFocus(true);
inputModel.toggleHistoryType();
}
@boundMethod
@mobx.action.bound
handleClickRemote() {
const inputModel = GlobalModel.inputModel;
inputModel.setAuxViewFocus(true);
@ -229,7 +234,7 @@ class HistoryInfo extends React.Component<{}, {}> {
render() {
const inputModel = GlobalModel.inputModel;
const selItem = inputModel.getHistorySelectedItem();
const hitems = inputModel.getFilteredHistoryItems();
const hitems = inputModel.filteredHistoryItems;
const opts = inputModel.historyQueryOpts.get();
let hitem: HistoryItem = null;
let snames: Record<string, string> = {};

View File

@ -3,6 +3,7 @@
import * as React from "react";
import * as mobxReact from "mobx-react";
import * as mobx from "mobx";
import { If, For } from "tsx-control-statements/components";
import cn from "classnames";
import dayjs from "dayjs";
@ -17,6 +18,11 @@ dayjs.extend(localizedFormat);
@mobxReact.observer
class InfoMsg extends React.Component<{}, {}> {
constructor(props) {
super(props);
mobx.makeObservable(this);
}
getAfterSlash(s: string): string {
if (s.startsWith("^/")) {
return s.substring(1);

View File

@ -117,7 +117,7 @@ class CmdInputKeybindings extends React.Component<{ inputObject: TextAreaInput }
const lastTab = this.lastTab;
this.lastTab = true;
this.curPress = "tab";
const curLine = inputModel.getCurLine();
const curLine = inputModel.curLine;
if (lastTab) {
GlobalModel.submitCommand(
"_compgen",
@ -250,9 +250,15 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
lastSP: StrWithPos = { str: "", pos: appconst.NoStrPos };
version: OV<number> = mobx.observable.box(0, { name: "textAreaInput-version" }); // forces render updates
constructor(props) {
super(props);
mobx.makeObservable(this);
}
@mobx.action
incVersion(): void {
const v = this.version.get();
mobx.action(() => this.version.set(v + 1))();
this.version.set(v + 1);
}
getCurSP(): StrWithPos {
@ -278,6 +284,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
GlobalModel.sendCmdInputText(this.props.screen.screenId, curSP);
}
@mobx.action
setFocus(): void {
GlobalModel.inputModel.giveFocus();
}
@ -311,7 +318,8 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
}
}
componentDidMount() {
@mobx.action.bound
handleComponentDidMount() {
const activeScreen = GlobalModel.getActiveScreen();
if (activeScreen != null) {
const focusType = activeScreen.focusType.get();
@ -324,6 +332,24 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
this.updateSP();
}
componentDidMount() {
this.handleComponentDidMount();
this.updateCursorPosIfForced();
}
updateCursorPosIfForced() {
const inputModel = GlobalModel.inputModel;
const fcpos = inputModel.forceCursorPos.get();
if (fcpos != null && fcpos != appconst.NoStrPos) {
if (this.mainInputRef.current != null) {
this.mainInputRef.current.selectionStart = fcpos;
this.mainInputRef.current.selectionEnd = fcpos;
}
inputModel.forceCursorPos.set(null);
}
}
@mobx.action
componentDidUpdate() {
const activeScreen = GlobalModel.getActiveScreen();
if (activeScreen != null) {
@ -334,14 +360,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
this.lastFocusType = focusType;
}
const inputModel = GlobalModel.inputModel;
const fcpos = inputModel.forceCursorPos.get();
if (fcpos != null && fcpos != appconst.NoStrPos) {
if (this.mainInputRef.current != null) {
this.mainInputRef.current.selectionStart = fcpos;
this.mainInputRef.current.selectionEnd = fcpos;
}
mobx.action(() => inputModel.forceCursorPos.set(null))();
}
this.updateCursorPosIfForced();
if (inputModel.forceInputFocus) {
inputModel.forceInputFocus = false;
this.setFocus();
@ -414,21 +433,18 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
return;
}
currentRef.setRangeText("\n", currentRef.selectionStart, currentRef.selectionEnd, "end");
GlobalModel.inputModel.setCurLine(currentRef.value);
GlobalModel.inputModel.curLine = currentRef.value;
}
@mobx.action
@boundMethod
onKeyDown(e: any) {}
@boundMethod
@mobx.action.bound
onChange(e: any) {
mobx.action(() => {
GlobalModel.inputModel.setCurLine(e.target.value);
})();
GlobalModel.inputModel.curLine = e.target.value;
}
@boundMethod
@mobx.action.bound
onSelect(e: any) {
this.incVersion();
}
@ -453,7 +469,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
GlobalModel.inputModel.updateCmdLine(cmdLineUpdate);
}
@boundMethod
@mobx.action.bound
controlP() {
const inputModel = GlobalModel.inputModel;
if (!inputModel.isHistoryLoaded()) {
@ -465,7 +481,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
this.lastHistoryUpDown = true;
}
@boundMethod
@mobx.action.bound
controlN() {
const inputModel = GlobalModel.inputModel;
inputModel.moveHistorySelection(-1);
@ -526,17 +542,15 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
});
}
@boundMethod
@mobx.action.bound
handleHistoryInput(e: any) {
const inputModel = GlobalModel.inputModel;
mobx.action(() => {
const opts = mobx.toJS(inputModel.historyQueryOpts.get());
opts.queryStr = e.target.value;
inputModel.setHistoryQueryOpts(opts);
})();
}
@boundMethod
@mobx.action.bound
handleFocus(e: any) {
e.preventDefault();
GlobalModel.inputModel.giveFocus();
@ -561,7 +575,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
render() {
const model = GlobalModel;
const inputModel = model.inputModel;
const curLine = inputModel.getCurLine();
const curLine = inputModel.curLine;
let displayLines = 1;
const numLines = curLine.split("\n").length;
const maxCols = this.getTextAreaMaxCols();
@ -606,7 +620,6 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
const renderCmdInputKeybindings = inputModel.shouldRenderAuxViewKeybindings(null);
const renderHistoryKeybindings = inputModel.shouldRenderAuxViewKeybindings(appconst.InputAuxView_History);
return (
<div
className="textareainput-div control is-expanded"

View File

@ -81,7 +81,7 @@ class BookmarksModel {
mobx.action(() => {
this.reset();
this.globalModel.showSessionView();
this.globalModel.inputModel.setCurLine(bm.cmdstr);
this.globalModel.inputModel.curLine = bm.cmdstr;
setTimeout(() => this.globalModel.inputModel.giveFocus(), 50);
})();
}

View File

@ -3,12 +3,10 @@
import type React from "react";
import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator";
import { isBlank } from "@/util/util";
import * as appconst from "@/app/appconst";
import type { Model } from "./model";
import { GlobalCommandRunner, GlobalModel } from "./global";
import { app } from "electron";
function getDefaultHistoryQueryOpts(): HistoryQueryOpts {
return {
@ -48,7 +46,6 @@ class InputModel {
name: "history-items",
deep: false,
}); // sorted in reverse (most recent is index 0)
filteredHistoryItems: mobx.IComputedValue<HistoryItem[]> = null;
historyIndex: mobx.IObservableValue<number> = mobx.observable.box(0, {
name: "history-index",
}); // 1-indexed (because 0 is current)
@ -73,11 +70,11 @@ class InputModel {
physicalInputFocused: OV<boolean> = mobx.observable.box(false);
forceInputFocus: boolean = false;
lastCurLine: string = "";
constructor(globalModel: Model) {
this.globalModel = globalModel;
this.filteredHistoryItems = mobx.computed(() => {
return this._getFilteredHistoryItems();
});
mobx.makeObservable(this);
mobx.action(() => {
this.codeSelectSelectedIndex.set(-1);
this.codeSelectBlockRefArray = [];
@ -85,12 +82,12 @@ class InputModel {
this.codeSelectUuid = "";
}
@mobx.action
setInputMode(inputMode: null | "comment" | "global"): void {
mobx.action(() => {
this.inputMode.set(inputMode);
})();
}
@mobx.action
toggleHistoryType(): void {
const opts = mobx.toJS(this.historyQueryOpts.get());
let htype = opts.queryType;
@ -104,6 +101,7 @@ class InputModel {
this.setHistoryType(htype);
}
@mobx.action
toggleRemoteType(): void {
const opts = mobx.toJS(this.historyQueryOpts.get());
if (opts.limitRemote) {
@ -116,33 +114,31 @@ class InputModel {
this.setHistoryQueryOpts(opts);
}
@mobx.action
onInputFocus(isFocused: boolean): void {
mobx.action(() => {
if (isFocused) {
this.inputFocused.set(true);
this.lineFocused.set(false);
} else if (this.inputFocused.get()) {
this.inputFocused.set(false);
}
})();
}
@mobx.action
onLineFocus(isFocused: boolean): void {
mobx.action(() => {
if (isFocused) {
this.inputFocused.set(false);
this.lineFocused.set(true);
} else if (this.lineFocused.get()) {
this.lineFocused.set(false);
}
})();
}
// Focuses the main input or the auxiliary view, depending on the active auxiliary view
@mobx.action
giveFocus(): void {
// Override active view to the main input if aux view does not have focus
const activeAuxView = this.getAuxViewFocus() ? this.getActiveAuxView() : null;
mobx.action(() => {
switch (activeAuxView) {
case appconst.InputAuxView_History: {
const elem: HTMLElement = document.querySelector(".cmd-input input.history-input");
@ -170,13 +166,11 @@ class InputModel {
break;
}
}
})();
}
@mobx.action
setPhysicalInputFocused(isFocused: boolean): void {
mobx.action(() => {
this.physicalInputFocused.set(isFocused);
})();
if (isFocused) {
const screen = this.globalModel.getActiveScreen();
if (screen != null) {
@ -203,6 +197,7 @@ class InputModel {
return false;
}
@mobx.action
setHistoryType(htype: HistoryTypeStrs): void {
if (this.historyQueryOpts.get().queryType == htype) {
return;
@ -214,7 +209,7 @@ class InputModel {
if (oldItem == null) {
return 0;
}
const newItems = this.getFilteredHistoryItems();
const newItems = this.filteredHistoryItems;
if (newItems.length == 0) {
return 0;
}
@ -234,15 +229,15 @@ class InputModel {
return bestIdx + 1;
}
@mobx.action
setHistoryQueryOpts(opts: HistoryQueryOpts): void {
mobx.action(() => {
const oldItem = this.getHistorySelectedItem();
this.historyQueryOpts.set(opts);
const bestIndex = this.findBestNewIndex(oldItem);
setTimeout(() => this.setHistoryIndex(bestIndex, true), 10);
})();
}
@mobx.action
setOpenAICmdInfoChat(chat: OpenAICmdInfoChatMessageType[]): void {
this.AICmdInfoChatItems.replace(chat);
this.codeSelectBlockRefArray = [];
@ -256,6 +251,7 @@ class InputModel {
return hitems != null;
}
@mobx.action
loadHistory(show: boolean, afterLoadIndex: number, htype: HistoryTypeStrs) {
if (this.historyLoading.get()) {
return;
@ -266,12 +262,11 @@ class InputModel {
}
}
this.historyAfterLoadIndex = afterLoadIndex;
mobx.action(() => {
this.historyLoading.set(true);
})();
GlobalCommandRunner.loadHistory(show, htype);
}
@mobx.action
openHistory(): void {
if (this.historyLoading.get()) {
return;
@ -287,13 +282,12 @@ class InputModel {
}
}
@mobx.action
updateCmdLine(cmdLine: StrWithPos): void {
mobx.action(() => {
this.setCurLine(cmdLine.str);
this.curLine = cmdLine.str;
if (cmdLine.pos != appconst.NoStrPos) {
this.forceCursorPos.set(cmdLine.pos);
}
})();
}
getHistorySelectedItem(): HistoryItem {
@ -301,7 +295,7 @@ class InputModel {
if (hidx == 0) {
return null;
}
const hitems = this.getFilteredHistoryItems();
const hitems = this.filteredHistoryItems;
if (hidx > hitems.length) {
return null;
}
@ -309,15 +303,16 @@ class InputModel {
}
getFirstHistoryItem(): HistoryItem {
const hitems = this.getFilteredHistoryItems();
const hitems = this.filteredHistoryItems;
if (hitems.length == 0) {
return null;
}
return hitems[0];
}
@mobx.action
setHistorySelectionNum(hnum: string): void {
const hitems = this.getFilteredHistoryItems();
const hitems = this.filteredHistoryItems;
for (const [i, hitem] of hitems.entries()) {
if (hitem.historynum == hnum) {
this.setHistoryIndex(i + 1);
@ -326,8 +321,8 @@ class InputModel {
}
}
@mobx.action
setHistoryInfo(hinfo: HistoryInfoType): void {
mobx.action(() => {
const oldItem = this.getHistorySelectedItem();
const hitems: HistoryItem[] = hinfo.items ?? [];
this.historyItems.set(hitems);
@ -349,14 +344,10 @@ class InputModel {
if (hinfo.show) {
this.openHistory();
}
})();
}
getFilteredHistoryItems(): HistoryItem[] {
return this.filteredHistoryItems.get();
}
_getFilteredHistoryItems(): HistoryItem[] {
@mobx.computed
get filteredHistoryItems(): HistoryItem[] {
const hitems: HistoryItem[] = this.historyItems.get() ?? [];
const rtn: HistoryItem[] = [];
const opts: HistoryQueryOpts = mobx.toJS(this.historyQueryOpts.get());
@ -416,16 +407,15 @@ class InputModel {
elem.scrollIntoView({ block: "nearest" });
}
@mobx.action
grabSelectedHistoryItem(): void {
const hitem = this.getHistorySelectedItem();
if (hitem == null) {
this.resetHistory();
return;
}
mobx.action(() => {
this.resetInput();
this.setCurLine(hitem.cmdstr);
})();
this.curLine = hitem.cmdstr;
}
// Closes the auxiliary view if it is open, focuses the main input
@ -449,8 +439,8 @@ class InputModel {
mobx.action(() => {
this.auxViewFocus.set(view != null);
this.activeAuxView.set(view);
})();
this.giveFocus();
})();
}
// Gets the focus state of the auxiliary view. If true, the view will get focus. Otherwise, the main input will get focus.
@ -463,16 +453,13 @@ class InputModel {
}
// Sets the focus state of the auxiliary view. If true, the view will get focus. Otherwise, the main input will get focus.
@mobx.action
setAuxViewFocus(focus: boolean): void {
mobx.action(() => {
this.auxViewFocus.set(focus);
})();
this.giveFocus();
}
shouldRenderAuxViewKeybindings(view: InputAuxViewType): boolean {
return mobx
.computed(() => {
if (view != null && this.getActiveAuxView() != view) {
return false;
}
@ -485,17 +472,13 @@ class InputModel {
if (view != null && this.getAuxViewFocus()) {
return true;
}
if (
GlobalModel.getActiveScreen().getFocusType() == "input" &&
GlobalModel.activeMainView.get() == "session"
) {
if (GlobalModel.getActiveScreen().getFocusType() == "input" && GlobalModel.activeMainView.get() == "session") {
return true;
}
return false;
})
.get();
}
@mobx.action
setHistoryIndex(hidx: number, force?: boolean): void {
if (hidx < 0) {
return;
@ -503,7 +486,6 @@ class InputModel {
if (!force && this.historyIndex.get() == hidx) {
return;
}
mobx.action(() => {
this.historyIndex.set(hidx);
if (this.getActiveAuxView() == appconst.InputAuxView_History) {
let hitem = this.getHistorySelectedItem();
@ -514,7 +496,6 @@ class InputModel {
this.scrollHistoryItemIntoView(hitem.historynum);
}
}
})();
}
moveHistorySelection(amt: number): void {
@ -524,7 +505,7 @@ class InputModel {
if (!this.isHistoryLoaded()) {
return;
}
const hitems = this.getFilteredHistoryItems();
const hitems = this.filteredHistoryItems;
let idx = this.historyIndex.get() + amt;
if (idx < 0) {
idx = 0;
@ -535,11 +516,10 @@ class InputModel {
this.setHistoryIndex(idx);
}
@mobx.action
flashInfoMsg(info: InfoType, timeoutMs: number): void {
this._clearInfoTimeout();
mobx.action(() => {
this.infoMsg.set(info);
})();
if (info == null && this.getActiveAuxView() == appconst.InputAuxView_Info) {
this.setActiveAuxView(null);
@ -578,7 +558,7 @@ class InputModel {
) {
const curBlockRef = this.codeSelectBlockRefArray[this.codeSelectSelectedIndex.get()];
const codeText = curBlockRef.current.innerText.replace(/\n$/, ""); // remove trailing newline
this.setCurLine(codeText);
this.curLine = codeText;
this.giveFocus();
}
}
@ -594,8 +574,8 @@ class InputModel {
return rtn;
}
@mobx.action
setCodeSelectSelectedCodeBlock(blockIndex: number) {
mobx.action(() => {
if (blockIndex >= 0 && blockIndex < this.codeSelectBlockRefArray.length) {
this.codeSelectSelectedIndex.set(blockIndex);
const currentRef = this.codeSelectBlockRefArray[blockIndex].current;
@ -606,20 +586,19 @@ class InputModel {
let elemBottom = elemTop - currentRef.offsetHeight;
const elementIsInView = elemBottom < chatWindowBottom && elemTop > chatWindowTop;
if (!elementIsInView) {
this.aiChatWindowRef.current.scrollTop =
elemBottom - this.aiChatWindowRef.current.clientHeight / 3;
this.aiChatWindowRef.current.scrollTop = elemBottom - this.aiChatWindowRef.current.clientHeight / 3;
}
}
}
this.codeSelectBlockRefArray = [];
this.setAIChatFocus();
})();
this.setActiveAuxView(appconst.InputAuxView_AIChat);
this.setAuxViewFocus(true);
}
@mobx.action
codeSelectSelectNextNewestCodeBlock() {
// oldest code block = index 0 in array
// this decrements codeSelectSelected index
mobx.action(() => {
if (this.codeSelectSelectedIndex.get() == this.codeSelectTop) {
this.codeSelectSelectedIndex.set(this.codeSelectBottom);
} else if (this.codeSelectSelectedIndex.get() == this.codeSelectBottom) {
@ -635,11 +614,10 @@ class InputModel {
if (incBlockIndex >= 0 && incBlockIndex < this.codeSelectBlockRefArray.length) {
this.setCodeSelectSelectedCodeBlock(incBlockIndex);
}
})();
}
@mobx.action
codeSelectSelectNextOldestCodeBlock() {
mobx.action(() => {
if (this.codeSelectSelectedIndex.get() == this.codeSelectBottom) {
if (this.codeSelectBlockRefArray.length > 0) {
this.codeSelectSelectedIndex.set(this.codeSelectBlockRefArray.length);
@ -659,7 +637,6 @@ class InputModel {
if (decBlockIndex >= 0 && decBlockIndex < this.codeSelectBlockRefArray.length) {
this.setCodeSelectSelectedCodeBlock(decBlockIndex);
}
})();
}
getCodeSelectSelectedIndex() {
@ -684,6 +661,7 @@ class InputModel {
})();
}
@mobx.action
openAIAssistantChat(): void {
this.setActiveAuxView(appconst.InputAuxView_AIChat);
this.setAuxViewFocus(true);
@ -723,19 +701,19 @@ class InputModel {
}
}
@mobx.action
clearInfoMsg(setNull: boolean): void {
this._clearInfoTimeout();
if (this.getActiveAuxView() == appconst.InputAuxView_Info) {
this.setActiveAuxView(null);
}
mobx.action(() => {
if (setNull) {
this.infoMsg.set(null);
}
})();
}
@mobx.action
toggleInfoMsg(): void {
this._clearInfoTimeout();
if (this.activeAuxView.get() == appconst.InputAuxView_Info) {
@ -745,41 +723,29 @@ class InputModel {
}
}
@boundMethod
uiSubmitCommand(): void {
mobx.action(() => {
const commandStr = this.getCurLine();
const commandStr = this.curLine;
if (commandStr.trim() == "") {
return;
}
mobx.action(() => {
this.resetInput();
this.globalModel.submitRawCommand(commandStr, true, true);
})();
this.globalModel.submitRawCommand(commandStr, true, true);
}
isEmpty(): boolean {
return this.getCurLine().trim() == "";
return this.curLine.trim() == "";
}
@mobx.action
resetInputMode(): void {
mobx.action(() => {
this.setInputMode(null);
this.setCurLine("");
})();
}
setCurLine(val: string): void {
const hidx = this.historyIndex.get();
mobx.action(() => {
if (this.modHistory.length <= hidx) {
this.modHistory.length = hidx + 1;
}
this.modHistory[hidx] = val;
})();
this.curLine = "";
}
@mobx.action
resetInput(): void {
mobx.action(() => {
this.setActiveAuxView(null);
this.inputMode.set(null);
this.resetHistory();
@ -787,23 +753,21 @@ class InputModel {
this.infoMsg.set(null);
this.inputExpanded.set(false);
this._clearInfoTimeout();
})();
}
@boundMethod
@mobx.action
toggleExpandInput(): void {
mobx.action(() => {
this.inputExpanded.set(!this.inputExpanded.get());
this.forceInputFocus = true;
})();
}
getCurLine(): string {
@mobx.computed
get curLine(): string {
const hidx = this.historyIndex.get();
if (hidx < this.modHistory.length && this.modHistory[hidx] != null) {
return this.modHistory[hidx];
}
const hitems = this.getFilteredHistoryItems();
const hitems = this.filteredHistoryItems;
if (hidx == 0 || hitems == null || hidx > hitems.length) {
return "";
}
@ -814,8 +778,19 @@ class InputModel {
return hitem.cmdstr;
}
dropModHistory(keepLine0: boolean): void {
set curLine(val: string) {
this.lastCurLine = this.curLine;
const hidx = this.historyIndex.get();
mobx.action(() => {
if (this.modHistory.length <= hidx) {
this.modHistory.length = hidx + 1;
}
this.modHistory[hidx] = val;
})();
}
@mobx.action
dropModHistory(keepLine0: boolean): void {
if (keepLine0) {
if (this.modHistory.length > 1) {
this.modHistory.splice(1, this.modHistory.length - 1);
@ -823,11 +798,10 @@ class InputModel {
} else {
this.modHistory.replace([""]);
}
})();
}
@mobx.action
resetHistory(): void {
mobx.action(() => {
if (this.getActiveAuxView() == appconst.InputAuxView_History) {
this.setActiveAuxView(null);
}
@ -838,7 +812,6 @@ class InputModel {
this.historyQueryOpts.set(getDefaultHistoryQueryOpts());
this.historyAfterLoadIndex = 0;
this.dropModHistory(true);
})();
}
}

View File

@ -37,7 +37,6 @@ import { GlobalCommandRunner } from "./global";
import { clearMonoFontCache, getMonoFontSize } from "@/util/textmeasure";
import type { TermWrap } from "@/plugins/terminal/term";
import * as util from "@/util/util";
import { url } from "node:inspector";
type SWLinePtr = {
line: LineType;
@ -1327,7 +1326,21 @@ class Model {
}
}
submitCommandPacket(cmdPk: FeCmdPacketType, interactive: boolean): Promise<CommandRtnType> {
/**
* Submits a command packet to the server and processes the response.
* @param cmdPk The command packet to submit.
* @param interactive Whether the command is interactive.
* @param runUpdate Whether to run the update after the command is submitted. If true, the update will be processed and the frontend will be updated. If false, the update will be returned in the promise.
* @returns A promise that resolves to a CommandRtnType.
* @throws An error if the command fails.
* @see CommandRtnType
* @see FeCmdPacketType
**/
submitCommandPacket(
cmdPk: FeCmdPacketType,
interactive: boolean,
runUpdate: boolean = true
): Promise<CommandRtnType> {
if (this.debugCmds > 0) {
console.log("[cmd]", cmdPacketString(cmdPk));
if (this.debugCmds > 1) {
@ -1345,16 +1358,20 @@ class Model {
})
.then((resp) => handleJsonFetchResponse(url, resp))
.then((data) => {
mobx.action(() => {
return mobx.action(() => {
const update = data.data;
if (update != null) {
if (runUpdate) {
this.runUpdate(update, interactive);
} else {
return { success: true, update: update };
}
}
if (interactive && !this.isInfoUpdate(update)) {
this.inputModel.clearInfoMsg(true);
}
})();
return { success: true };
})();
})
.catch((err) => {
this.errorHandler("calling run-command", err, interactive);
@ -1367,12 +1384,23 @@ class Model {
return prtn;
}
/**
* Submits a command to the server and processes the response.
* @param metaCmd The meta command to run.
* @param metaSubCmd The meta subcommand to run.
* @param args The arguments to pass to the command.
* @param kwargs The keyword arguments to pass to the command.
* @param interactive Whether the command is interactive.
* @param runUpdate Whether to run the update after the command is submitted. If true, the update will be processed and the frontend will be updated. If false, the update will be returned in the promise.
* @returns A promise that resolves to a CommandRtnType.
*/
submitCommand(
metaCmd: string,
metaSubCmd: string,
args: string[],
kwargs: Record<string, string>,
interactive: boolean
interactive: boolean,
runUpdate: boolean = true
): Promise<CommandRtnType> {
const pk: FeCmdPacketType = {
type: "fecmd",
@ -1393,7 +1421,7 @@ class Model {
pk.interactive
);
*/
return this.submitCommandPacket(pk, interactive);
return this.submitCommandPacket(pk, interactive, runUpdate);
}
getSingleEphemeralCommandOutput(url: URL): Promise<string> {
@ -1412,12 +1440,10 @@ class Model {
let stderr = "";
if (ephemeralCommandResponse.stdouturl) {
const url = new URL(this.getBaseHostPort() + ephemeralCommandResponse.stdouturl);
console.log("stdouturl", url);
stdout = await this.getSingleEphemeralCommandOutput(url);
}
if (ephemeralCommandResponse.stderrurl) {
const url = new URL(this.getBaseHostPort() + ephemeralCommandResponse.stderrurl);
console.log("stderrurl", url);
stderr = await this.getSingleEphemeralCommandOutput(url);
}
return { stdout: stdout, stderr: stderr };
@ -1476,14 +1502,14 @@ class Model {
interactive: interactive,
ephemeralopts: ephemeralopts,
};
console.log(
"CMD",
pk.metacmd + (pk.metasubcmd != null ? ":" + pk.metasubcmd : ""),
pk.args,
pk.kwargs,
pk.interactive,
pk.ephemeralopts
);
// console.log(
// "CMD",
// pk.metacmd + (pk.metasubcmd != null ? ":" + pk.metasubcmd : ""),
// pk.args,
// pk.kwargs,
// pk.interactive,
// pk.ephemeralopts
// );
return this.submitEphemeralCommandPacket(pk, interactive);
}

View File

@ -821,6 +821,7 @@ declare global {
type CommandRtnType = {
success: boolean;
error?: string;
update?: UpdatePacket;
};
type EphemeralCommandOutputType = {

View File

@ -833,6 +833,7 @@ type RunPacketType struct {
Detached bool `json:"detached,omitempty"`
ReturnState bool `json:"returnstate,omitempty"`
IsSudo bool `json:"issudo,omitempty"`
Timeout time.Duration `json:"timeout"` // TODO: added vnext. This is the timeout for the command to run. If the command does not complete in this time, it will be killed. The default zero value will not impose a timeout.
}
func (*RunPacketType) GetType() string {

View File

@ -926,12 +926,11 @@ func RunCommandSimple(pk *packet.RunPacketType, sender *packet.PacketSender, fro
rcFileName = fmt.Sprintf("/dev/fd/%d", rcFileFdNum)
}
if cmd.TmpRcFileName != "" {
go func() {
time.AfterFunc(2*time.Second, func() {
// cmd.Close() will also remove rcFileName
// adding this to also try to proactively clean up after 2-seconds.
time.Sleep(2 * time.Second)
os.Remove(cmd.TmpRcFileName)
}()
})
}
fullCmdStr := pk.Command
if pk.ReturnState {
@ -1109,6 +1108,14 @@ func RunCommandSimple(pk *packet.RunPacketType, sender *packet.PacketSender, fro
if err != nil {
return nil, err
}
if pk.Timeout > 0 {
// Cancel the command if it takes too long
time.AfterFunc(pk.Timeout, func() {
cmd.Cmd.Cancel()
cmd.Close()
})
}
return cmd, nil
}

View File

@ -5,6 +5,7 @@ go 1.22
toolchain go1.22.0
require (
github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9
github.com/alessio/shellescape v1.4.1
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2
github.com/creack/pty v1.1.18

View File

@ -1,3 +1,5 @@
github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9 h1:ez/4by2iGztzR4L0zgAOR8lTQK9VlyBVVd7G4omaOQs=
github.com/alecthomas/units v0.0.0-20231202071711-9a357b53e9c9/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 h1:7Ip0wMmLHLRJdrloDxZfhMm0xrLXZS8+COSu2bXmEQs=
@ -55,6 +57,7 @@ github.com/sawka/txwrap v0.1.2 h1:v8xS0Z1LE7/6vMZA81PYihI+0TSR6Zm1MalzzBIuXKc=
github.com/sawka/txwrap v0.1.2/go.mod h1:T3nlw2gVpuolo6/XEetvBbk1oMXnY978YmBFy1UyHvw=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/wavetermdev/ssh_config v0.0.0-20240306041034-17e2087ebde2 h1:onqZrJVap1sm15AiIGTfWzdr6cEF0KdtddeuuOVhzyY=
@ -71,6 +74,8 @@ golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/sh/v3 v3.7.0 h1:lSTjdP/1xsddtaKfGg7Myu7DnlHItd3/M2tomOcNNBg=

View File

@ -0,0 +1,623 @@
package blockstore
import (
"context"
"encoding/json"
"fmt"
"log"
"math"
"os"
"strings"
"sync"
"time"
"github.com/alecthomas/units"
)
type FileOptsType struct {
MaxSize int64
Circular bool
IJson bool
}
type FileMeta = map[string]any
type FileInfo struct {
BlockId string
Name string
Size int64
CreatedTs int64
ModTs int64
Opts FileOptsType
Meta FileMeta
}
const MaxBlockSize = int64(128 * units.Kilobyte)
const DefaultFlushTimeout = 1 * time.Second
type CacheEntry struct {
Lock *sync.Mutex
CacheTs int64
Info *FileInfo
DataBlocks []*CacheBlock
Refs int64
}
func (c *CacheEntry) IncRefs() {
c.Refs += 1
}
func (c *CacheEntry) DecRefs() {
c.Refs -= 1
}
type CacheBlock struct {
data []byte
size int
dirty bool
}
func MakeCacheEntry(info *FileInfo) *CacheEntry {
rtn := &CacheEntry{Lock: &sync.Mutex{}, CacheTs: int64(time.Now().UnixMilli()), Info: info, DataBlocks: []*CacheBlock{}, Refs: 0}
return rtn
}
// add ctx context.Context to all these methods
type BlockStore interface {
MakeFile(ctx context.Context, blockId string, name string, meta FileMeta, opts FileOptsType) error
WriteFile(ctx context.Context, blockId string, name string, meta FileMeta, opts FileOptsType, data []byte) (int, error)
AppendData(ctx context.Context, blockId string, name string, p []byte) (int, error)
WriteAt(ctx context.Context, blockId string, name string, p []byte, off int64) (int, error)
ReadAt(ctx context.Context, blockId string, name string, p *[]byte, off int64) (int, error)
Stat(ctx context.Context, blockId string, name string) (FileInfo, error)
CollapseIJson(ctx context.Context, blockId string, name string) error
WriteMeta(ctx context.Context, blockId string, name string, meta FileMeta) error
DeleteFile(ctx context.Context, blockId string, name string) error
DeleteBlock(ctx context.Context, blockId string) error
ListFiles(ctx context.Context, blockId string) []*FileInfo
FlushCache(ctx context.Context) error
GetAllBlockIds(ctx context.Context) []string
}
var cache map[string]*CacheEntry = make(map[string]*CacheEntry)
var globalLock *sync.Mutex = &sync.Mutex{}
var appendLock *sync.Mutex = &sync.Mutex{}
var flushTimeout = DefaultFlushTimeout
var lastWriteTime time.Time
func InsertFileIntoDB(ctx context.Context, fileInfo FileInfo) error {
metaJson, err := json.Marshal(fileInfo.Meta)
if err != nil {
return fmt.Errorf("Error writing file %s to db: %v", fileInfo.Name, err)
}
txErr := WithTx(ctx, func(tx *TxWrap) error {
query := `INSERT INTO block_file VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
tx.Exec(query, fileInfo.BlockId, fileInfo.Name, fileInfo.Opts.MaxSize, fileInfo.Opts.Circular, fileInfo.Size, fileInfo.CreatedTs, fileInfo.ModTs, metaJson)
return nil
})
if txErr != nil {
return fmt.Errorf("Error writing file %s to db: %v", fileInfo.Name, txErr)
}
return nil
}
func WriteFileToDB(ctx context.Context, fileInfo FileInfo) error {
metaJson, err := json.Marshal(fileInfo.Meta)
if err != nil {
return fmt.Errorf("Error writing file %s to db: %v", fileInfo.Name, err)
}
txErr := WithTx(ctx, func(tx *TxWrap) error {
query := `UPDATE block_file SET blockid = ?, name = ?, maxsize = ?, circular = ?, size = ?, createdts = ?, modts = ?, meta = ? where blockid = ? and name = ?`
tx.Exec(query, fileInfo.BlockId, fileInfo.Name, fileInfo.Opts.MaxSize, fileInfo.Opts.Circular, fileInfo.Size, fileInfo.CreatedTs, fileInfo.ModTs, metaJson, fileInfo.BlockId, fileInfo.Name)
return nil
})
if txErr != nil {
return fmt.Errorf("Error writing file %s to db: %v", fileInfo.Name, txErr)
}
return nil
}
func WriteDataBlockToDB(ctx context.Context, blockId string, name string, index int, data []byte) error {
txErr := WithTx(ctx, func(tx *TxWrap) error {
query := `REPLACE INTO block_data values (?, ?, ?, ?)`
tx.Exec(query, blockId, name, index, data)
return nil
})
if txErr != nil {
return fmt.Errorf("Error writing data block to db: %v", txErr)
}
return nil
}
func MakeFile(ctx context.Context, blockId string, name string, meta FileMeta, opts FileOptsType) error {
curTs := time.Now().UnixMilli()
fileInfo := FileInfo{BlockId: blockId, Name: name, Size: 0, CreatedTs: curTs, ModTs: curTs, Opts: opts, Meta: meta}
err := InsertFileIntoDB(ctx, fileInfo)
if err != nil {
return err
}
curCacheEntry := MakeCacheEntry(&fileInfo)
SetCacheEntry(ctx, GetCacheId(blockId, name), curCacheEntry)
return nil
}
func WriteToCacheBlockNum(ctx context.Context, blockId string, name string, p []byte, pos int, length int, cacheNum int, pullFromDB bool) (int64, int, error) {
cacheEntry, err := GetCacheEntryOrPopulate(ctx, blockId, name)
if err != nil {
return 0, 0, err
}
cacheEntry.IncRefs()
cacheEntry.Lock.Lock()
defer cacheEntry.Lock.Unlock()
block, err := GetCacheBlock(ctx, blockId, name, cacheNum, pullFromDB)
if err != nil {
return 0, 0, fmt.Errorf("Error getting cache block: %v", err)
}
var bytesWritten = 0
blockLen := len(block.data)
fileMaxSize := cacheEntry.Info.Opts.MaxSize
maxWriteSize := fileMaxSize - (int64(cacheNum) * MaxBlockSize)
numLeftPad := int64(0)
if pos > blockLen {
numLeftPad = int64(pos - blockLen)
leftPadBytes := []byte{}
for index := 0; index < int(numLeftPad); index++ {
leftPadBytes = append(leftPadBytes, 0)
}
leftPadPos := int64(pos) - numLeftPad
b, err := WriteToCacheBuf(&block.data, leftPadBytes, int(leftPadPos), int(numLeftPad), maxWriteSize)
if err != nil {
return int64(b), b, err
}
numLeftPad = int64(b)
cacheEntry.Info.Size += (int64(cacheNum) * MaxBlockSize)
}
b, writeErr := WriteToCacheBuf(&block.data, p, pos, length, maxWriteSize)
bytesWritten += b
blockLenDiff := len(block.data) - blockLen
block.size = len(block.data)
cacheEntry.Info.Size += int64(blockLenDiff)
block.dirty = true
cacheEntry.DecRefs()
return numLeftPad, bytesWritten, writeErr
}
func ReadFromCacheBlock(ctx context.Context, blockId string, name string, block *CacheBlock, p *[]byte, pos int, length int, destOffset int, maxRead int64) (int, error) {
defer func() {
if r := recover(); r != nil {
log.Printf("recovered from crash %v ", r)
log.Printf("values: %v %v %v %v %v %v", pos, length, destOffset, maxRead, p, block)
os.Exit(0)
}
}()
if pos > len(block.data) {
return 0, fmt.Errorf("Reading past end of cache block, should never happen")
}
bytesWritten := 0
index := pos
for ; index < length+pos; index++ {
if int64(index) >= maxRead {
return index - pos, fmt.Errorf(MaxSizeError)
}
if index >= len(block.data) {
return bytesWritten, nil
}
destIndex := index - pos + destOffset
if destIndex >= len(*p) {
return bytesWritten, nil
}
(*p)[destIndex] = block.data[index]
bytesWritten++
}
if int64(index) >= maxRead {
return bytesWritten, fmt.Errorf(MaxSizeError)
}
return bytesWritten, nil
}
const MaxSizeError = "Hit Max Size"
func WriteToCacheBuf(buf *[]byte, p []byte, pos int, length int, maxWrite int64) (int, error) {
bytesToWrite := length
if pos > len(*buf) {
return 0, fmt.Errorf("writing to a position (%v) in the cache that doesn't exist yet, something went wrong", pos)
}
if int64(pos+bytesToWrite) > MaxBlockSize {
return 0, fmt.Errorf("writing more bytes than max block size, not allowed - length of bytes to write: %v, length of cache: %v", bytesToWrite, len(*buf))
}
for index := pos; index < bytesToWrite+pos; index++ {
if index-pos >= len(p) {
return len(p), nil
}
if int64(index) >= maxWrite {
return index - pos, fmt.Errorf(MaxSizeError)
}
curByte := p[index-pos]
if len(*buf) == index {
*buf = append(*buf, curByte)
} else {
(*buf)[index] = curByte
}
}
return bytesToWrite, nil
}
func GetCacheId(blockId string, name string) string {
return blockId + "~SEP~" + name
}
func GetValuesFromCacheId(cacheId string) (blockId string, name string) {
vals := strings.Split(cacheId, "~SEP~")
if len(vals) == 2 {
return vals[0], vals[1]
} else {
log.Println("Failure in GetValuesFromCacheId, this should never happen")
return "", ""
}
}
func GetCacheEntry(ctx context.Context, blockId string, name string) (*CacheEntry, bool) {
globalLock.Lock()
defer globalLock.Unlock()
if curCacheEntry, found := cache[GetCacheId(blockId, name)]; found {
return curCacheEntry, true
} else {
return nil, false
}
}
func GetCacheEntryOrPopulate(ctx context.Context, blockId string, name string) (*CacheEntry, error) {
if cacheEntry, found := GetCacheEntry(ctx, blockId, name); found {
return cacheEntry, nil
} else {
log.Printf("populating cache entry\n")
_, err := Stat(ctx, blockId, name)
if err != nil {
return nil, err
}
if cacheEntry, found := GetCacheEntry(ctx, blockId, name); found {
return cacheEntry, nil
} else {
return nil, fmt.Errorf("Error getting cache entry %v %v", blockId, name)
}
}
}
func SetCacheEntry(ctx context.Context, cacheId string, cacheEntry *CacheEntry) {
globalLock.Lock()
defer globalLock.Unlock()
if _, found := cache[cacheId]; found {
return
}
cache[cacheId] = cacheEntry
}
func DeleteCacheEntry(ctx context.Context, blockId string, name string) {
globalLock.Lock()
defer globalLock.Unlock()
delete(cache, GetCacheId(blockId, name))
}
func GetCacheBlock(ctx context.Context, blockId string, name string, cacheNum int, pullFromDB bool) (*CacheBlock, error) {
curCacheEntry, err := GetCacheEntryOrPopulate(ctx, blockId, name)
if err != nil {
return nil, err
}
if len(curCacheEntry.DataBlocks) < cacheNum+1 {
for index := len(curCacheEntry.DataBlocks); index < cacheNum+1; index++ {
curCacheEntry.DataBlocks = append(curCacheEntry.DataBlocks, nil)
}
}
if curCacheEntry.DataBlocks[cacheNum] == nil {
var curCacheBlock *CacheBlock
if pullFromDB {
cacheData, err := GetCacheFromDB(ctx, blockId, name, 0, MaxBlockSize, int64(cacheNum))
if err != nil {
return nil, err
}
curCacheBlock = &CacheBlock{data: *cacheData, size: len(*cacheData), dirty: false}
curCacheEntry.DataBlocks[cacheNum] = curCacheBlock
} else {
curCacheBlock = &CacheBlock{data: []byte{}, size: 0, dirty: false}
curCacheEntry.DataBlocks[cacheNum] = curCacheBlock
}
return curCacheBlock, nil
} else {
return curCacheEntry.DataBlocks[cacheNum], nil
}
}
func DeepCopyFileInfo(fInfo *FileInfo) *FileInfo {
fInfoMeta := make(FileMeta)
for k, v := range fInfo.Meta {
fInfoMeta[k] = v
}
fInfoOpts := fInfo.Opts
fInfoCopy := &FileInfo{BlockId: fInfo.BlockId, Name: fInfo.Name, Size: fInfo.Size, CreatedTs: fInfo.CreatedTs, ModTs: fInfo.ModTs, Opts: fInfoOpts, Meta: fInfoMeta}
return fInfoCopy
}
func Stat(ctx context.Context, blockId string, name string) (*FileInfo, error) {
cacheEntry, found := GetCacheEntry(ctx, blockId, name)
if found {
return DeepCopyFileInfo(cacheEntry.Info), nil
}
curCacheEntry := MakeCacheEntry(nil)
curCacheEntry.Lock.Lock()
defer curCacheEntry.Lock.Unlock()
fInfo, err := GetFileInfo(ctx, blockId, name)
if err != nil {
return nil, err
}
curCacheEntry.Info = fInfo
SetCacheEntry(ctx, GetCacheId(blockId, name), curCacheEntry)
return DeepCopyFileInfo(fInfo), nil
}
func SetFlushTimeout(newTimeout time.Duration) {
flushTimeout = newTimeout
}
func GetClockString(t time.Time) string {
hour, min, sec := t.Clock()
return fmt.Sprintf("%v:%v:%v", hour, min, sec)
}
func StartFlushTimer(ctx context.Context) {
curTime := time.Now()
writeTimePassed := curTime.UnixNano() - lastWriteTime.UnixNano()
if writeTimePassed >= int64(flushTimeout) {
lastWriteTime = curTime
go func() {
time.Sleep(flushTimeout)
FlushCache(ctx)
}()
}
}
func WriteAt(ctx context.Context, blockId string, name string, p []byte, off int64) (int, error) {
return WriteAtHelper(ctx, blockId, name, p, off, true)
}
func WriteAtHelper(ctx context.Context, blockId string, name string, p []byte, off int64, flushCache bool) (int, error) {
bytesToWrite := len(p)
bytesWritten := 0
curCacheNum := int(math.Floor(float64(off) / float64(MaxBlockSize)))
numCaches := int(math.Ceil(float64(bytesToWrite) / float64(MaxBlockSize)))
cacheOffset := off - (int64(curCacheNum) * MaxBlockSize)
if (cacheOffset + int64(bytesToWrite)) > MaxBlockSize {
numCaches += 1
}
fInfo, err := Stat(ctx, blockId, name)
if err != nil {
return 0, fmt.Errorf("Write At err: %v", err)
}
if off > fInfo.Opts.MaxSize && fInfo.Opts.Circular {
numOver := off / fInfo.Opts.MaxSize
off = off - (numOver * fInfo.Opts.MaxSize)
}
for index := curCacheNum; index < curCacheNum+numCaches; index++ {
cacheOffset := off - (int64(index) * MaxBlockSize)
bytesToWriteToCurCache := int(math.Min(float64(bytesToWrite), float64(MaxBlockSize-cacheOffset)))
pullFromDB := true
if cacheOffset == 0 && int64(bytesToWriteToCurCache) == MaxBlockSize {
pullFromDB = false
}
_, b, err := WriteToCacheBlockNum(ctx, blockId, name, p, int(cacheOffset), bytesToWriteToCurCache, index, pullFromDB)
bytesWritten += b
bytesToWrite -= b
off += int64(b)
if err != nil {
if err.Error() == MaxSizeError {
if fInfo.Opts.Circular {
p = p[int64(b):]
b, err := WriteAtHelper(ctx, blockId, name, p, 0, false)
bytesWritten += b
if err != nil {
return bytesWritten, fmt.Errorf("Write to cache error: %v", err)
}
break
}
} else {
return bytesWritten, fmt.Errorf("Write to cache error: %v", err)
}
}
if len(p) == b {
break
}
p = p[int64(b):]
}
if flushCache {
StartFlushTimer(ctx)
}
return bytesWritten, nil
}
func GetAllBlockSizes(dataBlocks []*CacheBlock) (int, int) {
rtn := 0
numNil := 0
for idx, block := range dataBlocks {
if block == nil {
numNil += 1
continue
}
rtn += block.size
if block.size != len(block.data) {
log.Printf("error: block %v has incorrect block size : %v %v", idx, block.size, len(block.data))
}
}
return rtn, numNil
}
func FlushCache(ctx context.Context) error {
for _, cacheEntry := range cache {
err := WriteFileToDB(ctx, *cacheEntry.Info)
if err != nil {
return err
}
clearEntry := true
cacheEntry.Lock.Lock()
for index, block := range cacheEntry.DataBlocks {
if block == nil || block.size == 0 {
continue
}
if !block.dirty {
clearEntry = false
continue
}
err := WriteDataBlockToDB(ctx, cacheEntry.Info.BlockId, cacheEntry.Info.Name, index, block.data)
if err != nil {
return err
}
cacheEntry.DataBlocks[index] = nil
}
cacheEntry.Lock.Unlock()
if clearEntry && cacheEntry.Refs <= 0 {
DeleteCacheEntry(ctx, cacheEntry.Info.BlockId, cacheEntry.Info.Name)
}
}
return nil
}
func ReadAt(ctx context.Context, blockId string, name string, p *[]byte, off int64) (int, error) {
bytesRead := 0
fInfo, err := Stat(ctx, blockId, name)
if err != nil {
return 0, fmt.Errorf("Read At err: %v", err)
}
if off > fInfo.Opts.MaxSize && fInfo.Opts.Circular {
numOver := off / fInfo.Opts.MaxSize
off = off - (numOver * fInfo.Opts.MaxSize)
}
if off > fInfo.Size {
return 0, fmt.Errorf("Read At error: tried to read past the end of the file")
}
endReadPos := math.Min(float64(int64(len(*p))+off), float64(fInfo.Size))
bytesToRead := int64(endReadPos) - off
curCacheNum := int(math.Floor(float64(off) / float64(MaxBlockSize)))
numCaches := int(math.Ceil(float64(bytesToRead) / float64(MaxBlockSize)))
cacheOffset := off - (int64(curCacheNum) * MaxBlockSize)
if (cacheOffset + int64(bytesToRead)) > MaxBlockSize {
numCaches += 1
}
for index := curCacheNum; index < curCacheNum+numCaches; index++ {
curCacheBlock, err := GetCacheBlock(ctx, blockId, name, index, true)
if err != nil {
return bytesRead, fmt.Errorf("Error getting cache block: %v", err)
}
cacheOffset := off - (int64(index) * MaxBlockSize)
if cacheOffset < 0 {
return bytesRead, nil
}
bytesToReadFromCurCache := int(math.Min(float64(bytesToRead), float64(MaxBlockSize-cacheOffset)))
fileMaxSize := fInfo.Opts.MaxSize
maxReadSize := fileMaxSize - (int64(index) * MaxBlockSize)
b, err := ReadFromCacheBlock(ctx, blockId, name, curCacheBlock, p, int(cacheOffset), bytesToReadFromCurCache, bytesRead, maxReadSize)
if b == 0 {
log.Printf("something wrong %v %v %v %v %v %v %v %v", index, off, cacheOffset, curCacheNum, numCaches, bytesRead, bytesToRead, curCacheBlock)
cacheEntry, _ := GetCacheEntry(ctx, blockId, name)
blockSize, numNil := GetAllBlockSizes(cacheEntry.DataBlocks)
maybeDBSize := int64(numNil) * MaxBlockSize
maybeFullSize := int64(blockSize) + maybeDBSize
log.Printf("block actual sizes: %v %v %v %v %v\n", blockSize, numNil, maybeDBSize, maybeFullSize, len(cacheEntry.DataBlocks))
}
bytesRead += b
bytesToRead -= int64(b)
off += int64(b)
if err != nil {
if err.Error() == MaxSizeError {
if fInfo.Opts.Circular {
off = 0
newP := (*p)[b:]
b, err := ReadAt(ctx, blockId, name, &newP, off)
bytesRead += b
if err != nil {
return bytesRead, err
}
break
}
} else {
return bytesRead, fmt.Errorf("Read from cache error: %v", err)
}
}
}
return bytesRead, nil
}
func AppendData(ctx context.Context, blockId string, name string, p []byte) (int, error) {
appendLock.Lock()
defer appendLock.Unlock()
fInfo, err := Stat(ctx, blockId, name)
if err != nil {
return 0, fmt.Errorf("Append stat error: %v", err)
}
return WriteAt(ctx, blockId, name, p, fInfo.Size)
}
func DeleteFile(ctx context.Context, blockId string, name string) error {
DeleteCacheEntry(ctx, blockId, name)
err := DeleteFileFromDB(ctx, blockId, name)
return err
}
func DeleteBlock(ctx context.Context, blockId string) error {
for cacheId, _ := range cache {
curBlockId, name := GetValuesFromCacheId(cacheId)
if curBlockId == blockId {
err := DeleteFile(ctx, blockId, name)
if err != nil {
return fmt.Errorf("Error deleting %v %v: %v", blockId, name, err)
}
}
}
err := DeleteBlockFromDB(ctx, blockId)
return err
}
func WriteFile(ctx context.Context, blockId string, name string, meta FileMeta, opts FileOptsType, data []byte) (int, error) {
MakeFile(ctx, blockId, name, meta, opts)
return AppendData(ctx, blockId, name, data)
}
func WriteMeta(ctx context.Context, blockId string, name string, meta FileMeta) error {
_, err := Stat(ctx, blockId, name)
// stat so that we can make sure cache entry is popuplated
if err != nil {
return err
}
cacheEntry, found := GetCacheEntry(ctx, blockId, name)
if !found {
return fmt.Errorf("WriteAt error: cache entry not found")
}
cacheEntry.Lock.Lock()
defer cacheEntry.Lock.Unlock()
cacheEntry.Info.Meta = meta
return nil
}
func ListFiles(ctx context.Context, blockId string) []*FileInfo {
fInfoArr, err := GetAllFilesInDBForBlockId(ctx, blockId)
if err != nil {
return nil
}
return fInfoArr
}
func ListAllFiles(ctx context.Context) []*FileInfo {
fInfoArr, err := GetAllFilesInDB(ctx)
if err != nil {
return nil
}
return fInfoArr
}
func GetAllBlockIds(ctx context.Context) []string {
rtn, err := GetAllBlockIdsInDB(ctx)
if err != nil {
return nil
}
return rtn
}

View File

@ -0,0 +1,242 @@
package blockstore
import (
"context"
"encoding/json"
"fmt"
"log"
"path"
"sync"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"github.com/sawka/txwrap"
"github.com/wavetermdev/waveterm/wavesrv/pkg/dbutil"
"github.com/wavetermdev/waveterm/wavesrv/pkg/scbase"
)
const DBFileName = "blockstore.db"
type SingleConnDBGetter struct {
SingleConnLock *sync.Mutex
}
var dbWrap *SingleConnDBGetter
type TxWrap = txwrap.TxWrap
func InitDBState() {
dbWrap = &SingleConnDBGetter{SingleConnLock: &sync.Mutex{}}
}
func (dbg *SingleConnDBGetter) GetDB(ctx context.Context) (*sqlx.DB, error) {
db, err := GetDB(ctx)
if err != nil {
return nil, err
}
dbg.SingleConnLock.Lock()
return db, nil
}
func (dbg *SingleConnDBGetter) ReleaseDB(db *sqlx.DB) {
dbg.SingleConnLock.Unlock()
}
func WithTx(ctx context.Context, fn func(tx *TxWrap) error) error {
return txwrap.DBGWithTx(ctx, dbWrap, fn)
}
func WithTxRtn[RT any](ctx context.Context, fn func(tx *TxWrap) (RT, error)) (RT, error) {
var rtn RT
txErr := WithTx(ctx, func(tx *TxWrap) error {
temp, err := fn(tx)
if err != nil {
return err
}
rtn = temp
return nil
})
return rtn, txErr
}
var globalDBLock = &sync.Mutex{}
var globalDB *sqlx.DB
var globalDBErr error
func GetDBName() string {
scHome := scbase.GetWaveHomeDir()
return path.Join(scHome, DBFileName)
}
func GetDB(ctx context.Context) (*sqlx.DB, error) {
if txwrap.IsTxWrapContext(ctx) {
return nil, fmt.Errorf("cannot call GetDB from within a running transaction")
}
globalDBLock.Lock()
defer globalDBLock.Unlock()
if globalDB == nil && globalDBErr == nil {
dbName := GetDBName()
globalDB, globalDBErr = sqlx.Open("sqlite3", fmt.Sprintf("file:%s?cache=shared&mode=rwc&_journal_mode=WAL&_busy_timeout=5000", dbName))
if globalDBErr != nil {
globalDBErr = fmt.Errorf("opening db[%s]: %w", dbName, globalDBErr)
log.Printf("[db] error: %v\n", globalDBErr)
} else {
log.Printf("[db] successfully opened db %s\n", dbName)
}
}
return globalDB, globalDBErr
}
func CloseDB() {
globalDBLock.Lock()
defer globalDBLock.Unlock()
if globalDB == nil {
return
}
err := globalDB.Close()
if err != nil {
log.Printf("[db] error closing database: %v\n", err)
}
globalDB = nil
}
func (f *FileInfo) ToMap() map[string]interface{} {
rtn := make(map[string]interface{})
log.Printf("fileInfo ToMap is unimplemented!")
return rtn
}
func (fInfo *FileInfo) FromMap(m map[string]interface{}) bool {
fileOpts := FileOptsType{}
dbutil.QuickSetBool(&fileOpts.Circular, m, "circular")
dbutil.QuickSetInt64(&fileOpts.MaxSize, m, "maxsize")
var metaJson []byte
dbutil.QuickSetBytes(&metaJson, m, "meta")
var fileMeta FileMeta
err := json.Unmarshal(metaJson, &fileMeta)
if err != nil {
return false
}
dbutil.QuickSetStr(&fInfo.BlockId, m, "blockid")
dbutil.QuickSetStr(&fInfo.Name, m, "name")
dbutil.QuickSetInt64(&fInfo.Size, m, "size")
dbutil.QuickSetInt64(&fInfo.CreatedTs, m, "createdts")
dbutil.QuickSetInt64(&fInfo.ModTs, m, "modts")
fInfo.Opts = fileOpts
fInfo.Meta = fileMeta
return true
}
func GetFileInfo(ctx context.Context, blockId string, name string) (*FileInfo, error) {
fInfoArr, txErr := WithTxRtn(ctx, func(tx *TxWrap) ([]*FileInfo, error) {
var rtn []*FileInfo
query := `SELECT * FROM block_file WHERE name = 'file-1'`
marr := tx.SelectMaps(query)
for _, m := range marr {
rtn = append(rtn, dbutil.FromMap[*FileInfo](m))
}
return rtn, nil
})
if txErr != nil {
return nil, fmt.Errorf("GetFileInfo database error: %v", txErr)
}
if len(fInfoArr) > 1 {
return nil, fmt.Errorf("GetFileInfo duplicate files in database")
}
if len(fInfoArr) == 0 {
return nil, fmt.Errorf("GetFileInfo: File not found")
}
fInfo := fInfoArr[0]
return fInfo, nil
}
func GetCacheFromDB(ctx context.Context, blockId string, name string, off int64, length int64, cacheNum int64) (*[]byte, error) {
return WithTxRtn(ctx, func(tx *TxWrap) (*[]byte, error) {
var cacheData *[]byte
query := `SELECT substr(data,?,?) FROM block_data WHERE blockid = ? AND name = ? and partidx = ?`
tx.Get(&cacheData, query, off, length+1, blockId, name, cacheNum)
if cacheData == nil {
cacheData = &[]byte{}
}
return cacheData, nil
})
}
func DeleteFileFromDB(ctx context.Context, blockId string, name string) error {
txErr := WithTx(ctx, func(tx *TxWrap) error {
query := `DELETE from block_file where blockid = ? AND name = ?`
tx.Exec(query, blockId, name)
return nil
})
if txErr != nil {
return txErr
}
txErr = WithTx(ctx, func(tx *TxWrap) error {
query := `DELETE from block_data where blockid = ? AND name = ?`
tx.Exec(query, blockId, name)
return nil
})
if txErr != nil {
return txErr
}
return nil
}
func DeleteBlockFromDB(ctx context.Context, blockId string) error {
txErr := WithTx(ctx, func(tx *TxWrap) error {
query := `DELETE from block_file where blockid = ?`
tx.Exec(query, blockId)
return nil
})
if txErr != nil {
return txErr
}
txErr = WithTx(ctx, func(tx *TxWrap) error {
query := `DELETE from block_data where blockid = ?`
tx.Exec(query, blockId)
return nil
})
if txErr != nil {
return txErr
}
return nil
}
func GetAllFilesInDBForBlockId(ctx context.Context, blockId string) ([]*FileInfo, error) {
return WithTxRtn(ctx, func(tx *TxWrap) ([]*FileInfo, error) {
var rtn []*FileInfo
query := `SELECT * FROM block_file where blockid = ?`
marr := tx.SelectMaps(query, blockId)
for _, m := range marr {
rtn = append(rtn, dbutil.FromMap[*FileInfo](m))
}
return rtn, nil
})
}
func GetAllFilesInDB(ctx context.Context) ([]*FileInfo, error) {
return WithTxRtn(ctx, func(tx *TxWrap) ([]*FileInfo, error) {
var rtn []*FileInfo
query := `SELECT * FROM block_file`
marr := tx.SelectMaps(query)
for _, m := range marr {
rtn = append(rtn, dbutil.FromMap[*FileInfo](m))
}
return rtn, nil
})
}
func GetAllBlockIdsInDB(ctx context.Context) ([]string, error) {
return WithTxRtn(ctx, func(tx *TxWrap) ([]string, error) {
var rtn []string
query := `SELECT DISTINCT blockid FROM block_file`
marr := tx.SelectMaps(query)
for _, m := range marr {
var blockId string
dbutil.QuickSetStr(&blockId, m, "blockid")
rtn = append(rtn, blockId)
}
return rtn, nil
})
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,21 @@
CREATE TABLE schema_migrations (version uint64,dirty bool);
CREATE UNIQUE INDEX version_unique ON schema_migrations (version);
CREATE TABLE block_file (
blockid varchar(36) NOT NULL,
name varchar(200) NOT NULL,
maxsize bigint NOT NULL,
circular boolean NOT NULL,
size bigint NOT NULL,
createdts bigint NOT NULL,
modts bigint NOT NULL,
meta json NOT NULL,
PRIMARY KEY (blockid, name)
);
CREATE TABLE block_data (
blockid varchar(36) NOT NULL,
name varchar(200) NOT NULL,
partidx int NOT NULL,
data blob NOT NULL,
PRIMARY KEY(blockid, name, partidx)
);

View File

@ -15,6 +15,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/wavetermdev/waveterm/waveshell/pkg/wlog"
"github.com/wavetermdev/waveterm/wavesrv/pkg/scbase"
"github.com/wavetermdev/waveterm/wavesrv/pkg/waveenc"
)
@ -110,6 +111,7 @@ func (pipe *BufferedPipe) WriteTo(w io.Writer) (n int64, err error) {
// Close the pipe. This will cause any blocking WriteTo calls to return.
func (pipe *BufferedPipe) Close() error {
wlog.Logf("closing buffered pipe %s", pipe.Key)
defer pipe.bufferDataCond.Broadcast()
pipe.closed.Store(true)
return nil

View File

@ -6091,6 +6091,26 @@ func ClientShowCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (s
if pk.UIContext != nil && pk.UIContext.Build != "" {
clientVersion = pk.UIContext.Build
}
aiModel := clientData.OpenAIOpts.Model
if aiModel == "" {
aiModel = "(default) " + openai.DefaultModel
}
aiMaxTokens := fmt.Sprintf("%d", clientData.OpenAIOpts.MaxTokens)
if clientData.OpenAIOpts.MaxTokens == 0 {
aiMaxTokens = fmt.Sprintf("(default) %d", openai.DefaultMaxTokens)
}
aiMaxChoices := fmt.Sprintf("%d", clientData.OpenAIOpts.MaxChoices)
if clientData.OpenAIOpts.MaxChoices == 0 {
aiMaxChoices = "(not set)"
}
aiBaseUrl := clientData.OpenAIOpts.BaseURL
if aiBaseUrl == "" {
aiBaseUrl = "(openai default)"
}
aiTimeout := fmt.Sprintf("(default) %d", (OpenAIPacketTimeout / 1000))
if clientData.OpenAIOpts.Timeout != 0 {
aiTimeout = strconv.FormatFloat((float64(clientData.OpenAIOpts.Timeout) / 1000.0), 'f', -1, 64)
}
var buf bytes.Buffer
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "userid", clientData.UserId))
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "clientid", clientData.ClientId))
@ -6104,11 +6124,11 @@ func ClientShowCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (s
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "termfontfamily", clientData.FeOpts.TermFontFamily))
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "termfontfamily", clientData.FeOpts.Theme))
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "aiapitoken", clientData.OpenAIOpts.APIToken))
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "aimodel", clientData.OpenAIOpts.Model))
buf.WriteString(fmt.Sprintf(" %-15s %d\n", "aimaxtokens", clientData.OpenAIOpts.MaxTokens))
buf.WriteString(fmt.Sprintf(" %-15s %d\n", "aimaxchoices", clientData.OpenAIOpts.MaxChoices))
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "aibaseurl", clientData.OpenAIOpts.BaseURL))
buf.WriteString(fmt.Sprintf(" %-15s %ss\n", "aitimeout", strconv.FormatFloat((float64(clientData.OpenAIOpts.Timeout)/1000.0), 'f', -1, 64)))
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "aimodel", aiModel))
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "aimaxtokens", aiMaxTokens))
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "aimaxchoices", aiMaxChoices))
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "aibaseurl", aiBaseUrl))
buf.WriteString(fmt.Sprintf(" %-15s %ss\n", "aitimeout", aiTimeout))
update := scbus.MakeUpdatePacket()
update.AddUpdate(sstore.InfoMsgType{
InfoTitle: fmt.Sprintf("client info"),

View File

@ -1987,9 +1987,22 @@ func RunCommand(ctx context.Context, rcOpts RunCommandOpts, runPacket *packet.Ru
// Setting UsePty to false will ensure that the outputs get written to the correct file descriptors to extract stdout and stderr
runPacket.UsePty = rcOpts.EphemeralOpts.UsePty
// Ephemeral commands can override the cwd without persisting it to the DB
// Ephemeral commands can override the current working directory. We need to expand the home dir if it's relative.
if rcOpts.EphemeralOpts.OverrideCwd != "" {
currentState.Cwd = rcOpts.EphemeralOpts.OverrideCwd
overrideCwd := rcOpts.EphemeralOpts.OverrideCwd
if !strings.HasPrefix(overrideCwd, "/") {
expandedCwd, err := msh.GetRemoteRuntimeState().ExpandHomeDir(overrideCwd)
if err != nil {
return nil, nil, fmt.Errorf("cannot expand home dir for cwd: %w", err)
}
overrideCwd = expandedCwd
}
currentState.Cwd = overrideCwd
}
// Ephemeral commands can override the timeout
if rcOpts.EphemeralOpts.TimeoutMs > 0 {
runPacket.Timeout = time.Duration(rcOpts.EphemeralOpts.TimeoutMs) * time.Millisecond
}
// Ephemeral commands can override the env without persisting it to the DB
@ -2405,6 +2418,7 @@ func (msh *MShellProc) handleCmdStartError(rct *RunCmdType, startErr error) {
defer msh.RemoveRunningCmd(rct.CK)
if rct.EphemeralOpts != nil {
// nothing to do for ephemeral commands besides remove the running command
log.Printf("ephemeral command start error: %v\n", startErr)
return
}
ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)
@ -2472,6 +2486,11 @@ func (msh *MShellProc) handleCmdDonePacket(rct *RunCmdType, donePk *packet.CmdDo
// Close the ephemeral response writer if it exists
if rct.EphemeralOpts != nil && rct.EphemeralOpts.ExpectsResponse {
if donePk.ExitCode != 0 {
// if the command failed, we need to write the error to the response writer
log.Printf("writing error to ephemeral response writer\n")
rct.EphemeralOpts.StderrWriter.Write([]byte(fmt.Sprintf("error: %d\n", donePk.ExitCode)))
}
log.Printf("closing ephemeral response writers\n")
defer rct.EphemeralOpts.StdoutWriter.Close()
defer rct.EphemeralOpts.StderrWriter.Close()
@ -2577,6 +2596,7 @@ func (msh *MShellProc) handleDataPacket(rct *RunCmdType, dataPk *packet.DataPack
return
}
if rct.EphemeralOpts != nil {
log.Printf("ephemeral data packet: %s\n", dataPk.CK)
// Write to the response writer if it's set
if len(realData) > 0 && rct.EphemeralOpts.ExpectsResponse {
switch dataPk.FdNum {
@ -2594,6 +2614,9 @@ func (msh *MShellProc) handleDataPacket(rct *RunCmdType, dataPk *packet.DataPack
log.Printf("error handling data packet: invalid fdnum %d\n", dataPk.FdNum)
}
}
if dataPk.Error != "" {
log.Printf("ephemeral data packet error: %s\n", dataPk.Error)
}
ack := makeDataAckPacket(dataPk.CK, dataPk.FdNum, len(realData), nil)
msh.ServerProc.Input.SendPacket(ack)
return