308 lines
8.1 KiB
Markdown
308 lines
8.1 KiB
Markdown
# 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
|