diff --git a/Cargo.lock b/Cargo.lock
index 4db409c..3f49ee9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -47,7 +47,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -58,7 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -67,6 +67,70 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+[[package]]
+name = "askama"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4"
+dependencies = [
+ "askama_derive",
+ "itoa",
+ "percent-encoding",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "askama_derive"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f"
+dependencies = [
+ "askama_parser",
+ "basic-toml",
+ "memchr",
+ "proc-macro2",
+ "quote",
+ "rustc-hash",
+ "serde",
+ "serde_derive",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "askama_parser"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358"
+dependencies = [
+ "memchr",
+ "serde",
+ "serde_derive",
+ "winnow",
+]
+
+[[package]]
+name = "async-stream"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
+dependencies = [
+ "async-stream-impl",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-stream-impl"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -125,6 +189,15 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "basic-toml"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "bitflags"
version = "2.13.2"
@@ -195,7 +268,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -205,7 +278,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -217,6 +290,15 @@ dependencies = [
"percent-encoding",
]
+[[package]]
+name = "fsevent-sys"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "futures-core"
version = "0.3.34"
@@ -338,6 +420,26 @@ dependencies = [
"tower-service",
]
+[[package]]
+name = "inotify"
+version = "0.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592"
+dependencies = [
+ "bitflags",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -350,6 +452,26 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+[[package]]
+name = "kqueue"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea"
+dependencies = [
+ "kqueue-sys",
+ "libc",
+]
+
+[[package]]
+name = "kqueue-sys"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
+dependencies = [
+ "bitflags",
+ "libc",
+]
+
[[package]]
name = "lazy_static"
version = "1.5.1"
@@ -421,8 +543,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
+ "log",
"wasi",
- "windows-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "notify"
+version = "8.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
+dependencies = [
+ "bitflags",
+ "fsevent-sys",
+ "inotify",
+ "kqueue",
+ "libc",
+ "log",
+ "mio",
+ "notify-types",
+ "walkdir",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "notify-types"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
+dependencies = [
+ "bitflags",
]
[[package]]
@@ -431,7 +581,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -545,6 +695,12 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
[[package]]
name = "ryu"
version = "1.0.23"
@@ -670,7 +826,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -691,8 +847,11 @@ name = "stash-core"
version = "0.1.0"
dependencies = [
"anyhow",
+ "askama",
+ "async-stream",
"axum",
"mime_guess",
+ "notify",
"serde",
"serde_json",
"thiserror",
@@ -780,7 +939,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -984,7 +1143,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -993,6 +1152,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets",
+]
+
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -1002,6 +1170,80 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "zmij"
version = "1.0.23"
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..123903d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,255 @@
+# Stash
+
+Stash 是一个基于 Rust、Tokio 和 Axum 实现的本地静态页面托管工具。它既可以直接托管一个静态站点,也可以扫描托管目录下的多个前端项目,并通过内置导航页统一访问。
+
+## 核心功能
+
+### 单站点托管
+
+当托管根目录中存在 `index.html` 时,访问 `/` 会直接返回该页面。页面依赖的 JavaScript、CSS、图片、字体等资源继续通过静态文件服务加载,因此可以直接运行常见的纯静态前端项目。
+
+### 多项目导航
+
+当根目录没有 `index.html` 时,Stash 会扫描根目录下的一级子目录,并将包含 `index.html` 或 `index.htm` 的目录识别为前端项目。
+
+- 自动排除以 `.` 开头的隐藏目录。
+- 项目按名称进行不区分大小写的升序排列。
+- 每个项目生成一个卡片链接,地址格式为 `/<项目目录名>/`。
+- 项目名称首字符会作为圆形标记显示。
+- 没有识别到项目时显示空状态提示。
+- 导航页采用 4 列、2 列、1 列的响应式布局,分别适配桌面、平板和手机。
+- 项目和目录列表在每次请求时重新扫描,新增项目通常无需重启服务。
+- 文件监听会在项目快照发生变化后主动通知已打开的导航页,无需手动刷新。
+
+### 静态文件服务
+
+Stash 使用 `tower-http` 的 `ServeDir` 托管根目录内容:
+
+- 自动为目录追加 `index.html`。
+- 根据文件扩展名返回对应的 MIME 类型。
+- 未找到的路径会返回内置的中文 404 页面,并显示请求路径。
+- 每次 HTTP 请求都会经过 Trace 层,可通过调整日志级别观察请求日志。
+- 内置控制台资源通过保留路径 `/__stash__/` 提供,避免与用户托管的项目文件冲突。
+
+### 内置控制台
+
+- 使用 Askama 在编译期渲染项目导航页和 404 页面。
+- 页面 HTML、CSS 和 JavaScript 分别维护,不再使用字符串替换拼接卡片。
+- 项目名称和请求路径由模板引擎自动进行 HTML 转义。
+- 项目目录名会进行 URL 路径段编码,再生成项目访问地址。
+- 少量 JavaScript 通过 SSE 订阅项目变化,并在不刷新整个页面的情况下更新卡片;脚本不可用时,服务端渲染的导航和手动 API 仍可正常使用。
+
+### 同源 API
+
+服务提供两个只读 JSON 接口和一个 SSE 推送接口,可供导航页或同源前端调用。
+
+### CORS
+
+默认允许任意来源、任意请求方法和任意请求头访问服务,适合本地联调。通过 `--no-cors` 可以关闭 CORS 标头注入。
+
+### 服务管理
+
+- 默认监听 `127.0.0.1:8080`。
+- 支持自定义绑定地址、端口和托管目录。
+- 默认托管目录 `./public` 不存在时会自动创建。
+- 用户显式指定的托管目录不存在时,会输出错误并退出。
+- 端口绑定失败、目录无效等错误会以清晰的错误信息返回。
+- 支持分别通过 `Ctrl+C` 以及 Unix 系统的 `SIGTERM` 触发优雅停机。
+
+### 可复用核心库
+
+`stash-core` 同时作为库导出 `Config`、`StashError`、`start`、`ServerHandle`、`scan_projects` 和 `AppProject`,既可由 `stash-cli` 使用,也可以被其他 Rust 程序嵌入或复用。
+
+## 快速开始
+
+### 环境要求
+
+- Rust 工具链,需支持 Rust 2024 Edition。
+- Cargo。
+
+### 启动示例
+
+```bash
+# 托管默认目录 ./public,监听 127.0.0.1:8080
+cargo run -p stash-cli
+
+# 托管指定目录并修改端口
+cargo run -p stash-cli -- --root ./sites --port 3000
+
+# 允许局域网设备访问
+cargo run -p stash-cli -- --root ./sites --host 0.0.0.0 --port 3000
+
+# 关闭 CORS
+cargo run -p stash-cli -- --root ./sites --no-cors
+```
+
+启动成功后访问终端中显示的地址即可,例如:
+
+```text
+http://127.0.0.1:8080/
+```
+
+### 构建发布版本
+
+```bash
+cargo build --release -p stash-cli
+```
+
+构建产物为 `target/release/stash`;Windows 下为 `target/release/stash.exe`。
+
+## 托管目录示例
+
+### 单个静态站点
+
+```text
+public/
+├── index.html
+├── assets/
+│ ├── app.js
+│ └── style.css
+└── images/
+ └── logo.png
+```
+
+此时访问 `/` 会直接展示 `public/index.html`。
+
+### 多个前端项目
+
+```text
+public/
+├── admin-console/
+│ └── index.html
+├── docs/
+│ └── index.html
+└── landing-page/
+ └── index.html
+```
+
+由于根目录没有 `index.html`,访问 `/` 时会展示项目导航页。对应访问地址为:
+
+```text
+/admin-console/
+/docs/
+/landing-page/
+```
+
+## 命令行参数
+
+| 参数 | 默认值 | 说明 |
+| --- | --- | --- |
+| `-r, --root
` | `./public` | 静态页面托管根目录,支持相对路径和绝对路径 |
+| `-p, --port ` | `8080` | HTTP 服务监听端口 |
+| `--host ` | `127.0.0.1` | 监听地址;使用 `0.0.0.0` 可供局域网访问 |
+| `--no-listing` | 关闭 | 设计用于关闭根目录项目导航页 |
+| `--no-cors` | 关闭 | 关闭全局 CORS 标头注入 |
+| `-h, --help` | - | 查看命令行帮助 |
+| `-V, --version` | - | 查看程序版本 |
+
+注意:`--no-listing` 当前已由命令行解析并写入 `Config.directory_listing`,但根路径处理器尚未读取该配置。因此根目录没有 `index.html` 时,导航页目前仍会显示。
+
+## HTTP API
+
+### 健康检查
+
+```http
+GET /api/health
+```
+
+响应:
+
+```json
+{
+ "status": "ok",
+ "service": "stash"
+}
+```
+
+### 获取项目列表
+
+```http
+GET /api/projects
+```
+
+响应示例:
+
+```json
+{
+ "projects": [
+ {
+ "name": "admin-console",
+ "route_path": "/admin-console/"
+ },
+ {
+ "name": "docs",
+ "route_path": "/docs/"
+ }
+ ]
+}
+```
+
+该接口与导航页使用相同的项目扫描规则。即使托管根目录包含 `index.html`,接口仍会返回当前根目录下可识别的项目列表。
+
+### 订阅项目变化
+
+```http
+GET /api/projects/events
+Accept: text/event-stream
+```
+
+连接建立后,服务会立即发送当前项目快照,随后在文件监听检测到项目列表变化时推送新快照:
+
+```text
+event: projects
+data: {"projects":[{"name":"docs","route_path":"/docs/"}]}
+```
+
+服务端会递归监听托管目录,将短时间内的连续文件事件合并为一次扫描,并且只在项目名称或访问路径实际变化时广播。SSE 连接每 15 秒发送一次保活消息,浏览器断开后会自动重连。
+
+## 项目结构
+
+```text
+.
+├── Cargo.toml # Rust workspace 配置
+├── Cargo.lock # 依赖锁定文件
+├── README.md
+├── stash-cli/
+│ ├── Cargo.toml
+│ └── src/
+│ └── main.rs # CLI 参数解析、启动信息和停机信号
+└── stash-core/
+ ├── Cargo.toml
+ ├── assets/
+ │ ├── dashboard.css # 控制台与 404 页面样式
+ │ └── dashboard.js # 项目列表刷新增强
+ ├── src/
+ │ ├── config.rs # 服务配置
+ │ ├── error.rs # 错误类型与 HTTP 错误响应
+ │ ├── lib.rs # 核心库导出
+ │ ├── listing.rs # 项目扫描与页面渲染
+ │ ├── server.rs # Axum 路由、SSE、静态托管与服务器生命周期
+ │ └── watcher.rs # 文件监听、事件防抖和项目快照广播
+ └── templates/
+ ├── index.html # Askama 多项目导航页模板
+ └── 404.html # Askama 404 页面模板
+```
+
+## 测试
+
+运行核心库测试:
+
+```bash
+cargo test -p stash-core
+```
+
+当前测试覆盖项目目录扫描、隐藏目录过滤、一级目录限制、项目排序、项目路径编码、空状态渲染、项目内容 HTML 转义、404 页面渲染与路径转义,以及新增项目后的文件监听广播。
+
+## 已知限制
+
+- 项目扫描只检查托管根目录的一级子目录,不递归查找更深层项目。
+- 根目录存在 `index.html` 时会优先进入单站点模式,不再展示项目导航页。
+- 静态服务只为目录自动追加 `index.html`;导航扫描虽然也识别 `index.htm`,但建议项目统一使用 `index.html`。
+- 未配置 SPA History API 回退。刷新前端路由等不存在的物理路径时,会返回 404 页面。
+- SSE 仅用于服务端向浏览器推送项目快照,不提供浏览器向服务端发送命令的双向通道。
+- 文件事件会经过约 150ms 防抖后再扫描,因此项目出现在导航页中可能有短暂延迟。
+- 启动横幅中显示的接口地址为 `/api/pages`,实际实现的项目列表接口是 `/api/projects`。
+- 服务没有身份认证机制,默认仅建议绑定 `127.0.0.1`。使用 `0.0.0.0` 暴露到局域网前,应确认目录内容适合被其他设备访问。
diff --git a/stash-core/Cargo.toml b/stash-core/Cargo.toml
index cdcb6db..022b009 100644
--- a/stash-core/Cargo.toml
+++ b/stash-core/Cargo.toml
@@ -17,4 +17,7 @@ tower = { version = "0.4", features = ["util"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
walkdir = "2.4"
-mime_guess = "2.0"
\ No newline at end of file
+mime_guess = "2.0"
+askama = "0.14"
+async-stream = "0.3"
+notify = "8"
diff --git a/stash-core/assets/404.html b/stash-core/assets/404.html
deleted file mode 100644
index a5135a8..0000000
--- a/stash-core/assets/404.html
+++ /dev/null
@@ -1,86 +0,0 @@
-
-
-
-
-
- 404 - 资源未找到
-
-
-
-
-
404
-
资源或页面不存在
-
请求的路径 __PATH__ 不存在或缺少 index.html
-
返回应用大厅
-
-
-
\ No newline at end of file
diff --git a/stash-core/assets/dashboard.css b/stash-core/assets/dashboard.css
new file mode 100644
index 0000000..f7c67c8
--- /dev/null
+++ b/stash-core/assets/dashboard.css
@@ -0,0 +1,1018 @@
+:root {
+ --bg: #f7f8fa;
+ --surface: #ffffff;
+ --surface-soft: #fbfbfd;
+ --ink: #1d2129;
+ --text: #2c343c;
+ --text-secondary: #4e5969;
+ --muted: #86909c;
+ --faint: #c9cdd4;
+ --border: #eef0f3;
+ --border-strong: #e6e8ec;
+ --primary: #3858f6;
+ --primary-dark: #2947db;
+ --primary-soft: #eef1ff;
+ --primary-wash: rgba(56, 88, 246, 0.09);
+ --warm: #ffba3a;
+ --warm-soft: #fff7df;
+ --success: #4abd86;
+ --danger: #ff3e3e;
+ --shadow-card: 0 12px 38px rgba(27, 26, 49, 0.045);
+ --shadow-hover: 0 20px 52px rgba(27, 26, 49, 0.09);
+ --radius-sm: 10px;
+ --radius-md: 16px;
+ --radius-lg: 24px;
+ --ease: cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html {
+ min-width: 320px;
+ background: var(--bg);
+}
+
+body {
+ position: relative;
+ min-height: 100vh;
+ overflow-x: hidden;
+ background:
+ radial-gradient(circle at -5% 0%, rgba(255, 186, 58, 0.12), transparent 28rem),
+ radial-gradient(circle at 104% 7%, rgba(56, 88, 246, 0.1), transparent 34rem),
+ var(--bg);
+ color: var(--text);
+ font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+}
+
+a {
+ color: inherit;
+}
+
+button,
+input {
+ font: inherit;
+}
+
+.ambient {
+ position: fixed;
+ z-index: -1;
+ border-radius: 999px;
+ pointer-events: none;
+ filter: blur(1px);
+}
+
+.ambient-blue {
+ top: -120px;
+ right: 8%;
+ width: 360px;
+ height: 360px;
+ border: 1px solid rgba(56, 88, 246, 0.07);
+ background: rgba(56, 88, 246, 0.035);
+}
+
+.ambient-warm {
+ bottom: -170px;
+ left: -130px;
+ width: 320px;
+ height: 320px;
+ background: rgba(255, 186, 58, 0.045);
+}
+
+.workspace {
+ display: grid;
+ grid-template-columns: 286px minmax(0, 1fr);
+ gap: 28px;
+ width: min(1320px, calc(100% - 64px));
+ min-width: 0;
+ margin: 0 auto;
+ padding: 32px 0;
+}
+
+.sidebar {
+ position: sticky;
+ top: 32px;
+ display: flex;
+ flex-direction: column;
+ align-self: start;
+ min-width: 0;
+ min-height: calc(100vh - 64px);
+ padding: 26px;
+ border: 1px solid rgba(255, 255, 255, 0.9);
+ border-radius: var(--radius-lg);
+ background: rgba(255, 255, 255, 0.96);
+ box-shadow: var(--shadow-card);
+ backdrop-filter: blur(16px);
+}
+
+.sidebar-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--ink);
+ text-decoration: none;
+}
+
+.brand-mark {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 38px;
+ height: 38px;
+ border-radius: 12px;
+ background: var(--ink);
+ color: #ffffff;
+ box-shadow: 0 8px 20px rgba(29, 33, 41, 0.16);
+}
+
+.brand-name {
+ font-size: 23px;
+ font-weight: 750;
+ letter-spacing: -1.1px;
+}
+
+.brand-name span {
+ color: var(--primary);
+}
+
+.local-badge {
+ padding: 6px 9px;
+ border-radius: 999px;
+ background: var(--primary-soft);
+ color: var(--primary);
+ font-size: 9px;
+ font-weight: 800;
+ letter-spacing: 1.4px;
+}
+
+.sidebar-intro {
+ margin-top: 58px;
+}
+
+.eyebrow {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 750;
+ letter-spacing: 1.8px;
+ line-height: 1.4;
+ text-transform: uppercase;
+}
+
+.eyebrow-dot {
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--warm);
+ box-shadow: 0 0 0 4px var(--warm-soft);
+}
+
+.sidebar-intro h1 {
+ margin-top: 16px;
+ color: var(--ink);
+ font-size: clamp(34px, 3vw, 42px);
+ font-weight: 760;
+ letter-spacing: -2px;
+ line-height: 1.16;
+}
+
+.sidebar-lead {
+ margin-top: 18px;
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.85;
+}
+
+.overview-card {
+ min-width: 0;
+ margin-top: 34px;
+ padding: 19px 20px 18px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background:
+ radial-gradient(circle at 100% 0%, rgba(56, 88, 246, 0.09), transparent 9rem),
+ var(--surface-soft);
+}
+
+.overview-label {
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.project-total {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ margin-top: 8px;
+}
+
+.project-total strong {
+ color: var(--primary);
+ font-size: 52px;
+ font-weight: 760;
+ letter-spacing: -3px;
+ line-height: 1;
+}
+
+.project-total span {
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.service-status {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ margin-top: 16px;
+ padding-top: 14px;
+ border-top: 1px solid var(--border);
+ color: var(--text-secondary);
+ font-size: 11px;
+}
+
+.status-dot {
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--success);
+ box-shadow: 0 0 0 5px rgba(74, 189, 134, 0.12);
+}
+
+.sidebar-actions {
+ margin-top: 18px;
+}
+
+.refresh-button {
+ align-items: center;
+ justify-content: center;
+ gap: 9px;
+ width: 100%;
+ padding: 12px 16px;
+ border: 0;
+ border-radius: 12px;
+ background: var(--primary);
+ box-shadow: 0 9px 20px rgba(56, 88, 246, 0.2);
+ color: #ffffff;
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+ transition: background-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s var(--ease);
+}
+
+.refresh-button:hover {
+ background: var(--primary-dark);
+ box-shadow: 0 12px 24px rgba(56, 88, 246, 0.26);
+ transform: translateY(-1px);
+}
+
+.refresh-button:focus-visible {
+ outline: 3px solid rgba(56, 88, 246, 0.24);
+ outline-offset: 3px;
+}
+
+.refresh-button:disabled {
+ cursor: wait;
+ opacity: 0.7;
+ transform: none;
+}
+
+.refresh-button.is-loading svg {
+ animation: spin 0.8s linear infinite;
+}
+
+.js-only {
+ display: none;
+}
+
+.is-enhanced .js-only {
+ display: flex;
+}
+
+.sidebar-hint {
+ margin-top: 12px;
+ color: var(--muted);
+ font-size: 10px;
+ line-height: 1.65;
+ text-align: center;
+}
+
+.sidebar-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-top: auto;
+ padding-top: 22px;
+ border-top: 1px solid var(--border);
+ color: var(--faint);
+ font-size: 8px;
+ font-weight: 750;
+ letter-spacing: 1.3px;
+}
+
+.footer-ready {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--text-secondary);
+}
+
+.footer-ready i {
+ display: inline-block;
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: var(--success);
+}
+
+.content {
+ min-width: 0;
+ padding: 13px 2px 28px;
+}
+
+.content-header {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 28px;
+ margin: 0 4px 28px;
+}
+
+.content-heading h2 {
+ margin-top: 9px;
+ color: var(--ink);
+ font-size: clamp(29px, 3vw, 36px);
+ font-weight: 760;
+ letter-spacing: -1.6px;
+ line-height: 1.15;
+}
+
+.content-heading > p:last-child {
+ margin-top: 9px;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.7;
+}
+
+.content-meta {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 8px;
+ padding-bottom: 3px;
+}
+
+.live-pill,
+.count-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ min-height: 32px;
+ padding: 7px 12px;
+ border: 1px solid rgba(255, 255, 255, 0.9);
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.84);
+ box-shadow: 0 4px 16px rgba(27, 26, 49, 0.035);
+ color: var(--text-secondary);
+ font-size: 10px;
+ font-weight: 650;
+ backdrop-filter: blur(10px);
+}
+
+.live-pill i {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--success);
+ box-shadow: 0 0 0 4px rgba(74, 189, 134, 0.11);
+}
+
+.live-pill.is-live i {
+ animation: live-pulse 2.2s ease-out infinite;
+}
+
+.live-pill.is-reconnecting i {
+ background: var(--warm);
+ box-shadow: 0 0 0 4px rgba(255, 186, 58, 0.13);
+ animation: none;
+}
+
+.count-pill strong {
+ color: var(--primary);
+ font-size: 13px;
+ font-weight: 800;
+}
+
+.site-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(255px, 1fr));
+ gap: 18px;
+}
+
+.site-item {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ min-height: 212px;
+ overflow: hidden;
+ padding: 20px;
+ border: 1px solid rgba(255, 255, 255, 0.94);
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.96);
+ box-shadow: var(--shadow-card);
+ text-decoration: none;
+ opacity: 0;
+ transform: translateY(10px);
+ animation: card-arrive 0.55s var(--ease) forwards;
+ transition: border-color 0.25s ease, box-shadow 0.25s ease, transform 0.25s var(--ease);
+}
+
+.site-item::before {
+ position: absolute;
+ top: 0;
+ right: 24px;
+ left: 24px;
+ height: 2px;
+ border-radius: 0 0 4px 4px;
+ background: linear-gradient(90deg, transparent, var(--primary), transparent);
+ content: "";
+ opacity: 0;
+ transition: opacity 0.25s ease;
+}
+
+.site-item:hover {
+ border-color: rgba(56, 88, 246, 0.16);
+ box-shadow: var(--shadow-hover);
+ transform: translateY(-4px);
+}
+
+.site-item:hover::before {
+ opacity: 1;
+}
+
+.site-item:focus-visible {
+ outline: 3px solid rgba(56, 88, 246, 0.22);
+ outline-offset: 3px;
+}
+
+.site-item:nth-child(2) {
+ animation-delay: 45ms;
+}
+
+.site-item:nth-child(3) {
+ animation-delay: 90ms;
+}
+
+.site-item:nth-child(4) {
+ animation-delay: 135ms;
+}
+
+.site-item:nth-child(5) {
+ animation-delay: 180ms;
+}
+
+.site-item:nth-child(6) {
+ animation-delay: 225ms;
+}
+
+.site-item:nth-child(n + 7) {
+ animation-delay: 270ms;
+}
+
+.site-card-top {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.site-avatar {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 48px;
+ height: 48px;
+ border: 1px solid rgba(56, 88, 246, 0.08);
+ border-radius: 15px;
+ background:
+ linear-gradient(145deg, rgba(255, 255, 255, 0.6), transparent),
+ var(--primary-soft);
+ color: var(--primary);
+ font-size: 19px;
+ font-weight: 780;
+}
+
+.site-open {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 34px;
+ height: 34px;
+ border-radius: 50%;
+ background: var(--bg);
+ color: var(--muted);
+ transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s var(--ease);
+}
+
+.site-item:hover .site-open {
+ background: var(--primary);
+ color: #ffffff;
+ transform: translate(2px, -2px);
+}
+
+.site-info {
+ margin-top: 25px;
+ min-width: 0;
+}
+
+.site-name {
+ overflow: hidden;
+ color: var(--ink);
+ font-size: 18px;
+ font-weight: 720;
+ letter-spacing: -0.45px;
+ line-height: 1.3;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.site-desc {
+ display: block;
+ margin-top: 5px;
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.site-card-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-top: auto;
+ padding-top: 17px;
+ border-top: 1px solid var(--border);
+}
+
+.site-path {
+ max-width: calc(100% - 62px);
+ overflow: hidden;
+ color: var(--text-secondary);
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
+ font-size: 9.5px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.site-ready {
+ flex-shrink: 0;
+ color: var(--primary);
+ font-size: 8px;
+ font-weight: 800;
+ letter-spacing: 1.1px;
+}
+
+.empty-state {
+ grid-column: 1 / -1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 430px;
+ padding: 54px 28px;
+ border: 1px solid rgba(255, 255, 255, 0.94);
+ border-radius: 22px;
+ background: rgba(255, 255, 255, 0.88);
+ box-shadow: var(--shadow-card);
+ text-align: center;
+ animation: card-arrive 0.5s var(--ease) both;
+}
+
+.empty-visual {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 78px;
+ height: 78px;
+ border: 1px solid rgba(56, 88, 246, 0.09);
+ border-radius: 24px;
+ background: var(--primary-soft);
+ color: var(--primary);
+ transform: rotate(-4deg);
+}
+
+.empty-state h3 {
+ margin-top: 24px;
+ color: var(--ink);
+ font-size: 20px;
+ font-weight: 730;
+ letter-spacing: -0.5px;
+}
+
+.empty-state p {
+ max-width: 410px;
+ margin-top: 9px;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.8;
+}
+
+.empty-state code {
+ padding: 2px 6px;
+ border-radius: 5px;
+ background: var(--bg);
+ color: var(--text-secondary);
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 10px;
+}
+
+.refresh-status {
+ min-height: 18px;
+ margin-top: 14px;
+ padding-right: 3px;
+ color: var(--muted);
+ font-size: 10px;
+ text-align: right;
+}
+
+.error-page {
+ display: grid;
+ place-items: center;
+ padding: 28px;
+}
+
+.error-page::before {
+ position: fixed;
+ top: 15%;
+ right: 13%;
+ width: 240px;
+ height: 240px;
+ border-radius: 50%;
+ background: rgba(56, 88, 246, 0.065);
+ content: "";
+ filter: blur(2px);
+}
+
+.error-box {
+ position: relative;
+ width: min(100%, 480px);
+ overflow: hidden;
+ padding: 46px 42px 42px;
+ border: 1px solid rgba(255, 255, 255, 0.94);
+ border-radius: 26px;
+ background: rgba(255, 255, 255, 0.96);
+ box-shadow: var(--shadow-card);
+ text-align: center;
+}
+
+.error-box::before {
+ display: block;
+ margin-bottom: 30px;
+ color: var(--muted);
+ content: "STASH · 404";
+ font-size: 9px;
+ font-weight: 800;
+ letter-spacing: 2.2px;
+}
+
+.error-box::after {
+ position: absolute;
+ top: 0;
+ right: 25%;
+ left: 25%;
+ height: 3px;
+ border-radius: 0 0 4px 4px;
+ background: linear-gradient(90deg, transparent, var(--primary), transparent);
+ content: "";
+}
+
+.error-code {
+ margin-bottom: 15px;
+ background: linear-gradient(135deg, var(--primary), #6f88ff);
+ background-clip: text;
+ color: transparent;
+ font-size: 86px;
+ font-weight: 780;
+ letter-spacing: -6px;
+ line-height: 0.92;
+}
+
+.error-box h1 {
+ color: var(--ink);
+ font-size: 23px;
+ font-weight: 740;
+ letter-spacing: -0.7px;
+}
+
+.error-box p {
+ margin-top: 12px;
+ margin-bottom: 28px;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.75;
+ overflow-wrap: anywhere;
+}
+
+.error-box code {
+ padding: 3px 7px;
+ border-radius: 6px;
+ background: var(--bg);
+ color: var(--text-secondary);
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 10px;
+}
+
+.button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 42px;
+ padding: 10px 24px;
+ border-radius: 12px;
+ background: var(--primary);
+ box-shadow: 0 9px 20px rgba(56, 88, 246, 0.2);
+ color: #ffffff;
+ font-size: 12px;
+ font-weight: 700;
+ text-decoration: none;
+ transition: background-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s var(--ease);
+}
+
+.button:hover {
+ background: var(--primary-dark);
+ box-shadow: 0 12px 24px rgba(56, 88, 246, 0.26);
+ transform: translateY(-1px);
+}
+
+.button:focus-visible {
+ outline: 3px solid rgba(56, 88, 246, 0.24);
+ outline-offset: 3px;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes card-arrive {
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes live-pulse {
+ 0%,
+ 100% {
+ box-shadow: 0 0 0 4px rgba(74, 189, 134, 0.11);
+ }
+
+ 50% {
+ box-shadow: 0 0 0 7px rgba(74, 189, 134, 0.04);
+ }
+}
+
+@media (max-width: 1080px) {
+ .workspace {
+ grid-template-columns: 252px minmax(0, 1fr);
+ gap: 22px;
+ width: min(100% - 40px, 1180px);
+ }
+
+ .sidebar {
+ padding: 22px;
+ }
+
+ .sidebar-intro {
+ margin-top: 44px;
+ }
+
+ .sidebar-intro h1 {
+ font-size: 34px;
+ }
+
+ .site-grid {
+ grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
+ gap: 15px;
+ }
+}
+
+@media (max-width: 820px) {
+ .workspace {
+ grid-template-columns: minmax(0, 1fr);
+ width: min(100% - 36px, 720px);
+ padding: 20px 0 28px;
+ }
+
+ .sidebar {
+ position: static;
+ min-height: 0;
+ padding: 22px;
+ }
+
+ .sidebar-intro {
+ margin-top: 34px;
+ }
+
+ .sidebar-intro h1 {
+ font-size: 36px;
+ }
+
+ .sidebar-lead {
+ max-width: 470px;
+ }
+
+ .overview-card {
+ margin-top: 24px;
+ }
+
+ .sidebar-actions {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ }
+
+ .refresh-button {
+ width: auto;
+ min-width: 142px;
+ flex-shrink: 0;
+ }
+
+ .sidebar-hint {
+ margin-top: 0;
+ text-align: left;
+ }
+
+ .sidebar-footer {
+ margin-top: 24px;
+ }
+
+ .content {
+ padding-top: 8px;
+ }
+}
+
+@media (max-width: 560px) {
+ body {
+ background:
+ radial-gradient(circle at 100% 0%, rgba(56, 88, 246, 0.09), transparent 23rem),
+ var(--bg);
+ }
+
+ .workspace {
+ width: calc(100% - 24px);
+ padding-top: 12px;
+ }
+
+ .sidebar {
+ padding: 18px;
+ border-radius: 20px;
+ }
+
+ .brand-mark {
+ width: 35px;
+ height: 35px;
+ border-radius: 11px;
+ }
+
+ .brand-name {
+ font-size: 21px;
+ }
+
+ .sidebar-intro {
+ margin-top: 24px;
+ }
+
+ .sidebar-intro h1 {
+ margin-top: 12px;
+ font-size: 29px;
+ letter-spacing: -1.4px;
+ }
+
+ .sidebar-intro h1 br {
+ display: none;
+ }
+
+ .sidebar-lead {
+ display: -webkit-box;
+ margin-top: 11px;
+ overflow: hidden;
+ font-size: 11.5px;
+ line-height: 1.65;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ }
+
+ .overview-card {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 2px 16px;
+ margin-top: 16px;
+ padding: 13px 15px;
+ }
+
+ .overview-label {
+ grid-column: 1;
+ font-size: 10px;
+ }
+
+ .project-total {
+ grid-column: 1;
+ margin-top: 2px;
+ }
+
+ .project-total strong {
+ font-size: 38px;
+ }
+
+ .service-status {
+ grid-column: 2;
+ grid-row: 1 / 3;
+ margin-top: 0;
+ padding-top: 0;
+ border-top: 0;
+ font-size: 10px;
+ white-space: nowrap;
+ }
+
+ .sidebar-actions {
+ display: block;
+ margin-top: 12px;
+ }
+
+ .refresh-button {
+ width: 100%;
+ }
+
+ .sidebar-hint {
+ display: none;
+ }
+
+ .sidebar-footer {
+ display: none;
+ }
+
+ .content-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 16px;
+ margin-bottom: 20px;
+ }
+
+ .content-meta {
+ justify-content: flex-start;
+ }
+
+ .site-grid {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 13px;
+ }
+
+ .site-item {
+ min-height: 200px;
+ }
+
+ .empty-state {
+ min-height: 380px;
+ }
+
+ .error-page {
+ padding: 14px;
+ }
+
+ .error-box {
+ padding: 38px 24px 34px;
+ border-radius: 22px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ transition-duration: 0.01ms !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ }
+}
diff --git a/stash-core/assets/dashboard.js b/stash-core/assets/dashboard.js
new file mode 100644
index 0000000..988d76f
--- /dev/null
+++ b/stash-core/assets/dashboard.js
@@ -0,0 +1,198 @@
+(() => {
+ "use strict";
+
+ const grid = document.querySelector("#project-grid");
+ const refreshButton = document.querySelector("#refresh-projects");
+ const status = document.querySelector("#refresh-status");
+ const projectCounts = document.querySelectorAll("[data-project-count]");
+ const liveStatus = document.querySelector("#live-status");
+ const liveStatusLabel = document.querySelector("#live-status-label");
+
+ if (!grid || !refreshButton || !status || !liveStatus || !liveStatusLabel) {
+ return;
+ }
+
+ document.body.classList.add("is-enhanced");
+
+ const createProjectCard = (project) => {
+ if (
+ typeof project.name !== "string" ||
+ typeof project.route_path !== "string" ||
+ !project.route_path.startsWith("/") ||
+ project.route_path.startsWith("//")
+ ) {
+ return null;
+ }
+
+ const link = document.createElement("a");
+ link.className = "site-item";
+ link.href = project.route_path;
+ link.title = project.name;
+
+ const cardTop = document.createElement("div");
+ cardTop.className = "site-card-top";
+
+ const avatar = document.createElement("span");
+ avatar.className = "site-avatar";
+ avatar.setAttribute("aria-hidden", "true");
+ avatar.textContent = Array.from(project.name)[0]?.toUpperCase() || "A";
+
+ const open = document.createElement("span");
+ open.className = "site-open";
+ open.setAttribute("aria-hidden", "true");
+ open.innerHTML = `
+
+ `;
+ cardTop.append(avatar, open);
+
+ const info = document.createElement("div");
+ info.className = "site-info";
+
+ const name = document.createElement("h3");
+ name.className = "site-name";
+ name.textContent = project.name;
+
+ const description = document.createElement("span");
+ description.className = "site-desc";
+ description.textContent = "本地静态应用";
+ info.append(name, description);
+
+ const footer = document.createElement("div");
+ footer.className = "site-card-footer";
+
+ const path = document.createElement("span");
+ path.className = "site-path";
+ path.textContent = project.route_path;
+
+ const ready = document.createElement("span");
+ ready.className = "site-ready";
+ ready.textContent = "READY";
+
+ footer.append(path, ready);
+ link.append(cardTop, info, footer);
+ return link;
+ };
+
+ let renderedSignature = JSON.stringify(
+ Array.from(grid.querySelectorAll(".site-item")).map((link) => [
+ link.title,
+ link.getAttribute("href"),
+ ]),
+ );
+
+ const projectsSignature = (projects) =>
+ JSON.stringify(
+ projects.map((project) => [project.name, project.route_path]),
+ );
+
+ const renderProjects = (projects) => {
+ const nextSignature = projectsSignature(projects);
+ if (nextSignature === renderedSignature) {
+ return false;
+ }
+
+ grid.replaceChildren();
+
+ if (projects.length === 0) {
+ const emptyState = document.createElement("div");
+ emptyState.className = "empty-state";
+ emptyState.innerHTML = `
+
+
+
+ 这里还没有项目
+ 在托管根目录下创建子目录,并放入 index.html,刷新后即可在这里看到它。
+ `;
+ grid.append(emptyState);
+ renderedSignature = nextSignature;
+ return true;
+ }
+
+ const cards = projects.map(createProjectCard).filter(Boolean);
+ grid.append(...cards);
+ renderedSignature = nextSignature;
+ return true;
+ };
+
+ const updateProjectCounts = (count) => {
+ projectCounts.forEach((element) => {
+ element.textContent = String(count);
+ });
+ };
+
+ refreshButton.addEventListener("click", async () => {
+ refreshButton.disabled = true;
+ refreshButton.classList.add("is-loading");
+ grid.setAttribute("aria-busy", "true");
+ status.textContent = "正在重新扫描项目...";
+
+ try {
+ const response = await fetch("/api/projects", {
+ cache: "no-store",
+ headers: { Accept: "application/json" },
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const payload = await response.json();
+ if (!Array.isArray(payload.projects)) {
+ throw new Error("项目列表格式无效");
+ }
+
+ const changed = renderProjects(payload.projects);
+ updateProjectCounts(payload.projects.length);
+ status.textContent = changed ? "项目列表已刷新" : "项目列表已是最新";
+ } catch (error) {
+ console.error("刷新项目列表失败", error);
+ status.textContent = "刷新失败,请稍后重试";
+ } finally {
+ refreshButton.disabled = false;
+ refreshButton.classList.remove("is-loading");
+ grid.removeAttribute("aria-busy");
+ }
+ });
+
+ if (!("EventSource" in window)) {
+ liveStatusLabel.textContent = "手动刷新";
+ return;
+ }
+
+ const eventSource = new EventSource("/api/projects/events");
+
+ eventSource.addEventListener("open", () => {
+ liveStatusLabel.textContent = "实时同步";
+ liveStatus.classList.add("is-live");
+ liveStatus.classList.remove("is-reconnecting");
+ });
+
+ eventSource.addEventListener("projects", (event) => {
+ try {
+ const payload = JSON.parse(event.data);
+ if (!Array.isArray(payload.projects)) {
+ throw new Error("项目列表格式无效");
+ }
+
+ const changed = renderProjects(payload.projects);
+ updateProjectCounts(payload.projects.length);
+ if (changed) {
+ status.textContent = "检测到目录变化,项目列表已自动更新";
+ }
+ } catch (error) {
+ console.error("处理项目更新事件失败", error);
+ status.textContent = "收到项目更新,但无法解析";
+ }
+ });
+
+ eventSource.addEventListener("error", () => {
+ liveStatusLabel.textContent = "同步重连中";
+ liveStatus.classList.remove("is-live");
+ liveStatus.classList.add("is-reconnecting");
+ });
+})();
diff --git a/stash-core/assets/index.html b/stash-core/assets/index.html
deleted file mode 100644
index 719c485..0000000
--- a/stash-core/assets/index.html
+++ /dev/null
@@ -1,212 +0,0 @@
-
-
-
-
-
- Stash 本地应用控制台
-
-
-
-
-
-
\ No newline at end of file
diff --git a/stash-core/src/config.rs b/stash-core/src/config.rs
index 4669ea4..51a4019 100644
--- a/stash-core/src/config.rs
+++ b/stash-core/src/config.rs
@@ -24,4 +24,4 @@ impl Default for Config {
enable_cors: true,
}
}
-}
\ No newline at end of file
+}
diff --git a/stash-core/src/error.rs b/stash-core/src/error.rs
index e80faf4..f66ea14 100644
--- a/stash-core/src/error.rs
+++ b/stash-core/src/error.rs
@@ -1,7 +1,7 @@
use axum::{
+ Json,
http::StatusCode,
response::{IntoResponse, Response},
- Json,
};
use serde_json::json;
use thiserror::Error;
@@ -11,6 +11,12 @@ pub enum StashError {
#[error("IO 错误: {0}")]
Io(#[from] std::io::Error),
+ #[error("模板渲染失败: {0}")]
+ Template(#[from] askama::Error),
+
+ #[error("文件监听失败: {0}")]
+ Watch(#[from] notify::Error),
+
#[error("目录不存在或非法: {0}")]
InvalidDirectory(String),
@@ -39,4 +45,4 @@ impl IntoResponse for StashError {
(status, body).into_response()
}
-}
\ No newline at end of file
+}
diff --git a/stash-core/src/lib.rs b/stash-core/src/lib.rs
index 0e43e3d..83319d4 100644
--- a/stash-core/src/lib.rs
+++ b/stash-core/src/lib.rs
@@ -2,8 +2,9 @@ pub mod config;
pub mod error;
pub mod listing;
pub mod server;
+mod watcher;
pub use config::Config;
pub use error::StashError;
-pub use listing::{scan_projects, AppProject};
-pub use server::{start, ServerHandle};
\ No newline at end of file
+pub use listing::{AppProject, scan_projects};
+pub use server::{ServerHandle, start};
diff --git a/stash-core/src/listing.rs b/stash-core/src/listing.rs
index a630b94..17aaa90 100644
--- a/stash-core/src/listing.rs
+++ b/stash-core/src/listing.rs
@@ -1,15 +1,38 @@
+use askama::Template;
use serde::Serialize;
-use std::path::Path;
+use std::{fmt::Write as _, path::Path};
-const INDEX_TEMPLATE: &str = include_str!("../assets/index.html");
-const NOT_FOUND_TEMPLATE: &str = include_str!("../assets/404.html");
+use crate::error::StashError;
-#[derive(Debug, Serialize, Clone)]
+#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
pub struct AppProject {
pub name: String,
pub route_path: String,
}
+impl AppProject {
+ pub fn initial(&self) -> String {
+ self.name
+ .chars()
+ .next()
+ .unwrap_or('A')
+ .to_uppercase()
+ .to_string()
+ }
+}
+
+#[derive(Template)]
+#[template(path = "index.html")]
+struct DashboardTemplate<'a> {
+ projects: &'a [AppProject],
+}
+
+#[derive(Template)]
+#[template(path = "404.html")]
+struct NotFoundTemplate<'a> {
+ path: &'a str,
+}
+
pub fn scan_projects(root: &Path) -> Vec {
let mut projects = Vec::new();
@@ -28,48 +51,187 @@ pub fn scan_projects(root: &Path) -> Vec {
if path.is_dir() {
let has_index = path.join("index.html").exists() || path.join("index.htm").exists();
if has_index {
- let route_path = format!("/{}/", name);
+ let route_path = format!("/{}/", encode_path_segment(&name));
projects.push(AppProject { name, route_path });
}
}
}
- projects.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
+ projects.sort_by(|a, b| {
+ a.name
+ .to_lowercase()
+ .cmp(&b.name.to_lowercase())
+ .then_with(|| a.name.cmp(&b.name))
+ });
projects
}
-pub fn render_dashboard(projects: &[AppProject]) -> String {
- let mut cards_html = String::new();
-
- for proj in projects {
- // 提取首字符作为圆形头像字母
- let initial = proj.name.chars().next().unwrap_or('A').to_uppercase().to_string();
-
- cards_html.push_str(&format!(
- r#"
-
- {initial}
-
- {name}
- 本地托管应用 • {url}
-
-
- "#,
- url = proj.route_path,
- name = proj.name,
- initial = initial
- ));
- }
-
- let final_cards = if cards_html.is_empty() {
- r#"当前目录下暂无含有 index.html 的前端项目
"#.to_string()
- } else {
- cards_html
- };
-
- INDEX_TEMPLATE.replace("__CARDS__", &final_cards)
+pub fn render_dashboard(projects: &[AppProject]) -> Result {
+ Ok(DashboardTemplate { projects }.render()?)
}
-pub fn render_not_found(path: &str) -> String {
- NOT_FOUND_TEMPLATE.replace("__PATH__", path)
-}
\ No newline at end of file
+pub fn render_not_found(path: &str) -> Result {
+ Ok(NotFoundTemplate { path }.render()?)
+}
+
+fn encode_path_segment(segment: &str) -> String {
+ let mut encoded = String::with_capacity(segment.len());
+
+ for byte in segment.bytes() {
+ if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
+ encoded.push(char::from(byte));
+ } else {
+ write!(&mut encoded, "%{byte:02X}").expect("写入字符串不会失败");
+ }
+ }
+
+ encoded
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::{
+ fs,
+ path::PathBuf,
+ sync::atomic::{AtomicU64, Ordering},
+ };
+
+ static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
+
+ struct TestDir {
+ path: PathBuf,
+ }
+
+ impl TestDir {
+ fn new() -> Self {
+ let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
+ let path =
+ std::env::temp_dir().join(format!("stash-core-test-{}-{id}", std::process::id()));
+ fs::create_dir_all(&path).expect("应能创建测试目录");
+ Self { path }
+ }
+
+ fn path(&self) -> &Path {
+ &self.path
+ }
+
+ fn add_project(&self, name: &str, index_file: &str) {
+ let project = self.path.join(name);
+ fs::create_dir_all(&project).expect("应能创建项目目录");
+ fs::write(project.join(index_file), "").expect("应能创建入口文件");
+ }
+ }
+
+ impl Drop for TestDir {
+ fn drop(&mut self) {
+ let _ = fs::remove_dir_all(&self.path);
+ }
+ }
+
+ fn contains_escaped_less_than(html: &str) -> bool {
+ ["<", "<", "<", "<"]
+ .iter()
+ .any(|entity| html.contains(entity))
+ }
+
+ fn contains_escaped_ampersand(html: &str) -> bool {
+ ["&", "&", "&", "&"]
+ .iter()
+ .any(|entity| html.contains(entity))
+ }
+
+ #[test]
+ fn scan_projects_detects_only_immediate_visible_projects() {
+ let root = TestDir::new();
+ root.add_project("with-html", "index.html");
+ root.add_project("with-htm", "index.htm");
+ root.add_project(".hidden", "index.html");
+ root.add_project("no-index", "readme.txt");
+ root.add_project("parent/child", "index.html");
+ fs::create_dir_all(root.path().join("plain-directory")).expect("应能创建普通目录");
+
+ let names: Vec<_> = scan_projects(root.path())
+ .into_iter()
+ .map(|project| project.name)
+ .collect();
+
+ assert_eq!(names, vec!["with-htm", "with-html"]);
+ }
+
+ #[test]
+ fn scan_projects_sorts_names_case_insensitively() {
+ let root = TestDir::new();
+ root.add_project("zeta", "index.html");
+ root.add_project("ALPHA", "index.html");
+ root.add_project("middle", "index.html");
+
+ let names: Vec<_> = scan_projects(root.path())
+ .into_iter()
+ .map(|project| project.name)
+ .collect();
+
+ assert_eq!(names, vec!["ALPHA", "middle", "zeta"]);
+ }
+
+ #[test]
+ fn scan_projects_url_encodes_route_segments() {
+ let root = TestDir::new();
+ root.add_project("space name", "index.html");
+ root.add_project("中文", "index.html");
+
+ let projects = scan_projects(root.path());
+
+ assert_eq!(projects[0].name, "space name");
+ assert_eq!(projects[0].route_path, "/space%20name/");
+ assert_eq!(projects[1].name, "中文");
+ assert_eq!(projects[1].route_path, "/%E4%B8%AD%E6%96%87/");
+ }
+
+ #[test]
+ fn path_segment_encoding_preserves_unreserved_characters() {
+ assert_eq!(encode_path_segment("aZ09-_.~/?#%"), "aZ09-_.~%2F%3F%23%25");
+ }
+
+ #[test]
+ fn dashboard_renders_empty_state() {
+ let html = render_dashboard(&[]).expect("空项目列表应能正常渲染");
+
+ assert!(html.contains("这里还没有项目"));
+ assert!(html.contains("index.html"));
+ assert!(!html.contains("class=\"site-item\""));
+ }
+
+ #[test]
+ fn dashboard_escapes_project_content() {
+ let projects = vec![AppProject {
+ name: "<项目 & \"测试\">".to_string(),
+ route_path: "/safe%20path/".to_string(),
+ }];
+
+ let html = render_dashboard(&projects).expect("项目卡片应能正常渲染");
+
+ assert!(
+ contains_escaped_less_than(&html),
+ "项目名称中的 < 应被 HTML 转义,实际输出: {html}"
+ );
+ assert!(
+ contains_escaped_ampersand(&html),
+ "项目名称中的 & 应被 HTML 转义,实际输出: {html}"
+ );
+ assert!(!html.contains("<项目 & \"测试\">"));
+ }
+
+ #[test]
+ fn not_found_page_renders_and_escapes_request_path() {
+ let html =
+ render_not_found("/missing/").expect("404 页面应能正常渲染");
+
+ assert!(html.contains("资源或页面不存在"));
+ assert!(
+ contains_escaped_less_than(&html),
+ "请求路径中的 < 应被 HTML 转义,实际输出: {html}"
+ );
+ assert!(!html.contains(""));
+ }
+}
diff --git a/stash-core/src/server.rs b/stash-core/src/server.rs
index 1b12bdd..672405e 100644
--- a/stash-core/src/server.rs
+++ b/stash-core/src/server.rs
@@ -1,27 +1,32 @@
-use std::net::SocketAddr;
-use std::sync::Arc;
use axum::{
+ Json, Router,
extract::{OriginalUri, State},
- http::{header, StatusCode},
+ http::{StatusCode, header},
+ response::sse::{Event, KeepAlive, Sse},
response::{Html, IntoResponse, Response},
routing::get,
- Json, Router,
};
+use std::{convert::Infallible, net::SocketAddr, sync::Arc, time::Duration};
+use tokio::sync::{broadcast, oneshot};
use tower_http::{
cors::{Any, CorsLayer},
services::ServeDir,
trace::TraceLayer,
};
-use tokio::sync::oneshot;
use tracing::{info, warn};
use crate::config::Config;
use crate::error::StashError;
-use crate::listing::{render_dashboard, render_not_found, scan_projects};
+use crate::listing::{AppProject, render_dashboard, render_not_found, scan_projects};
+use crate::watcher;
+
+const DASHBOARD_CSS: &str = include_str!("../assets/dashboard.css");
+const DASHBOARD_JS: &str = include_str!("../assets/dashboard.js");
#[derive(Clone)]
pub struct AppState {
pub config: Arc,
+ pub project_updates: broadcast::Sender>,
}
pub struct ServerHandle {
@@ -42,19 +47,39 @@ async fn root_handler(State(state): State) -> Result impl IntoResponse {
- let html = render_not_found(&uri.path());
- (StatusCode::NOT_FOUND, Html(html))
+async fn not_found_handler(
+ OriginalUri(uri): OriginalUri,
+) -> Result<(StatusCode, Html), StashError> {
+ let html = render_not_found(uri.path())?;
+ Ok((StatusCode::NOT_FOUND, Html(html)))
+}
+
+async fn dashboard_css() -> impl IntoResponse {
+ (
+ [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
+ DASHBOARD_CSS,
+ )
+}
+
+async fn dashboard_js() -> impl IntoResponse {
+ (
+ [(header::CONTENT_TYPE, "text/javascript; charset=utf-8")],
+ DASHBOARD_JS,
+ )
}
/// API: 健康检查
@@ -73,6 +98,46 @@ async fn list_projects_api(State(state): State) -> impl IntoResponse {
}))
}
+/// SSE:先发送当前快照,再持续推送文件监听产生的项目列表变化。
+async fn project_events(State(state): State) -> impl IntoResponse {
+ let root = state.config.root.clone();
+ let initial_projects = scan_projects(&root);
+ let mut updates = state.project_updates.subscribe();
+
+ let stream = async_stream::stream! {
+ yield Ok::(project_event(initial_projects));
+
+ loop {
+ match updates.recv().await {
+ Ok(projects) => yield Ok(project_event(projects)),
+ Err(broadcast::error::RecvError::Lagged(skipped)) => {
+ warn!("项目事件订阅落后 {skipped} 条,正在重新扫描");
+ yield Ok(project_event(scan_projects(&root)));
+ }
+ Err(broadcast::error::RecvError::Closed) => break,
+ }
+ }
+ };
+
+ Sse::new(stream).keep_alive(
+ KeepAlive::new()
+ .interval(Duration::from_secs(15))
+ .text("keep-alive"),
+ )
+}
+
+fn project_event(projects: Vec) -> Event {
+ Event::default()
+ .event("projects")
+ .json_data(serde_json::json!({ "projects": projects }))
+ .unwrap_or_else(|_| {
+ warn!("序列化项目事件失败,将发送空项目列表");
+ Event::default()
+ .event("projects")
+ .data(r#"{"projects":[]}"#)
+ })
+}
+
pub async fn start(config: Config) -> Result {
if !config.root.exists() {
return Err(StashError::InvalidDirectory(
@@ -80,18 +145,27 @@ pub async fn start(config: Config) -> Result {
));
}
+ let (project_updates, _) = broadcast::channel(32);
+ watcher::start(config.root.clone(), project_updates.clone())?;
+
let shared_state = AppState {
config: Arc::new(config.clone()),
+ project_updates,
};
// 静态文件服务:找不到静态资源时 fallback 到自定义 not_found_handler
let serve_dir = ServeDir::new(&config.root)
.append_index_html_on_directories(true)
- .fallback(axum::handler::HandlerWithoutStateExt::into_service(not_found_handler));
+ .fallback(axum::handler::HandlerWithoutStateExt::into_service(
+ not_found_handler,
+ ));
let mut app = Router::new()
.route("/api/health", get(health_check))
.route("/api/projects", get(list_projects_api))
+ .route("/api/projects/events", get(project_events))
+ .route("/__stash__/dashboard.css", get(dashboard_css))
+ .route("/__stash__/dashboard.js", get(dashboard_js))
.route("/", get(root_handler))
.fallback_service(serve_dir)
.with_state(shared_state)
@@ -117,11 +191,10 @@ pub async fn start(config: Config) -> Result {
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
tokio::spawn(async move {
- let server = axum::serve(listener, app)
- .with_graceful_shutdown(async move {
- let _ = shutdown_rx.await;
- info!("Stash 优雅停机...");
- });
+ let server = axum::serve(listener, app).with_graceful_shutdown(async move {
+ let _ = shutdown_rx.await;
+ info!("Stash 优雅停机...");
+ });
if let Err(err) = server.await {
warn!("服务异常退出: {}", err);
@@ -132,4 +205,4 @@ pub async fn start(config: Config) -> Result {
shutdown_tx,
local_addr,
})
-}
\ No newline at end of file
+}
diff --git a/stash-core/src/watcher.rs b/stash-core/src/watcher.rs
new file mode 100644
index 0000000..cea0ec9
--- /dev/null
+++ b/stash-core/src/watcher.rs
@@ -0,0 +1,122 @@
+use crate::{AppProject, error::StashError, scan_projects};
+use notify::{Event, EventKind, RecursiveMode, Watcher};
+use std::{path::PathBuf, time::Duration};
+use tokio::sync::{broadcast, mpsc};
+use tracing::warn;
+
+const EVENT_DEBOUNCE: Duration = Duration::from_millis(150);
+
+pub fn start(root: PathBuf, updates: broadcast::Sender>) -> Result<(), StashError> {
+ let mut previous_projects = scan_projects(&root);
+ let (event_tx, mut event_rx) = mpsc::unbounded_channel();
+ let mut watcher = notify::recommended_watcher(move |result| {
+ let _ = event_tx.send(result);
+ })?;
+ watcher.watch(&root, RecursiveMode::Recursive)?;
+
+ tokio::spawn(async move {
+ let _watcher = watcher;
+
+ while let Some(event) = event_rx.recv().await {
+ if !should_rescan(&event) {
+ continue;
+ }
+
+ // Editors and build tools often emit a burst of file events for one change.
+ tokio::time::sleep(EVENT_DEBOUNCE).await;
+ while event_rx.try_recv().is_ok() {}
+
+ let projects = scan_projects(&root);
+ if projects == previous_projects {
+ continue;
+ }
+
+ previous_projects = projects.clone();
+ let _ = updates.send(projects);
+ }
+ });
+
+ Ok(())
+}
+
+fn should_rescan(event: ¬ify::Result) -> bool {
+ match event {
+ Ok(event) => !matches!(event.kind, EventKind::Access(_)),
+ Err(error) => {
+ warn!("文件监听事件出错,将重新扫描项目: {error}");
+ true
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use notify::{
+ Event,
+ event::{AccessKind, CreateKind, EventKind},
+ };
+ use std::{
+ fs,
+ path::{Path, PathBuf},
+ sync::atomic::{AtomicU64, Ordering},
+ time::Duration,
+ };
+
+ static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
+
+ struct TestDir {
+ path: PathBuf,
+ }
+
+ impl TestDir {
+ fn new() -> Self {
+ let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
+ let path = std::env::temp_dir()
+ .join(format!("stash-watcher-test-{}-{id}", std::process::id()));
+ fs::create_dir_all(&path).expect("应能创建测试目录");
+ Self { path }
+ }
+
+ fn path(&self) -> &Path {
+ &self.path
+ }
+ }
+
+ impl Drop for TestDir {
+ fn drop(&mut self) {
+ let _ = fs::remove_dir_all(&self.path);
+ }
+ }
+
+ #[test]
+ fn access_events_do_not_trigger_project_scans() {
+ let access = Event::new(EventKind::Access(AccessKind::Read));
+ let create = Event::new(EventKind::Create(CreateKind::Folder));
+
+ assert!(!should_rescan(&Ok(access)));
+ assert!(should_rescan(&Ok(create)));
+ }
+
+ #[tokio::test]
+ async fn broadcasts_when_a_project_is_added() {
+ let root = TestDir::new();
+ let (updates, _) = broadcast::channel(8);
+ let mut receiver = updates.subscribe();
+
+ start(root.path().to_path_buf(), updates).expect("应能启动文件监听");
+
+ let project = root.path().join("new-project");
+ fs::create_dir_all(&project).expect("应能创建项目目录");
+ fs::write(project.join("index.html"), "").expect("应能创建入口文件");
+
+ let projects = tokio::time::timeout(Duration::from_secs(5), receiver.recv())
+ .await
+ .expect("应在超时前收到项目更新")
+ .expect("项目更新通道不应关闭");
+
+ assert_eq!(projects.len(), 1);
+ assert_eq!(projects[0].name, "new-project");
+ assert_eq!(projects[0].route_path, "/new-project/");
+ }
+}
diff --git a/stash-core/templates/404.html b/stash-core/templates/404.html
new file mode 100644
index 0000000..c2441a5
--- /dev/null
+++ b/stash-core/templates/404.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+ 404 - 资源未找到
+
+
+
+
+ 404
+ 资源或页面不存在
+ 请求的路径 {{ path }} 不存在或缺少 index.html
+ 返回应用大厅
+
+
+
diff --git a/stash-core/templates/index.html b/stash-core/templates/index.html
new file mode 100644
index 0000000..edbe1c6
--- /dev/null
+++ b/stash-core/templates/index.html
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+ Stash · 本地应用工作区
+
+
+
+
+
+
+
+
+
+