commit 07fe81b4628560e8fbf18e9570af31e8866c7f11 Author: Scott Penrose Date: Thu Jul 16 12:08:54 2026 +1000 Initial public release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..19993ed --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,107 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zed-bridge" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ae8d86b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "zed-bridge" +version = "0.1.0" +edition = "2021" +description = "Local HTTP bridge to open remote site files in Zed editor" +authors = ["Scott Penrose"] +license = "MIT" +repository = "https://github.com/scottp/zed-bridge" +readme = "README.md" +keywords = ["zed", "editor", "http", "localhost", "bridge"] +categories = ["command-line-utilities", "web-programming::http-server"] + +[[bin]] +name = "zed-bridge" +path = "src/main.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ed8b463 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Scott Penrose + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e7b13a --- /dev/null +++ b/README.md @@ -0,0 +1,307 @@ +# zed-bridge + +A tiny Rust HTTP server that runs on your local workstation at `http://localhost:7654`. +Click an **Edit in Zed** button on any remote website you control, and the file opens +instantly in the [Zed editor](https://zed.dev) — pointing directly at your +already-checked-out copy. + +Zero external Rust dependencies (only `serde` / `serde_json` for config). +Binds to `127.0.0.1` only — nothing reachable from outside your machine. + +--- + +## Why? + +You run a private or internal site (a [Hugo](https://gohugo.io) blog, a docs site, a +CMS, a wiki) hosted on a remote server. The content lives in a git repo you also have +checked out locally. When you spot a typo or want to expand a page while browsing the +live site, you want to be editing it in Zed **immediately** — not `cd`-ing through +directories, guessing paths, or opening your editor manually. + +zed-bridge lets you drop an **Edit in Zed** button on any page of the remote site. +One click and the file is open in your local Zed editor, at the exact path you were +just looking at. No SSH, no sync, no finding the file — just edit and go. + +It's designed for **sites you control** (private dev blogs, internal docs, Hugo +preview environments). It is not a generic remote-editing service: the bridge only +opens files in repos you've explicitly allowlisted in its config, and it only listens +on `127.0.0.1`. + +--- + +## How it works + +``` +Your browser (remote HTTPS site) + │ + │ window.open("http://localhost:7654/open?repo=mysite&file=content/posts/foo.md") + │ + ▼ +zed-bridge (systemd user service, always running) + │ + ├─ validates repo name against ~/.config/zed-bridge/config.json + ├─ resolves & canonicalises file path (blocks path traversal) + ├─ optional: checks git staleness (local cached state, no network call) + └─ launches: zed /home/you/repos/mysite/content/posts/foo.md +``` + +The pop-up tab shows a result page (success or error) then closes itself after 1.8 s. + +--- + +## Installation + +### Prerequisites + +- [Rust](https://rustup.rs) (stable) +- [Zed](https://zed.dev) installed and on `$PATH` (run `zed --version` to confirm) +- Linux with systemd (Linux Mint, Ubuntu, Fedora, Arch, etc.) + +### Build & install + +```bash +# Clone or extract this project, then: +chmod +x install.sh +./install.sh +``` + +The script: +1. Runs `cargo build --release` +2. Copies the binary to `~/.local/bin/zed-bridge` +3. Installs the systemd user service +4. Creates `~/.config/zed-bridge/config.json` from the example (if not present) +5. Enables and starts the service + +### Manual install (if you prefer) + +```bash +cargo build --release +cp target/release/zed-bridge ~/.local/bin/ +cp zed-bridge.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now zed-bridge +``` + +--- + +## Configuration + +Edit `~/.config/zed-bridge/config.json`: + +```json +{ + "port": 7654, + "check_git": true, + "allowed_origin": null, + "repos": { + "mysite": "~/repos/mysite", + "blog": "~/repos/blog", + "docs": "~/repos/docs" + } +} +``` + +| Field | Default | Description | +|---|---|---| +| `port` | `7654` | Local port to listen on | +| `check_git` | `true` | Show warning if local branch is behind upstream | +| `allowed_origin` | `null` | If set, only requests from this origin are accepted (e.g. `"https://mysite.example.com"`) | +| `repos` | `{}` | Map of short name → local path. Supports `~` expansion. | + +The service reads config on every request, so changes take effect immediately — no restart needed. + +--- + +## Adding Edit buttons to your remote site + +### Quickest — plain link + +```html + + ✏️ Edit in Zed + +``` + +This opens a small browser tab that closes itself after 1.8 s. + +### Better UX — small pop-up + +Add this JS once (e.g. in your base layout footer): + +```html + +``` + +Then use buttons anywhere: + +```html + +``` + +### Hugo template + +In a list or single template: + +```html +{{ if .File }} + +{{ end }} +``` + +To restrict to dev/local only: + +```html +{{ if eq (getenv "HUGO_ENV") "development" }} + +{{ end }} +``` + +Or check the request IP in your nginx config and set a header, then gate on it in your template. + +### Nginx — show edit buttons only from your own IP + +In your nginx server block: + +```nginx +# Only pass the header when the request comes from your workstation/VPN +geo $show_edit_button { + default 0; + 203.0.113.42 1; # your static IP + 100.64.0.0/10 1; # Tailscale range +} + +server { + ... + location / { + proxy_set_header X-Dev-Edit $show_edit_button; + ... + } +} +``` + +Then in your app/template, check `X-Dev-Edit: 1` to conditionally render the buttons. + +--- + +## API reference + +### `GET /open` + +Opens a file in Zed. + +| Parameter | Required | Description | +|---|---|---| +| `repo` | ✅ | Repo key from config (e.g. `mysite`) | +| `file` | ✅ | Relative path within the repo (e.g. `content/posts/foo.md`) | + +**Responses:** All return HTML. `200` on success (Zed launched). `400` bad params. +`403` forbidden (path traversal or origin check failed). `404` repo/file not found. +`500` config error or Zed failed to launch. + +### `GET /health` + +Returns `{"status":"ok","service":"zed-bridge"}` — useful for checking the service +is alive from the browser (see the status indicator in `example/edit-button.html`). + +--- + +## Git staleness check + +When `check_git` is `true`, the bridge runs: + +``` +git rev-list --left-right --count HEAD...@{upstream} +``` + +This reads from the **locally cached tracking ref** — it is instant and makes no +network calls. The result reflects the state at your last `git fetch` or `git pull`. + +If your local branch is behind, a yellow warning appears in the result pop-up before +Zed opens. The file is still opened — it's a warning, not a block. + +To disable: set `"check_git": false` in config. + +--- + +## Security + +- Binds to `127.0.0.1` only — not reachable from the network. +- All resolved file paths are canonicalised and checked to be inside the declared + repo root before `zed` is called (prevents `../../etc/passwd` style traversal). +- `allowed_origin` optionally restricts which website can trigger the bridge. +- No shell interpolation — `zed` is called with `Command::new("zed").arg(path)`. + +--- + +## Logs & management + +```bash +# View live logs +journalctl --user -u zed-bridge -f + +# Restart after editing config or upgrading +systemctl --user restart zed-bridge + +# Stop +systemctl --user stop zed-bridge + +# Disable autostart +systemctl --user disable zed-bridge +``` + +--- + +## Troubleshooting + +**"Zed not found"** — Make sure `zed` is on your `$PATH`. Check with `which zed`. +The systemd user service inherits your login session's PATH; if Zed is installed +in a non-standard location, add `Environment=PATH=...` to the service file. + +**Button does nothing / CORS error** — Browsers block `fetch()` to localhost from +HTTPS pages (Private Network Access), but `window.open()` still works. Use the +pop-up approach shown above, not `fetch()`. + +**Pop-up blocked** — Some browsers block `window.open` unless triggered directly by +a user click. Make sure the `editInZed()` call is inside a click handler, not a timeout. + +**Service won't start on login** — Ensure systemd user lingering is enabled if you +need it to start without an interactive session: +```bash +loginctl enable-linger $USER +``` + +--- + +## File structure + +``` +zed-bridge/ +├── src/ +│ └── main.rs # Full server — no async, no HTTP framework +├── example/ +│ └── edit-button.html # Live demo + copy-paste snippets +├── Cargo.toml +├── config.example.json +├── zed-bridge.service # systemd user unit +├── install.sh +├── test.sh +├── LICENSE +└── README.md +``` + +--- + +## License + +MIT diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..df1188f --- /dev/null +++ b/config.example.json @@ -0,0 +1,9 @@ +{ + "port": 7654, + "check_git": true, + "allowed_origin": null, + "repo_dirs": ["~/repos", "~/projects"], + "repos": { + "special-repo": "~/other/special-repo" + } +} diff --git a/example/edit-button.html b/example/edit-button.html new file mode 100644 index 0000000..2967fa4 --- /dev/null +++ b/example/edit-button.html @@ -0,0 +1,236 @@ + + + + + + zed-bridge — Integration Examples + + + + +

🔗 zed-bridge Integration Examples

+

+ This page shows how to add Edit in Zed buttons to your remote site. + Clicking any button below will open the file in Zed on your local workstation + (as long as zed-bridge is running). +

+ +
Checking if zed-bridge is running…
+ + +

Example 1 — Simple anchor link

+

The simplest approach. Opens a small pop-up tab that closes itself after 1.8s.

+ +
+ + + + Edit in Zed + +
+ +
<a class="edit-btn"
+   href="http://localhost:7654/open?repo=mysite&file=content/posts/hello-world.md"
+   target="_blank"
+   rel="noopener">
+  Edit in Zed
+</a>
+ + +

Example 2 — JavaScript pop-up (cleaner UX)

+

+ Opens a small 400×160 browser pop-up that closes itself automatically. + Doesn't leave a stray tab in your tab bar. +

+ +
+ +
+ +
<script>
+function editInZed(repo, file) {
+  const url = 'http://localhost:7654/open'
+            + '?repo=' + encodeURIComponent(repo)
+            + '&file=' + encodeURIComponent(file);
+  window.open(url, 'zed-bridge',
+    'width=420,height=160,menubar=no,toolbar=no,location=no,status=no');
+}
+</script>
+
+<button onclick="editInZed('mysite', 'content/posts/hello-world.md')">
+  Edit in Zed
+</button>
+ + +

Example 3 — Content listing table (Hugo / static site style)

+

+ A realistic file list, like you might generate from a Hugo template or a + server-side script. Each row has an edit button. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
FilePathAction
Hello Worldcontent/posts/hello-world.md + +
Solar Setup Guidecontent/posts/solar-setup.md + +
LoRa Node Configcontent/docs/lora-node.md + +
+
+ + +

Example 4 — Hugo template snippet

+

Drop this into any Hugo list or single template. Requires {{ .File.Path }}.

+ +
{{`{{ if .File }}`}}
+<button class="edit-btn"
+        onclick="editInZed('mysite', '{{`{{ .File.Path }}`}}');">
+  ✏️ Edit in Zed
+</button>
+{{`{{ end }}`}}
+ +

Add the editInZed() function once in your base layout's <head> or footer:

+ +
<script>
+function editInZed(repo, file) {
+  var url = 'http://localhost:7654/open'
+          + '?repo=' + encodeURIComponent(repo)
+          + '&file=' + encodeURIComponent(file);
+  window.open(url, 'zed-bridge',
+    'width=420,height=160,menubar=no,toolbar=no,location=no');
+}
+</script>
+ +

+ Tip: Wrap the button in an environment check so it only appears + in development or when accessed from your own IP: +

+ +
{{`{{ if eq (getenv "HUGO_ENV") "development" }}`}}
+  <!-- edit button here -->
+{{`{{ end }}`}}
+ + + + + + + diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..520b96d --- /dev/null +++ b/install.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# install.sh — build and install zed-bridge on Linux Mint / Ubuntu +set -euo pipefail + +BINARY_NAME="zed-bridge" +INSTALL_DIR="$HOME/.local/bin" +CONFIG_DIR="$HOME/.config/zed-bridge" +SERVICE_DIR="$HOME/.config/systemd/user" + +echo "==> Checking for Rust toolchain..." +if ! command -v cargo &>/dev/null; then + echo " Rust not found. Install from https://rustup.rs then re-run this script." + exit 1 +fi + +echo "==> Building release binary..." +cargo build --release + +echo "==> Installing binary to $INSTALL_DIR/$BINARY_NAME" +mkdir -p "$INSTALL_DIR" +cp "target/release/$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME" +chmod +x "$INSTALL_DIR/$BINARY_NAME" + +echo "==> Installing systemd user service..." +mkdir -p "$SERVICE_DIR" +cp "zed-bridge.service" "$SERVICE_DIR/$BINARY_NAME.service" + +echo "==> Setting up config (if not already present)..." +mkdir -p "$CONFIG_DIR" +if [ ! -f "$CONFIG_DIR/config.json" ]; then + cp config.example.json "$CONFIG_DIR/config.json" + echo "" + echo " *** Config created at $CONFIG_DIR/config.json ***" + echo " Edit it to add your repo paths, then continue." + echo "" +else + echo " Config already exists — not overwriting." +fi + +echo "==> Enabling and starting service..." +systemctl --user daemon-reload +systemctl --user enable "$BINARY_NAME" +systemctl --user start "$BINARY_NAME" + +echo "" +echo "✅ Done! Service status:" +systemctl --user status "$BINARY_NAME" --no-pager + +echo "" +echo "Test it: curl 'http://localhost:7654/health'" +echo "Logs: journalctl --user -u $BINARY_NAME -f" diff --git a/local.sh b/local.sh new file mode 100755 index 0000000..9549b5d --- /dev/null +++ b/local.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +CONFIG="$HOME/.config/zed-bridge/config.json" +if [[ ! -f "$CONFIG" ]]; then + echo "No config found at $CONFIG" + echo "Copy config.example.json there and edit it:" + echo " mkdir -p ~/.config/zed-bridge" + echo " cp config.example.json $CONFIG" + exit 1 +fi + +cargo build --release --quiet +exec ./target/release/zed-bridge diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..5d38873 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,620 @@ +use serde::Deserialize; +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::Duration; + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +struct Config { + /// Map of short repo name → local path, e.g. "mysite": "~/repos/mysite" + /// Explicit entries take precedence over auto-detected repos. + #[serde(default)] + repos: HashMap, + /// List of parent directories to scan for repos, e.g. ["~/repos", "~/projects"] + /// A request for repo "foo" will match the first dir containing "foo/". + #[serde(default)] + repo_dirs: Vec, + /// Warn when local branch is behind upstream (reads cached git state; no network call) + #[serde(default = "default_check_git")] + check_git: bool, + /// Port to listen on (default 7654) + #[serde(default = "default_port")] + port: u16, + /// Optional: only accept requests from this origin prefix. + /// e.g. "https://mysite.example.com" + allowed_origin: Option, +} + +fn default_check_git() -> bool { true } +fn default_port() -> u16 { 7654 } + +fn expand_tilde(p: &str) -> String { + if let Some(suffix) = p.strip_prefix("~/") { + format!("{}/{suffix}", std::env::var("HOME").unwrap_or_else(|_| ".".into())) + } else { + p.to_string() + } +} + +impl Config { + /// Resolve a repo name to its local path. + /// Explicit `repos` entries win; otherwise scan `repo_dirs` for a matching directory. + fn resolve_repo(&self, name: &str) -> Option { + if let Some(explicit) = self.repos.get(name) { + return Some(PathBuf::from(expand_tilde(explicit))); + } + for dir in &self.repo_dirs { + let candidate = PathBuf::from(expand_tilde(dir)).join(name); + if candidate.is_dir() { + return Some(candidate); + } + } + None + } +} + +fn config_path() -> PathBuf { + if let Ok(p) = std::env::var("ZED_BRIDGE_CONFIG") { + return PathBuf::from(p); + } + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".config").join("zed-bridge").join("config.json") +} + +fn load_config() -> Result { + let path = config_path(); + let data = std::fs::read_to_string(&path) + .map_err(|e| format!("Cannot read config {}: {}", path.display(), e))?; + serde_json::from_str(&data).map_err(|e| format!("Config parse error: {e}")) +} + +// --------------------------------------------------------------------------- +// Minimal HTTP parser — no external deps +// --------------------------------------------------------------------------- + +struct Request { + method: String, + path: String, + query: HashMap, + origin: Option, +} + +fn parse_request(stream: &TcpStream) -> Option { + let mut reader = BufReader::new(stream); + let mut first_line = String::new(); + reader.read_line(&mut first_line).ok()?; + + let mut origin: Option = None; + loop { + let mut header = String::new(); + reader.read_line(&mut header).ok()?; + let trimmed = header.trim(); + if trimmed.is_empty() { break; } + let lower = trimmed.to_lowercase(); + if lower.starts_with("origin:") || lower.starts_with("referer:") { + if let Some(colon) = trimmed.find(':') { + origin = Some(trimmed[colon + 1..].trim().to_string()); + } + } + } + + let parts: Vec<&str> = first_line.trim().splitn(3, ' ').collect(); + if parts.len() < 2 { return None; } + + let (path, query_str) = match parts[1].split_once('?') { + Some((p, q)) => (p.to_string(), q.to_string()), + None => (parts[1].to_string(), String::new()), + }; + + Some(Request { + method: parts[0].to_string(), + path, + query: parse_query(&query_str), + origin, + }) +} + +fn parse_query(q: &str) -> HashMap { + let mut map = HashMap::new(); + for pair in q.split('&') { + if let Some((k, v)) = pair.split_once('=') { + map.insert(url_decode(k), url_decode(v)); + } + } + map +} + +fn url_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char); + } + _ => { + out.push_str(&format!("%{:02X}", b)); + } + } + } + out +} + +fn url_decode(s: &str) -> String { + // Accumulate raw bytes, then interpret as UTF-8. Decoding %XX one char at + // a time mangles multi-byte sequences (e.g. é = %C3%A9 → "é"). + let bytes = s.as_bytes(); + let mut out: Vec = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'+' => { out.push(b' '); i += 1; } + b'%' if i + 3 <= bytes.len() => { + let hex = std::str::from_utf8(&bytes[i+1..i+3]).unwrap_or(""); + match u8::from_str_radix(hex, 16) { + Ok(byte) => { out.push(byte); i += 3; } + Err(_) => { out.push(bytes[i]); i += 1; } + } + } + b => { out.push(b); i += 1; } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +// --------------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------------- + +fn send_html(stream: &mut TcpStream, status: u16, status_text: &str, body: &str) { + let response = format!( + "HTTP/1.1 {status} {status_text}\r\n\ + Content-Type: text/html; charset=utf-8\r\n\ + Access-Control-Allow-Origin: *\r\n\ + Access-Control-Allow-Methods: GET, OPTIONS\r\n\ + Connection: close\r\n\ + Content-Length: {len}\r\n\r\n{body}", + len = body.len() + ); + let _ = stream.write_all(response.as_bytes()); +} + +fn html_page(title: &str, message: &str, auto_close: bool) -> String { + let close_script = if auto_close { + r#""# + } else { "" }; + + format!(r#" + + + + {title} + + + +
+

{title}

+
{message}
+

{hint}

+
+ {close_script} + + +"#, + hint = if auto_close { "This tab will close automatically." } + else { "Review the warnings above." } + ) +} + +// --------------------------------------------------------------------------- +// Git staleness — reads local cached tracking ref, instant, no network call +// --------------------------------------------------------------------------- + +struct GitStatus { behind: i64, ahead: i64, error: Option } + +fn check_git_staleness(repo_path: &Path) -> GitStatus { + match Command::new("git") + .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]) + .current_dir(repo_path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + { + Ok(out) if out.status.success() => { + let text = String::from_utf8_lossy(&out.stdout); + let parts: Vec<&str> = text.trim().split_whitespace().collect(); + if parts.len() == 2 { + GitStatus { + ahead: parts[0].parse().unwrap_or(0), + behind: parts[1].parse().unwrap_or(0), + error: None, + } + } else { + GitStatus { behind: 0, ahead: 0, error: Some("Unexpected git output".into()) } + } + } + Ok(out) => GitStatus { + behind: 0, ahead: 0, + error: Some(String::from_utf8_lossy(&out.stderr).trim().to_string()), + }, + Err(e) => GitStatus { behind: 0, ahead: 0, error: Some(e.to_string()) }, + } +} + +/// Check if a specific file has local modifications (staged or unstaged). +fn check_file_dirty(repo_path: &Path, file_rel: &str) -> bool { + match Command::new("git") + .args(["status", "--porcelain", "--", file_rel]) + .current_dir(repo_path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + { + Ok(out) => !String::from_utf8_lossy(&out.stdout).trim().is_empty(), + Err(_) => false, + } +} + +/// Run git pull in the given repo. Returns (success, output_text). +fn git_pull(repo_path: &Path) -> (bool, String) { + match Command::new("git") + .args(["pull"]) + .current_dir(repo_path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + { + Ok(out) => { + let mut text = String::from_utf8_lossy(&out.stdout).to_string(); + let err = String::from_utf8_lossy(&out.stderr); + if !err.trim().is_empty() { + text.push('\n'); + text.push_str(&err); + } + (out.status.success(), text.trim().to_string()) + } + Err(e) => (false, e.to_string()), + } +} + +// --------------------------------------------------------------------------- +// Open in Zed +// --------------------------------------------------------------------------- + +fn find_zed() -> Option { + let candidates = [ + format!("{}/.local/bin/zed", std::env::var("HOME").unwrap_or_default()), + "/usr/local/bin/zed".into(), + "/usr/bin/zed".into(), + ]; + for c in &candidates { + let p = PathBuf::from(c); + if p.is_file() { return Some(p); } + } + // Fall back to bare "zed" in case PATH has it + Some(PathBuf::from("zed")) +} + +fn open_in_zed(path: &Path) -> Result<(), String> { + let zed = find_zed().unwrap_or_else(|| PathBuf::from("zed")); + let mut child = Command::new(&zed) + .arg(path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("Failed to launch Zed ({}): {e}", zed.display()))?; + + // Reap the child so it doesn't accumulate as a zombie. When zed is already + // running, the spawned `zed` process hands the path off and exits within + // milliseconds; this thread then returns. When it's a cold launch, the + // thread parks until the user quits zed. + std::thread::spawn(move || { + let _ = child.wait(); + }); + Ok(()) +} + +// --------------------------------------------------------------------------- +// /open handler +// --------------------------------------------------------------------------- + +fn handle_open(stream: &mut TcpStream, req: &Request) { + let config = match load_config() { + Ok(c) => c, + Err(e) => { + return send_html(stream, 500, "Internal Server Error", &html_page( + "⚠️ Config Error", + &format!("{e}

\ + Create ~/.config/zed-bridge/config.json"), + false, + )); + } + }; + + // Optional origin guard + if let Some(ref allowed) = config.allowed_origin { + let origin = req.origin.as_deref().unwrap_or(""); + if !origin.starts_with(allowed.as_str()) { + return send_html(stream, 403, "Forbidden", &html_page( + "🚫 Forbidden", "Origin not allowed.", false, + )); + } + } + + macro_rules! require_param { + ($name:expr) => { + match req.query.get($name) { + Some(v) => v.clone(), + None => return send_html(stream, 400, "Bad Request", &html_page( + "⚠️ Bad Request", + &format!("Missing {} parameter.", $name), + false, + )), + } + }; + } + + let repo = require_param!("repo"); + let file_rel = require_param!("file"); + + let repo_path = match config.resolve_repo(&repo) { + Some(p) => p, + None => return send_html(stream, 404, "Not Found", &html_page( + "⚠️ Unknown Repo", + &format!( + "Repo {repo} not found.
\ + Add it to repos or ensure it exists under a repo_dirs path \ + in ~/.config/zed-bridge/config.json" + ), + false, + )), + }; + let local_file = repo_path.join(&file_rel); + + let canon_repo = match repo_path.canonicalize() { + Ok(p) => p, + Err(_) => return send_html(stream, 404, "Not Found", &html_page( + "⚠️ Repo Path Missing", + &format!("Path not found: {}", repo_path.display()), + false, + )), + }; + + let canon_file = match local_file.canonicalize() { + Ok(p) => p, + Err(_) => return send_html(stream, 404, "Not Found", &html_page( + "⚠️ File Not Found", + &format!("Not found locally:
{}
", + local_file.display()), + false, + )), + }; + + // Path-traversal guard + if !canon_file.starts_with(&canon_repo) { + return send_html(stream, 403, "Forbidden", &html_page( + "🚫 Forbidden", "Path traversal blocked.", false, + )); + } + + // Always open in Zed first + if let Err(e) = open_in_zed(&canon_file) { + return send_html(stream, 500, "Internal Server Error", &html_page( + "⚠️ Launch Failed", &format!("{e}"), false, + )); + } + + // Determine file & repo git status + let mut auto_close = true; + let mut messages: Vec = vec![ + format!("

Opened in Zed:

{}

", canon_file.display()), + ]; + + if config.check_git { + let file_dirty = check_file_dirty(&canon_repo, &file_rel); + let git = check_git_staleness(&canon_repo); + + if file_dirty { + auto_close = false; + messages.push( + "

This file has local modifications.

".into() + ); + } + + if let Some(ref e) = git.error { + eprintln!("[zed-bridge] git check: {e}"); + } else { + if git.behind > 0 { + auto_close = false; + let pull_url = format!("/pull?repo={}", url_encode(&repo)); + messages.push(format!( + "

Repo is {} commit(s) behind origin \ + (as of last fetch).

\ +

", + git.behind + )); + } + if git.ahead > 0 { + messages.push(format!( + "

Repo is {} commit(s) ahead of origin.

", + git.ahead + )); + } + } + } + + let body = messages.join("\n"); + send_html(stream, 200, "OK", &html_page("Opening in Zed…", &body, auto_close)); +} + +// --------------------------------------------------------------------------- +// /pull handler +// --------------------------------------------------------------------------- + +fn handle_pull(stream: &mut TcpStream, req: &Request) { + let config = match load_config() { + Ok(c) => c, + Err(e) => { + return send_json(stream, 500, &format!(r#"{{"ok":false,"output":"Config error: {e}"}}"#)); + } + }; + + let repo = match req.query.get("repo") { + Some(v) => v.clone(), + None => return send_json(stream, 400, r#"{"ok":false,"output":"Missing repo param"}"#), + }; + + let repo_path = match config.resolve_repo(&repo) { + Some(p) => p, + None => return send_json(stream, 404, r#"{"ok":false,"output":"Unknown repo"}"#), + }; + + let canon_repo = match repo_path.canonicalize() { + Ok(p) => p, + Err(_) => return send_json(stream, 404, r#"{"ok":false,"output":"Repo path not found"}"#), + }; + + let (ok, output) = git_pull(&canon_repo); + let escaped = output.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n"); + send_json(stream, 200, &format!(r#"{{"ok":{ok},"output":"{escaped}"}}"#)); +} + +fn send_json(stream: &mut TcpStream, status: u16, body: &str) { + let status_text = match status { + 200 => "OK", 400 => "Bad Request", 404 => "Not Found", _ => "Error" + }; + let response = format!( + "HTTP/1.1 {status} {status_text}\r\n\ + Content-Type: application/json; charset=utf-8\r\n\ + Access-Control-Allow-Origin: *\r\n\ + Connection: close\r\n\ + Content-Length: {len}\r\n\r\n{body}", + len = body.len() + ); + let _ = stream.write_all(response.as_bytes()); +} + +// --------------------------------------------------------------------------- +// Connection dispatch +// --------------------------------------------------------------------------- + +fn handle_connection(mut stream: TcpStream) { + let _ = stream.set_read_timeout(Some(Duration::from_secs(5))); + let req = match parse_request(&stream) { + Some(r) => r, + None => return, + }; + + let origin = req.origin.as_deref().unwrap_or("-"); + let repo = req.query.get("repo").map(|s| s.as_str()).unwrap_or("-"); + let file = req.query.get("file").map(|s| s.as_str()).unwrap_or("-"); + + match (req.method.as_str(), req.path.as_str()) { + ("OPTIONS", _) => { + eprintln!("[zed-bridge] OPTIONS from {origin}"); + let _ = stream.write_all( + b"HTTP/1.1 204 No Content\r\n\ + Access-Control-Allow-Origin: *\r\n\ + Access-Control-Allow-Methods: GET, OPTIONS\r\n\ + Connection: close\r\n\r\n", + ); + } + ("GET", "/open") => { + eprintln!("[zed-bridge] /open repo={repo} file={file} from {origin}"); + handle_open(&mut stream, &req); + } + ("GET", "/pull") => { + eprintln!("[zed-bridge] /pull repo={repo} from {origin}"); + handle_pull(&mut stream, &req); + } + ("GET", "/health") => { + eprintln!("[zed-bridge] /health from {origin}"); + let body = r#"{"status":"ok","service":"zed-bridge"}"#; + let _ = stream.write_all(format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ + Access-Control-Allow-Origin: *\r\n\ + Connection: close\r\nContent-Length: {}\r\n\r\n{}", + body.len(), body + ).as_bytes()); + } + _ => { + eprintln!("[zed-bridge] {} {} from {origin} (404)", req.method, req.path); + send_html(&mut stream, 404, "Not Found", + &html_page("404", "Endpoint not found.", false)); + } + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +fn main() { + let port = load_config().map(|c| c.port).unwrap_or_else(|e| { + eprintln!("[zed-bridge] {e}"); + eprintln!("[zed-bridge] Using default port 7654. Create {} to configure.", + config_path().display()); + 7654 + }); + + let addr = format!("localhost:{port}"); + let listener = TcpListener::bind(&addr).unwrap_or_else(|e| { + eprintln!("[zed-bridge] Cannot bind {addr}: {e}"); + std::process::exit(1); + }); + + let local_addr = listener.local_addr().map(|a| a.to_string()).unwrap_or(addr.clone()); + eprintln!("[zed-bridge] Listening on http://{local_addr}"); + eprintln!("[zed-bridge] Config: {}", config_path().display()); + + for stream in listener.incoming() { + match stream { + Ok(s) => { std::thread::spawn(move || handle_connection(s)); } + Err(e) => eprintln!("[zed-bridge] Accept error: {e}"), + } + } +} diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..7fdba9b --- /dev/null +++ b/test.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +set -euo pipefail + +PORT=17654 +BASE="http://127.0.0.1:$PORT" +PID="" +TMPDIR=$(mktemp -d) + +cleanup() { + if [[ -n "$PID" ]]; then + kill "$PID" 2>/dev/null || true + wait "$PID" 2>/dev/null || true + fi + rm -rf "$TMPDIR" +} +trap cleanup EXIT + +# -- Build ---------------------------------------------------------------- +echo "==> Building..." +cargo build --quiet + +# -- Set up temp config & test repo (never touches ~/.config) ------------- +TEST_REPO="$TMPDIR/repos/test-repo" +mkdir -p "$TEST_REPO" +echo "hello" > "$TEST_REPO/README.md" +git -C "$TEST_REPO" init -q +git -C "$TEST_REPO" add -A +git -C "$TEST_REPO" commit -q -m "init" + +TEST_CONFIG="$TMPDIR/config.json" +cat > "$TEST_CONFIG" < Starting zed-bridge on port $PORT..." +ZED_BRIDGE_CONFIG="$TEST_CONFIG" ./target/debug/zed-bridge & +PID=$! +sleep 0.3 + +if ! kill -0 "$PID" 2>/dev/null; then + echo "FAIL: server did not start" + exit 1 +fi + +# -- Test helpers --------------------------------------------------------- +PASS=0 +FAIL=0 + +check() { + local label="$1" url="$2" expect="$3" + local status + status=$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 -0 "$url" 2>/dev/null || echo "000") + if [[ "$status" == "$expect" ]]; then + echo " PASS $label (HTTP $status)" + PASS=$((PASS + 1)) + else + echo " FAIL $label expected $expect got $status" + FAIL=$((FAIL + 1)) + fi +} + +check_body() { + local label="$1" url="$2" pattern="$3" + local body + body=$(curl -s --max-time 3 -0 "$url" 2>/dev/null || true) + if echo "$body" | grep -q "$pattern"; then + echo " PASS $label (body contains '$pattern')" + PASS=$((PASS + 1)) + else + echo " FAIL $label body missing '$pattern'" + echo " got: ${body:0:500}" + FAIL=$((FAIL + 1)) + fi +} + +# -- Tests ---------------------------------------------------------------- +echo "==> Running tests..." + +check "health endpoint" \ + "$BASE/health" 200 + +check_body "health JSON" \ + "$BASE/health" '"status":"ok"' + +check "unknown endpoint" \ + "$BASE/nope" 404 + +check "missing params" \ + "$BASE/open" 400 + +check "missing file param" \ + "$BASE/open?repo=test-repo" 400 + +check "unknown repo" \ + "$BASE/open?repo=nonexistent&file=x" 404 + +# repo_dirs auto-detection +check "repo_dirs resolve (test-repo)" \ + "$BASE/open?repo=test-repo&file=README.md" 200 + +# explicit repos entry +check "explicit repos entry" \ + "$BASE/open?repo=explicit&file=README.md" 200 + +# file not found within valid repo +check "file not found" \ + "$BASE/open?repo=test-repo&file=nope.txt" 404 + +# path traversal — returns 404 (file doesn't exist) or 403 (file exists but outside repo) +check "path traversal blocked" \ + "$BASE/open?repo=test-repo&file=../../../etc/passwd" 404 + +# clean file → auto-close +check_body "clean file auto-closes" \ + "$BASE/open?repo=test-repo&file=README.md" "close automatically" + +# dirty file → stays open with warning +echo "modified" >> "$TEST_REPO/README.md" +check_body "dirty file warns" \ + "$BASE/open?repo=test-repo&file=README.md" "local modifications" + +# pull endpoint — missing repo +check "pull missing repo" \ + "$BASE/pull" 400 + +# pull endpoint — unknown repo +check_body "pull unknown repo" \ + "$BASE/pull?repo=nonexistent" "Unknown repo" + +# pull endpoint — valid repo (no remote, will fail but returns JSON) +check_body "pull returns JSON" \ + "$BASE/pull?repo=test-repo" '"ok"' + +# -- Summary -------------------------------------------------------------- +echo "" +echo "==> $PASS passed, $FAIL failed" +[[ "$FAIL" -eq 0 ]] diff --git a/zed-bridge.service b/zed-bridge.service new file mode 100644 index 0000000..cd74dc0 --- /dev/null +++ b/zed-bridge.service @@ -0,0 +1,22 @@ +[Unit] +Description=Zed Bridge — open remote files locally in Zed editor +Documentation=https://github.com/scottp/zed-bridge +After=graphical-session.target + +[Service] +Type=simple +ExecStart=%h/.local/bin/zed-bridge +Restart=on-failure +RestartSec=5 + +# Pass through the display environment so Zed can open windows +Environment=DISPLAY=:0 +Environment=WAYLAND_DISPLAY=wayland-0 + +# Log goes to journald — view with: +# journalctl --user -u zed-bridge -f +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=default.target