[Feat] finished codesnap client

This commit is contained in:
Mist 2024-02-21 18:33:44 +08:00
parent f8773e538e
commit 383be3a533
19 changed files with 1756 additions and 114 deletions

View file

@ -1,15 +1,17 @@
local logger = require("codesnap.utils.logger") local logger = require("codesnap.utils.logger")
local path_utils = require("codesnap.utils.path") local static = require("codesnap.static")
local client = { local client = {
job_id = 0, job_id = 0,
} }
local cwd = static.cwd .. "/snap-server"
function client:connect() function client:connect()
return vim.fn.jobstart({ return vim.fn.jobstart({
path_utils.back(path_utils.back(debug.getinfo(1, "S").source:sub(2):match("(.*[/\\])"))) cwd .. "/target/release/snap-server",
.. "/snap-server/target/debug/snap-server",
}, { }, {
cwd = cwd,
stderr_buffered = true, stderr_buffered = true,
rpc = true, rpc = true,
on_stderr = function(_, err) on_stderr = function(_, err)

View file

@ -3,15 +3,14 @@ local static = require("codesnap.static")
local client = require("codesnap.client") local client = require("codesnap.client")
local visual_utils = require("codesnap.utils.visual") local visual_utils = require("codesnap.utils.visual")
local main = {} local main = {
cwd = static.cwd,
preview_switch = static.preview_switch,
}
function main.setup(config) function main.setup(config)
static.config = table_utils.merge(static.config, config == nil and {} or config) static.config = table_utils.merge(static.config, config == nil and {} or config)
print(vim.inspect(static.config))
print(table_utils.serialize_json(static.config))
print()
if static.config.auto_load then if static.config.auto_load then
client:start() client:start()
end end
@ -23,4 +22,12 @@ function main.preview_code()
client:send("preview_code", { content = visual_utils.get_selected_text(), language = vim.bo.filetype }) client:send("preview_code", { content = visual_utils.get_selected_text(), language = vim.bo.filetype })
end end
function main.open_preview()
client:send("open_preview")
end
function main.stop_client()
client:stop()
end
return main return main

View file

@ -1,11 +1,12 @@
local path_utils = require("codesnap.utils.path")
return { return {
config = { config = {
breadcrumbs = true,
column_number = true,
mac_window_bar = true, mac_window_bar = true,
opacity = true, opacity = true,
watermark = "CodeSnap.nvim", watermark = "CodeSnap.nvim",
auto_load = true, auto_load = true,
}, },
cwd = path_utils.back(path_utils.back(debug.getinfo(1, "S").source:sub(2):match("(.*[/\\])"))),
preview_switch = true, preview_switch = true,
} }

20
plugin/build.lua Normal file
View file

@ -0,0 +1,20 @@
local codesnap = require("codesnap")
local snap_client_cwd = codesnap.cwd .. "/snap-client"
local snap_server_cwd = codesnap.cwd .. "/snap-server"
-- Build preview client
os.execute(
"cd "
.. snap_client_cwd
.. " && "
.. "npm i "
.. " && "
.. "npm run build"
.. " && "
.. "mv ./build "
.. snap_server_cwd
.. "/public"
)
-- Build server
os.execute("cd " .. snap_server_cwd .. " && " .. "cargo build --release")

View file

@ -1,19 +1,19 @@
local codesnap = require("codesnap") local codesnap = require("codesnap")
local static = require("codesnap.static") -- local client = require("codesnap.client")
local client = require("codesnap.client")
-- snap code -- vim.api.nvim_create_user_command("CodeSnap", function()
vim.api.nvim_create_user_command("CodeSnap", function() end, {}) -- client:send("copy")
-- end, {})
vim.api.nvim_create_user_command("CodeSnapPreviewOn", function() end, {}) vim.api.nvim_create_user_command("CodeSnapPreviewOn", function()
codesnap.open_preview()
vim.api.nvim_create_user_command("CodeSnapPreviewOff", function() end, {}) end, {})
vim.api.nvim_create_autocmd({ "CursorMoved" }, { vim.api.nvim_create_autocmd({ "CursorMoved" }, {
callback = function() callback = function()
local mode = vim.api.nvim_get_mode().mode local mode = vim.api.nvim_get_mode().mode
if mode ~= "v" or not static.preview_switch then if mode ~= "v" or not codesnap.preview_switch then
return return
end end
@ -24,6 +24,6 @@ vim.api.nvim_create_autocmd({ "CursorMoved" }, {
vim.api.nvim_create_autocmd({ "VimLeavePre" }, { vim.api.nvim_create_autocmd({ "VimLeavePre" }, {
pattern = "*", pattern = "*",
callback = function() callback = function()
client:stop() codesnap.stop_client()
end, end,
}) })

View file

@ -1,34 +1,37 @@
import React, { import { useCallback, useEffect, useRef, useState } from "react";
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import useWebSocket, { ReadyState } from "react-use-websocket"; import useWebSocket, { ReadyState } from "react-use-websocket";
import { ControlBar, Editor, Frame, Panel } from "./components"; import { ControlBar, Editor, Frame, Panel } from "./components";
import { useConfig, useEvent } from "./hooks"; import { useConfig, useEvent } from "./hooks";
import { toPng, toJpeg, toBlob, toPixelData, toSvg } from "html-to-image"; import { toPng, toBlob } from "html-to-image";
import download from "downloadjs"; import download from "downloadjs";
const CODE_EMPTY_PLACEHOLDER = `print "Hello, CodeSnap.nvim!"`; const CODE_EMPTY_PLACEHOLDER = `print "Hello, CodeSnap.nvim!"`;
function App() { function App() {
const [socketUrl] = useState("ws://127.0.0.1:8080/ws"); const [socketUrl] = useState(`ws://${window.location.host}/ws`);
const { sendMessage, lastMessage, readyState } = useWebSocket(socketUrl); const { sendMessage, lastMessage, readyState } = useWebSocket(socketUrl);
const event = useEvent(lastMessage); const event = useEvent(lastMessage);
const config = useConfig(event?.config_setup); const config = useConfig(event?.config_setup);
const frameRef = useRef<HTMLDivElement | null>(null); const frameRef = useRef<HTMLDivElement | null>(null);
const [isCopyButtonDisabled, setIsCopyButtonDisabled] = useState(false);
const handleCopyButtonClick = useCallback(async () => { const handleCopyButtonClick = useCallback(async () => {
if (!frameRef.current) { if (!frameRef.current) {
return; return;
} }
const blob = await toBlob(frameRef.current); setIsCopyButtonDisabled(true);
const clipboardItem = new ClipboardItem({ "image/png": blob! });
navigator.clipboard.write([clipboardItem]); try {
const blob = await toBlob(frameRef.current);
const clipboardItem = new ClipboardItem({ "image/png": blob! });
navigator.clipboard.write([clipboardItem]);
} catch (e) {
console.error(e);
} finally {
setIsCopyButtonDisabled(false);
}
}, []); }, []);
const handleExportClick = useCallback(async () => { const handleExportClick = useCallback(async () => {
@ -41,6 +44,24 @@ function App() {
download(dataURL, "codesnap.png"); download(dataURL, "codesnap.png");
}, []); }, []);
const notifyCopyCommand = useCallback(async () => {
if (!frameRef.current) {
return;
}
const dataURL = await toPng(frameRef.current);
sendMessage(dataURL);
}, [sendMessage]);
useEffect(() => {
if (readyState !== ReadyState.OPEN || !event?.copy) {
return;
}
notifyCopyCommand();
}, [event, readyState, notifyCopyCommand]);
return ( return (
<div className="w-full h-full flex flex-col items-center bg-deep-gray"> <div className="w-full h-full flex flex-col items-center bg-deep-gray">
<p className="rainbow-text text-4xl font-extrabold mt-20"> <p className="rainbow-text text-4xl font-extrabold mt-20">
@ -48,11 +69,12 @@ function App() {
</p> </p>
<Panel> <Panel>
<ControlBar <ControlBar
isCopyButtonDisabled={isCopyButtonDisabled}
onExportClick={handleExportClick} onExportClick={handleExportClick}
onCopyClick={handleCopyButtonClick} onCopyClick={handleCopyButtonClick}
readyState={readyState} readyState={readyState}
/> />
<div className="rounded-xl overflow-hidden"> <div id="frame" className="rounded-xl overflow-hidden">
<Frame ref={frameRef} watermark={config?.watermark}> <Frame ref={frameRef} watermark={config?.watermark}>
<Editor <Editor
language={event?.code?.language} language={event?.code?.language}

View file

@ -0,0 +1,53 @@
import { useMemo } from "react";
import { ReadyState } from "react-use-websocket";
interface ConnectionStatusProps {
readyState: ReadyState;
}
const CONNECTION_STATUS_MAP = {
[ReadyState.CONNECTING]: {
text: "Connecting",
color: "#fdcb6e",
},
[ReadyState.OPEN]: {
text: "Connected",
color: "#00b894",
},
[ReadyState.CLOSING]: {
text: "Closing",
color: "#fab1a0",
},
[ReadyState.CLOSED]: {
text: "Closed",
color: "#636e72",
},
[ReadyState.UNINSTANTIATED]: {
text: "Uninstantiated",
color: "#2d3436",
},
} as const;
const UNKNOWN_STATE = { text: "Unknown", color: "#a29bfe" };
export const ConnectionStatus = ({ readyState }: ConnectionStatusProps) => {
const parsedState = useMemo(
() => CONNECTION_STATUS_MAP[readyState] ?? UNKNOWN_STATE,
[readyState],
);
return (
<div className="flex flex-grow flex-row items-center mr-1 ml-2">
<div
className="flex w-5 h-5 mr-2 justify-center items-center rounded-full"
style={{ backgroundColor: `${parsedState.color}50` }}
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: parsedState.color }}
></div>
</div>
{parsedState.text}
</div>
);
};

View file

@ -2,6 +2,7 @@ import { ConnectionStatus } from "./connection-status";
import { ReadyState } from "react-use-websocket"; import { ReadyState } from "react-use-websocket";
interface ControlBarProps { interface ControlBarProps {
isCopyButtonDisabled: boolean;
onCopyClick(): void; onCopyClick(): void;
onExportClick(): void; onExportClick(): void;
readyState: ReadyState; readyState: ReadyState;
@ -10,13 +11,11 @@ interface ControlBarProps {
export const ControlBar = ({ export const ControlBar = ({
onCopyClick, onCopyClick,
onExportClick, onExportClick,
isCopyButtonDisabled,
readyState, readyState,
}: ControlBarProps) => { }: ControlBarProps) => {
return ( return (
<div <div className="bg-neutral rounded-xl mb-2 p-1 flex flex-row items-center">
className="bg-neutral rounded-xl mb-2 p-1 flex flex-row items-center"
onClick={onCopyClick}
>
<ConnectionStatus readyState={readyState} /> <ConnectionStatus readyState={readyState} />
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
{/* {/*
@ -44,7 +43,11 @@ export const ControlBar = ({
<path d="M480-320 280-520l56-58 104 104v-326h80v326l104-104 56 58-200 200ZM240-160q-33 0-56.5-23.5T160-240v-120h80v120h480v-120h80v120q0 33-23.5 56.5T720-160H240Z" /> <path d="M480-320 280-520l56-58 104 104v-326h80v326l104-104 56 58-200 200ZM240-160q-33 0-56.5-23.5T160-240v-120h80v120h480v-120h80v120q0 33-23.5 56.5T720-160H240Z" />
</svg> </svg>
</button> </button>
<button className="btn"> <button
onClick={onCopyClick}
id="copy"
className={`btn ${isCopyButtonDisabled && "btn-disabled"}`}
>
Copy Copy
<svg <svg
className="fill-neutral-content" className="fill-neutral-content"

View file

@ -1,8 +1,6 @@
import { useLocalStorage } from "./use-storage"; import { useLocalStorage } from "./use-storage";
export interface Config { export interface Config {
breadcrumbs: boolean;
column_number: boolean;
mac_window_bar: boolean; mac_window_bar: boolean;
opacity: boolean; opacity: boolean;
watermark: string; watermark: string;

View file

@ -4,6 +4,7 @@ import { Config } from "./use-config";
export enum EventType { export enum EventType {
CONFIG_SETUP = "config_setup", CONFIG_SETUP = "config_setup",
CODE = "code", CODE = "code",
COPY = "copy",
} }
type CodeMessage = { type CodeMessage = {
@ -14,6 +15,7 @@ type CodeMessage = {
type ParsedConfig = { type ParsedConfig = {
[EventType.CODE]: CodeMessage; [EventType.CODE]: CodeMessage;
[EventType.CONFIG_SETUP]: Config; [EventType.CONFIG_SETUP]: Config;
[EventType.COPY]: undefined;
}; };
export const useEvent = ( export const useEvent = (

1519
snap-server/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -3,13 +3,17 @@ name = "snap-server"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
actix = "0.13.3" actix = "0.13.3"
actix-files = "0.6.5"
actix-web = "4.5.1" actix-web = "4.5.1"
actix-web-actors = "4.3.0" actix-web-actors = "4.3.0"
arboard = "3.3.1"
headless_chrome = "1.0.9"
image = "0.24.8"
image-base64 = "0.1.0"
neovim-lib = "0.6.1" neovim-lib = "0.6.1"
rand = "0.8.5" rand = "0.8.5"
serde = {version = "1.0.196", features = ["derive", "serde_derive", "std"]} serde = {version = "1.0.196", features = ["derive", "serde_derive", "std"]}
serde_json = "1.0.113" serde_json = "1.0.113"
webbrowser = "0.8.12"

View file

@ -0,0 +1,21 @@
use arboard::Clipboard;
use arboard::ImageData;
use image::load_from_memory;
pub fn copy_base64_image_into_clipboard(base64_image: String) {
copy_memory_image_into_clipboard(&image_base64::from_base64(base64_image))
}
pub fn copy_memory_image_into_clipboard(buffer: &Vec<u8>) {
let dynamic_image = load_from_memory(buffer).unwrap();
let image = ImageData {
width: dynamic_image.width() as usize,
height: dynamic_image.height() as usize,
bytes: dynamic_image.as_bytes().into(),
};
let mut clipboard = Clipboard::new().unwrap();
clipboard.set_image(image).unwrap();
}

View file

@ -3,15 +3,15 @@ pub mod config;
pub mod messages; pub mod messages;
pub mod neovim; pub mod neovim;
use actix::{Actor, Addr, AsyncContext, Context}; use actix::{Actor, Addr, Context};
use arguments::parse_string_first; use arguments::parse_string_first;
pub use config::Config; pub use config::Config;
use headless_chrome::{protocol::cdp::Page, Browser, Tab};
pub use messages::Message; pub use messages::Message;
use neovim::Neovim; use neovim::Neovim;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::{ use std::{
any::Any, error::Error,
collections::HashMap,
sync::{Arc, Mutex}, sync::{Arc, Mutex},
}; };
@ -23,6 +23,7 @@ use crate::{
pub struct EventHandler { pub struct EventHandler {
neovim: Arc<Mutex<Neovim>>, neovim: Arc<Mutex<Neovim>>,
server: Arc<Addr<Server>>, server: Arc<Addr<Server>>,
port: u16,
} }
impl Actor for EventHandler { impl Actor for EventHandler {
@ -34,17 +35,47 @@ impl Actor for EventHandler {
} }
impl EventHandler { impl EventHandler {
pub fn new(neovim: Arc<Mutex<Neovim>>, server: Arc<Addr<Server>>) -> EventHandler { pub fn new(neovim: Arc<Mutex<Neovim>>, server: Arc<Addr<Server>>, port: u16) -> EventHandler {
EventHandler { neovim, server } EventHandler {
neovim,
server,
port,
}
}
pub fn open_browser(&self) -> Result<Arc<Tab>, Box<dyn Error>> {
let browser = Browser::default()?;
let tab = browser.new_tab()?;
tab.navigate_to(format!("http://localhost:{}", self.port).as_str())?;
Ok(tab)
} }
pub fn start_listen(&mut self) { pub fn start_listen(&mut self) {
let receiver = self.neovim.lock().unwrap().create_receiver(); let receiver = self.neovim.lock().unwrap().create_receiver();
for (event_name, values) in receiver { for (event_name, values) in receiver {
self.neovim.lock().unwrap().print(&event_name); // self.neovim.lock().unwrap().print(&event_name);
match Message::from(event_name.clone()) { match Message::from(event_name.clone()) {
Message::OpenPreview => {
let _ = webbrowser::open(format!("http://localhost:{}", self.port).as_str());
}
Message::Copy => {
// let _png_data = tab
// .wait_for_element("#frame")
// .unwrap()
// .capture_screenshot(Page::CaptureScreenshotFormatOption::Png)
// .unwrap();
//
// std::fs::write("hello_world.png", _png_data).unwrap();
// self.server.do_send(ClientMessage {
// msg: Event::new("copy", json!("{}")).into(),
// })
}
Message::PreviewCode => self.server.do_send(ClientMessage { Message::PreviewCode => self.server.do_send(ClientMessage {
msg: Event::new( msg: Event::new(
"code", "code",

View file

@ -2,8 +2,6 @@ use serde::{Deserialize, Serialize};
#[derive(Serialize, Debug, Deserialize, Clone)] #[derive(Serialize, Debug, Deserialize, Clone)]
pub struct Config { pub struct Config {
breadcrumbs: bool,
column_number: bool,
mac_window_bar: bool, mac_window_bar: bool,
opacity: bool, opacity: bool,
watermark: Option<String>, watermark: Option<String>,

View file

@ -2,6 +2,8 @@
pub enum Message { pub enum Message {
PreviewCode, PreviewCode,
ConfigSetup, ConfigSetup,
Copy,
OpenPreview,
Unknown, Unknown,
} }
@ -10,6 +12,8 @@ impl Eq for Message {}
impl From<String> for Message { impl From<String> for Message {
fn from(value: String) -> Self { fn from(value: String) -> Self {
match value.as_str() { match value.as_str() {
"open_preview" => Message::OpenPreview,
"copy" => Message::Copy,
"preview_code" => Message::PreviewCode, "preview_code" => Message::PreviewCode,
"config_setup" => Message::ConfigSetup, "config_setup" => Message::ConfigSetup,
_ => Message::Unknown, _ => Message::Unknown,

View file

@ -1,18 +1,27 @@
mod clipboard;
mod event; mod event;
mod event_handler; mod event_handler;
mod port;
mod server; mod server;
mod session; mod session;
use std::sync::{Arc, Mutex}; use std::{
net::TcpListener,
sync::{Arc, Mutex},
};
use actix::{Actor, Addr, Arbiter, StreamHandler}; use actix::{Actor, Addr, Arbiter};
use actix_files::{Files, NamedFile};
use actix_web::{ use actix_web::{
web::{self, Data, Payload}, web::{self, Data, Payload},
App, Error, HttpRequest, HttpResponse, HttpServer, App, Error, HttpRequest, HttpResponse, HttpServer, Responder,
}; };
use actix_web_actors::ws; use actix_web_actors::ws;
use event_handler::{neovim::Neovim, Config}; use clipboard::copy_memory_image_into_clipboard;
use event_handler::{EventHandler, Message}; use event_handler::neovim::Neovim;
use event_handler::EventHandler;
use headless_chrome::{protocol::cdp::Page, Browser, Tab};
use port::get_available_port;
use server::Server; use server::Server;
use session::Session; use session::Session;
@ -24,40 +33,33 @@ async fn index(
ws::start(Session::new(server), &req, stream) ws::start(Session::new(server), &req, stream)
} }
async fn root() -> impl Responder {
NamedFile::open_async("./public/index.html").await.unwrap()
}
#[actix_web::main] #[actix_web::main]
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
let neovim = Arc::new(Mutex::new(Neovim::new())); let neovim = Arc::new(Mutex::new(Neovim::new()));
let server = Arc::new(Server::new(neovim.clone()).start()); let server = Arc::new(Server::new(neovim.clone()).start());
let cloned_server = Arc::clone(&server); let cloned_server = Arc::clone(&server);
let cloned_neovim = neovim.clone();
let available_port = get_available_port().unwrap();
Arbiter::new().spawn(async { Arbiter::new().spawn(async move {
EventHandler::new(neovim, cloned_server).start(); EventHandler::new(cloned_neovim.clone(), cloned_server.clone(), available_port).start();
}); });
HttpServer::new(move || { let _ = HttpServer::new(move || {
App::new() App::new()
.app_data(web::Data::new(server.clone())) .app_data(web::Data::new(server.clone()))
.route("/ws", web::get().to(index)) .route("/ws", web::get().to(index))
.service(web::resource("/").to(root))
.service(Files::new("/public", "./public"))
.service(Files::new("/static", "./public/static"))
}) })
.bind(("127.0.0.1", 8080))? .bind(("127.0.0.1", available_port))?
.run() .run()
.await .await;
}
// fn main() { Ok(())
// let data = r#" }
// {
// "breadcrumbs":true,
// "watermark":"CodeSnap.nvim",
// "mac_window_bar":true,
// "column_number":true,
// "auto_load":true,
// "background":{
// "grandient":true
// }
// }"#;
//
// let config: Config = serde_json::from_str(data).unwrap();
//
// println!("{:?}", config)
// }

12
snap-server/src/port.rs Normal file
View file

@ -0,0 +1,12 @@
use std::net::TcpListener;
pub fn get_available_port() -> Option<u16> {
(8000..10000).find(|port| port_is_available(*port))
}
fn port_is_available(port: u16) -> bool {
match TcpListener::bind(("127.0.0.1", port)) {
Ok(_) => true,
Err(_) => false,
}
}

View file

@ -1,4 +1,4 @@
use crate::server::{ClientMessage, Connect, Disconnect, Server, ServerMessage}; use crate::server::{Connect, Disconnect, Server, ServerMessage};
use actix::{ use actix::{
dev::ContextFutureSpawner, fut, Actor, ActorContext, ActorFutureExt, Addr, AsyncContext, dev::ContextFutureSpawner, fut, Actor, ActorContext, ActorFutureExt, Addr, AsyncContext,
Handler, Running, StreamHandler, WrapFuture, Handler, Running, StreamHandler, WrapFuture,
@ -13,6 +13,10 @@ use std::{
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
const CLIENT_TIMEOUT: Duration = Duration::from_secs(30); const CLIENT_TIMEOUT: Duration = Duration::from_secs(30);
fn is_valid_base64_image(base64_image: &str) -> bool {
base64_image.starts_with("data:image/png;base64")
}
pub struct Session { pub struct Session {
id: usize, id: usize,
server: Arc<Addr<Server>>, server: Arc<Addr<Server>>,
@ -30,7 +34,6 @@ impl Session {
fn heartbeat(&self, ctx: &mut ws::WebsocketContext<Self>) { fn heartbeat(&self, ctx: &mut ws::WebsocketContext<Self>) {
ctx.run_interval(HEARTBEAT_INTERVAL, |act, ctx| { ctx.run_interval(HEARTBEAT_INTERVAL, |act, ctx| {
// check client heartbeats
if Instant::now().duration_since(act.heartbeat) > CLIENT_TIMEOUT { if Instant::now().duration_since(act.heartbeat) > CLIENT_TIMEOUT {
act.server.do_send(Disconnect { id: act.id }); act.server.do_send(Disconnect { id: act.id });
@ -89,9 +92,13 @@ impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for Session {
self.heartbeat = Instant::now(); self.heartbeat = Instant::now();
} }
Ok(ws::Message::Text(text)) => { Ok(ws::Message::Text(text)) => {
self.server.do_send(ClientMessage { // let image = text.to_string();
msg: "aaaaa".to_string(),
}); // if !is_valid_base64_image(&image) {
ctx.text(text)
// }
// copy_base64_image_into_clipboard(image);
} }
Ok(ws::Message::Binary(bin)) => ctx.binary(bin), Ok(ws::Message::Binary(bin)) => ctx.binary(bin),
_ => (), _ => (),