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