Initial public release

This commit is contained in:
2026-07-16 12:08:54 +10:00
commit 07fe81b462
12 changed files with 1554 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+107
View File
@@ -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"
+19
View File
@@ -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"
+21
View File
@@ -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.
+307
View File
@@ -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
<a href="http://localhost:7654/open?repo=mysite&file=content/posts/my-post.md"
target="_blank" rel="noopener">
✏️ Edit in Zed
</a>
```
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
<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>
```
Then use buttons anywhere:
```html
<button onclick="editInZed('mysite', 'content/posts/my-post.md')">
✏️ Edit in Zed
</button>
```
### Hugo template
In a list or single template:
```html
{{ if .File }}
<button onclick="editInZed('mysite', '{{ .File.Path }}')">✏️ Edit</button>
{{ end }}
```
To restrict to dev/local only:
```html
{{ if eq (getenv "HUGO_ENV") "development" }}
<!-- edit button -->
{{ 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
+9
View File
@@ -0,0 +1,9 @@
{
"port": 7654,
"check_git": true,
"allowed_origin": null,
"repo_dirs": ["~/repos", "~/projects"],
"repos": {
"special-repo": "~/other/special-repo"
}
}
+236
View File
@@ -0,0 +1,236 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>zed-bridge — Integration Examples</title>
<style>
* { box-sizing: border-box; }
body {
font-family: system-ui, sans-serif;
max-width: 860px;
margin: 2em auto;
padding: 0 1.5em;
background: #f8f9fa;
color: #333;
}
h1 { color: #1a1a2e; }
h2 { color: #16213e; margin-top: 2em; border-bottom: 2px solid #e0e0e0; padding-bottom: .3em; }
pre {
background: #1e1e2e;
color: #cdd6f4;
border-radius: 8px;
padding: 1.2em;
overflow-x: auto;
font-size: .9em;
}
code { font-family: 'Cascadia Code', 'Fira Mono', monospace; }
/* --- Edit button styles --- */
.edit-btn {
display: inline-flex;
align-items: center;
gap: .4em;
background: #6c8ebf;
color: #fff;
border: none;
border-radius: 5px;
padding: .35em .8em;
font-size: .85em;
cursor: pointer;
text-decoration: none;
transition: background .15s;
}
.edit-btn:hover { background: #4a6fa5; }
.edit-btn svg { width: 14px; height: 14px; fill: currentColor; }
/* --- Demo table --- */
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
th, td { text-align: left; padding: .6em .8em; border-bottom: 1px solid #ddd; }
th { background: #eef0f4; }
tr:hover { background: #f5f7ff; }
.demo-section { background: #fff; border-radius: 10px; padding: 1.5em; margin: 1em 0;
box-shadow: 0 2px 8px #0001; }
.bridge-status { font-size: .85em; color: #888; margin-top: .4em; }
.bridge-status.ok { color: #2d7a2d; }
.bridge-status.err { color: #c0392b; }
</style>
</head>
<body>
<h1>🔗 zed-bridge Integration Examples</h1>
<p>
This page shows how to add <strong>Edit in Zed</strong> buttons to your remote site.
Clicking any button below will open the file in Zed on your local workstation
(as long as <code>zed-bridge</code> is running).
</p>
<div id="bridge-check" class="bridge-status">Checking if zed-bridge is running…</div>
<!-- ===================================================================== -->
<h2>Example 1 — Simple anchor link</h2>
<p>The simplest approach. Opens a small pop-up tab that closes itself after 1.8s.</p>
<div class="demo-section">
<a class="edit-btn"
href="http://localhost:7654/open?repo=mysite&file=content/posts/hello-world.md"
target="_blank"
rel="noopener">
<!-- pencil icon -->
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zm17.71-10.21a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
Edit in Zed
</a>
</div>
<pre><code>&lt;a class="edit-btn"
href="http://localhost:7654/open?repo=mysite&amp;file=content/posts/hello-world.md"
target="_blank"
rel="noopener"&gt;
Edit in Zed
&lt;/a&gt;</code></pre>
<!-- ===================================================================== -->
<h2>Example 2 — JavaScript pop-up (cleaner UX)</h2>
<p>
Opens a small 400×160 browser pop-up that closes itself automatically.
Doesn't leave a stray tab in your tab bar.
</p>
<div class="demo-section">
<button class="edit-btn" onclick="editInZed('mysite', 'content/posts/hello-world.md')">
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zm17.71-10.21a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
Edit in Zed (pop-up)
</button>
</div>
<pre><code>&lt;script&gt;
function editInZed(repo, file) {
const url = 'http://localhost:7654/open'
+ '?repo=' + encodeURIComponent(repo)
+ '&amp;file=' + encodeURIComponent(file);
window.open(url, 'zed-bridge',
'width=420,height=160,menubar=no,toolbar=no,location=no,status=no');
}
&lt;/script&gt;
&lt;button onclick="editInZed('mysite', 'content/posts/hello-world.md')"&gt;
Edit in Zed
&lt;/button&gt;</code></pre>
<!-- ===================================================================== -->
<h2>Example 3 — Content listing table (Hugo / static site style)</h2>
<p>
A realistic file list, like you might generate from a Hugo template or a
server-side script. Each row has an edit button.
</p>
<div class="demo-section">
<table>
<thead>
<tr>
<th>File</th>
<th>Path</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr>
<td>Hello World</td>
<td><code>content/posts/hello-world.md</code></td>
<td>
<button class="edit-btn"
onclick="editInZed('mysite', 'content/posts/hello-world.md')">
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zm17.71-10.21a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
Edit
</button>
</td>
</tr>
<tr>
<td>Solar Setup Guide</td>
<td><code>content/posts/solar-setup.md</code></td>
<td>
<button class="edit-btn"
onclick="editInZed('mysite', 'content/posts/solar-setup.md')">
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zm17.71-10.21a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
Edit
</button>
</td>
</tr>
<tr>
<td>LoRa Node Config</td>
<td><code>content/docs/lora-node.md</code></td>
<td>
<button class="edit-btn"
onclick="editInZed('mysite', 'content/docs/lora-node.md')">
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zm17.71-10.21a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
Edit
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- ===================================================================== -->
<h2>Example 4 — Hugo template snippet</h2>
<p>Drop this into any Hugo list or single template. Requires <code>{{ .File.Path }}</code>.</p>
<pre><code>{{`{{ if .File }}`}}
&lt;button class="edit-btn"
onclick="editInZed('mysite', '{{`{{ .File.Path }}`}}');"&gt;
✏️ Edit in Zed
&lt;/button&gt;
{{`{{ end }}`}}</code></pre>
<p>Add the <code>editInZed()</code> function once in your base layout's <code>&lt;head&gt;</code> or footer:</p>
<pre><code>&lt;script&gt;
function editInZed(repo, file) {
var url = 'http://localhost:7654/open'
+ '?repo=' + encodeURIComponent(repo)
+ '&amp;file=' + encodeURIComponent(file);
window.open(url, 'zed-bridge',
'width=420,height=160,menubar=no,toolbar=no,location=no');
}
&lt;/script&gt;</code></pre>
<p>
<strong>Tip:</strong> Wrap the button in an environment check so it only appears
in development or when accessed from your own IP:
</p>
<pre><code>{{`{{ if eq (getenv "HUGO_ENV") "development" }}`}}
&lt;!-- edit button here --&gt;
{{`{{ end }}`}}</code></pre>
<!-- ===================================================================== -->
<script>
// --- Bridge status check ---
(function() {
var el = document.getElementById('bridge-check');
fetch('http://localhost:7654/health')
.then(function(r) { return r.json(); })
.then(function() {
el.textContent = '✅ zed-bridge is running on localhost:7654';
el.className = 'bridge-status ok';
})
.catch(function() {
el.textContent = '⚠️ zed-bridge not detected — buttons will not work until you start it.';
el.className = 'bridge-status err';
});
})();
// --- Edit in Zed helper ---
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,status=no');
}
</script>
</body>
</html>
Executable
+51
View File
@@ -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"
Executable
+16
View File
@@ -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
+620
View File
@@ -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<String, String>,
/// 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<String>,
/// 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<String>,
}
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<PathBuf> {
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<Config, String> {
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<String, String>,
origin: Option<String>,
}
fn parse_request(stream: &TcpStream) -> Option<Request> {
let mut reader = BufReader::new(stream);
let mut first_line = String::new();
reader.read_line(&mut first_line).ok()?;
let mut origin: Option<String> = 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<String, String> {
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<u8> = 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#"<script>setTimeout(()=>{try{window.close()}catch(_){}},1800);</script>"#
} else { "" };
format!(r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{title}</title>
<style>
*{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:system-ui,sans-serif;background:#1e1e2e;color:#cdd6f4;
display:flex;align-items:center;justify-content:center;min-height:100vh}}
.card{{background:#313244;border-radius:12px;padding:2em 2.5em;max-width:520px;
width:90%;box-shadow:0 4px 32px #0009;text-align:center}}
h2{{margin-bottom:1em;font-size:1.3em}}
p{{margin:.6em 0}}
code{{background:#45475a;border-radius:4px;padding:2px 7px;
font-size:.88em;word-break:break-all}}
.warn{{color:#f9e2af}} .ok{{color:#a6e3a1}} .err{{color:#f38ba8}}
.pull-btn{{background:#89b4fa;color:#1e1e2e;border:none;border-radius:6px;
padding:.5em 1.2em;font-size:.95em;cursor:pointer;margin-top:.4em;
font-weight:600;transition:background .15s}}
.pull-btn:hover{{background:#74c7ec}}
.pull-btn:disabled{{opacity:.5;cursor:default}}
.pull-result{{margin-top:.6em;font-size:.85em;white-space:pre-wrap;
text-align:left;background:#45475a;border-radius:6px;padding:.6em;
max-height:10em;overflow:auto}}
.hint{{font-size:.75em;opacity:.4;margin-top:1.4em}}
</style>
</head>
<body>
<div class="card">
<h2>{title}</h2>
<div>{message}</div>
<p class="hint">{hint}</p>
</div>
{close_script}
<script>
function doPull(url) {{
var btn = event.target;
btn.disabled = true;
btn.textContent = 'Pulling…';
fetch('http://localhost:7654' + url)
.then(function(r) {{ return r.json(); }})
.then(function(d) {{
var el = document.createElement('div');
el.className = 'pull-result ' + (d.ok ? 'ok' : 'err');
el.textContent = d.output;
btn.parentNode.appendChild(el);
btn.textContent = d.ok ? 'Pulled!' : 'Failed';
if (d.ok) {{ setTimeout(function(){{ try{{window.close()}}catch(_){{}} }}, 2000); }}
}})
.catch(function() {{
btn.textContent = 'Error';
}});
}}
</script>
</body>
</html>"#,
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<String> }
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<PathBuf> {
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!("<span class='err'>{e}</span><br><br>\
Create <code>~/.config/zed-bridge/config.json</code>"),
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", "<span class='err'>Origin not allowed.</span>", 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!("<span class='err'>Missing <code>{}</code> parameter.</span>", $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!(
"<span class='err'>Repo <code>{repo}</code> not found.</span><br>\
Add it to <code>repos</code> or ensure it exists under a <code>repo_dirs</code> path \
in <code>~/.config/zed-bridge/config.json</code>"
),
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!("<span class='err'>Path not found: <code>{}</code></span>", 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!("<span class='err'>Not found locally:<br><code>{}</code></span>",
local_file.display()),
false,
)),
};
// Path-traversal guard
if !canon_file.starts_with(&canon_repo) {
return send_html(stream, 403, "Forbidden", &html_page(
"🚫 Forbidden", "<span class='err'>Path traversal blocked.</span>", 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!("<span class='err'>{e}</span>"), false,
));
}
// Determine file & repo git status
let mut auto_close = true;
let mut messages: Vec<String> = vec![
format!("<p class='ok'>Opened in Zed:</p><p><code>{}</code></p>", 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(
"<p class='warn'>This file has local modifications.</p>".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!(
"<p class='warn'>Repo is <strong>{}</strong> commit(s) behind origin \
(as of last fetch).</p>\
<p><button class='pull-btn' onclick=\"doPull('{pull_url}')\">git pull</button></p>",
git.behind
));
}
if git.ahead > 0 {
messages.push(format!(
"<p class='warn'>Repo is <strong>{}</strong> commit(s) ahead of origin.</p>",
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", "<span class='err'>Endpoint not found.</span>", 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}"),
}
}
}
Executable
+145
View File
@@ -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" <<EOF
{
"port": $PORT,
"check_git": true,
"repo_dirs": ["$TMPDIR/repos"],
"repos": {
"explicit": "$TEST_REPO"
}
}
EOF
# -- Start server ---------------------------------------------------------
echo "==> 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 ]]
+22
View File
@@ -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