better workspace support

This commit is contained in:
Parker TenBroeck 2026-03-11 14:36:46 -04:00
parent 53eb596861
commit c6136920cb
6 changed files with 78 additions and 70 deletions

View file

@ -6,6 +6,7 @@ use tokio::process::{Child, Command};
use crate::HResult; use crate::HResult;
const EMBEDDED_VHDL_UI_LIB: &[u8] = include_bytes!(env!("EMBEDDED_VHDL_CONN_LIB_PATH")); const EMBEDDED_VHDL_UI_LIB: &[u8] = include_bytes!(env!("EMBEDDED_VHDL_CONN_LIB_PATH"));
const EMBEDDED_TB_VHDL: &str = include_str!("../../rtl/tb.vhdl");
async fn ensure_ok(child: Child) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { async fn ensure_ok(child: Child) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let result = child.wait_with_output().await?; let result = child.wait_with_output().await?;
@ -73,10 +74,12 @@ pub async fn copy_and_build(
} }
pub async fn build(path: &Path, src: &Path) -> HResult<()>{ pub async fn build(build: &Path, src: &Path) -> HResult<()>{
std::fs::create_dir_all(path)?; std::fs::create_dir_all(build)?;
let embedded_lib_path = path.join("libvhdl_conn.a"); let embedded_lib_path = build.join("libvhdl_conn.a");
let embedded_tb_path = build.join("tb.vhdl");
std::fs::write(&embedded_lib_path, EMBEDDED_VHDL_UI_LIB)?; std::fs::write(&embedded_lib_path, EMBEDDED_VHDL_UI_LIB)?;
std::fs::write(&embedded_tb_path, EMBEDDED_TB_VHDL)?;
let mut cmd = Command::new("ghdl"); let mut cmd = Command::new("ghdl");
cmd.kill_on_drop(true); cmd.kill_on_drop(true);
@ -84,32 +87,28 @@ pub async fn build(path: &Path, src: &Path) -> HResult<()>{
for file in src.read_dir().unwrap().flatten(){ for file in src.read_dir().unwrap().flatten(){
if Path::new(&file.file_name()).extension() == Some(OsStr::new("vhdl")) { if Path::new(&file.file_name()).extension() == Some(OsStr::new("vhdl")) {
cmd.arg(file.path()); cmd.arg(file.path().canonicalize()?);
} }
} }
cmd.arg(&embedded_tb_path.canonicalize()?);
cmd.arg(std::fs::canonicalize("../rtl/tb.vhdl")?);
cmd.stdin(std::process::Stdio::piped()) cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped()); .stderr(std::process::Stdio::piped());
cmd.current_dir(path); cmd.current_dir(build);
ensure_ok(cmd.spawn()?).await?; ensure_ok(cmd.spawn()?).await?;
let mut cmd = Command::new("ghdl"); let mut cmd = Command::new("ghdl");
cmd.kill_on_drop(true); cmd.kill_on_drop(true);
cmd.args(["-m", "--std=08"]); cmd.args(["-m", "--std=08"]);
cmd.arg(format!( cmd.arg(format!(
"-Wl,{}", "-Wl,{}",
embedded_lib_path.display() embedded_lib_path.canonicalize()?.display()
)); ));
cmd.arg("tb"); cmd.arg("tb");
cmd.current_dir(path); cmd.current_dir(build);
cmd.stdin(std::process::Stdio::piped()) cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped()); .stderr(std::process::Stdio::piped());

View file

@ -13,17 +13,13 @@ use std::{
pub mod build; pub mod build;
pub mod run; pub mod run;
pub mod local; pub mod uploaded;
pub mod remote; pub mod workspace;
const UI_INDEX_HTML: &str = include_str!("../ui/index.html"); const UI_INDEX_HTML: &str = include_str!("../ui/index.html");
const UI_STYLES_CSS: &str = include_str!("../ui/styles.css"); const UI_STYLES_CSS: &str = include_str!("../ui/styles.css");
const UI_APP_JS: &str = include_str!("../ui/app.js"); const UI_APP_JS: &str = include_str!("../ui/app.js");
async fn serve_index() -> impl IntoResponse {
Html(UI_INDEX_HTML)
}
async fn serve_styles() -> impl IntoResponse { async fn serve_styles() -> impl IntoResponse {
( (
[(header::CONTENT_TYPE, "text/css; charset=utf-8")], [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
@ -38,6 +34,10 @@ async fn serve_app_js() -> impl IntoResponse {
) )
} }
async fn serve_index() -> impl IntoResponse {
Html(UI_INDEX_HTML)
}
async fn not_found() -> impl IntoResponse { async fn not_found() -> impl IntoResponse {
(StatusCode::NOT_FOUND, "not found") (StatusCode::NOT_FOUND, "not found")
} }
@ -47,6 +47,7 @@ struct Config {
ip: IpAddr, ip: IpAddr,
port: u16, port: u16,
update_ms: u64, update_ms: u64,
workspace_ws: bool,
} }
impl Default for Config { impl Default for Config {
@ -55,6 +56,7 @@ impl Default for Config {
ip: IpAddr::from([127, 0, 0, 1]), ip: IpAddr::from([127, 0, 0, 1]),
port: 8080, port: 8080,
update_ms: 30, update_ms: 30,
workspace_ws: true,
} }
} }
} }
@ -84,12 +86,17 @@ fn parse_config_from_args() -> Result<Config, String> {
.parse::<u64>() .parse::<u64>()
.map_err(|err| format!("invalid --update-ms `{value}`: {err}"))?; .map_err(|err| format!("invalid --update-ms `{value}`: {err}"))?;
} }
"--workspace" => {
cfg.workspace_ws = true;
}
"--help" | "-h" => { "--help" | "-h" => {
return Err("usage: relay [--ip <ip>] [--port <port>] [--update-ms <ms>]".into()); return Err(
"usage: relay [--ip <ip>] [--port <port>] [--update-ms <ms>] [--workspace]".into(),
);
} }
_ => { _ => {
return Err(format!( return Err(format!(
"unknown argument `{arg}`\nusage: relay [--ip <ip>] [--port <port>] [--update-ms <ms>]" "unknown argument `{arg}`\nusage: relay [--ip <ip>] [--port <port>] [--update-ms <ms>] [--workspace]"
)); ));
} }
} }
@ -109,39 +116,44 @@ async fn main() {
}; };
let update_interval = Duration::from_millis(cfg.update_ms); let update_interval = Duration::from_millis(cfg.update_ms);
let app = Router::new() let mut app = Router::new()
.route("/", get(serve_index)) .route(
.route("/index.html", get(serve_index)) "/",
get(move || {
async move { serve_index().await }
}),
)
.route(
"/index.html",
get(move || {
async move { serve_index().await }
}),
)
.route("/styles.css", get(serve_styles)) .route("/styles.css", get(serve_styles))
.route("/app.js", get(serve_app_js)) .route("/app.js", get(serve_app_js))
.route( .route(
"/ws/remote", "/ws/uploaded",
get(move |ws: WebSocketUpgrade| { get(move |ws: WebSocketUpgrade| {
let update_interval = update_interval; let update_interval = update_interval;
async move { async move {
ws.on_upgrade(move |socket| remote::ws_handler(socket, update_interval)) ws.on_upgrade(move |socket| uploaded::ws_handler(socket, update_interval))
} }
}), }),
) );
.route(
"/ws/local", if cfg.workspace_ws {
app = app.route(
"/ws/workspace",
get(move |ws: WebSocketUpgrade| { get(move |ws: WebSocketUpgrade| {
let update_interval = update_interval; let update_interval = update_interval;
async move { async move {
ws.on_upgrade(move |socket| local::ws_handler(socket, update_interval)) ws.on_upgrade(move |socket| workspace::ws_handler(socket, update_interval))
} }
}), }),
) );
.route(
"/ws",
get(move |ws: WebSocketUpgrade| {
let update_interval = update_interval;
async move {
ws.on_upgrade(move |socket| remote::ws_handler(socket, update_interval))
} }
}),
) app = app.fallback(get(not_found));
.fallback(get(not_found));
let addr = SocketAddr::new(cfg.ip, cfg.port); let addr = SocketAddr::new(cfg.ip, cfg.port);
println!("Open UI: http://{}/", addr); println!("Open UI: http://{}/", addr);

View file

@ -37,7 +37,7 @@ struct Handler {
impl Handler { impl Handler {
fn local(socket: WebSocket, build: PathBuf, src: PathBuf, refresh_time: Duration) -> Self { fn workspace(socket: WebSocket, build: PathBuf, src: PathBuf, refresh_time: Duration) -> Self {
let (sender, receiver) = socket.split(); let (sender, receiver) = socket.split();
Self { Self {
sender, sender,
@ -208,5 +208,5 @@ impl Handler {
} }
pub async fn ws_handler(socket: WebSocket, refresh_time: Duration) { pub async fn ws_handler(socket: WebSocket, refresh_time: Duration) {
Handler::local(socket, "../target".into(), "../src".into(), refresh_time).run().await; Handler::workspace(socket, "./target".into(), "./src".into(), refresh_time).run().await;
} }

View file

@ -648,7 +648,7 @@ class CircuitUiApp {
this.editor = new EditorController({ this.editor = new EditorController({
...this.dom, ...this.dom,
enabled: config.initialMode === "local", enabled: config.initialMode === "uploaded",
externalFiles: config.externalFiles, externalFiles: config.externalFiles,
}); });
@ -670,7 +670,7 @@ class CircuitUiApp {
this.outputs.resetVisuals(); this.outputs.resetVisuals();
}, },
onOpen: () => { onOpen: () => {
if (this.mode === "local") { if (this.mode === "uploaded") {
this.connection.send(this.editor.getFilesPayload()); this.connection.send(this.editor.getFilesPayload());
} }
this.connection.send({ input: this.inputs.getInputPayload() }); this.connection.send({ input: this.inputs.getInputPayload() });
@ -705,7 +705,7 @@ class CircuitUiApp {
this.setRunButtonEnabled(false); this.setRunButtonEnabled(false);
this.setRunning(false); this.setRunning(false);
this.updateStatusIndicator(); this.updateStatusIndicator();
if (this.mode === "remote") { if (this.mode === "workspace") {
this.scheduleReconnect(); this.scheduleReconnect();
} }
}, },
@ -741,13 +741,14 @@ class CircuitUiApp {
wireModeControls() { wireModeControls() {
this.dom.modeToggle.addEventListener("change", () => { this.dom.modeToggle.addEventListener("change", () => {
const nextMode = this.dom.modeToggle.checked ? "remote" : "local"; const nextMode = this.dom.modeToggle.checked ? "uploaded" : "workspace";
this.applyMode(nextMode); this.applyMode(nextMode);
}); });
} }
applyMode(nextMode, fromInit = false) { applyMode(nextMode, fromInit = false) {
const mode = nextMode === "remote" ? "remote" : "local"; const mode =
nextMode === "workspace" && this.config.workspaceEnabled ? "workspace" : "uploaded";
const changed = this.mode !== mode; const changed = this.mode !== mode;
if (!fromInit && changed && this.connection.isConnected()) { if (!fromInit && changed && this.connection.isConnected()) {
@ -759,13 +760,14 @@ class CircuitUiApp {
localStorage.setItem(LS_KEY_MODE, mode); localStorage.setItem(LS_KEY_MODE, mode);
} catch {} } catch {}
const isRemote = mode === "remote"; const isUploaded = mode === "uploaded";
this.dom.modeToggle.checked = isRemote; this.dom.modeToggle.checked = isUploaded;
this.editor.setEnabled(!isRemote); this.dom.modeToggle.disabled = !this.config.workspaceEnabled;
this.dom.connectToggleBtn.classList.toggle("is-hidden", isRemote); this.editor.setEnabled(isUploaded);
this.dom.runToggleBtn.classList.toggle("is-hidden", !isRemote); this.dom.connectToggleBtn.classList.toggle("is-hidden", !isUploaded);
this.dom.runToggleBtn.classList.toggle("is-hidden", isUploaded);
if (isRemote) { if (!isUploaded) {
this.scheduleReconnect(0); this.scheduleReconnect(0);
} else { } else {
this.cancelReconnect(); this.cancelReconnect();
@ -831,27 +833,29 @@ function resolveConfig() {
const config = window.VHDL_UI_CONFIG ?? {}; const config = window.VHDL_UI_CONFIG ?? {};
const query = new URLSearchParams(location.search); const query = new URLSearchParams(location.search);
const queryMode = (query.get("mode") ?? "").toLowerCase(); const queryMode = (query.get("mode") ?? "").toLowerCase();
const workspaceEnabled = config.workspaceEnabled !== false;
let storedMode = ""; let storedMode = "";
try { try {
storedMode = (localStorage.getItem(LS_KEY_MODE) ?? "").toLowerCase(); storedMode = (localStorage.getItem(LS_KEY_MODE) ?? "").toLowerCase();
} catch {} } catch {}
let initialMode = "local"; let initialMode = workspaceEnabled ? "workspace" : "uploaded";
if (queryMode === "local" || queryMode === "remote") { if (queryMode === "workspace" || queryMode === "uploaded") {
initialMode = queryMode; initialMode = queryMode;
} else if (storedMode === "local" || storedMode === "remote") { } else if (storedMode === "workspace" || storedMode === "uploaded") {
initialMode = storedMode; initialMode = storedMode;
} else if (config.mode === "local" || config.mode === "remote") { } else if (config.mode === "workspace" || config.mode === "uploaded") {
initialMode = config.mode; initialMode = config.mode;
} else if (query.has("externalEditor")) { } else if (query.has("externalEditor")) {
initialMode = parseBoolean(query.get("externalEditor")) ? "remote" : "local"; initialMode = parseBoolean(query.get("externalEditor")) ? "uploaded" : "workspace";
} else if (parseBoolean(config.externalEditor)) { } else if (parseBoolean(config.externalEditor)) {
initialMode = "remote"; initialMode = "uploaded";
} }
return { return {
initialMode, initialMode: initialMode === "workspace" && !workspaceEnabled ? "uploaded" : initialMode,
workspaceEnabled,
externalFiles: config.externalFiles ?? null, externalFiles: config.externalFiles ?? null,
}; };
} }

View file

@ -29,10 +29,10 @@
<div class="panelTopStatus"> <div class="panelTopStatus">
<span class="pill state-disabled" id="statusPill">DISABLED</span> <span class="pill state-disabled" id="statusPill">DISABLED</span>
<label class="modeToggle" for="modeToggle"> <label class="modeToggle" for="modeToggle">
<span class="modeLabel">Local</span> <span class="modeLabel">Workspace</span>
<input id="modeToggle" type="checkbox" aria-label="Toggle remote mode" /> <input id="modeToggle" type="checkbox" aria-label="Toggle uploaded mode" />
<span class="modeSlider" aria-hidden="true"></span> <span class="modeSlider" aria-hidden="true"></span>
<span class="modeLabel">Remote</span> <span class="modeLabel">Uploaded</span>
</label> </label>
<button id="connectToggleBtn">Connect</button> <button id="connectToggleBtn">Connect</button>
<button id="runToggleBtn" class="secondary" disabled>Start</button> <button id="runToggleBtn" class="secondary" disabled>Start</button>
@ -796,13 +796,6 @@
</section> </section>
</main> </main>
<script>
// Optional runtime config hook:
// window.VHDL_UI_CONFIG = {
// mode: "local" | "remote",
// externalFiles: { "circuit.vhdl": "..." }
// };
</script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>