Initial public release
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+107
@@ -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
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"port": 7654,
|
||||
"check_git": true,
|
||||
"allowed_origin": null,
|
||||
"repo_dirs": ["~/repos", "~/projects"],
|
||||
"repos": {
|
||||
"special-repo": "~/other/special-repo"
|
||||
}
|
||||
}
|
||||
@@ -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><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></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><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></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 }}`}}
|
||||
<button class="edit-btn"
|
||||
onclick="editInZed('mysite', '{{`{{ .File.Path }}`}}');">
|
||||
✏️ Edit in Zed
|
||||
</button>
|
||||
{{`{{ end }}`}}</code></pre>
|
||||
|
||||
<p>Add the <code>editInZed()</code> function once in your base layout's <code><head></code> or footer:</p>
|
||||
|
||||
<pre><code><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></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" }}`}}
|
||||
<!-- edit button here -->
|
||||
{{`{{ 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
@@ -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"
|
||||
@@ -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
@@ -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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ]]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user