From 2e18c564715786ba2dd4493993d14b53786228b8 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 1 Sep 2023 15:20:13 -0700 Subject: [PATCH] PE-41 Remote File API (#7) * added API for read-file * implement writeRemoteFile -- multipart upload, params/data * format file, implement readOnly flag on file return from readRemoteFile * fix model.ts typescript errors * add usetemp to write-file api * add GlobalCommandRunner.setLineState() * implment PE-13, PE-60 --- src/linecomps.tsx | 1 + src/lines.less | 4 ++ src/model.ts | 84 +++++++++++++++++++++++++++++++++++++++++- src/simplerenderer.tsx | 84 +++++++++++++++++++++++++++++++++++++----- src/types.ts | 14 +++++++ src/view/code.tsx | 3 +- 6 files changed, 178 insertions(+), 12 deletions(-) diff --git a/src/linecomps.tsx b/src/linecomps.tsx index dae0e060..5ee077bc 100644 --- a/src/linecomps.tsx +++ b/src/linecomps.tsx @@ -594,6 +594,7 @@ class LineCmd extends React.Component< savedHeight: savedHeight, opts: this.getRendererOpts(cmd), ptyDataSource: getTermPtyData, + lineState: line.linestate, api: api, rawCmd: cmd.getAsWebCmd(line.lineid), }; diff --git a/src/lines.less b/src/lines.less index 7839499a..b6639c22 100644 --- a/src/lines.less +++ b/src/lines.less @@ -21,6 +21,10 @@ } } +.line .load-error-text { + color: #cc0000; +} + .line.line-cmd { flex-direction: column; scroll-margin-bottom: 20px; diff --git a/src/model.ts b/src/model.ts index db868b08..3fe21ae2 100644 --- a/src/model.ts +++ b/src/model.ts @@ -59,6 +59,7 @@ import type { WebCmd, WebRemote, } from "./types"; +import * as T from "./types"; import { WSControl } from "./ws"; import { measureText, @@ -254,7 +255,7 @@ class Cmd { return webCmd; } - getExitCode(): boolean { + getExitCode(): number { return this.data.get().exitcode; } @@ -3606,6 +3607,71 @@ class Model { } return remote.remotecanonicalname; } + + readRemoteFile(screenId: string, lineId: string, path: string): Promise { + let urlParams = { + screenid: screenId, + lineid: lineId, + path: path, + }; + let usp = new URLSearchParams(urlParams); + let url = new URL(GlobalModel.getBaseHostPort() + "/api/read-file?" + usp.toString()); + let fetchHeaders = this.getFetchHeaders(); + let fileInfo: T.FileInfoType = null; + let contentType: string = null; + let isError = false; + let badResponseStr: string = null; + let prtn = fetch(url, { method: "get", headers: fetchHeaders }) + .then((resp) => { + if (!resp.ok) { + isError = true; + badResponseStr = sprintf( + "Bad fetch response for /api/read-file: %d %s", + resp.status, + resp.statusText + ); + return resp.text() as any; + } + contentType = resp.headers.get("Content-Type"); + fileInfo = JSON.parse(atob(resp.headers.get("X-FileInfo"))); + return resp.blob(); + }) + .then((blobOrText: any) => { + if (blobOrText instanceof Blob) { + let blob: Blob = blobOrText; + let file = new File([blob], fileInfo.name, { type: blob.type, lastModified: fileInfo.modts }); + let isWriteable = (fileInfo.perm & 0o222) > 0; // checks for unix permission "w" bits + (file as any).readOnly = !isWriteable; + return file; + } else { + let textError: string = blobOrText; + if (textError == null || textError.length == 0) { + throw new Error(badResponseStr); + } + throw new Error(textError); + return null; + } + }); + return prtn; + } + + writeRemoteFile(screenId: string, lineId: string, path: string, data: Uint8Array, opts?: { useTemp?: boolean }) { + opts = opts || {}; + let params = { + screenid: screenId, + lineid: lineId, + path: path, + usetemp: !!opts.useTemp, + }; + let formData = new FormData(); + formData.append("params", JSON.stringify(params)); + let blob = new Blob([data], { type: "application/octet-stream" }); + formData.append("data", blob); + let url = new URL(GlobalModel.getBaseHostPort() + "/api/write-file"); + let fetchHeaders = this.getFetchHeaders(); + let prtn = fetch(url, { method: "post", headers: fetchHeaders, body: formData }); + return prtn; + } } class CommandRunner { @@ -3913,6 +3979,22 @@ class CommandRunner { openSharedSession(): void { GlobalModel.submitCommand("session", "openshared", null, { nohist: "1" }, true); } + + setLineState( + screenId: string, + lineId: string, + state: T.LineStateType, + interactive: boolean + ): Promise { + let stateStr = JSON.stringify(state); + return GlobalModel.submitCommand( + "line", + "set", + [lineId], + { screen: screenId, nohist: "1", state: stateStr }, + interactive + ); + } } function cmdPacketString(pk: FeCmdPacketType): string { diff --git a/src/simplerenderer.tsx b/src/simplerenderer.tsx index 4e48ec57..c7136319 100644 --- a/src/simplerenderer.tsx +++ b/src/simplerenderer.tsx @@ -15,12 +15,14 @@ import type { PtyDataType, RendererModel, RendererOptsUpdate, + LineStateType, LineType, TermContextUnion, RendererContainerType, } from "./types"; import { PacketDataBuffer } from "./ptydata"; import { debounce, throttle } from "throttle-debounce"; +import * as util from "./util"; type OV = mobx.IObservableValue; type CV = mobx.IComputedValue; @@ -35,8 +37,11 @@ class SimpleBlobRendererModel { loadError: OV = mobx.observable.box(null, { name: "renderer-loadError", }); + lineState: LineStateType; ptyData: PtyDataType; ptyDataSource: (termContext: TermContextUnion) => Promise; + dataBlob: Blob; + readOnly: boolean; initialize(params: RendererModelInitializeParams): void { this.loading = mobx.observable.box(true, { name: "renderer-loading" }); @@ -46,6 +51,7 @@ class SimpleBlobRendererModel { this.context = params.context; this.opts = params.opts; this.api = params.api; + this.lineState = params.lineState; this.savedHeight = params.savedHeight; this.ptyDataSource = params.ptyDataSource; if (this.isDone.get()) { @@ -86,21 +92,71 @@ class SimpleBlobRendererModel { mobx.action(() => { this.loading.set(true); })(); + if (delayMs == 0) { + this.reload_noDelay(); + } + else { + setTimeout(() => { + reload_noDelay(); + }, delayMs); + } + } + + reload_noDelay(): void { + let source = this.lineState["prompt:source"] || "pty"; + if (source == "pty") { + this.reloadPtyData(); + } + else if (source == "file") { + this.reloadFileData(); + } + else { + mobx.action(() => { + this.loadError.set("error: invalid load source: " + source); + })(); + } + } + + reloadFileData(): void { + // todo add file methods to API, so we don't have a GlobalModel dependency here! + let path = this.lineState["prompt:file"]; + if (util.isBlank(path)) { + mobx.action(() => { + this.loadError.set("renderer has file source, but no prompt:file specified"); + })(); + return; + } + let rtnp = GlobalModel.readRemoteFile(this.context.screenId, this.context.lineId, path); + rtnp.then((file) => { + this.readOnly = file.readOnly; + this.dataBlob = file; + console.log("got file", file); + mobx.action(() => { + this.loading.set(false); + this.loadError.set(null); + })(); + }).catch((e) => { + mobx.action(() => { + this.loadError.set("error loading file data: " + e); + })(); + }); + } + + reloadPtyData(): void { + this.readOnly = true; let rtnp = this.ptyDataSource(this.context); if (rtnp == null) { console.log("no promise returned from ptyDataSource (simplerenderer)", this.context); return; } rtnp.then((ptydata) => { - setTimeout(() => { - this.ptyData = ptydata; - mobx.action(() => { - this.loading.set(false); - this.loadError.set(null); - })(); - }, delayMs); + this.ptyData = ptydata; + this.dataBlob = new Blob([this.ptyData.data]); + mobx.action(() => { + this.loading.set(false); + this.loadError.set(null); + })(); }).catch((e) => { - console.log("error loading data", e); mobx.action(() => { this.loadError.set("error loading data: " + e); })(); @@ -185,6 +241,14 @@ class SimpleBlobRenderer extends React.Component< render() { let { plugin } = this.props; let model = this.model; + if (model.loadError.get() != null) { + let height = this.model.savedHeight; + return ( +
+
ERROR: {model.loadError.get()}
+
+ ); + } if (model.loading.get()) { let height = this.model.savedHeight; return ( @@ -197,7 +261,6 @@ class SimpleBlobRenderer extends React.Component< if (Comp == null) {
(no component found in plugin)
; } - let dataBlob = new Blob([model.ptyData.data]); let simpleModel = model as SimpleBlobRendererModel; let { festate, cmdstr } = this.props.initParams.rawCmd; return ( @@ -205,7 +268,8 @@ class SimpleBlobRenderer extends React.Component< Promise; }; @@ -600,8 +604,17 @@ type OpenAIPacketType = { error: string; }; +type FileInfoType = { + name: string; + size: number; + modts: number; + isdir: boolean; + perm: number; +}; + export type { SessionDataType, + LineStateType, LineType, RemoteType, RemoteStateType, @@ -667,4 +680,5 @@ export type { RemoteViewType, CommandRtnType, OpenAIPacketType, + FileInfoType, }; diff --git a/src/view/code.tsx b/src/view/code.tsx index 5a76c01c..b07b943d 100644 --- a/src/view/code.tsx +++ b/src/view/code.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import * as mobx from "mobx"; import * as mobxReact from "mobx-react"; -import { RendererContext, RendererOpts } from "../types"; +import { RendererContext, RendererOpts, LineStateType } from "../types"; import Editor from "@monaco-editor/react"; import { GlobalModel } from "../model"; @@ -16,6 +16,7 @@ class SourceCodeRenderer extends React.Component< context: RendererContext; opts: RendererOpts; savedHeight: number; + lineState: LineStateType; }, {} > {