Skip to content

Commit

Permalink
feat: add save file mechanism in frontend (#408)
Browse files Browse the repository at this point in the history
  • Loading branch information
halajohn authored Dec 17, 2024
1 parent fff8cff commit 9151ac1
Show file tree
Hide file tree
Showing 7 changed files with 267 additions and 38 deletions.
3 changes: 1 addition & 2 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -420,8 +420,7 @@
"args": [
"--verbose",
"designer",
"--frontend-dev",
"--base-dir=/home/wei/MyData/MyProject/ten_framework_internal_base/ten_framework/out/linux/x64/tests/ten_runtime/integration/cpp/http_basic/restful_http_app/",
"--base-dir=/home/wei/MyData/MyProject/ten_framework_internal_base/ten_framework/out/linux/x64/tests/ten_runtime/integration/python/aio_http_server_python/aio_http_server_python_app/",
],
"preRunCommands": [
"script import pathlib;import subprocess;import lldb;rustc_sysroot = subprocess.getoutput(\"rustc --print sysroot\");rustlib_etc = pathlib.Path(rustc_sysroot) / \"lib\" / \"rustlib\" / \"etc\";lldb.debugger.HandleCommand(f'command script import \"{rustlib_etc / \"lldb_lookup.py\"}\"');lldb.debugger.HandleCommand(f'command source -s 0 \"{rustlib_etc / \"lldb_commands\"}\"')"
Expand Down
68 changes: 61 additions & 7 deletions core/src/ten_manager/designer_frontend/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
ApiResponse,
BackendConnection,
BackendNode,
FileContentResponse,
SaveFileRequest,
SuccessResponse,
} from "./interface";

Expand All @@ -17,6 +19,12 @@ export interface ExtensionAddon {
api?: any;
}

export const isSuccessResponse = <T>(
response: ApiResponse<T>
): response is SuccessResponse<T> => {
return response.status === "ok";
};

export const fetchNodes = async (): Promise<BackendNode[]> => {
const response = await fetch(`/api/designer/v1/graphs/default/nodes`);
if (!response.ok) {
Expand All @@ -25,7 +33,7 @@ export const fetchNodes = async (): Promise<BackendNode[]> => {
const data: ApiResponse<BackendNode[]> = await response.json();

if (!isSuccessResponse(data)) {
throw new Error(`Error fetching nodes: ${data.message}`);
throw new Error(`Failed to fetch nodes: ${data.message}`);
}

return data.data;
Expand All @@ -39,7 +47,7 @@ export const fetchConnections = async (): Promise<BackendConnection[]> => {
const data: ApiResponse<BackendConnection[]> = await response.json();

if (!isSuccessResponse(data)) {
throw new Error(`Error fetching connections: ${data.message}`);
throw new Error(`Failed to fetch connection: ${data.message}`);
}

return data.data;
Expand All @@ -63,12 +71,58 @@ export const fetchExtensionAddonByName = async (
if (isSuccessResponse(data)) {
return data.data;
} else {
throw new Error(`Error fetching addon '${name}': ${data.message}`);
throw new Error(`Failed to fetch addon '${name}': ${data.message}`);
}
};

export const isSuccessResponse = <T>(
response: ApiResponse<T>
): response is SuccessResponse<T> => {
return response.status === "ok";
// Get the contents of the file at the specified path.
export const getFileContent = async (
path: string
): Promise<FileContentResponse> => {
const encodedPath = encodeURIComponent(path);
const response = await fetch(`/api/designer/v1/file-content/${encodedPath}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});

if (!response.ok) {
throw new Error(`Failed to fetch file content: ${response.status}`);
}

const data: ApiResponse<FileContentResponse> = await response.json();

if (!isSuccessResponse(data)) {
throw new Error(`Failed to fetch file content: ${data.status}`);
}

return data.data;
};

// Save the contents of the file at the specified path.
export const saveFileContent = async (
path: string,
content: string
): Promise<void> => {
const encodedPath = encodeURIComponent(path);
const body: SaveFileRequest = { content };

const response = await fetch(`/api/designer/v1/file-content/${encodedPath}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});

if (!response.ok) {
throw new Error(`Failed to save file content: ${response.status}`);
}

const data: ApiResponse<null> = await response.json();

if (data.status !== "ok") {
throw new Error(`Failed to save file content: ${data.status}`);
}
};
8 changes: 8 additions & 0 deletions core/src/ten_manager/designer_frontend/src/api/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,11 @@ export interface BackendConnection {
}[];
}[];
}

export interface FileContentResponse {
content: string;
}

export interface SaveFileRequest {
content: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,16 @@
flex-direction: column;
}
}

.popup-confirm {
.confirm-dialog-content {
padding: 20px;
text-align: center;
}

.confirm-dialog-actions {
margin-top: 20px;
display: flex;
justify-content: space-around;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Editor from "@monaco-editor/react";
import Popup from "../Popup/Popup";

import "./EditorPopup.scss";
import { getFileContent, saveFileContent } from "../../api/api";

const DEFAULT_WIDTH = 800;
const DEFAULT_HEIGHT = 400;
Expand All @@ -29,20 +30,50 @@ interface EditorPopupProps {
onClose: () => void;
}

interface ConfirmDialogProps {
message: string;
onConfirm: () => void;
onCancel: () => void;
}

const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
message,
onConfirm,
onCancel,
}) => {
return (
<Popup
title="Confirmation"
onClose={onCancel}
preventFocusSteal={true}
className="popup-confirm"
resizable={false}
initialWidth={400}
initialHeight={200}
>
<div className="confirm-dialog-content">
<p>{message}</p>
<div className="confirm-dialog-actions">
<button onClick={onCancel}>Cancel</button>
<button onClick={onConfirm}>Ok</button>
</div>
</div>
</Popup>
);
};

const EditorPopup: React.FC<EditorPopupProps> = ({ data, onClose }) => {
const [fileContent, setFileContent] = useState(data.content);

const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingAction, setPendingAction] = useState<null | (() => void)>(null);

// Fetch the specified file content from the backend.
useEffect(() => {
const fetchFileContent = async () => {
try {
const encodedUrl = encodeURIComponent(data.url);
const response = await fetch(
`/api/designer/v1/file-content/${encodedUrl}`
);
if (!response.ok) throw new Error("Failed to fetch file content");
const respData = await response.json();
setFileContent(respData.data.content);
const respData = await getFileContent(data.url);
setFileContent(respData.content);
} catch (error) {
console.error("Failed to fetch file content:", error);
}
Expand All @@ -51,29 +82,91 @@ const EditorPopup: React.FC<EditorPopupProps> = ({ data, onClose }) => {
fetchFileContent();
}, [data.url]);

const saveFile = async (content: string) => {
try {
await saveFileContent(data.url, content);
console.log("File saved successfully");
// We can add UI prompts, such as displaying a success notification.
} catch (error) {
console.error("Failed to save file content:", error);
// We can add UI prompts, such as popping up an error notification.
}
};

const handleActionWithOptionalConfirm = (
action: () => void,
needsConfirm: boolean
) => {
if (needsConfirm) {
setPendingAction(() => action);
setShowConfirmDialog(true);
} else {
action();
}
};

return (
<Popup
title={data.title}
onClose={onClose}
preventFocusSteal={true}
className="popup-editor"
resizable={true}
initialWidth={DEFAULT_WIDTH}
initialHeight={DEFAULT_HEIGHT}
>
<Editor
height="100%"
defaultLanguage="json"
value={fileContent}
options={{
readOnly: false,
automaticLayout: true,
}}
onMount={(editor) => {
editor.focus(); // Set the keyboard focus to the editor.
}}
/>
</Popup>
<>
<Popup
title={data.title}
onClose={onClose}
preventFocusSteal={true}
className="popup-editor"
resizable={true}
initialWidth={DEFAULT_WIDTH}
initialHeight={DEFAULT_HEIGHT}
>
<Editor
height="100%"
defaultLanguage="json"
value={fileContent}
options={{
readOnly: false,
automaticLayout: true,
}}
onChange={(value) => setFileContent(value || "")}
onMount={(editor) => {
editor.focus(); // Set the keyboard focus to the editor.

editor.addAction({
id: "save-file",
label: "Save",
contextMenuGroupId: "navigation",
contextMenuOrder: 1.5,
run: async (ed) => {
// When the user clicks this menu item, first display a
// confirmation popup.
handleActionWithOptionalConfirm(
() => {
// Confirm before saving.
const currentContent = ed.getValue();
setFileContent(currentContent);
saveFile(currentContent);
},
true // Display a confirmation popup.
);
},
});
}}
/>
</Popup>

{/* Conditional Rendering Confirmation Popup. */}
{showConfirmDialog && pendingAction && (
<ConfirmDialog
message="Are you sure you want to save this file?"
onConfirm={() => {
setShowConfirmDialog(false);
pendingAction();
setPendingAction(null);
}}
onCancel={() => {
setShowConfirmDialog(false);
setPendingAction(null);
}}
/>
)}
</>
);
};

Expand Down
58 changes: 58 additions & 0 deletions core/src/ten_manager/src/designer/file_content/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// Refer to the "LICENSE" file in the root directory for more information.
//
use std::fs;
use std::path::Path;
use std::sync::{Arc, RwLock};

use actix_web::{web, HttpResponse, Responder};
Expand Down Expand Up @@ -46,3 +47,60 @@ pub async fn get_file_content(
}
}
}

#[derive(Serialize, Deserialize, Debug)]
pub struct SaveFileRequest {
content: String,
}

pub async fn save_file_content(
path: web::Path<String>,
req: web::Json<SaveFileRequest>,
_state: web::Data<Arc<RwLock<DesignerState>>>,
) -> HttpResponse {
let file_path_str = path.into_inner();
let content = &req.content; // Access the content field.

let file_path = Path::new(&file_path_str);

// Attempt to create parent directories if they don't exist.
if let Some(parent) = file_path.parent() {
if let Err(e) = fs::create_dir_all(parent) {
eprintln!(
"Error creating directories for {}: {}",
parent.display(),
e
);
let response = ApiResponse {
status: Status::Fail,
data: (),
meta: None,
};
return HttpResponse::BadRequest().json(response);
}
}

match fs::write(file_path, content) {
Ok(_) => {
let response = ApiResponse {
status: Status::Ok,
data: (),
meta: None,
};
HttpResponse::Ok().json(response)
}
Err(err) => {
eprintln!(
"Error writing file at path {}: {}",
file_path.display(),
err
);
let response = ApiResponse {
status: Status::Fail,
data: (),
meta: None,
};
HttpResponse::BadRequest().json(response)
}
}
}
Loading

0 comments on commit 9151ac1

Please sign in to comment.