mirror of
https://github.com/wavetermdev/backup.git
synced 2026-04-22 15:26:58 -07:00
454 lines
16 KiB
TypeScript
454 lines
16 KiB
TypeScript
// Copyright 2023, Command Line Inc.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
import * as React from "react";
|
|
import * as T from "../../types/types";
|
|
import Editor from "@monaco-editor/react";
|
|
import { Markdown } from "../../app/common/common";
|
|
import { GlobalModel, GlobalCommandRunner } from "../../model/model";
|
|
import Split from "react-split-it";
|
|
import loader from "@monaco-editor/loader";
|
|
loader.config({ paths: { vs: "./node_modules/monaco-editor/min/vs" } });
|
|
|
|
import "./code.less";
|
|
|
|
function renderCmdText(text: string): any {
|
|
return <span>⌘{text}</span>;
|
|
}
|
|
|
|
// there is a global monaco variable (TODO get the correct TS type)
|
|
declare var monaco: any;
|
|
|
|
class SourceCodeRenderer extends React.Component<
|
|
{
|
|
data: T.ExtBlob;
|
|
cmdstr: string;
|
|
cwd: string;
|
|
readOnly: boolean;
|
|
notFound: boolean;
|
|
exitcode: number;
|
|
context: T.RendererContext;
|
|
opts: T.RendererOpts;
|
|
savedHeight: number;
|
|
scrollToBringIntoViewport: () => void;
|
|
lineState: T.LineStateType;
|
|
isSelected: boolean;
|
|
shouldFocus: boolean;
|
|
rendererApi: T.RendererModelContainerApi;
|
|
},
|
|
{
|
|
code: string;
|
|
languages: string[];
|
|
selectedLanguage: string;
|
|
isSave: boolean;
|
|
isClosed: boolean;
|
|
editorHeight: number;
|
|
message: { status: string; text: string };
|
|
isPreviewerAvailable: boolean;
|
|
showPreview: boolean;
|
|
editorFraction: number;
|
|
showReadonly: boolean;
|
|
}
|
|
> {
|
|
/**
|
|
* codeCache is a Hashmap with key=screenId:lineId:filepath and value=code
|
|
* Editor should never read the code directly from the filesystem. it should read from the cache.
|
|
*/
|
|
static codeCache = new Map();
|
|
|
|
// which languages have preview options
|
|
languagesWithPreviewer = ["markdown"];
|
|
filePath;
|
|
cacheKey;
|
|
originalCode;
|
|
monacoEditor: any; // reference to mounted monaco editor. TODO need the correct type
|
|
markdownRef;
|
|
syncing;
|
|
|
|
constructor(props) {
|
|
super(props);
|
|
this.monacoEditor = null;
|
|
const editorHeight = Math.max(props.savedHeight - 25, 0); // must subtract the padding/margin to get the real editorHeight
|
|
this.markdownRef = React.createRef();
|
|
this.syncing = false;
|
|
let isClosed = props.lineState["prompt:closed"];
|
|
this.state = {
|
|
code: null,
|
|
languages: [],
|
|
selectedLanguage: "",
|
|
isSave: false,
|
|
isClosed: isClosed,
|
|
editorHeight,
|
|
message: null,
|
|
isPreviewerAvailable: false,
|
|
showPreview: this.props.lineState["showPreview"],
|
|
editorFraction: this.props.lineState["editorFraction"] || 0.5,
|
|
showReadonly: false,
|
|
};
|
|
}
|
|
|
|
componentDidMount(): void {
|
|
this.filePath = this.props.lineState["prompt:file"];
|
|
const { screenId, lineId } = this.props.context;
|
|
this.cacheKey = `${screenId}-${lineId}-${this.filePath}`;
|
|
const code = SourceCodeRenderer.codeCache.get(this.cacheKey);
|
|
if (code) {
|
|
this.setState({ code });
|
|
} else {
|
|
this.props.data.text().then((code) => {
|
|
this.originalCode = code;
|
|
this.setState({ code });
|
|
SourceCodeRenderer.codeCache.set(this.cacheKey, code);
|
|
});
|
|
}
|
|
}
|
|
|
|
componentDidUpdate(prevProps: any): void {
|
|
if (!prevProps.shouldFocus && this.props.shouldFocus) {
|
|
if (this.monacoEditor) {
|
|
this.monacoEditor.focus();
|
|
}
|
|
}
|
|
}
|
|
|
|
saveLineState = (kvp) => {
|
|
const { screenId, lineId } = this.props.context;
|
|
GlobalCommandRunner.setLineState(screenId, lineId, { ...this.props.lineState, ...kvp }, false);
|
|
};
|
|
|
|
setInitialLanguage = (editor) => {
|
|
// set all languages
|
|
const languages = monaco.languages.getLanguages().map((lang) => lang.id);
|
|
this.setState({ languages });
|
|
// detect the current language from previous settings
|
|
let detectedLanguage = this.props.lineState["lang"];
|
|
// if not found, we try to grab the filename from with filePath (coming from lineState["prompt:file"]) or cmdstr
|
|
if (!detectedLanguage) {
|
|
const strForFilePath = this.filePath || this.props.cmdstr;
|
|
const extension = strForFilePath.match(/(?:[^\\\/:*?"<>|\r\n]+\.)([a-zA-Z0-9]+)\b/)?.[1] || "";
|
|
const detectedLanguageObj = monaco.languages
|
|
.getLanguages()
|
|
.find((lang) => lang.extensions?.includes("." + extension));
|
|
if (detectedLanguageObj) {
|
|
detectedLanguage = detectedLanguageObj.id;
|
|
this.saveLineState({ lang: detectedLanguage });
|
|
}
|
|
}
|
|
if (detectedLanguage) {
|
|
const model = editor.getModel();
|
|
if (model) {
|
|
monaco.editor.setModelLanguage(model, detectedLanguage);
|
|
this.setState({
|
|
selectedLanguage: detectedLanguage,
|
|
isPreviewerAvailable: this.languagesWithPreviewer.includes(detectedLanguage),
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
handleEditorDidMount = (editor, monaco) => {
|
|
this.monacoEditor = editor;
|
|
this.setInitialLanguage(editor);
|
|
this.setEditorHeight();
|
|
editor.onKeyDown((e) => {
|
|
if (e.code === "KeyS" && (e.ctrlKey || e.metaKey) && this.state.isSave) {
|
|
e.preventDefault();
|
|
this.doSave();
|
|
}
|
|
if (e.code === "KeyD" && (e.ctrlKey || e.metaKey)) {
|
|
e.preventDefault();
|
|
this.doClose();
|
|
}
|
|
if (e.code === "KeyP" && (e.ctrlKey || e.metaKey)) {
|
|
e.preventDefault();
|
|
this.togglePreview();
|
|
}
|
|
});
|
|
editor.onDidScrollChange((e) => {
|
|
if (!this.syncing && e.scrollTopChanged) {
|
|
this.syncing = true;
|
|
this.handleEditorScrollChange(e);
|
|
this.syncing = false;
|
|
}
|
|
});
|
|
if (this.props.shouldFocus) {
|
|
this.monacoEditor.focus();
|
|
this.props.rendererApi.onFocusChanged(true);
|
|
}
|
|
if (this.monacoEditor.onDidFocusEditorWidget) {
|
|
this.monacoEditor.onDidFocusEditorWidget(() => {
|
|
this.props.rendererApi.onFocusChanged(true);
|
|
});
|
|
this.monacoEditor.onDidBlurEditorWidget(() => {
|
|
this.props.rendererApi.onFocusChanged(false);
|
|
});
|
|
}
|
|
if (!this.getAllowEditing()) this.setState({ showReadonly: true });
|
|
};
|
|
|
|
handleEditorScrollChange(e) {
|
|
if (!this.state.showPreview) return;
|
|
const scrollableHeightEditor = this.monacoEditor.getScrollHeight() - this.monacoEditor.getLayoutInfo().height;
|
|
const verticalScrollPercentage = e.scrollTop / scrollableHeightEditor;
|
|
const markdownDiv = this.markdownRef.current;
|
|
if (markdownDiv) {
|
|
const scrollableHeightMarkdown = markdownDiv.scrollHeight - markdownDiv.clientHeight;
|
|
markdownDiv.scrollTop = verticalScrollPercentage * scrollableHeightMarkdown;
|
|
}
|
|
}
|
|
|
|
handleDivScroll() {
|
|
if (!this.syncing) {
|
|
this.syncing = true;
|
|
// Calculate the scroll percentage for the markdown div
|
|
const markdownDiv = this.markdownRef.current;
|
|
const scrollableHeightMarkdown = markdownDiv.scrollHeight - markdownDiv.clientHeight;
|
|
const verticalScrollPercentage = markdownDiv.scrollTop / scrollableHeightMarkdown;
|
|
|
|
// Apply the same percentage to the editor
|
|
const scrollableHeightEditor =
|
|
this.monacoEditor.getScrollHeight() - this.monacoEditor.getLayoutInfo().height;
|
|
this.monacoEditor.setScrollTop(verticalScrollPercentage * scrollableHeightEditor);
|
|
|
|
this.syncing = false;
|
|
}
|
|
}
|
|
|
|
handleLanguageChange = (event) => {
|
|
const selectedLanguage = event.target.value;
|
|
this.setState({
|
|
selectedLanguage,
|
|
isPreviewerAvailable: this.languagesWithPreviewer.includes(selectedLanguage),
|
|
});
|
|
if (this.monacoEditor) {
|
|
const model = this.monacoEditor.getModel();
|
|
if (model) {
|
|
monaco.editor.setModelLanguage(model, selectedLanguage);
|
|
this.saveLineState({ lang: selectedLanguage });
|
|
}
|
|
}
|
|
};
|
|
|
|
doSave = (onSave = () => {}) => {
|
|
if (!this.state.isSave) return;
|
|
const { screenId, lineId } = this.props.context;
|
|
const encodedCode = new TextEncoder().encode(this.state.code);
|
|
GlobalModel.writeRemoteFile(screenId, lineId, this.filePath, encodedCode, { useTemp: true })
|
|
.then(() => {
|
|
this.originalCode = this.state.code;
|
|
this.setState(
|
|
{
|
|
isSave: false,
|
|
message: { status: "success", text: `Saved to ${this.props.cwd}/${this.filePath}` },
|
|
},
|
|
onSave
|
|
);
|
|
setTimeout(() => this.setState({ message: null }), 3000);
|
|
})
|
|
.catch((e) => {
|
|
this.setState({ message: { status: "error", text: e.message } });
|
|
setTimeout(() => this.setState({ message: null }), 3000);
|
|
});
|
|
};
|
|
|
|
doClose = () => {
|
|
// if there is unsaved data
|
|
if (this.state.isSave)
|
|
return GlobalModel.showAlert({
|
|
message: "Do you want to Save your changes before closing?",
|
|
confirm: true,
|
|
}).then((result) => {
|
|
if (result) return this.doSave(this.doClose);
|
|
this.setState({ code: this.originalCode, isSave: false }, this.doClose);
|
|
});
|
|
const { screenId, lineId } = this.props.context;
|
|
GlobalCommandRunner.setLineState(screenId, lineId, { ...this.props.lineState, "prompt:closed": true }, false)
|
|
.then(() => {
|
|
this.setState({
|
|
isClosed: true,
|
|
message: { status: "success", text: `Closed. This editor is now read-only` },
|
|
showReadonly: true,
|
|
});
|
|
setTimeout(() => {
|
|
this.setEditorHeight();
|
|
}, 100);
|
|
setTimeout(() => {
|
|
this.setState({ message: null });
|
|
}, 3000);
|
|
})
|
|
.catch((e) => {
|
|
this.setState({ message: { status: "error", text: e.message } });
|
|
|
|
setTimeout(() => {
|
|
this.setState({ message: null });
|
|
}, 3000);
|
|
});
|
|
if (this.props.shouldFocus) {
|
|
GlobalCommandRunner.screenSetFocus("input");
|
|
}
|
|
};
|
|
|
|
handleEditorChange = (code) => {
|
|
SourceCodeRenderer.codeCache.set(this.cacheKey, code);
|
|
this.setState({ code }, () => {
|
|
this.setEditorHeight();
|
|
this.setState({ isSave: code !== this.originalCode });
|
|
});
|
|
};
|
|
|
|
setEditorHeight = () => {
|
|
const fullWindowHeight = this.props.opts.maxSize.height;
|
|
let _editorHeight = fullWindowHeight;
|
|
let allowEditing = this.getAllowEditing();
|
|
if (!allowEditing) {
|
|
const noOfLines = Math.max(this.state.code.split("\n").length, 5);
|
|
const lineHeight = Math.ceil(GlobalModel.termFontSize.get() * 1.5);
|
|
_editorHeight = Math.min(noOfLines * lineHeight + 10, fullWindowHeight);
|
|
}
|
|
this.setState({ editorHeight: _editorHeight }, () => {
|
|
if (this.props.isSelected) {
|
|
this.props.scrollToBringIntoViewport();
|
|
}
|
|
});
|
|
};
|
|
|
|
getAllowEditing(): boolean {
|
|
let lineState = this.props.lineState;
|
|
let mode = lineState["mode"] || "view";
|
|
if (mode == "view") {
|
|
return false;
|
|
}
|
|
return !(this.props.readOnly || this.state.isClosed);
|
|
}
|
|
|
|
getCodeEditor = () => (
|
|
<div style={{ maxHeight: this.props.opts.maxSize.height }}>
|
|
{this.state.showReadonly && <div className="readonly">{"read-only"}</div>}
|
|
<Editor
|
|
theme="hc-black"
|
|
height={this.state.editorHeight}
|
|
defaultLanguage={this.state.selectedLanguage}
|
|
value={this.state.code}
|
|
onMount={this.handleEditorDidMount}
|
|
options={{
|
|
scrollBeyondLastLine: false,
|
|
fontSize: GlobalModel.termFontSize.get() * 0.9,
|
|
/* fontFamily: "Martian Mono", */
|
|
readOnly: !this.getAllowEditing(),
|
|
}}
|
|
onChange={this.handleEditorChange}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
getPreviewer = () => {
|
|
return (
|
|
<div
|
|
className="scroller"
|
|
style={{ maxHeight: this.props.opts.maxSize.height }}
|
|
ref={this.markdownRef}
|
|
onScroll={() => this.handleDivScroll()}
|
|
>
|
|
<Markdown text={this.state.code} style={{ width: "100%", padding: "1rem" }} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
togglePreview = () => {
|
|
this.saveLineState({ showPreview: !this.state.showPreview });
|
|
this.setState({ showPreview: !this.state.showPreview });
|
|
};
|
|
|
|
getEditorControls = () => {
|
|
const { selectedLanguage, isSave, languages, isPreviewerAvailable, showPreview } = this.state;
|
|
let allowEditing = this.getAllowEditing();
|
|
return (
|
|
<div className="buttonContainer">
|
|
{isPreviewerAvailable && (
|
|
<div className="button">
|
|
<div onClick={this.togglePreview} className={`preview`}>
|
|
{`${showPreview ? "hide" : "show"} preview (`}
|
|
{renderCmdText("P")}
|
|
{`)`}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<select className="dropdown" value={selectedLanguage} onChange={this.handleLanguageChange}>
|
|
{languages.map((lang, index) => (
|
|
<option key={index} value={lang}>
|
|
{lang}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{allowEditing && (
|
|
<div className={`button ${isSave ? "" : "disabled"}`}>
|
|
<div onClick={() => this.doSave()}>
|
|
{`save (`}
|
|
{renderCmdText("S")}
|
|
{`)`}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{allowEditing && (
|
|
<div className="button">
|
|
<div onClick={this.doClose} className={`close`}>
|
|
{`close (`}
|
|
{renderCmdText("D")}
|
|
{`)`}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
getMessage = () => (
|
|
<div className="messageContainer">
|
|
<div className={`message ${this.state.message.status === "error" ? "error" : ""}`}>
|
|
{this.state.message.text}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
setSizes = (sizes) => {
|
|
this.setState({ editorFraction: sizes[0] });
|
|
this.saveLineState({ editorFraction: sizes[0] });
|
|
};
|
|
|
|
render() {
|
|
const { exitcode } = this.props;
|
|
const { code, message, isPreviewerAvailable, showPreview, editorFraction } = this.state;
|
|
if (this.state.isClosed) {
|
|
return <div className="code-renderer"></div>;
|
|
}
|
|
if (code == null) {
|
|
return <div className="code-renderer" style={{ height: this.props.savedHeight }} />;
|
|
}
|
|
if (exitcode === 1) {
|
|
return (
|
|
<div
|
|
className="code-renderer"
|
|
style={{
|
|
fontSize: GlobalModel.termFontSize.get(),
|
|
color: "white",
|
|
}}
|
|
>
|
|
{code}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="code-renderer">
|
|
<Split sizes={[editorFraction, 1 - editorFraction]} onSetSizes={this.setSizes}>
|
|
{this.getCodeEditor()}
|
|
{isPreviewerAvailable && showPreview && this.getPreviewer()}
|
|
</Split>
|
|
{this.getEditorControls()}
|
|
{message && this.getMessage()}
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
export { SourceCodeRenderer };
|