Skip to content

Commit 7955edc

Browse files
committed
feat(web): host the web bridge inside the session daemon
`fresh --web` ran a private editor in its own process, so a browser session was unreachable from the terminal. It now runs the ordinary session daemon in the foreground with the web bridge hosted inside it: `fresh -a` in the same directory attaches a terminal to the very editor the browser is showing, edits in either reach the other, and closing one leaves the session running for the other. The browser-facing half of `webui` is extracted into a reusable `WebBridge` so both hosts drive one implementation and the transports can't drift: `webui::run` (editor-owning, used by the `webui_server` example and the parity/Playwright harnesses) and `EditorServer`, which polls the bridge in its loop, folds browser input into its render signal, and pushes the scene alongside the terminal broadcast. Consequences of sharing one editor: * the rendered grid is the element-wise min of every viewport watching it — browsers and the primary terminal — so a larger window letterboxes rather than showing a grid the smaller one can't display; * chrome suppression is scoped to the browser's render, so attached terminals still get chrome drawn into their cells; * `POST /reset` is refused with 409 under the daemon (it rebuilds the editor, discarding the recovery session, restored workspace and attached terminals); the standalone bridge still allows it; * cell animations are off for the session while `--web` is serving, as the standalone bridge always did; * a connected browser counts as a client for the idle timeout; * web sessions get the workspace/hot-exit restore terminals always had, and show up in `fresh --cmd daemon list`. Files named on the `--web` command line ride in on the new `EditorServerConfig::startup_files` and are queued through the same path the `OpenFiles` control message uses. A session already live in the directory is not shadowed: `--web` refuses cleanly and points at `fresh -a` or `--session-name NAME`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TjGX8SFLLvHLESAw2NDBUs
1 parent 95bc736 commit 7955edc

11 files changed

Lines changed: 927 additions & 213 deletions

File tree

crates/fresh-editor/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,9 @@ http = ["dep:ureq"]
124124
# link the extraction/TLS stack.
125125
self-update = ["http", "dep:tar", "dep:xz2", "dep:zip"]
126126
# Web UI: `fresh --web [ADDR]` serves the real editor to a browser over a
127-
# local HTTP + WebSocket bridge (the `webui` module). The frontend (the page
127+
# local HTTP + WebSocket bridge (the `webui` module), hosted inside the session
128+
# daemon so `fresh -a` can attach a terminal to the same editor. The frontend
129+
# (the page
128130
# build.rs assembles from web-ui/) is embedded into the binary so the build
129131
# is self-contained. Opt-in (not in `default`) because it embeds ~140 KiB of
130132
# HTML and pulls in the sha1 WebSocket-handshake dep. Requires the full

crates/fresh-editor/src/lib.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,11 @@ pub mod client;
4747
#[cfg(feature = "runtime")]
4848
pub mod server;
4949

50-
// Local HTTP bridge hosting the real Editor for the web UI frontend (no mocks).
51-
// Opt-in via the `web` feature (implies `runtime`); reached from `fresh --web`.
50+
// Local HTTP bridge serving the real Editor to the web UI frontend (no mocks).
51+
// Opt-in via the `web` feature (implies `runtime`). `fresh --web` hosts the
52+
// bridge inside the session daemon (`server`), so browsers and `fresh -a`
53+
// terminals share one editor; `webui::run` is the standalone, editor-owning
54+
// host used by the example binary and the parity harness.
5255
#[cfg(feature = "web")]
5356
pub mod webui;
5457

crates/fresh-editor/src/main.rs

Lines changed: 116 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,9 @@ struct Cli {
147147

148148
/// Serve the editor to a browser over a local HTTP/WebSocket bridge.
149149
/// Optionally give a bind address (default 127.0.0.1:8137). Any FILES are
150-
/// opened in the served editor.
150+
/// opened in the served editor. This also runs the session daemon, so
151+
/// `fresh -a` in the same directory attaches a terminal to the very same
152+
/// editor.
151153
#[cfg(feature = "web")]
152154
#[arg(long, value_name = "ADDR", num_args = 0..=1, default_missing_value = "127.0.0.1:8137")]
153155
web: Option<String>,
@@ -2778,19 +2780,48 @@ fn kill_session_command(session: Option<&str>, args: &Args) -> AnyhowResult<()>
27782780
}
27792781

27802782
/// Run as a daemon server
2781-
fn run_server_command(args: &Args) -> AnyhowResult<()> {
2783+
/// Run the session daemon in this process.
2784+
///
2785+
/// Two entry points land here, and they run the SAME daemon:
2786+
///
2787+
/// - `fresh --server` — the detached process a `fresh -a` client spawns
2788+
/// when no daemon is live for the working directory. Chatty on stderr,
2789+
/// which the spawner has already redirected into the session log.
2790+
/// - `fresh --web [ADDR]` (`web_addr = Some`) — the same daemon in the
2791+
/// foreground, additionally serving the web UI. Same session, same
2792+
/// sockets: `fresh -a` in this working directory attaches a terminal to
2793+
/// the very editor the browser is looking at, and closing either one
2794+
/// leaves the session (and the other) running.
2795+
///
2796+
/// `web_addr` also picks the console posture. The detached daemon logs at
2797+
/// `debug` into its log file; a foreground `--web` would flood the user's
2798+
/// terminal with it, so it logs at `warn` and skips the boot chatter.
2799+
/// `RUST_LOG` still overrides either.
2800+
fn run_server_command(args: &Args, web_addr: Option<String>) -> AnyhowResult<()> {
27822801
use fresh::server::{EditorServer, EditorServerConfig};
27832802

2803+
let detached = web_addr.is_none();
2804+
// Boot progress, useful in the daemon's log file but noise in a terminal.
2805+
macro_rules! boot {
2806+
($($arg:tt)*) => {
2807+
if detached {
2808+
eprintln!($($arg)*);
2809+
}
2810+
};
2811+
}
2812+
27842813
// Initialize tracing to stderr (will go to log file when spawned detached)
27852814
use tracing_subscriber::{fmt, EnvFilter};
2786-
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"));
2815+
let default_level = if detached { "debug" } else { "warn" };
2816+
let filter =
2817+
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level));
27872818
fmt()
27882819
.with_env_filter(filter)
27892820
.with_writer(std::io::stderr)
27902821
.with_ansi(false)
27912822
.init();
27922823

2793-
eprintln!(
2824+
boot!(
27942825
"[server] Starting server process for session {:?}",
27952826
args.session_name
27962827
);
@@ -2828,7 +2859,7 @@ fn run_server_command(args: &Args) -> AnyhowResult<()> {
28282859
None => std::env::current_dir()?,
28292860
};
28302861
let config_dir = std::env::current_dir()?;
2831-
eprintln!("[server] Working directory: {:?}", working_dir);
2862+
boot!("[server] Working directory: {:?}", working_dir);
28322863

28332864
let dir_context = fresh::config_io::DirectoryContext::from_system()?;
28342865

@@ -2851,15 +2882,33 @@ fn run_server_command(args: &Args) -> AnyhowResult<()> {
28512882
);
28522883

28532884
// Load editor config
2854-
eprintln!("[server] Loading editor config...");
2855-
let editor_config = if let Some(config_path) = &args.config {
2885+
boot!("[server] Loading editor config...");
2886+
let mut editor_config = if let Some(config_path) = &args.config {
28562887
config::Config::load_from_file(config_path)?
28572888
} else {
28582889
config::Config::load_with_layers(&dir_context, &config_dir)
28592890
};
2860-
eprintln!("[server] Editor config loaded");
2891+
boot!("[server] Editor config loaded");
2892+
// Cell-level (TUI) animations would stream to the browser as bursts of
2893+
// frame diffs, on top of the CSS-level motion the web frontend does for
2894+
// itself — the same reason the standalone bridge forces them off. One
2895+
// editor serves both transports here, so the whole session (attached
2896+
// terminals included) goes without them while `--web` is serving.
2897+
if web_addr.is_some() {
2898+
editor_config.editor.animations = false;
2899+
}
28612900
editor_config.apply_runtime_flags();
28622901

2902+
// `--web` has no client to send an `OpenFiles` after the handshake, so the
2903+
// files from its own command line ride in on the config and are queued once
2904+
// the editor is up. The detached daemon leaves this empty — its `fresh -a`
2905+
// client sends the list itself, `--wait` and all.
2906+
let startup_files = if web_addr.is_some() {
2907+
build_file_requests(&args.files, &working_dir)
2908+
} else {
2909+
Vec::new()
2910+
};
2911+
28632912
let session_keepalive: Option<Box<dyn std::any::Any + Send>> =
28642913
remote_session.map(|rs| Box::new(rs) as Box<dyn std::any::Any + Send>);
28652914
let startup_authority = if remote_info.is_some() {
@@ -2880,31 +2929,77 @@ fn run_server_command(args: &Args) -> AnyhowResult<()> {
28802929
workspace_trust,
28812930
env_provider,
28822931
session_keepalive,
2932+
startup_files,
2933+
#[cfg(feature = "web")]
2934+
web_addr: web_addr.clone(),
28832935
};
28842936

2885-
eprintln!("[server] Creating EditorServer...");
2937+
boot!("[server] Creating EditorServer...");
28862938
let mut server = match EditorServer::new(config) {
28872939
Ok(s) => {
2888-
eprintln!("[server] EditorServer created successfully");
2940+
boot!("[server] EditorServer created successfully");
28892941
s
28902942
}
28912943
Err(e) => {
2892-
eprintln!("[server] EditorServer::new failed: {:?}", e);
2944+
boot!("[server] EditorServer::new failed: {:?}", e);
28932945
return Err(e.into());
28942946
}
28952947
};
28962948

2897-
eprintln!("[server] Server ready at {:?}", server.socket_paths());
2949+
boot!("[server] Server ready at {:?}", server.socket_paths());
28982950
tracing::info!("Editor server started at {:?}", server.socket_paths());
2951+
// The web banner is the foreground path's only startup output: the URL to
2952+
// open, and the `fresh -a` invocation that attaches a terminal to the same
2953+
// session. Sessions are keyed by working directory unless named, which is
2954+
// exactly how `-a` resolves them.
2955+
if let Some(addr) = &web_addr {
2956+
eprintln!("fresh web bridge on http://{addr} (WS push on /ws)");
2957+
match &args.session_name {
2958+
Some(name) => eprintln!("attach a terminal to this session: fresh -a {name}"),
2959+
None => eprintln!(
2960+
"attach a terminal to this session: fresh -a (in {})",
2961+
working_dir.display()
2962+
),
2963+
}
2964+
}
28992965

29002966
// Run the server (blocking)
2901-
eprintln!("[server] Entering main loop...");
2967+
boot!("[server] Entering main loop...");
29022968
server.run()?;
29032969

2904-
eprintln!("[server] Server shutting down");
2970+
boot!("[server] Server shutting down");
29052971
Ok(())
29062972
}
29072973

2974+
/// `fresh --web [ADDR] [FILES…]` — run the session daemon in the foreground
2975+
/// with the web UI bridge hosted inside it.
2976+
///
2977+
/// The daemon owns the editor; the browser and every `fresh -a` terminal are
2978+
/// transports onto that one editor, so a change made in the browser shows up in
2979+
/// an attached terminal (and the other way round), and closing either leaves the
2980+
/// session running. Because this binds the ordinary session sockets, a session
2981+
/// that is already live here would be shadowed rather than shared — refuse
2982+
/// instead, and say what to do about it.
2983+
#[cfg(feature = "web")]
2984+
fn run_web_command(args: &Args, addr: &str) -> AnyhowResult<()> {
2985+
let socket_paths = resolve_session(args.session_name.as_deref())?;
2986+
// A daemon that died without unlinking its sockets must not block the bind.
2987+
socket_paths.cleanup_if_stale();
2988+
if socket_paths.is_server_alive() {
2989+
// A user error, not a bug — printed clean and exited, the same way
2990+
// `main` special-cases SSH connection failures. Returning an
2991+
// `anyhow::Error` here would hand the user a backtrace instead
2992+
// (`real_main` turns backtraces on so genuine crashes are diagnosable).
2993+
eprintln!(
2994+
"Error: a fresh session is already running here — `fresh -a` attaches a terminal to it."
2995+
);
2996+
eprintln!("To serve a separate session over the web, give it a name:");
2997+
eprintln!(" fresh --web {addr} --session-name NAME");
2998+
std::process::exit(1);
2999+
}
3000+
run_server_command(args, Some(addr.to_string()))
3001+
}
3002+
29083003
/// Resolve a session name to socket paths.
29093004
///
29103005
/// When `session_name` is `None`, uses the current working directory.
@@ -3874,7 +3969,7 @@ fn run_if_subcommand(
38743969
return Some(kill_session_command(session.as_deref(), args));
38753970
}
38763971
if args.server {
3877-
return Some(run_server_command(args));
3972+
return Some(run_server_command(args, None));
38783973
}
38793974
if let Some((session_name, files, wait)) = &args.open_files_in_session {
38803975
return Some(run_open_files_command(
@@ -3886,10 +3981,14 @@ fn run_if_subcommand(
38863981
if args.attach {
38873982
return Some(run_attach_command(args));
38883983
}
3984+
// `--web` runs the session daemon in the foreground with the web bridge
3985+
// hosted inside it, so the browser and any `fresh -a` terminal share one
3986+
// editor. It is NOT a separate editor process: the daemon binds the usual
3987+
// session sockets for this working directory (or `--session NAME`) first,
3988+
// so a session that is already live is joined rather than shadowed.
38893989
#[cfg(feature = "web")]
38903990
if let Some(addr) = &args.web {
3891-
let files: Vec<PathBuf> = args.files.iter().map(PathBuf::from).collect();
3892-
return Some(fresh::webui::run(addr, &files));
3991+
return Some(run_web_command(args, addr));
38933992
}
38943993
#[cfg(feature = "gui")]
38953994
if !console_available || args.gui {

0 commit comments

Comments
 (0)