feat: add rust browser terminal prototype
This commit is contained in:
13
.cargo/config.toml
Normal file
13
.cargo/config.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[target.wasm32-unknown-unknown]
|
||||
rustflags = [
|
||||
"--cfg",
|
||||
"getrandom_backend=\"wasm_js\"",
|
||||
"--cfg",
|
||||
"erase_components",
|
||||
]
|
||||
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
rustflags = [
|
||||
"--cfg",
|
||||
"erase_components",
|
||||
]
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/target/
|
||||
.env.development
|
||||
node_modules
|
||||
3798
Cargo.lock
generated
Normal file
3798
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
74
Cargo.toml
Normal file
74
Cargo.toml
Normal file
@@ -0,0 +1,74 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["app", "server", "app_config"]
|
||||
|
||||
[workspace.dependencies]
|
||||
app_config = { path = "app_config" }
|
||||
icons = { path = "../ui/crates/icons", features = ["leptos"] }
|
||||
registry = { path = "../ui/app_crates/registry" }
|
||||
leptos_ui = { path = "../ui/crates/leptos_ui" }
|
||||
tw_merge = { path = "../ui/crates/tw_merge/tw_merge", features = ["variant"] }
|
||||
tw_merge_variants = { path = "../ui/crates/tw_merge/tw_merge_variants" }
|
||||
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
console_error_panic_hook = "0.1"
|
||||
console_log = "1"
|
||||
futures-util = "0.3"
|
||||
leptos = { version = "0.8", features = ["nightly"] }
|
||||
leptos_axum = "0.8"
|
||||
leptos_meta = "0.8"
|
||||
leptos_router = { version = "0.8", features = ["nightly"] }
|
||||
log = "0.4"
|
||||
portable-pty = "0.9"
|
||||
redis = { version = "0.32", features = ["tokio-comp"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
strum = { version = "0.27", features = ["derive"] }
|
||||
time = { version = "0.3", features = ["serde", "wasm-bindgen"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "sync"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["fs"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
validator = { version = "0.20", features = ["derive"] }
|
||||
vt100 = "0.15"
|
||||
heck = "0.5"
|
||||
js-sys = "0.3"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", default-features = false, features = [
|
||||
"BinaryType",
|
||||
"CloseEvent",
|
||||
"console",
|
||||
"Document",
|
||||
"DomRectReadOnly",
|
||||
"Element",
|
||||
"ErrorEvent",
|
||||
"Event",
|
||||
"EventTarget",
|
||||
"HtmlElement",
|
||||
"KeyboardEvent",
|
||||
"Location",
|
||||
"MessageEvent",
|
||||
"ResizeObserver",
|
||||
"ResizeObserverEntry",
|
||||
"WebSocket",
|
||||
"Window",
|
||||
] }
|
||||
|
||||
[[workspace.metadata.leptos]]
|
||||
name = "base_path_demo"
|
||||
bin-package = "server"
|
||||
lib-package = "app"
|
||||
site-root = "target/site"
|
||||
site-pkg-dir = "pkg"
|
||||
tailwind-input-file = "style/tailwind.css"
|
||||
assets-dir = "public"
|
||||
site-addr = "127.0.0.1:3100"
|
||||
reload-port = 3101
|
||||
browserquery = "defaults"
|
||||
bin-features = []
|
||||
bin-default-features = false
|
||||
lib-features = ["hydrate"]
|
||||
lib-default-features = false
|
||||
17
README.md
Normal file
17
README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# BASE_PATH Demo
|
||||
|
||||
Minimal Leptos SSR project that reuses a few components from `ui` to validate `SiteConfig::BASE_PATH`.
|
||||
|
||||
The public app path is `/rustui`, while Leptos SSR routes are still declared as `/`, `/check`, and `/deep/nested`. The server strips `/rustui` before route matching, and the client router uses `/rustui` as its base.
|
||||
|
||||
```bash
|
||||
cargo leptos watch
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
- `http://127.0.0.1:3100/rustui`
|
||||
- `http://127.0.0.1:3100/rustui/check`
|
||||
- `http://127.0.0.1:3100/rustui/deep/nested`
|
||||
|
||||
Change `app_config/src/lib.rs` to test another base path.
|
||||
568
TERMINAL_DEVELOPMENT_GUIDE.md
Normal file
568
TERMINAL_DEVELOPMENT_GUIDE.md
Normal file
@@ -0,0 +1,568 @@
|
||||
# Rust Browser Terminal Development Guide
|
||||
|
||||
本指南用于在 `base-path-demo` 中实现一套 Rust 终端能力:
|
||||
|
||||
- 后端:Axum + `portable-pty` 创建真实伪终端,负责启动 shell、读写 PTY、处理 resize。
|
||||
- 前端:Rust/WASM + Leptos 实现浏览器终端组件,负责输入、渲染、滚屏、选区和终端状态。
|
||||
- 沉淀:先在业务项目中跑通,再拆成可复用 Rust 终端组件库。
|
||||
|
||||
## 1. 核心判断
|
||||
|
||||
浏览器终端必须拆成两层:
|
||||
|
||||
```text
|
||||
Browser Rust/WASM Terminal Component
|
||||
<-> WebSocket protocol
|
||||
Axum terminal session service
|
||||
<-> portable-pty master
|
||||
Shell process: bash/zsh/fish/vim/top
|
||||
```
|
||||
|
||||
浏览器里的 WASM 不能直接创建系统 PTY,因为浏览器沙箱不能启动本机 shell。PTY 必须在 Rust native 后端或桌面端创建。浏览器终端组件只做终端仿真、渲染和用户输入采集。
|
||||
|
||||
`portable-pty` 是 Rust crate,提供跨平台 PTY API。它适合放在后端适配层,不应该放进浏览器 WASM 组件。
|
||||
|
||||
## 2. 目标架构
|
||||
|
||||
建议分四层沉淀。
|
||||
|
||||
```text
|
||||
base-path-demo/
|
||||
app/
|
||||
src/
|
||||
pages/terminal.rs
|
||||
terminal/
|
||||
mod.rs
|
||||
component.rs
|
||||
core.rs
|
||||
dom_renderer.rs
|
||||
keyboard.rs
|
||||
protocol.rs
|
||||
server/
|
||||
src/
|
||||
terminal/
|
||||
mod.rs
|
||||
pty_session.rs
|
||||
ws.rs
|
||||
main.rs
|
||||
```
|
||||
|
||||
后续抽库时再拆成 workspace crates:
|
||||
|
||||
```text
|
||||
crates/
|
||||
terminal-core/ # no web-sys, no axum; parser/buffer/cursor/style
|
||||
terminal-leptos/ # Leptos component and browser renderer
|
||||
terminal-protocol/ # shared client/server message types
|
||||
terminal-pty-server/ # portable-pty + Axum integration
|
||||
```
|
||||
|
||||
第一阶段可以先在 `app/src/terminal` 和 `server/src/terminal` 内实现,等 API 稳定后再抽 crate。这样比一开始拆库更快,也能减少早期重构成本。
|
||||
|
||||
## 3. 依赖规划
|
||||
|
||||
当前 workspace 已有 `axum = "0.8"`,但 WebSocket 模块需要启用 `ws` feature。
|
||||
|
||||
建议先调整 `base-path-demo/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[workspace.dependencies]
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
futures-util = "0.3"
|
||||
portable-pty = "0.9"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "sync"] }
|
||||
vt100 = "0.16"
|
||||
```
|
||||
|
||||
`server/Cargo.toml` 建议增加:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
futures-util.workspace = true
|
||||
portable-pty.workspace = true
|
||||
```
|
||||
|
||||
`app/Cargo.toml` 建议增加:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
vt100.workspace = true
|
||||
```
|
||||
|
||||
前端需要扩展 `web-sys` features:
|
||||
|
||||
```toml
|
||||
web-sys = { version = "0.3", default-features = false, features = [
|
||||
"BinaryType",
|
||||
"Blob",
|
||||
"CloseEvent",
|
||||
"Document",
|
||||
"Element",
|
||||
"ErrorEvent",
|
||||
"Event",
|
||||
"EventTarget",
|
||||
"HtmlElement",
|
||||
"KeyboardEvent",
|
||||
"MessageEvent",
|
||||
"Url",
|
||||
"WebSocket",
|
||||
"Window",
|
||||
] }
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `portable-pty` 只用于 native server。
|
||||
- `vt100` 可先用于 WASM 端解析字节流并维护屏幕状态,后续再替换为自研 `terminal-core`。
|
||||
- `futures-util` 用于 Axum WebSocket split。
|
||||
|
||||
## 4. WebSocket 协议
|
||||
|
||||
先用简单、可调试的协议,不要过早优化。
|
||||
|
||||
浏览器到服务端:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ClientTerminalMessage {
|
||||
Input { data: String },
|
||||
Resize {
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
pixel_width: u16,
|
||||
pixel_height: u16,
|
||||
},
|
||||
Ping,
|
||||
}
|
||||
```
|
||||
|
||||
服务端到浏览器:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ServerTerminalMessage {
|
||||
Output { data: String },
|
||||
Exit { code: Option<i32> },
|
||||
Error { message: String },
|
||||
Pong,
|
||||
}
|
||||
```
|
||||
|
||||
MVP 可以先用 JSON 文本传输。稳定后再优化为:
|
||||
|
||||
- 控制消息走 JSON text frame。
|
||||
- PTY output/input 走 binary frame。
|
||||
- 大输出使用批处理,避免每个字节触发一次 render。
|
||||
|
||||
## 5. 后端实现计划
|
||||
|
||||
### 5.1 路由
|
||||
|
||||
在 `server/src/main.rs` 中新增 WebSocket route。
|
||||
|
||||
因为当前项目有 `SiteConfig::BASE_PATH = "/rustui"` 的场景,建议同时支持:
|
||||
|
||||
```text
|
||||
/terminal/ws
|
||||
/rustui/terminal/ws
|
||||
```
|
||||
|
||||
挂载位置建议放在 `leptos_router` 外层,避免被 SSR fallback 吃掉。
|
||||
|
||||
目标形态:
|
||||
|
||||
```rust
|
||||
let terminal_routes = Router::new()
|
||||
.route("/terminal/ws", axum::routing::get(terminal_ws_handler));
|
||||
|
||||
let app = if SiteConfig::BASE_PATH.is_empty() {
|
||||
terminal_routes.merge(leptos_router)
|
||||
} else {
|
||||
Router::new()
|
||||
.route("/rustui/api/{*fn_name}", axum::routing::post(handle_server_fns))
|
||||
.route("/api/{*fn_name}", axum::routing::post(handle_server_fns))
|
||||
.merge(terminal_routes.clone())
|
||||
.nest(SiteConfig::BASE_PATH, terminal_routes.merge(leptos_router))
|
||||
};
|
||||
```
|
||||
|
||||
### 5.2 PTY session
|
||||
|
||||
新增 `server/src/terminal/pty_session.rs`。
|
||||
|
||||
职责:
|
||||
|
||||
- 创建 PTY。
|
||||
- 启动 shell。
|
||||
- 拿到 master reader/writer。
|
||||
- 支持 resize。
|
||||
- 在断开连接时终止 child。
|
||||
|
||||
核心流程:
|
||||
|
||||
```rust
|
||||
use portable_pty::{CommandBuilder, PtySize, PtySystem, native_pty_system};
|
||||
|
||||
let pty_system = native_pty_system();
|
||||
let pair = pty_system.openpty(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width,
|
||||
pixel_height,
|
||||
})?;
|
||||
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "bash".to_owned());
|
||||
let child = pair.slave.spawn_command(CommandBuilder::new(shell))?;
|
||||
let reader = pair.master.try_clone_reader()?;
|
||||
let writer = pair.master.take_writer()?;
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
- `portable-pty` 的 reader/writer 是阻塞 IO,放进 `tokio::task::spawn_blocking` 或专用线程,不要直接阻塞 async runtime。
|
||||
- 每个 WebSocket 连接对应一个 PTY session。
|
||||
- socket close、read error、child exit 时要清理 session。
|
||||
- 初版只允许启动固定 shell,不允许前端传任意命令。
|
||||
|
||||
### 5.3 WebSocket 桥接
|
||||
|
||||
新增 `server/src/terminal/ws.rs`。
|
||||
|
||||
需要两个方向的任务:
|
||||
|
||||
```text
|
||||
PTY reader thread -> mpsc -> WebSocket sender
|
||||
WebSocket receiver -> PTY writer
|
||||
```
|
||||
|
||||
建议结构:
|
||||
|
||||
```rust
|
||||
pub async fn terminal_ws_handler(ws: WebSocketUpgrade) -> Response {
|
||||
ws.on_upgrade(handle_terminal_socket)
|
||||
}
|
||||
|
||||
async fn handle_terminal_socket(socket: WebSocket) {
|
||||
let session = PtySession::spawn(default_size)?;
|
||||
let (mut ws_sender, mut ws_receiver) = socket.split();
|
||||
|
||||
// Task A: PTY output -> WS output.
|
||||
// Task B: WS input/resize -> PTY.
|
||||
// On either side ending, abort/cleanup the other side.
|
||||
}
|
||||
```
|
||||
|
||||
MVP 验收标准:
|
||||
|
||||
- 打开页面能看到 shell prompt。
|
||||
- 输入 `pwd` 能看到输出。
|
||||
- 输入 `top` 或 `vim` 不会把 UI 卡死。
|
||||
- 浏览器断开后后端 child 被终止。
|
||||
|
||||
## 6. 前端实现计划
|
||||
|
||||
### 6.1 页面接入
|
||||
|
||||
新增:
|
||||
|
||||
```text
|
||||
app/src/pages/terminal.rs
|
||||
app/src/terminal/mod.rs
|
||||
```
|
||||
|
||||
修改:
|
||||
|
||||
- `app/src/pages/mod.rs` 暴露 terminal page。
|
||||
- `app/src/app.rs` 增加 `/terminal` route。
|
||||
- `app/src/menu_items.rs` 增加导航项。
|
||||
|
||||
建议页面名:
|
||||
|
||||
```rust
|
||||
#[component]
|
||||
pub fn TerminalPage() -> impl IntoView {
|
||||
view! {
|
||||
<AdminShell>
|
||||
<TerminalPanel />
|
||||
</AdminShell>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 TerminalPanel
|
||||
|
||||
新增 `app/src/terminal/component.rs`。
|
||||
|
||||
职责:
|
||||
|
||||
- 建立 WebSocket。
|
||||
- 捕获键盘输入。
|
||||
- 调用 terminal core 处理服务端 output。
|
||||
- 渲染 terminal screen。
|
||||
- 根据容器尺寸计算 cols/rows,并发送 resize。
|
||||
|
||||
MVP 可以先使用 DOM 渲染:
|
||||
|
||||
```text
|
||||
<div class="terminal-root" tabindex="0">
|
||||
<For each=screen_lines>
|
||||
<div class="terminal-row">
|
||||
<span style=cell_style>...</span>
|
||||
</div>
|
||||
</For>
|
||||
</div>
|
||||
```
|
||||
|
||||
后续再切 Canvas/WebGL。
|
||||
|
||||
### 6.3 输入处理
|
||||
|
||||
新增 `app/src/terminal/keyboard.rs`。
|
||||
|
||||
先支持基础输入:
|
||||
|
||||
```text
|
||||
Enter -> "\r"
|
||||
Backspace -> "\x7f"
|
||||
Tab -> "\t"
|
||||
ArrowUp -> "\x1b[A"
|
||||
ArrowDown -> "\x1b[B"
|
||||
ArrowRight -> "\x1b[C"
|
||||
ArrowLeft -> "\x1b[D"
|
||||
Ctrl+C -> "\x03"
|
||||
Ctrl+D -> "\x04"
|
||||
普通字符 -> event.key
|
||||
```
|
||||
|
||||
后续增强:
|
||||
|
||||
- Alt/meta 组合键。
|
||||
- bracketed paste。
|
||||
- IME composition。
|
||||
- 鼠标协议。
|
||||
- Kitty keyboard protocol。
|
||||
|
||||
### 6.4 终端 core
|
||||
|
||||
MVP 不建议立即手写完整 ANSI/VT parser。先用 `vt100::Parser` 做 core:
|
||||
|
||||
```rust
|
||||
pub struct TerminalCore {
|
||||
parser: vt100::Parser,
|
||||
}
|
||||
|
||||
impl TerminalCore {
|
||||
pub fn new(rows: u16, cols: u16) -> Self;
|
||||
pub fn process(&mut self, bytes: &[u8]);
|
||||
pub fn resize(&mut self, rows: u16, cols: u16);
|
||||
pub fn snapshot(&self) -> TerminalSnapshot;
|
||||
}
|
||||
```
|
||||
|
||||
`TerminalSnapshot` 是组件渲染所需的稳定数据结构:
|
||||
|
||||
```rust
|
||||
pub struct TerminalSnapshot {
|
||||
pub rows: Vec<TerminalRow>,
|
||||
pub cursor: TerminalCursor,
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
pub struct TerminalRow {
|
||||
pub cells: Vec<TerminalCell>,
|
||||
}
|
||||
|
||||
pub struct TerminalCell {
|
||||
pub text: String,
|
||||
pub fg: TerminalColor,
|
||||
pub bg: TerminalColor,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
pub underline: bool,
|
||||
pub inverse: bool,
|
||||
}
|
||||
```
|
||||
|
||||
后续自研 core 时,可以逐步替换:
|
||||
|
||||
- `terminal-core::parser`
|
||||
- `terminal-core::buffer`
|
||||
- `terminal-core::screen`
|
||||
- `terminal-core::style`
|
||||
- `terminal-core::unicode_width`
|
||||
|
||||
## 7. 渲染策略
|
||||
|
||||
### MVP: DOM renderer
|
||||
|
||||
优点:
|
||||
|
||||
- 容易实现。
|
||||
- 容易调试。
|
||||
- 选区、复制、无障碍更自然。
|
||||
|
||||
缺点:
|
||||
|
||||
- 大量输出时性能一般。
|
||||
- 每个 cell 都渲染 DOM 会很重。
|
||||
|
||||
建议 MVP 不要每个 cell 一个 span,而是按 style run 合并:
|
||||
|
||||
```text
|
||||
row = [
|
||||
run(style A, "hello "),
|
||||
run(style B, "world"),
|
||||
]
|
||||
```
|
||||
|
||||
### 第二阶段: Canvas renderer
|
||||
|
||||
优点:
|
||||
|
||||
- 性能好。
|
||||
- 更接近 xterm.js 的渲染模型。
|
||||
|
||||
缺点:
|
||||
|
||||
- 选区、光标、字体测量需要自己做。
|
||||
- IME 和可访问性需要额外 DOM 辅助层。
|
||||
|
||||
推荐路线:先 DOM,等 parser/protocol/session 稳定后再做 Canvas。
|
||||
|
||||
## 8. 安全边界
|
||||
|
||||
浏览器终端是高风险能力,必须先定边界。
|
||||
|
||||
MVP 安全策略:
|
||||
|
||||
- 默认只在 development 环境启用。
|
||||
- WebSocket route 必须鉴权,至少先做本地开发开关。
|
||||
- 不允许前端指定任意启动命令。
|
||||
- shell 工作目录固定在项目目录或安全 sandbox 目录。
|
||||
- 限制并发 session 数。
|
||||
- 限制空闲时间,超时自动 kill child。
|
||||
- 记录 session start/stop/error 日志,但不要记录全部输入输出,避免泄露 secret。
|
||||
|
||||
后续生产策略:
|
||||
|
||||
- 接入用户身份和 RBAC。
|
||||
- 每个 session 用独立低权限系统用户或容器隔离。
|
||||
- 支持 allowlist 命令模式。
|
||||
- 限制环境变量透传。
|
||||
- 添加审计和资源配额。
|
||||
|
||||
## 9. 里程碑
|
||||
|
||||
### M0: 文档与目录准备
|
||||
|
||||
- 新增本指南。
|
||||
- 明确 crate/模块命名。
|
||||
- 确认 Axum route 如何兼容 `/rustui` base path。
|
||||
|
||||
验收:团队能按本文拆任务。
|
||||
|
||||
### M1: 后端 PTY + WebSocket
|
||||
|
||||
- 启用 `axum/ws`。
|
||||
- 实现 `PtySession`。
|
||||
- 实现 `/terminal/ws`。
|
||||
- 支持 input/output/resize。
|
||||
- 断连自动清理 child。
|
||||
|
||||
验收:用浏览器或 WebSocket client 连接后,可以和 shell 交互。
|
||||
|
||||
### M2: Leptos DOM 终端 MVP
|
||||
|
||||
- 新增 `/terminal` 页面。
|
||||
- 建立 WebSocket。
|
||||
- 捕获基础键盘输入。
|
||||
- 用 `vt100` 解析 output。
|
||||
- DOM 渲染 rows/runs。
|
||||
- 支持 resize。
|
||||
|
||||
验收:浏览器页面能执行 `pwd`、`ls`、`clear`、`top`,基础 ANSI 颜色可见。
|
||||
|
||||
### M3: 组件化与体验
|
||||
|
||||
- 抽出 `TerminalCore`、`TerminalPanel`、`TerminalTransport`。
|
||||
- 增加滚屏 scrollback。
|
||||
- 增加复制、粘贴、选区。
|
||||
- 增加连接状态、重连、错误提示。
|
||||
- 增加主题 token。
|
||||
|
||||
验收:组件可在其他 Leptos 页面复用。
|
||||
|
||||
### M4: 抽 Rust 组件库
|
||||
|
||||
- 从 `app/src/terminal` 抽出 `terminal-core`。
|
||||
- 从页面组件抽出 `terminal-leptos`。
|
||||
- 从消息类型抽出 `terminal-protocol`。
|
||||
- 从 server module 抽出 `terminal-pty-server`。
|
||||
|
||||
验收:`base-path-demo` 只依赖这些 crates,不再持有核心实现。
|
||||
|
||||
### M5: 自研 parser/buffer
|
||||
|
||||
- 保留 `vt100` 作为参考测试 oracle。
|
||||
- 实现自研 escape parser。
|
||||
- 实现 buffer、cursor、SGR style、scroll region。
|
||||
- 对比 `vt100` snapshot 做回归测试。
|
||||
|
||||
验收:常见 shell、git、vim、top、cargo 输出行为稳定。
|
||||
|
||||
## 10. 测试策略
|
||||
|
||||
后端测试:
|
||||
|
||||
- `PtySession` 创建和清理。
|
||||
- resize 后 PTY size 更新。
|
||||
- WebSocket 收到 input 后能产生 output。
|
||||
- socket 断开后 child 退出。
|
||||
|
||||
前端 core 测试:
|
||||
|
||||
- 普通文本换行。
|
||||
- ANSI 颜色。
|
||||
- cursor movement。
|
||||
- clear screen。
|
||||
- resize reflow。
|
||||
|
||||
集成测试:
|
||||
|
||||
- 启动 `cargo leptos watch` 或测试 server。
|
||||
- 打开 `/rustui/terminal`。
|
||||
- 输入 `echo hello`。
|
||||
- 断言页面出现 `hello`。
|
||||
|
||||
## 11. 开发注意事项
|
||||
|
||||
- 不要在 async task 中直接阻塞读取 PTY。
|
||||
- 不要把 `portable-pty` 编译到 WASM 目标。
|
||||
- 不要一开始支持“前端传 command”,这会扩大安全面。
|
||||
- 不要先做 Canvas,先把协议和 core 跑稳。
|
||||
- 不要把每个 cell 都渲染成独立 DOM 节点,优先按 style run 合并。
|
||||
- 不要把 xterm.js 当成逐行移植对象,只参考它的边界:parser、buffer、renderer、input、addon。
|
||||
|
||||
## 12. 推荐第一批改动清单
|
||||
|
||||
建议按这个顺序提交:
|
||||
|
||||
1. `Cargo.toml` 增加 `axum/ws`、`portable-pty`、`futures-util`、`vt100`。
|
||||
2. `server/src/terminal/pty_session.rs` 实现 PTY session。
|
||||
3. `server/src/terminal/ws.rs` 实现 WebSocket handler。
|
||||
4. `server/src/main.rs` 挂 `/terminal/ws` 和 `/rustui/terminal/ws`。
|
||||
5. `app/src/terminal/protocol.rs` 定义共享消息。
|
||||
6. `app/src/terminal/core.rs` 包装 `vt100::Parser`。
|
||||
7. `app/src/terminal/component.rs` 实现 DOM 终端。
|
||||
8. `app/src/pages/terminal.rs` 接入页面。
|
||||
9. `app/src/app.rs` 和 `app/src/menu_items.rs` 接入路由和导航。
|
||||
10. 增加基础测试和手动验收记录。
|
||||
|
||||
## 13. 参考资料
|
||||
|
||||
- `portable-pty`: Rust 跨平台 PTY crate,用于后端创建和控制伪终端。
|
||||
- `axum::extract::ws`: Axum WebSocket 支持,需要启用 `ws` feature。
|
||||
- `vt100`: Rust terminal byte stream parser,可作为 MVP parser 和后续自研 core 的对照实现。
|
||||
- `xterm.js`: 参考模块边界,不逐行移植。
|
||||
281
TERMINAL_HANDOFF.md
Normal file
281
TERMINAL_HANDOFF.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# Terminal Handoff
|
||||
|
||||
本文档用于把 `base-path-demo` 当前浏览器终端实现的状态、已验证结论、未解决问题和下一步建议整理清楚,方便后续继续开发。
|
||||
|
||||
## 1. 当前目标
|
||||
|
||||
项目目标不是简单接一个现成 JS 终端,而是在 `base-path-demo` 中逐步沉淀一套 Rust 终端能力:
|
||||
|
||||
- 后端:`Axum + portable-pty`
|
||||
- 前端:`Leptos + Rust/WASM`
|
||||
- 长期方向:抽出可复用 Rust 终端组件库
|
||||
|
||||
当前已经完成:
|
||||
|
||||
- PTY 启动与 shell 会话
|
||||
- WebSocket 双向通信
|
||||
- Rust/WASM 终端渲染 MVP
|
||||
- ANSI 样式分段渲染
|
||||
- 宽高测量与 PTY resize
|
||||
- 基础 scrollback/history 浏览能力
|
||||
|
||||
当前仍未稳定完成:
|
||||
|
||||
- 长历史滚动后的视口一致性
|
||||
- 从 history 模式返回 live 模式时的可靠定位
|
||||
- 超长输出下的 scrollback / DOM / parser 三者完全一致
|
||||
|
||||
## 2. 当前实现位置
|
||||
|
||||
### 后端
|
||||
|
||||
- `server/src/terminal/pty_session.rs`
|
||||
- `server/src/terminal/ws.rs`
|
||||
- `server/src/main.rs`
|
||||
|
||||
### 前端
|
||||
|
||||
- `app/src/terminal/core.rs`
|
||||
- `app/src/terminal/component.rs`
|
||||
- `app/src/terminal/scrollback.rs`
|
||||
- `app/src/terminal/protocol.rs`
|
||||
- `app/src/pages/terminal.rs`
|
||||
- `style/tailwind.css`
|
||||
|
||||
## 3. 当前前端结构
|
||||
|
||||
当前前端已经从“组件里直接推导滚动”改成三层:
|
||||
|
||||
1. `TerminalCore`
|
||||
- 基于 `vt100::Parser`
|
||||
- 负责终端字节流解析
|
||||
- 负责从 parser 生成 `TerminalSnapshot`
|
||||
- 暴露 `snapshot_live()` 与 `snapshot_for_top_row()`
|
||||
|
||||
2. `ScrollbackModel`
|
||||
- 位于 `app/src/terminal/scrollback.rs`
|
||||
- 负责管理:
|
||||
- `live/history` 状态
|
||||
- `top_row`
|
||||
- `ScrollMetrics`
|
||||
- `scrollTop <-> top_row` 映射
|
||||
- spacer 渲染计划
|
||||
|
||||
3. `TerminalPanel`
|
||||
- 负责 DOM 事件和 WebSocket
|
||||
- 把滚动、输入、resize 转换为 model 更新
|
||||
- 再根据 model 请求 `TerminalCore` 产出 snapshot
|
||||
|
||||
这是一次结构性重构,不再依赖前面几轮“scrollback_offset / scrollTop / spacer”互相修补的逻辑。
|
||||
|
||||
## 4. 已确认有效的部分
|
||||
|
||||
这些能力在当前实现里基本是成立的:
|
||||
|
||||
- WebSocket 通信链路稳定
|
||||
- shell 启动和 PTY 读写稳定
|
||||
- 普通命令行输入输出稳定
|
||||
- `btm` 这种全屏 TUI 在 live 模式下效果已经明显优于最初版本
|
||||
- 宽度和高度测量相比初版更合理
|
||||
- 终端样式、ANSI 颜色和 cursor 基本可用
|
||||
|
||||
## 5. 当前未解决问题
|
||||
|
||||
用户最新反馈:滚动相关问题仍然存在。
|
||||
|
||||
典型症状:
|
||||
|
||||
1. 滚动到高处后,再滚回底部,视图仍可能错位。
|
||||
2. 认为已经回到底部,但看不到当前 prompt 或新输出。
|
||||
3. 某些情况下顶部会出现大块空白。
|
||||
4. history 模式和 live 模式切换仍然不完全符合真实终端体验。
|
||||
|
||||
## 6. 为什么当前结构仍可能不稳
|
||||
|
||||
虽然已经抽出了 `ScrollbackModel`,但当前方案本质上仍然依赖一个前提:
|
||||
|
||||
> 用 DOM 滚动条 + spacer 高度,去模拟“完整终端历史缓冲区”。
|
||||
|
||||
这条路的优势是实现快,但仍有天然风险:
|
||||
|
||||
### 6.1 `vt100` 只给出“当前视口”
|
||||
|
||||
`vt100` 当前更适合表达:
|
||||
|
||||
- 当前可见 screen
|
||||
- 当前 scrollback offset
|
||||
|
||||
它不是为浏览器里的“无限历史虚拟滚动列表”直接设计的 UI 数据结构。
|
||||
|
||||
也就是说,浏览器里看到的“长历史列表”不是 parser 原生给出来的,而是我们通过 spacer + 视口切换模拟出来的。
|
||||
|
||||
### 6.2 DOM 滚动不是终端滚动
|
||||
|
||||
浏览器滚动条是像素系统,终端历史是行系统。
|
||||
|
||||
即使我们做了:
|
||||
|
||||
- 行高量化
|
||||
- near-bottom 阈值
|
||||
- top row model
|
||||
- 程序滚动忽略回流
|
||||
|
||||
仍然可能在以下场景出现边界问题:
|
||||
|
||||
- resize 后 scrollHeight 改变
|
||||
- 某些行因字符宽度或字体 fallback 产生高度/宽度偏差
|
||||
- cursor / prompt / wrapped line 在 parser 里和 DOM 里对应关系不完全一致
|
||||
|
||||
### 6.3 spacer 模型仍然是“UI 侧估计”
|
||||
|
||||
现在的 spacer 高度来自:
|
||||
|
||||
- `scrollback_rows`
|
||||
- `viewport_rows`
|
||||
- `top_row`
|
||||
|
||||
但这仍然是假设“每一逻辑行高度固定、所有历史行都可用同一高度表达”。
|
||||
|
||||
对于普通命令输出通常够用,但当用户期待系统终端级别的一致性时,仍可能暴露问题。
|
||||
|
||||
## 7. 建议的下一步方向
|
||||
|
||||
不建议继续围绕当前 spacer 方案做大量微调。
|
||||
|
||||
### 推荐方向 A:显式历史行缓冲
|
||||
|
||||
最推荐。
|
||||
|
||||
思路:
|
||||
|
||||
1. 在前端维护一份显式的“历史行缓冲”。
|
||||
2. 每次收到 PTY 输出时,不只保留当前 snapshot,还要把可确认滚出屏幕的行纳入历史缓冲。
|
||||
3. 浏览器滚动条直接绑定:
|
||||
- `history_rows`
|
||||
- `visible_rows`
|
||||
4. 当前视图渲染为:
|
||||
- 历史区真实行
|
||||
- 当前 screen 行
|
||||
|
||||
这样浏览器滚动就不再依赖 spacer 去“伪装总高度”,而是真正拥有一份历史数据模型。
|
||||
|
||||
更具体地说,应当抽成类似:
|
||||
|
||||
```text
|
||||
terminal-core
|
||||
parser
|
||||
screen
|
||||
scrollback_store
|
||||
viewport_state
|
||||
|
||||
terminal-leptos
|
||||
renderer
|
||||
dom scroll adapter
|
||||
input adapter
|
||||
```
|
||||
|
||||
建议引入一个新的模型,例如:
|
||||
|
||||
```rust
|
||||
pub struct TerminalBufferModel {
|
||||
pub history_rows: Vec<RenderedRow>,
|
||||
pub screen_rows: Vec<RenderedRow>,
|
||||
pub viewport_rows: usize,
|
||||
pub mode: ViewMode,
|
||||
}
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `history_rows` 是真实可滚动历史
|
||||
- `screen_rows` 是当前 parser screen
|
||||
- `ViewMode` 表示 live/history
|
||||
|
||||
然后滚动行为只是在这份显式数据上切片,不再通过“scrollback offset + spacer”来推导。
|
||||
|
||||
### 推荐方向 B:虚拟列表化
|
||||
|
||||
如果历史很多,后续要避免一次性渲染全部历史行。
|
||||
|
||||
那就基于显式历史行缓冲再做:
|
||||
|
||||
- 虚拟滚动窗口
|
||||
- 仅渲染当前可见行切片
|
||||
- 上下填充高度由真实行数计算
|
||||
|
||||
这个方向适合在方向 A 稳定后再做。
|
||||
|
||||
### 不推荐继续投入的方向
|
||||
|
||||
除非只是临时验证,否则不建议继续在下面这些点上做大量时间投入:
|
||||
|
||||
- 单纯继续调 `scrollTop -> topRow` 的换算公式
|
||||
- 继续加更多“忽略下一次 scroll 事件”之类的回流保护
|
||||
- 继续依赖 spacer 修正 live/history 错位
|
||||
|
||||
这些都只能减轻问题,不太可能从结构上彻底解决。
|
||||
|
||||
## 8. 一个更稳的实施顺序
|
||||
|
||||
建议下一位接手的人按这个顺序推进:
|
||||
|
||||
1. 先保留当前 PTY / WS / 输入输出链路,不动后端。
|
||||
2. 在前端新增显式历史缓冲层,不急着删除现有 `ScrollbackModel`。
|
||||
3. 把当前渲染改成:
|
||||
- `history_rows + screen_rows`
|
||||
- 不再通过 `snapshot.scrollback_offset` 去制造大块 spacer
|
||||
4. 先让滚动逻辑只在显式数据上成立。
|
||||
5. 确认:
|
||||
- 滚到顶部没有空白
|
||||
- 滚回底部一定能恢复 prompt
|
||||
- history 模式继续输入一定回 live
|
||||
6. 再考虑是否保留 `vt100` 的 scrollback offset 接口,还是让它只负责 screen,而历史完全交给自有 buffer model。
|
||||
|
||||
## 9. 当前代码是否值得保留
|
||||
|
||||
值得保留的部分:
|
||||
|
||||
- `portable-pty` 后端
|
||||
- Axum WebSocket 协议与会话层
|
||||
- `TerminalCore` 的 ANSI 分段渲染能力
|
||||
- 宽高测量和 PTY resize
|
||||
- `ScrollbackModel` 里关于 live/history 的概念划分
|
||||
|
||||
可以重做的部分:
|
||||
|
||||
- 终端历史滚动的底层表现方式
|
||||
- spacer 方案本身
|
||||
- DOM scroll 与 parser scrollback 的耦合方式
|
||||
|
||||
## 10. 建议的 handoff 结论
|
||||
|
||||
当前项目已经证明:
|
||||
|
||||
- Rust 后端 PTY 是可行的
|
||||
- Rust/WASM 终端组件是可行的
|
||||
- `btm` 和普通 shell 已经能跑
|
||||
|
||||
但要让它真正成为一个稳定的 Rust 终端组件库,下一步不应继续在“spacer + 当前视口推导”上做小修小补,而应转向:
|
||||
|
||||
> 显式历史缓冲模型 + 真实终端行数据结构 + 可选虚拟滚动
|
||||
|
||||
这会更接近真正终端组件库内部的设计,也更利于后续抽成 crate。
|
||||
|
||||
## 11. 当前验证命令
|
||||
|
||||
建议继续用下面这些命令回归:
|
||||
|
||||
- `pwd`
|
||||
- `ls`
|
||||
- `cat <long-file>`
|
||||
- `btm`
|
||||
- `top` / `htop`(如果环境可用)
|
||||
- 连续回车制造长历史
|
||||
|
||||
重点检查:
|
||||
|
||||
- 滚到顶部是否有空白
|
||||
- 滚回底部是否恢复 prompt
|
||||
- history 模式输入后是否回到 live
|
||||
- resize 后历史视图是否稳定
|
||||
|
||||
45
app/Cargo.toml
Normal file
45
app/Cargo.toml
Normal file
@@ -0,0 +1,45 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
app_config.workspace = true
|
||||
icons.workspace = true
|
||||
registry.workspace = true
|
||||
|
||||
axum = { workspace = true, optional = true }
|
||||
console_error_panic_hook.workspace = true
|
||||
console_log.workspace = true
|
||||
leptos.workspace = true
|
||||
leptos_axum = { workspace = true, optional = true }
|
||||
leptos_meta.workspace = true
|
||||
leptos_router.workspace = true
|
||||
log.workspace = true
|
||||
redis = { workspace = true, optional = true }
|
||||
reqwest = { workspace = true, optional = true }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
vt100.workspace = true
|
||||
wasm-bindgen.workspace = true
|
||||
wasm-bindgen-futures.workspace = true
|
||||
web-sys.workspace = true
|
||||
dotenvy = { version = "0.15", optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
hydrate = ["leptos/hydrate"]
|
||||
ssr = [
|
||||
"leptos/ssr",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
"dep:axum",
|
||||
"dep:leptos_axum",
|
||||
"dep:redis",
|
||||
"dep:reqwest",
|
||||
"dep:dotenvy",
|
||||
"registry/ssr",
|
||||
]
|
||||
3
app/build.rs
Normal file
3
app/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
println!("cargo:rustc-env=LEPTOS_OUTPUT_NAME=base_path_demo");
|
||||
}
|
||||
1
app/src/api/mod.rs
Normal file
1
app/src/api/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod project;
|
||||
490
app/src/api/project.rs
Normal file
490
app/src/api/project.rs
Normal file
@@ -0,0 +1,490 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos::server_fn::codec::{Json, PostUrl};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
const DEFAULT_LABEL_API_BASE_URL: &str = "https://label.softtest.cowarobot.com";
|
||||
#[cfg(feature = "ssr")]
|
||||
const PROJECT_LIST_PATH: &str = "/api/v1/label_server/project/list";
|
||||
#[cfg(feature = "ssr")]
|
||||
const SESSION_COOKIE_NAME: &str = "session";
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ProjectListRequest {
|
||||
pub page_number: i64,
|
||||
pub page_size: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub project_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub project_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub owner: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<Vec<i64>>,
|
||||
}
|
||||
|
||||
impl Default for ProjectListRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
page_number: 1,
|
||||
page_size: 15,
|
||||
project_name: None,
|
||||
project_type: None,
|
||||
owner: None,
|
||||
status: Some(vec![
|
||||
100, 191, 199, 200, 201, 202, 203, 400, 500, 580, 600, 700,
|
||||
]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct ProjectListResponse {
|
||||
#[serde(default)]
|
||||
pub project_list: Vec<ProjectData>,
|
||||
#[serde(default)]
|
||||
pub total_pages: i64,
|
||||
#[serde(default)]
|
||||
pub total_items: i64,
|
||||
#[serde(default)]
|
||||
pub source: ProjectDataSource,
|
||||
#[serde(default)]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl ProjectListResponse {
|
||||
pub fn fallback(message: impl Into<String>) -> Self {
|
||||
let message = compact_message(message.into());
|
||||
|
||||
Self {
|
||||
source: ProjectDataSource::Fallback,
|
||||
message: (!message.is_empty()).then_some(message),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProjectDataSource {
|
||||
Upstream,
|
||||
#[default]
|
||||
Fallback,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct ProjectData {
|
||||
pub id: i64,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub status: i64,
|
||||
#[serde(default)]
|
||||
pub owner: Option<String>,
|
||||
#[serde(default)]
|
||||
pub create_user: Option<String>,
|
||||
#[serde(default)]
|
||||
pub project_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub label_schema_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub label_schema_version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub data_size: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub labeled_scale: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub reviewed_scale: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub is_collect: bool,
|
||||
#[serde(default)]
|
||||
pub is_embedding: bool,
|
||||
#[serde(default)]
|
||||
pub is_terminate: bool,
|
||||
#[serde(default)]
|
||||
pub embedding_status: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub remarks: Option<String>,
|
||||
#[serde(default)]
|
||||
pub create_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub update_at: Option<String>,
|
||||
}
|
||||
|
||||
#[server(FetchProjectList, "/rustui/api", input = PostUrl, output = Json)]
|
||||
pub async fn fetch_project_list(
|
||||
request: ProjectListRequest,
|
||||
) -> Result<ProjectListResponse, ServerFnError> {
|
||||
fetch_project_list_impl(request).await
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
#[allow(dead_code)]
|
||||
async fn fetch_project_list_impl(
|
||||
_request: ProjectListRequest,
|
||||
) -> Result<ProjectListResponse, ServerFnError> {
|
||||
Ok(ProjectListResponse::fallback(
|
||||
"fetch_project_list should run on the server",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn fetch_project_list_impl(
|
||||
request: ProjectListRequest,
|
||||
) -> Result<ProjectListResponse, ServerFnError> {
|
||||
use axum::http::HeaderMap;
|
||||
use leptos_axum::extract;
|
||||
|
||||
let headers: HeaderMap = match extract().await {
|
||||
Ok(headers) => headers,
|
||||
Err(err) => {
|
||||
let message = format!("读取请求头失败: {err}");
|
||||
log::warn!("{message}");
|
||||
return Ok(ProjectListResponse::fallback(message));
|
||||
}
|
||||
};
|
||||
let tokens = match resolve_tokens(&headers).await {
|
||||
Ok(tokens) if tokens.access_token.is_some() || tokens.refresh_token.is_some() => tokens,
|
||||
Ok(_) => {
|
||||
let message = "未获取到项目列表 token";
|
||||
log::warn!("{message}");
|
||||
return Ok(ProjectListResponse::fallback(message));
|
||||
}
|
||||
Err(error) => {
|
||||
let message = server_fn_error_message(&error);
|
||||
log::warn!("获取项目列表 token 失败: {message}");
|
||||
return Ok(ProjectListResponse::fallback(message));
|
||||
}
|
||||
};
|
||||
let base_url = label_api_base_url();
|
||||
let url = format!("{base_url}{PROJECT_LIST_PATH}");
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut upstream_request = client
|
||||
.post(url)
|
||||
.header("content-type", "application/json")
|
||||
.json(&request);
|
||||
|
||||
if let Some(access_token) = tokens.access_token.as_deref() {
|
||||
upstream_request = upstream_request.header("Token", access_token);
|
||||
}
|
||||
if let Some(refresh_token) = tokens.refresh_token.as_deref() {
|
||||
upstream_request = upstream_request.header("Refresh-Token", refresh_token);
|
||||
}
|
||||
|
||||
let response = match upstream_request.send().await {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
let message = format!("请求项目列表服务失败: {err}");
|
||||
log::warn!("{message}");
|
||||
return Ok(ProjectListResponse::fallback(message));
|
||||
}
|
||||
};
|
||||
let status = response.status();
|
||||
let text = match response.text().await {
|
||||
Ok(text) => text,
|
||||
Err(err) => {
|
||||
let message = format!("读取项目列表响应失败: {err}");
|
||||
log::warn!("{message}");
|
||||
return Ok(ProjectListResponse::fallback(message));
|
||||
}
|
||||
};
|
||||
|
||||
if !status.is_success() {
|
||||
let detail = response_message_from_text(&text).unwrap_or_else(|| compact_message(text));
|
||||
let message = if detail.is_empty() {
|
||||
format!("项目列表服务返回异常: HTTP {status}")
|
||||
} else {
|
||||
format!("项目列表服务返回异常: HTTP {status}, {detail}")
|
||||
};
|
||||
log::warn!("{message}");
|
||||
return Ok(ProjectListResponse::fallback(message));
|
||||
}
|
||||
|
||||
let mut payload = match parse_project_list_payload(&text) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => {
|
||||
let message = server_fn_error_message(&error);
|
||||
log::warn!("解析项目列表响应失败: {message}");
|
||||
return Ok(ProjectListResponse::fallback(message));
|
||||
}
|
||||
};
|
||||
payload.source = ProjectDataSource::Upstream;
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[derive(Debug, Default)]
|
||||
struct TokenPair {
|
||||
access_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn resolve_tokens(headers: &axum::http::HeaderMap) -> Result<TokenPair, ServerFnError> {
|
||||
let header_tokens = TokenPair {
|
||||
access_token: read_header(headers, "token")
|
||||
.or_else(|| read_header(headers, "authorization").and_then(strip_bearer_prefix)),
|
||||
refresh_token: read_header(headers, "refresh-token"),
|
||||
};
|
||||
|
||||
if header_tokens.access_token.is_some() || header_tokens.refresh_token.is_some() {
|
||||
return Ok(header_tokens);
|
||||
}
|
||||
|
||||
let env_tokens = TokenPair {
|
||||
access_token: std::env::var("LABEL_ACCESS_TOKEN").ok(),
|
||||
refresh_token: std::env::var("LABEL_REFRESH_TOKEN").ok(),
|
||||
};
|
||||
|
||||
if env_tokens.access_token.is_some() || env_tokens.refresh_token.is_some() {
|
||||
return Ok(env_tokens);
|
||||
}
|
||||
|
||||
let Some(session_key) = session_key_from_headers(headers) else {
|
||||
return Err(ServerFnError::new(
|
||||
"未获取到 session cookie,无法获取项目列表 token",
|
||||
));
|
||||
};
|
||||
|
||||
let Some(session_data) = load_session_from_redis(&session_key).await? else {
|
||||
return Err(ServerFnError::new("Redis 中未获取到 session 用户信息"));
|
||||
};
|
||||
|
||||
Ok(TokenPair {
|
||||
access_token: session_data
|
||||
.get("access_token")
|
||||
.and_then(|value| value.as_str())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
refresh_token: session_data
|
||||
.get("refresh_token")
|
||||
.and_then(|value| value.as_str())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn read_header(headers: &axum::http::HeaderMap, name: &'static str) -> Option<String> {
|
||||
headers
|
||||
.get(name)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn strip_bearer_prefix(value: String) -> Option<String> {
|
||||
let token = value
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| value.strip_prefix("bearer "))
|
||||
.unwrap_or(&value)
|
||||
.trim()
|
||||
.to_owned();
|
||||
|
||||
(!token.is_empty()).then_some(token)
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn parse_project_list_payload(text: &str) -> Result<ProjectListResponse, ServerFnError> {
|
||||
let value: serde_json::Value = serde_json::from_str(text)
|
||||
.map_err(|err| ServerFnError::new(format!("解析项目列表响应失败: {err}; {text}")))?;
|
||||
|
||||
if let Some(code) = value.get("code").and_then(|value| value.as_i64()) {
|
||||
if code != 200 {
|
||||
let message =
|
||||
response_message(&value).unwrap_or_else(|| "项目列表服务返回业务异常".to_owned());
|
||||
return Err(ServerFnError::new(format!("{message}: code {code}")));
|
||||
}
|
||||
|
||||
if let Some(data) = value.get("data") {
|
||||
return serde_json::from_value::<ProjectListResponse>(data.clone()).map_err(|err| {
|
||||
ServerFnError::new(format!("解析项目列表 data 字段失败: {err}; {text}"))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
serde_json::from_value::<ProjectListResponse>(value)
|
||||
.map_err(|err| ServerFnError::new(format!("解析项目列表响应失败: {err}; {text}")))
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn response_message(value: &serde_json::Value) -> Option<String> {
|
||||
value
|
||||
.get("message")
|
||||
.or_else(|| value.get("msg"))
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn response_message_from_text(text: &str) -> Option<String> {
|
||||
serde_json::from_str::<serde_json::Value>(text)
|
||||
.ok()
|
||||
.and_then(|value| response_message(&value))
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn session_key_from_headers(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
let cookie_header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
|
||||
cookie_header.split(';').find_map(|pair| {
|
||||
let (name, value) = pair.trim().split_once('=')?;
|
||||
if name == SESSION_COOKIE_NAME {
|
||||
Some(normalize_session_cookie_value(value))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn normalize_session_cookie_value(value: &str) -> String {
|
||||
let decoded = percent_decode(value);
|
||||
serde_json::from_str::<String>(&decoded).unwrap_or(decoded)
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn percent_decode(value: &str) -> String {
|
||||
let bytes = value.as_bytes();
|
||||
let mut output = Vec::with_capacity(bytes.len());
|
||||
let mut index = 0;
|
||||
|
||||
while index < bytes.len() {
|
||||
if bytes[index] == b'%'
|
||||
&& index + 2 < bytes.len()
|
||||
&& let (Some(high), Some(low)) =
|
||||
(hex_value(bytes[index + 1]), hex_value(bytes[index + 2]))
|
||||
{
|
||||
output.push((high << 4) | low);
|
||||
index += 3;
|
||||
} else if bytes[index] == b'+' {
|
||||
output.push(b' ');
|
||||
index += 1;
|
||||
} else {
|
||||
output.push(bytes[index]);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
String::from_utf8(output).unwrap_or_else(|_| value.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn hex_value(byte: u8) -> Option<u8> {
|
||||
match byte {
|
||||
b'0'..=b'9' => Some(byte - b'0'),
|
||||
b'a'..=b'f' => Some(byte - b'a' + 10),
|
||||
b'A'..=b'F' => Some(byte - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn load_session_from_redis(
|
||||
session_key: &str,
|
||||
) -> Result<Option<serde_json::Value>, ServerFnError> {
|
||||
let redis_url = redis_url();
|
||||
let redis_target = redis_target_label();
|
||||
let client = redis::Client::open(redis_url.as_str())
|
||||
.map_err(|err| ServerFnError::new(format!("创建 Redis 客户端失败: {err}")))?;
|
||||
let mut connection = client
|
||||
.get_multiplexed_async_connection()
|
||||
.await
|
||||
.map_err(|err| ServerFnError::new(format!("连接 Redis 失败 ({redis_target}): {err}")))?;
|
||||
let value: Option<String> = redis::AsyncCommands::get(&mut connection, session_key)
|
||||
.await
|
||||
.map_err(|err| ServerFnError::new(format!("读取 Redis session 失败: {err}")))?;
|
||||
|
||||
value
|
||||
.map(|raw| {
|
||||
serde_json::from_str::<serde_json::Value>(&raw)
|
||||
.map_err(|err| ServerFnError::new(format!("解析 Redis session 失败: {err}")))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn redis_url() -> String {
|
||||
if let Ok(url) = std::env::var("REDIS_URL").or_else(|_| std::env::var("NEXT_PUBLIC_REDIS_DSN"))
|
||||
{
|
||||
return url;
|
||||
}
|
||||
|
||||
let host = std::env::var("NEXT_PUBLIC_REDIS_URL").unwrap_or_else(|_| "127.0.0.1".to_owned());
|
||||
let port = std::env::var("NEXT_PUBLIC_REDIS_PORT").unwrap_or_else(|_| "6379".to_owned());
|
||||
let username = std::env::var("NEXT_PUBLIC_REDIS_USERNAME").unwrap_or_default();
|
||||
let password = std::env::var("NEXT_PUBLIC_REDIS_PASSWORD").unwrap_or_default();
|
||||
let db = std::env::var("NEXT_PUBLIC_REDIS_DB").unwrap_or_else(|_| "0".to_owned());
|
||||
|
||||
if username.is_empty() && password.is_empty() {
|
||||
format!("redis://{host}:{port}/{db}")
|
||||
} else if username.is_empty() {
|
||||
format!("redis://:{password}@{host}:{port}/{db}")
|
||||
} else {
|
||||
format!("redis://{username}:{password}@{host}:{port}/{db}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn redis_target_label() -> String {
|
||||
let host = std::env::var("NEXT_PUBLIC_REDIS_URL").unwrap_or_else(|_| "127.0.0.1".to_owned());
|
||||
let port = std::env::var("NEXT_PUBLIC_REDIS_PORT").unwrap_or_else(|_| "6379".to_owned());
|
||||
let username = std::env::var("NEXT_PUBLIC_REDIS_USERNAME").unwrap_or_default();
|
||||
let db = std::env::var("NEXT_PUBLIC_REDIS_DB").unwrap_or_else(|_| "0".to_owned());
|
||||
|
||||
if username.is_empty() {
|
||||
format!("{host}:{port}/{db}")
|
||||
} else {
|
||||
format!("{host}:{port}/{db} as {username}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn label_api_base_url() -> String {
|
||||
if let Ok(url) = std::env::var("BASE_LABEL_API") {
|
||||
return trim_trailing_slash(url);
|
||||
}
|
||||
|
||||
let env = std::env::var("NEXT_PUBLIC_ENV").unwrap_or_default();
|
||||
let url = match env.as_str() {
|
||||
"production" => "http://172.16.103.224:9110",
|
||||
"staging" => "http://172.16.115.128:9110",
|
||||
_ => DEFAULT_LABEL_API_BASE_URL,
|
||||
};
|
||||
|
||||
trim_trailing_slash(url.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn trim_trailing_slash(mut value: String) -> String {
|
||||
while value.ends_with('/') {
|
||||
value.pop();
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn compact_message(value: String) -> String {
|
||||
const MAX_CHARS: usize = 240;
|
||||
|
||||
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let total_chars = normalized.chars().count();
|
||||
|
||||
if total_chars <= MAX_CHARS {
|
||||
normalized
|
||||
} else {
|
||||
let truncated = normalized.chars().take(MAX_CHARS).collect::<String>();
|
||||
format!("{truncated}...")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn server_fn_error_message(error: &ServerFnError) -> String {
|
||||
match error {
|
||||
ServerFnError::ServerError(message) => compact_message(message.clone()),
|
||||
_ => compact_message(error.to_string()),
|
||||
}
|
||||
}
|
||||
39
app/src/app.rs
Normal file
39
app/src/app.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use app_config::SiteConfig;
|
||||
use leptos::prelude::*;
|
||||
use leptos_meta::{Title, provide_meta_context};
|
||||
use leptos_router::components::{Route, Router, Routes};
|
||||
use leptos_router::{StaticSegment, path};
|
||||
|
||||
use crate::pages::project_list::{
|
||||
CategoryPage, CostPage, DailyPaperPage, EmployeePage, NotFound, OrganizationPage,
|
||||
ProjectAllPage, ProjectAuditPage, WorkloadPage,
|
||||
};
|
||||
use crate::pages::terminal::TerminalPage;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const ROUTER_BASE: &str = SiteConfig::BASE_PATH;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const ROUTER_BASE: &str = "";
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
provide_meta_context();
|
||||
|
||||
view! {
|
||||
<Title text=SiteConfig::TITLE />
|
||||
<Router base=ROUTER_BASE>
|
||||
<Routes fallback=NotFound>
|
||||
<Route path=StaticSegment("") view=EmployeePage />
|
||||
<Route path=StaticSegment("employee") view=EmployeePage />
|
||||
<Route path=path!("/team/organization") view=OrganizationPage />
|
||||
<Route path=path!("/team/dailypaper") view=DailyPaperPage />
|
||||
<Route path=path!("/team/cost") view=CostPage />
|
||||
<Route path=path!("/team/workload") view=WorkloadPage />
|
||||
<Route path=StaticSegment("category") view=CategoryPage />
|
||||
<Route path=path!("/project/all") view=ProjectAllPage />
|
||||
<Route path=path!("/project/audit") view=ProjectAuditPage />
|
||||
<Route path=path!("/terminal") view=TerminalPage />
|
||||
</Routes>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
346
app/src/client_icon.rs
Normal file
346
app/src/client_icon.rs
Normal file
@@ -0,0 +1,346 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
fn TablerOutlineIcon(#[prop(into, optional)] class: String, children: Children) -> impl IntoView {
|
||||
view! {
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class=class
|
||||
aria-hidden="true"
|
||||
>
|
||||
{children()}
|
||||
</svg>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn DashboardIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<TablerOutlineIcon class=class>
|
||||
<path d="M5 4h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1v-6a1 1 0 0 1 1 -1" />
|
||||
<path d="M5 16h4a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1" />
|
||||
<path d="M15 12h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1v-6a1 1 0 0 1 1 -1" />
|
||||
<path d="M15 4h4a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1" />
|
||||
</TablerOutlineIcon>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn EmployeeManagementIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<TablerOutlineIcon class=class>
|
||||
<path d="M10 13a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
|
||||
<path d="M8 21v-1a2 2 0 0 1 2 -2h4a2 2 0 0 1 2 2v1" />
|
||||
<path d="M15 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
|
||||
<path d="M17 10h2a2 2 0 0 1 2 2v1" />
|
||||
<path d="M5 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
|
||||
<path d="M3 13v-1a2 2 0 0 1 2 -2h2" />
|
||||
</TablerOutlineIcon>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ImageAnnotationIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<TablerOutlineIcon class=class>
|
||||
<path d="M15 8h.01" />
|
||||
<path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12" />
|
||||
<path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5" />
|
||||
<path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3" />
|
||||
</TablerOutlineIcon>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn LidarAnnotationIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<TablerOutlineIcon class=class>
|
||||
<path d="M6 17.6l-2 -1.1v-2.5" />
|
||||
<path d="M4 10v-2.5l2 -1.1" />
|
||||
<path d="M10 4.1l2 -1.1l2 1.1" />
|
||||
<path d="M18 6.4l2 1.1v2.5" />
|
||||
<path d="M20 14v2.5l-2 1.12" />
|
||||
<path d="M14 19.9l-2 1.1l-2 -1.1" />
|
||||
<path d="M12 12l2 -1.1" />
|
||||
<path d="M18 8.6l2 -1.1" />
|
||||
<path d="M12 12l0 2.5" />
|
||||
<path d="M12 18.5l0 2.5" />
|
||||
<path d="M12 12l-2 -1.12" />
|
||||
<path d="M6 8.6l-2 -1.1" />
|
||||
</TablerOutlineIcon>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ManagementCenterIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<TablerOutlineIcon class=class>
|
||||
<path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065" />
|
||||
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />
|
||||
</TablerOutlineIcon>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn MyProjectsIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<TablerOutlineIcon class=class>
|
||||
<path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2" />
|
||||
</TablerOutlineIcon>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn PersonalCenterIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<TablerOutlineIcon class=class>
|
||||
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
|
||||
<path d="M9 10a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
|
||||
<path d="M6.168 18.849a4 4 0 0 1 3.832 -2.849h4a4 4 0 0 1 3.834 2.855" />
|
||||
</TablerOutlineIcon>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ProjectCategoryIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<TablerOutlineIcon class=class>
|
||||
<path d="M14 4h6v6h-6l0 -6" />
|
||||
<path d="M4 14h6v6h-6l0 -6" />
|
||||
<path d="M14 17a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
|
||||
<path d="M4 7a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
|
||||
</TablerOutlineIcon>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ProjectManagementIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<TablerOutlineIcon class=class>
|
||||
<path d="M9 3h3l2 2h5a2 2 0 0 1 2 2v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2" />
|
||||
<path d="M17 16v2a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2h2" />
|
||||
</TablerOutlineIcon>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ReportIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<TablerOutlineIcon class=class>
|
||||
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
|
||||
<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2" />
|
||||
<path d="M9 9l1 0" />
|
||||
<path d="M9 13l6 0" />
|
||||
<path d="M9 17l6 0" />
|
||||
</TablerOutlineIcon>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn WorkloadIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<TablerOutlineIcon class=class>
|
||||
<path d="M9 5h-2a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-12a2 2 0 0 0 -2 -2h-2" />
|
||||
<path d="M9 5a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2" />
|
||||
<path d="M10 14l4 0" />
|
||||
<path d="M12 12l0 4" />
|
||||
</TablerOutlineIcon>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn TeamManagementIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<TablerOutlineIcon class=class>
|
||||
<path d="M10 5a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M6 12a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M10 19a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M18 19a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M2 19a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M14 12a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M5 17l2 -3" />
|
||||
<path d="M9 10l2 -3" />
|
||||
<path d="M13 7l2 3" />
|
||||
<path d="M17 14l2 3" />
|
||||
<path d="M15 14l-2 3" />
|
||||
<path d="M9 14l2 3" />
|
||||
</TablerOutlineIcon>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn VideoAnnotationIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<TablerOutlineIcon class=class>
|
||||
<path d="M15 10l4.553 -2.276a1 1 0 0 1 1.447 .894v6.764a1 1 0 0 1 -1.447 .894l-4.553 -2.276v-4" />
|
||||
<path d="M3 8a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2l0 -8" />
|
||||
</TablerOutlineIcon>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn LockIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<TablerOutlineIcon class=class>
|
||||
<path d="M5 13a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-6" />
|
||||
<path d="M11 16a1 1 0 1 0 2 0a1 1 0 0 0 -2 0" />
|
||||
<path d="M8 11v-4a4 4 0 1 1 8 0v4" />
|
||||
</TablerOutlineIcon>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn LogoutIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<TablerOutlineIcon class=class>
|
||||
<path d="M14 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2" />
|
||||
<path d="M9 12h12l-3 -3" />
|
||||
<path d="M18 15l3 -3" />
|
||||
</TablerOutlineIcon>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ChevronDownIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<TablerOutlineIcon class=class>
|
||||
<path d="M6 9l6 6l6 -6" />
|
||||
</TablerOutlineIcon>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ChevronLeftIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<TablerOutlineIcon class=class>
|
||||
<path d="M15 6l-6 6l6 6" />
|
||||
</TablerOutlineIcon>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ChevronRightIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<TablerOutlineIcon class=class>
|
||||
<path d="M9 6l6 6l-6 6" />
|
||||
</TablerOutlineIcon>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn EditIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<TablerOutlineIcon class=class>
|
||||
<path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1" />
|
||||
<path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z" />
|
||||
<path d="M16 5l3 3" />
|
||||
</TablerOutlineIcon>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn UploadIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<TablerOutlineIcon class=class>
|
||||
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
|
||||
<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2" />
|
||||
<path d="M12 17v-6" />
|
||||
<path d="M9.5 13.5l2.5 -2.5l2.5 2.5" />
|
||||
</TablerOutlineIcon>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn InfoCircleIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! {
|
||||
<TablerOutlineIcon class=class>
|
||||
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
|
||||
<path d="M12 9h.01" />
|
||||
<path d="M11 12h1v4h1" />
|
||||
</TablerOutlineIcon>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn SystemIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! { <ManagementCenterIcon class=class /> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn PersonalMenuIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! { <PersonalCenterIcon class=class /> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn TaskMenuIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! { <DashboardIcon class=class /> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn AuthMenuIcon(#[prop(into, optional)] class: String) -> impl IntoView {
|
||||
view! { <ReportIcon class=class /> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ClientIcon(
|
||||
icon: Option<&'static str>,
|
||||
#[prop(into, optional)] class: String,
|
||||
) -> impl IntoView {
|
||||
match icon {
|
||||
Some("DashboardIcon") => view! { <DashboardIcon class=class.clone() /> }.into_any(),
|
||||
Some("EmployeeManagementIcon") => {
|
||||
view! { <EmployeeManagementIcon class=class.clone() /> }.into_any()
|
||||
}
|
||||
Some("ImageAnnotationIcon") => {
|
||||
view! { <ImageAnnotationIcon class=class.clone() /> }.into_any()
|
||||
}
|
||||
Some("LidarAnnotationIcon") => {
|
||||
view! { <LidarAnnotationIcon class=class.clone() /> }.into_any()
|
||||
}
|
||||
Some("ManagementCenterIcon") => {
|
||||
view! { <ManagementCenterIcon class=class.clone() /> }.into_any()
|
||||
}
|
||||
Some("MyProjectsIcon") => view! { <MyProjectsIcon class=class.clone() /> }.into_any(),
|
||||
Some("PersonalCenterIcon") => {
|
||||
view! { <PersonalCenterIcon class=class.clone() /> }.into_any()
|
||||
}
|
||||
Some("ProjectCategoryIcon") => {
|
||||
view! { <ProjectCategoryIcon class=class.clone() /> }.into_any()
|
||||
}
|
||||
Some("ProjectManagementIcon") => {
|
||||
view! { <ProjectManagementIcon class=class.clone() /> }.into_any()
|
||||
}
|
||||
Some("ReportIcon") => view! { <ReportIcon class=class.clone() /> }.into_any(),
|
||||
Some("WorkloadIcon") => view! { <WorkloadIcon class=class.clone() /> }.into_any(),
|
||||
Some("TeamManagementIcon") => {
|
||||
view! { <TeamManagementIcon class=class.clone() /> }.into_any()
|
||||
}
|
||||
Some("VideoAnnotationIcon") => {
|
||||
view! { <VideoAnnotationIcon class=class.clone() /> }.into_any()
|
||||
}
|
||||
Some("LockIcon") => view! { <LockIcon class=class.clone() /> }.into_any(),
|
||||
Some("LogoutIcon") => view! { <LogoutIcon class=class.clone() /> }.into_any(),
|
||||
Some("ChevronDownIcon") => view! { <ChevronDownIcon class=class.clone() /> }.into_any(),
|
||||
Some("ChevronLeftIcon") => view! { <ChevronLeftIcon class=class.clone() /> }.into_any(),
|
||||
Some("ChevronRightIcon") => view! { <ChevronRightIcon class=class.clone() /> }.into_any(),
|
||||
Some("EditIcon") => view! { <EditIcon class=class.clone() /> }.into_any(),
|
||||
Some("UploadIcon") => view! { <UploadIcon class=class.clone() /> }.into_any(),
|
||||
Some("InfoCircleIcon") => view! { <InfoCircleIcon class=class.clone() /> }.into_any(),
|
||||
Some("SystemIcon") => view! { <SystemIcon class=class.clone() /> }.into_any(),
|
||||
Some("PersonalMenuIcon") => view! { <PersonalMenuIcon class=class.clone() /> }.into_any(),
|
||||
Some("TaskMenuIcon") => view! { <TaskMenuIcon class=class.clone() /> }.into_any(),
|
||||
Some("AuthMenuIcon") => view! { <AuthMenuIcon class=class /> }.into_any(),
|
||||
_ => {
|
||||
let _: () = view! { <></> };
|
||||
().into_any()
|
||||
}
|
||||
}
|
||||
}
|
||||
16
app/src/components/layout/breadcrumb.rs
Normal file
16
app/src/components/layout/breadcrumb.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
use crate::client_icon::ClientIcon;
|
||||
|
||||
#[component]
|
||||
pub fn Breadcrumb(current: &'static str) -> impl IntoView {
|
||||
view! {
|
||||
<div class="cowa-breadcrumb">
|
||||
<span>"数据平台"</span>
|
||||
<ClientIcon icon=Some("ChevronRightIcon") class="cowa-breadcrumb-icon".to_owned() />
|
||||
<span>"管理中心"</span>
|
||||
<ClientIcon icon=Some("ChevronRightIcon") class="cowa-breadcrumb-icon".to_owned() />
|
||||
<strong>{current}</strong>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
140
app/src/components/layout/header.rs
Normal file
140
app/src/components/layout/header.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
use app_config::SiteConfig;
|
||||
use leptos::prelude::*;
|
||||
|
||||
use crate::client_icon::ClientIcon;
|
||||
use crate::menu_items::{COMPONENT_LIST, MenuItem};
|
||||
|
||||
fn header_menu_href(item: MenuItem) -> String {
|
||||
match item.url {
|
||||
"person" => "/person/dashboard".to_owned(),
|
||||
"rustui" => SiteConfig::with_base_path("/employee"),
|
||||
_ => format!("/{}", item.url),
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub(super) fn Header() -> impl IntoView {
|
||||
let logo_src = SiteConfig::with_base_path("/header.svg");
|
||||
|
||||
view! {
|
||||
<header class="cowa-topbar">
|
||||
<div class="cowa-brand">
|
||||
<img class="cowa-brand-logo" src=logo_src alt="酷哇标注平台" />
|
||||
<span>"酷哇标注平台"</span>
|
||||
</div>
|
||||
|
||||
<div class="cowa-topbar-main">
|
||||
<div class="cowa-module-tabs">
|
||||
<For
|
||||
each=move || COMPONENT_LIST.iter().copied()
|
||||
key=|item| item.url
|
||||
children=move |item| {
|
||||
let href = header_menu_href(item);
|
||||
let is_active = item.url == "rustui";
|
||||
|
||||
view! {
|
||||
<a
|
||||
class="cowa-module-tab"
|
||||
class=("cowa-module-tab-active", is_active)
|
||||
href=href
|
||||
>
|
||||
<ClientIcon icon=item.icon class="cowa-module-tab-icon".to_owned() />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<HeaderUserMenu />
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn HeaderUserMenu() -> impl IntoView {
|
||||
let is_password_modal_open = RwSignal::new(false);
|
||||
|
||||
view! {
|
||||
<div class="cowa-user-menu">
|
||||
<button
|
||||
class="cowa-user-trigger"
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-label="用户菜单"
|
||||
>
|
||||
<span class="cowa-avatar">"张"</span>
|
||||
<span class="cowa-user-meta">
|
||||
<span class="cowa-user-name">"张俊峰"</span>
|
||||
<span class="cowa-user-city">"上海市"</span>
|
||||
</span>
|
||||
<ClientIcon icon=Some("ChevronDownIcon") class="cowa-user-chevron".to_owned() />
|
||||
</button>
|
||||
|
||||
<div class="cowa-user-dropdown" role="menu">
|
||||
<div class="cowa-user-dropdown-label">"用户信息"</div>
|
||||
<div class="cowa-user-dropdown-info">"张俊峰"</div>
|
||||
<div class="cowa-user-dropdown-divider" />
|
||||
<div class="cowa-user-dropdown-label">"操作"</div>
|
||||
<button
|
||||
class="cowa-user-dropdown-item"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
on:click=move |_| is_password_modal_open.set(true)
|
||||
>
|
||||
<ClientIcon icon=Some("LockIcon") class="cowa-user-dropdown-icon".to_owned() />
|
||||
<span>"修改密码"</span>
|
||||
</button>
|
||||
<a class="cowa-user-dropdown-item" href="/login" role="menuitem">
|
||||
<ClientIcon icon=Some("LogoutIcon") class="cowa-user-dropdown-icon".to_owned() />
|
||||
<span>"退出登录"</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{move || {
|
||||
if is_password_modal_open.get() {
|
||||
view! {
|
||||
<div class="cowa-modal-backdrop" role="presentation">
|
||||
<div class="cowa-password-modal" role="dialog" aria-modal="true" aria-labelledby="password-modal-title">
|
||||
<h2 id="password-modal-title">"修改密码"</h2>
|
||||
<p>"请输入当前密码,并再次确认新密码后提交修改。"</p>
|
||||
<form
|
||||
class="cowa-password-form"
|
||||
on:submit=move |event| {
|
||||
event.prevent_default();
|
||||
is_password_modal_open.set(false);
|
||||
}
|
||||
>
|
||||
<label>
|
||||
<span>"当前密码"</span>
|
||||
<input type="password" placeholder="请输入当前密码" />
|
||||
</label>
|
||||
<label>
|
||||
<span>"新密码"</span>
|
||||
<input type="password" placeholder="请输入新密码" />
|
||||
</label>
|
||||
<label>
|
||||
<span>"确认新密码"</span>
|
||||
<input type="password" placeholder="请再次输入新密码" />
|
||||
</label>
|
||||
<div class="cowa-password-actions">
|
||||
<button
|
||||
class="cowa-password-cancel"
|
||||
type="button"
|
||||
on:click=move |_| is_password_modal_open.set(false)
|
||||
>
|
||||
"取消"
|
||||
</button>
|
||||
<button class="cowa-password-submit" type="submit">"确认修改"</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
let _: () = view! { <></> };
|
||||
().into_any()
|
||||
}
|
||||
}}
|
||||
}
|
||||
}
|
||||
7
app/src/components/layout/mod.rs
Normal file
7
app/src/components/layout/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod breadcrumb;
|
||||
mod header;
|
||||
mod shell;
|
||||
mod sidebar;
|
||||
|
||||
pub use breadcrumb::Breadcrumb;
|
||||
pub use shell::AdminShell;
|
||||
25
app/src/components/layout/shell.rs
Normal file
25
app/src/components/layout/shell.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
use super::header::Header;
|
||||
use super::sidebar::Sidebar;
|
||||
|
||||
#[component]
|
||||
pub fn AdminShell(children: Children) -> impl IntoView {
|
||||
let is_sidebar_open = RwSignal::new(true);
|
||||
|
||||
view! {
|
||||
<div
|
||||
class="cowa-shell"
|
||||
class=("cowa-shell-collapsed", move || !is_sidebar_open.get())
|
||||
>
|
||||
<Header />
|
||||
<Sidebar is_open=is_sidebar_open />
|
||||
|
||||
<main class="cowa-main">
|
||||
<div class="cowa-content">
|
||||
{children()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
184
app/src/components/layout/sidebar.rs
Normal file
184
app/src/components/layout/sidebar.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
use app_config::SiteConfig;
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::use_location;
|
||||
|
||||
use crate::client_icon::ClientIcon;
|
||||
use crate::menu_items::{MenuItem, RUSTUI_NAV_ITEMS};
|
||||
|
||||
fn is_route_active(current_path: &str, item: MenuItem) -> bool {
|
||||
if item.url == "/employee" && (current_path == "/" || current_path == SiteConfig::BASE_PATH) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if item.url == "/" {
|
||||
return current_path == "/" || current_path == SiteConfig::BASE_PATH;
|
||||
}
|
||||
|
||||
let path = item.url.strip_prefix('/').unwrap_or(item.url);
|
||||
current_path.ends_with(item.url) || current_path.ends_with(path)
|
||||
}
|
||||
|
||||
fn is_child_route_active(current_path: &str, item: MenuItem) -> bool {
|
||||
item.items
|
||||
.iter()
|
||||
.any(|sub_item| is_route_active(current_path, *sub_item))
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub(super) fn Sidebar(is_open: RwSignal<bool>) -> impl IntoView {
|
||||
let location = use_location();
|
||||
let current_path = Memo::new(move |_| location.pathname.get());
|
||||
|
||||
view! {
|
||||
<aside class="cowa-sidebar">
|
||||
<nav class="cowa-nav" aria-label="RustUI 导航">
|
||||
<For
|
||||
each=move || RUSTUI_NAV_ITEMS.iter().copied()
|
||||
key=|item| item.title
|
||||
children=move |item| {
|
||||
let href = SiteConfig::with_base_path(item.url);
|
||||
let has_children = !item.items.is_empty();
|
||||
let active = Memo::new(move |_| {
|
||||
!has_children && is_route_active(¤t_path.get(), item)
|
||||
});
|
||||
let child_active = Memo::new(move |_| {
|
||||
is_child_route_active(¤t_path.get(), item)
|
||||
});
|
||||
let expanded = RwSignal::new(child_active.get_untracked());
|
||||
|
||||
view! {
|
||||
<div class="cowa-nav-group">
|
||||
{if has_children {
|
||||
view! {
|
||||
<button
|
||||
class="cowa-nav-item"
|
||||
class=("cowa-nav-item-has-active-child", move || child_active.get())
|
||||
type="button"
|
||||
title=item.title
|
||||
aria-label=item.title
|
||||
aria-expanded=move || expanded.get().to_string()
|
||||
on:click=move |_| expanded.update(|open| *open = !*open)
|
||||
>
|
||||
<ClientIcon icon=item.icon class="cowa-nav-icon".to_owned() />
|
||||
<span class="cowa-nav-label">{item.title}</span>
|
||||
{move || {
|
||||
if is_open.get() {
|
||||
view! {
|
||||
<ClientIcon
|
||||
icon=Some("ChevronDownIcon")
|
||||
class="cowa-nav-caret".to_owned()
|
||||
/>
|
||||
}.into_any()
|
||||
} else {
|
||||
let _: () = view! { <></> };
|
||||
().into_any()
|
||||
}
|
||||
}}
|
||||
</button>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<a
|
||||
class="cowa-nav-item"
|
||||
class=("cowa-nav-item-active", move || active.get())
|
||||
href=href
|
||||
title=item.title
|
||||
aria-label=item.title
|
||||
>
|
||||
<ClientIcon icon=item.icon class="cowa-nav-icon".to_owned() />
|
||||
<span class="cowa-nav-label">{item.title}</span>
|
||||
</a>
|
||||
}.into_any()
|
||||
}}
|
||||
{move || {
|
||||
if has_children && is_open.get() && expanded.get() {
|
||||
view! {
|
||||
<div class="cowa-nav-sublist">
|
||||
<For
|
||||
each=move || item.items.iter().copied()
|
||||
key=|sub| sub.title
|
||||
children=move |sub| {
|
||||
let sub_href = SiteConfig::with_base_path(sub.url);
|
||||
let sub_active = Memo::new(move |_| {
|
||||
is_route_active(¤t_path.get(), sub)
|
||||
});
|
||||
|
||||
view! {
|
||||
<a
|
||||
class="cowa-nav-subitem"
|
||||
class=("cowa-nav-subitem-active", move || sub_active.get())
|
||||
href=sub_href
|
||||
>
|
||||
{sub.title}
|
||||
</a>
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
let _: () = view! { <></> };
|
||||
().into_any()
|
||||
}
|
||||
}}
|
||||
{move || {
|
||||
if has_children {
|
||||
view! {
|
||||
<div class="cowa-nav-flyout" role="menu">
|
||||
<div class="cowa-nav-flyout-title">{item.title}</div>
|
||||
<For
|
||||
each=move || item.items.iter().copied()
|
||||
key=|sub| sub.title
|
||||
children=move |sub| {
|
||||
let sub_href = SiteConfig::with_base_path(sub.url);
|
||||
let sub_active = Memo::new(move |_| {
|
||||
is_route_active(¤t_path.get(), sub)
|
||||
});
|
||||
|
||||
view! {
|
||||
<a
|
||||
class="cowa-nav-flyout-item"
|
||||
class=("cowa-nav-flyout-item-active", move || sub_active.get())
|
||||
href=sub_href
|
||||
role="menuitem"
|
||||
>
|
||||
{sub.title}
|
||||
</a>
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
let _: () = view! { <></> };
|
||||
().into_any()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
/>
|
||||
</nav>
|
||||
<div class="cowa-sidebar-footer">
|
||||
<button
|
||||
class="cowa-sidebar-toggle"
|
||||
type="button"
|
||||
aria-label=move || if is_open.get() { "收起菜单" } else { "展开菜单" }
|
||||
on:click=move |_| is_open.update(|open| *open = !*open)
|
||||
>
|
||||
{move || {
|
||||
if is_open.get() {
|
||||
view! {
|
||||
<ClientIcon icon=Some("ChevronLeftIcon") class="cowa-sidebar-toggle-icon".to_owned() />
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<ClientIcon icon=Some("ChevronRightIcon") class="cowa-sidebar-toggle-icon".to_owned() />
|
||||
}.into_any()
|
||||
}
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
}
|
||||
}
|
||||
1
app/src/components/mod.rs
Normal file
1
app/src/components/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod layout;
|
||||
19
app/src/lib.rs
Normal file
19
app/src/lib.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
pub mod api;
|
||||
pub mod app;
|
||||
pub mod client_icon;
|
||||
pub mod components;
|
||||
pub mod menu_items;
|
||||
pub mod pages;
|
||||
pub mod shell;
|
||||
pub mod terminal;
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||
pub fn hydrate() {
|
||||
console_error_panic_hook::set_once();
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
|
||||
leptos::mount::hydrate_body(app::App);
|
||||
}
|
||||
142
app/src/menu_items.rs
Normal file
142
app/src/menu_items.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct MenuItem {
|
||||
pub url: &'static str,
|
||||
pub title: &'static str,
|
||||
pub icon: Option<&'static str>,
|
||||
pub items: &'static [MenuItem],
|
||||
}
|
||||
|
||||
pub const PERSONAL_CENTER_ITEMS: &[MenuItem] = &[
|
||||
MenuItem {
|
||||
title: "个人看板",
|
||||
url: "dashboard",
|
||||
icon: Some("DashboardIcon"),
|
||||
items: &[],
|
||||
},
|
||||
MenuItem {
|
||||
title: "我的日报",
|
||||
url: "report",
|
||||
icon: Some("ReportIcon"),
|
||||
items: &[],
|
||||
},
|
||||
MenuItem {
|
||||
title: "工时列表",
|
||||
url: "workload",
|
||||
icon: Some("WorkloadIcon"),
|
||||
items: &[],
|
||||
},
|
||||
];
|
||||
|
||||
pub const COMPONENT_LIST: &[MenuItem] = &[
|
||||
MenuItem {
|
||||
title: "个人中心",
|
||||
url: "person",
|
||||
icon: Some("PersonalCenterIcon"),
|
||||
items: PERSONAL_CENTER_ITEMS,
|
||||
},
|
||||
MenuItem {
|
||||
title: "图片标注",
|
||||
url: "image",
|
||||
icon: Some("ImageAnnotationIcon"),
|
||||
items: &[],
|
||||
},
|
||||
MenuItem {
|
||||
title: "视频标注",
|
||||
url: "video",
|
||||
icon: Some("VideoAnnotationIcon"),
|
||||
items: &[],
|
||||
},
|
||||
MenuItem {
|
||||
title: "点云标注",
|
||||
url: "lidar",
|
||||
icon: Some("LidarAnnotationIcon"),
|
||||
items: &[],
|
||||
},
|
||||
MenuItem {
|
||||
title: "管理中心",
|
||||
url: "mgt",
|
||||
icon: Some("ManagementCenterIcon"),
|
||||
items: &[],
|
||||
},
|
||||
MenuItem {
|
||||
title: "Rustui",
|
||||
url: "rustui",
|
||||
icon: Some("ManagementCenterIcon"),
|
||||
items: &[],
|
||||
},
|
||||
];
|
||||
|
||||
pub const TEAM_MANAGEMENT_ITEMS: &[MenuItem] = &[
|
||||
MenuItem {
|
||||
title: "组织管理",
|
||||
url: "/team/organization",
|
||||
icon: None,
|
||||
items: &[],
|
||||
},
|
||||
MenuItem {
|
||||
title: "团队日报",
|
||||
url: "/team/dailypaper",
|
||||
icon: None,
|
||||
items: &[],
|
||||
},
|
||||
MenuItem {
|
||||
title: "团队成本",
|
||||
url: "/team/cost",
|
||||
icon: None,
|
||||
items: &[],
|
||||
},
|
||||
MenuItem {
|
||||
title: "团队工时",
|
||||
url: "/team/workload",
|
||||
icon: None,
|
||||
items: &[],
|
||||
},
|
||||
];
|
||||
|
||||
pub const PROJECT_MANAGEMENT_ITEMS: &[MenuItem] = &[
|
||||
MenuItem {
|
||||
title: "全部项目",
|
||||
url: "/project/all",
|
||||
icon: None,
|
||||
items: &[],
|
||||
},
|
||||
MenuItem {
|
||||
title: "项目审核",
|
||||
url: "/project/audit",
|
||||
icon: None,
|
||||
items: &[],
|
||||
},
|
||||
];
|
||||
|
||||
pub const RUSTUI_NAV_ITEMS: &[MenuItem] = &[
|
||||
MenuItem {
|
||||
title: "员工管理",
|
||||
url: "/employee",
|
||||
icon: Some("EmployeeManagementIcon"),
|
||||
items: &[],
|
||||
},
|
||||
MenuItem {
|
||||
title: "团队管理",
|
||||
url: "/team/organization",
|
||||
icon: Some("TeamManagementIcon"),
|
||||
items: TEAM_MANAGEMENT_ITEMS,
|
||||
},
|
||||
MenuItem {
|
||||
title: "标注方案",
|
||||
url: "/category",
|
||||
icon: Some("ProjectCategoryIcon"),
|
||||
items: &[],
|
||||
},
|
||||
MenuItem {
|
||||
title: "项目管理",
|
||||
url: "/project/all",
|
||||
icon: Some("ProjectManagementIcon"),
|
||||
items: PROJECT_MANAGEMENT_ITEMS,
|
||||
},
|
||||
MenuItem {
|
||||
title: "浏览器终端",
|
||||
url: "/terminal",
|
||||
icon: Some("ManagementCenterIcon"),
|
||||
items: &[],
|
||||
},
|
||||
];
|
||||
2
app/src/pages/mod.rs
Normal file
2
app/src/pages/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod project_list;
|
||||
pub mod terminal;
|
||||
1175
app/src/pages/project_list.rs
Normal file
1175
app/src/pages/project_list.rs
Normal file
File diff suppressed because it is too large
Load Diff
16
app/src/pages/terminal.rs
Normal file
16
app/src/pages/terminal.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
use crate::components::layout::{AdminShell, Breadcrumb};
|
||||
use crate::terminal::TerminalPanel;
|
||||
|
||||
#[component]
|
||||
pub fn TerminalPage() -> impl IntoView {
|
||||
view! {
|
||||
<AdminShell>
|
||||
<Breadcrumb current="浏览器终端" />
|
||||
<section class="cowa-work-surface terminal-page-shell">
|
||||
<TerminalPanel />
|
||||
</section>
|
||||
</AdminShell>
|
||||
}
|
||||
}
|
||||
36
app/src/shell.rs
Normal file
36
app/src/shell.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use app_config::SiteConfig;
|
||||
use leptos::prelude::*;
|
||||
use leptos_meta::MetaTags;
|
||||
|
||||
use crate::app::App;
|
||||
|
||||
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||
let css_href = SiteConfig::with_base_path("/pkg/base_path_demo.css");
|
||||
let js_href = SiteConfig::with_base_path("/pkg/base_path_demo.js");
|
||||
let wasm_href = SiteConfig::with_base_path("/pkg/base_path_demo.wasm");
|
||||
let hydration_script = format!(
|
||||
r#"import("{js_href}").then((mod) => {{
|
||||
mod.default({{ module_or_path: "{wasm_href}" }}).then(() => mod.hydrate());
|
||||
}});"#
|
||||
);
|
||||
|
||||
view! {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content=SiteConfig::DESCRIPTION />
|
||||
<link rel="stylesheet" href=css_href />
|
||||
<link rel="modulepreload" href=js_href />
|
||||
<link rel="preload" href=wasm_href r#as="fetch" r#type="application/wasm" crossorigin="anonymous" />
|
||||
<AutoReload options=options.clone() />
|
||||
<script type="module" inner_html=hydration_script></script>
|
||||
<MetaTags />
|
||||
</head>
|
||||
<body>
|
||||
<App />
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
}
|
||||
662
app/src/terminal/component.rs
Normal file
662
app/src/terminal/component.rs
Normal file
@@ -0,0 +1,662 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use app_config::SiteConfig;
|
||||
use leptos::html;
|
||||
use leptos::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use web_sys::js_sys::{Array, Function};
|
||||
use web_sys::{
|
||||
BinaryType, ErrorEvent, Event, HtmlElement, KeyboardEvent, MessageEvent,
|
||||
ResizeObserver, ResizeObserverEntry, WebSocket,
|
||||
};
|
||||
|
||||
use super::core::{
|
||||
DEFAULT_COLS, DEFAULT_ROWS, TerminalCore, TerminalRow, TerminalSegment,
|
||||
TerminalSnapshot,
|
||||
};
|
||||
use super::protocol::{ClientTerminalMessage, ServerTerminalMessage};
|
||||
use super::scrollback::{ScrollMetrics, ScrollbackModel};
|
||||
|
||||
const DEFAULT_CELL_WIDTH: f64 = 8.0;
|
||||
const DEFAULT_CELL_HEIGHT: f64 = 18.0;
|
||||
const MIN_COLS: u16 = 40;
|
||||
const MIN_ROWS: u16 = 12;
|
||||
const MEASURE_SAMPLE_TEXT: &str =
|
||||
"WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW";
|
||||
const SCROLL_LINE_HEIGHT: f64 = 18.0;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct TerminalViewport {
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
pixel_width: u16,
|
||||
pixel_height: u16,
|
||||
}
|
||||
|
||||
impl Default for TerminalViewport {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cols: DEFAULT_COLS,
|
||||
rows: DEFAULT_ROWS,
|
||||
pixel_width: (f64::from(DEFAULT_COLS) * DEFAULT_CELL_WIDTH).round()
|
||||
as u16,
|
||||
pixel_height: (f64::from(DEFAULT_ROWS) * DEFAULT_CELL_HEIGHT)
|
||||
.round() as u16,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TerminalPanel() -> impl IntoView {
|
||||
let terminal_ref = NodeRef::<html::Div>::new();
|
||||
let measure_ref = NodeRef::<html::Span>::new();
|
||||
let snapshot = RwSignal::new(TerminalSnapshot::default());
|
||||
let connection_status = RwSignal::new("Connecting".to_owned());
|
||||
let ws_signal = RwSignal::new(None::<WebSocket>);
|
||||
let current_viewport = RwSignal::new(TerminalViewport::default());
|
||||
let output_updates = RwSignal::new(0_u64);
|
||||
let scrollback_model = RwSignal::new(ScrollbackModel::default());
|
||||
let ignore_next_scroll = RwSignal::new(false);
|
||||
let terminal_core = Rc::new(RefCell::new(TerminalCore::default()));
|
||||
|
||||
Effect::new({
|
||||
let terminal_core = terminal_core.clone();
|
||||
|
||||
move |_| {
|
||||
let url = websocket_url();
|
||||
let Ok(socket) = WebSocket::new(&url) else {
|
||||
connection_status.set("Failed to connect".to_owned());
|
||||
return;
|
||||
};
|
||||
|
||||
socket.set_binary_type(BinaryType::Arraybuffer);
|
||||
let socket = Rc::new(socket);
|
||||
|
||||
let open_socket = socket.clone();
|
||||
let open_status = connection_status;
|
||||
let open_ws = ws_signal;
|
||||
let open_viewport = current_viewport;
|
||||
let open_ref = terminal_ref;
|
||||
let open_measure = measure_ref;
|
||||
let open_snapshot = snapshot;
|
||||
let open_model = scrollback_model;
|
||||
let open_ignore_scroll = ignore_next_scroll;
|
||||
let open_core = terminal_core.clone();
|
||||
let on_open = Closure::<dyn Fn(Event)>::new(move |_| {
|
||||
open_status.set("Connected".to_owned());
|
||||
open_ws.set(Some((*open_socket).clone()));
|
||||
|
||||
let viewport = measure_terminal_viewport(open_ref, open_measure);
|
||||
open_viewport.set(viewport);
|
||||
|
||||
{
|
||||
let mut core = open_core.borrow_mut();
|
||||
resize_terminal_core(&mut core, viewport);
|
||||
let mut model = open_model.get_untracked();
|
||||
model.enter_live(metrics_from_core(viewport, &mut core));
|
||||
open_model.set(model);
|
||||
let next_snapshot = snapshot_for_model(&mut core, model, viewport);
|
||||
open_snapshot.set(next_snapshot.clone());
|
||||
sync_scroll_to_model(
|
||||
open_ref,
|
||||
next_snapshot,
|
||||
model,
|
||||
viewport,
|
||||
open_ignore_scroll,
|
||||
);
|
||||
}
|
||||
|
||||
send_resize_message(&open_socket, viewport);
|
||||
focus_terminal(open_ref);
|
||||
});
|
||||
socket.set_onopen(Some(on_open.as_ref().unchecked_ref()));
|
||||
on_open.forget();
|
||||
|
||||
let message_snapshot = snapshot;
|
||||
let message_core = terminal_core.clone();
|
||||
let message_status = connection_status;
|
||||
let message_ref = terminal_ref;
|
||||
let message_updates = output_updates;
|
||||
let message_viewport = current_viewport;
|
||||
let message_model = scrollback_model;
|
||||
let message_ignore_scroll = ignore_next_scroll;
|
||||
let on_message =
|
||||
Closure::<dyn Fn(MessageEvent)>::new(move |event: MessageEvent| {
|
||||
let Some(text) = event.data().as_string() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(message) =
|
||||
serde_json::from_str::<ServerTerminalMessage>(&text)
|
||||
else {
|
||||
message_status.set("Protocol error".to_owned());
|
||||
return;
|
||||
};
|
||||
|
||||
match message {
|
||||
ServerTerminalMessage::Output { data } => {
|
||||
let viewport = message_viewport.get_untracked();
|
||||
let mut core = message_core.borrow_mut();
|
||||
core.process(data.as_bytes());
|
||||
|
||||
let mut model = message_model.get_untracked();
|
||||
model.normalize(metrics_from_core(viewport, &mut core));
|
||||
message_model.set(model);
|
||||
|
||||
let next_snapshot =
|
||||
snapshot_for_model(&mut core, model, viewport);
|
||||
message_snapshot.set(next_snapshot.clone());
|
||||
message_updates.update(|count| *count += 1);
|
||||
|
||||
if model.is_live() {
|
||||
sync_scroll_to_model(
|
||||
message_ref,
|
||||
next_snapshot,
|
||||
model,
|
||||
viewport,
|
||||
message_ignore_scroll,
|
||||
);
|
||||
}
|
||||
|
||||
focus_terminal(message_ref);
|
||||
}
|
||||
ServerTerminalMessage::Exit { code } => {
|
||||
message_status.set(match code {
|
||||
Some(code) => format!("Exited ({code})"),
|
||||
None => "Exited".to_owned(),
|
||||
});
|
||||
}
|
||||
ServerTerminalMessage::Error { message } => {
|
||||
message_status.set(format!("Error: {message}"));
|
||||
}
|
||||
ServerTerminalMessage::Pong => {}
|
||||
}
|
||||
});
|
||||
socket.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
|
||||
on_message.forget();
|
||||
|
||||
let close_status = connection_status;
|
||||
let close_ws = ws_signal;
|
||||
let on_close = Closure::<dyn Fn(Event)>::new(move |_| {
|
||||
close_status.set("Disconnected".to_owned());
|
||||
close_ws.set(None);
|
||||
});
|
||||
socket.set_onclose(Some(on_close.as_ref().unchecked_ref()));
|
||||
on_close.forget();
|
||||
|
||||
let error_status = connection_status;
|
||||
let on_error = Closure::<dyn Fn(ErrorEvent)>::new(move |_| {
|
||||
error_status.set("Connection error".to_owned());
|
||||
});
|
||||
socket.set_onerror(Some(on_error.as_ref().unchecked_ref()));
|
||||
on_error.forget();
|
||||
}
|
||||
});
|
||||
|
||||
Effect::new({
|
||||
let terminal_core = terminal_core.clone();
|
||||
|
||||
move |_| {
|
||||
let Some(element) = terminal_ref.get() else {
|
||||
return;
|
||||
};
|
||||
let Some(socket) = ws_signal.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let resize_socket = socket.clone();
|
||||
let resize_viewport = current_viewport;
|
||||
let resize_ref = terminal_ref;
|
||||
let resize_measure = measure_ref;
|
||||
let resize_snapshot = snapshot;
|
||||
let resize_model = scrollback_model;
|
||||
let resize_ignore_scroll = ignore_next_scroll;
|
||||
let resize_core = terminal_core.clone();
|
||||
let callback = Closure::<dyn FnMut(Array, ResizeObserver)>::new(
|
||||
move |entries: Array, _observer: ResizeObserver| {
|
||||
let Some(entry) = entries
|
||||
.get(0)
|
||||
.dyn_into::<ResizeObserverEntry>()
|
||||
.ok()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let rect = entry.content_rect();
|
||||
let viewport = measure_terminal_viewport_from_rect(
|
||||
rect.width(),
|
||||
rect.height(),
|
||||
resize_measure,
|
||||
);
|
||||
|
||||
if viewport != resize_viewport.get_untracked() {
|
||||
resize_viewport.set(viewport);
|
||||
|
||||
let mut core = resize_core.borrow_mut();
|
||||
resize_terminal_core(&mut core, viewport);
|
||||
|
||||
let mut model = resize_model.get_untracked();
|
||||
model.normalize(metrics_from_core(viewport, &mut core));
|
||||
resize_model.set(model);
|
||||
|
||||
let next_snapshot =
|
||||
snapshot_for_model(&mut core, model, viewport);
|
||||
resize_snapshot.set(next_snapshot.clone());
|
||||
sync_scroll_to_model(
|
||||
resize_ref,
|
||||
next_snapshot,
|
||||
model,
|
||||
viewport,
|
||||
resize_ignore_scroll,
|
||||
);
|
||||
|
||||
send_resize_message(&resize_socket, viewport);
|
||||
}
|
||||
|
||||
focus_terminal(resize_ref);
|
||||
},
|
||||
);
|
||||
|
||||
let Ok(observer) = ResizeObserver::new(
|
||||
callback.as_ref().unchecked_ref::<Function>(),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
|
||||
observer.observe(&element);
|
||||
callback.forget();
|
||||
std::mem::forget(observer);
|
||||
}
|
||||
});
|
||||
|
||||
let handle_keydown = {
|
||||
let terminal_core = terminal_core.clone();
|
||||
let keydown_viewport = current_viewport;
|
||||
let keydown_model = scrollback_model;
|
||||
let keydown_ignore_scroll = ignore_next_scroll;
|
||||
|
||||
move |event: KeyboardEvent| {
|
||||
let Some(socket) = ws_signal.get_untracked() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(data) = map_key_to_terminal_input(&event) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let model = keydown_model.get_untracked();
|
||||
if !model.is_live() {
|
||||
let viewport = keydown_viewport.get_untracked();
|
||||
let mut core = terminal_core.borrow_mut();
|
||||
let mut next_model = model;
|
||||
next_model.enter_live(metrics_from_core(viewport, &mut core));
|
||||
keydown_model.set(next_model);
|
||||
|
||||
let live_snapshot =
|
||||
snapshot_for_model(&mut core, next_model, viewport);
|
||||
snapshot.set(live_snapshot.clone());
|
||||
sync_scroll_to_model(
|
||||
terminal_ref,
|
||||
live_snapshot,
|
||||
next_model,
|
||||
viewport,
|
||||
keydown_ignore_scroll,
|
||||
);
|
||||
}
|
||||
|
||||
event.prevent_default();
|
||||
send_terminal_message(&socket, &ClientTerminalMessage::Input { data });
|
||||
}
|
||||
};
|
||||
|
||||
let handle_scroll = {
|
||||
let terminal_core = terminal_core.clone();
|
||||
let scroll_viewport = current_viewport;
|
||||
let scroll_model = scrollback_model;
|
||||
let scroll_ignore_next = ignore_next_scroll;
|
||||
|
||||
move |_| {
|
||||
if scroll_ignore_next.get_untracked() {
|
||||
scroll_ignore_next.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(element) = terminal_ref.get_untracked() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let viewport = scroll_viewport.get_untracked();
|
||||
let mut core = terminal_core.borrow_mut();
|
||||
let metrics = metrics_from_core(viewport, &mut core);
|
||||
|
||||
let mut model = scroll_model.get_untracked();
|
||||
model.apply_scroll_top(
|
||||
element.scroll_top(),
|
||||
element.scroll_height(),
|
||||
element.client_height(),
|
||||
SCROLL_LINE_HEIGHT,
|
||||
metrics,
|
||||
);
|
||||
model.normalize(metrics);
|
||||
scroll_model.set(model);
|
||||
|
||||
let next_snapshot = snapshot_for_model(&mut core, model, viewport);
|
||||
snapshot.set(next_snapshot.clone());
|
||||
sync_scroll_to_model(
|
||||
terminal_ref,
|
||||
next_snapshot,
|
||||
model,
|
||||
viewport,
|
||||
scroll_ignore_next,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let handle_click = move |_| {
|
||||
focus_terminal(terminal_ref);
|
||||
};
|
||||
|
||||
view! {
|
||||
<section class="terminal-shell-card">
|
||||
<div class="terminal-header">
|
||||
<div>
|
||||
<h2 class="terminal-title">"Rust Browser Terminal"</h2>
|
||||
<p class="terminal-subtitle">
|
||||
"Axum + portable-pty backend, Leptos + Rust/WASM client."
|
||||
</p>
|
||||
</div>
|
||||
<div class="terminal-status-stack">
|
||||
<span class="terminal-badge">{move || connection_status.get()}</span>
|
||||
<span class="terminal-metrics">
|
||||
{move || {
|
||||
let viewport = current_viewport.get();
|
||||
let buffer = snapshot.get();
|
||||
let model = scrollback_model.get();
|
||||
format!(
|
||||
"view {} x {} / buffer {} x {} / scroll {} of {} / mode {} / {} updates",
|
||||
viewport.cols,
|
||||
viewport.rows,
|
||||
buffer.terminal_cols,
|
||||
buffer.terminal_rows,
|
||||
buffer.scrollback_offset,
|
||||
buffer.max_scrollback,
|
||||
if model.is_live() { "live" } else { "history" },
|
||||
output_updates.get()
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
node_ref=terminal_ref
|
||||
class="terminal-screen"
|
||||
tabindex="0"
|
||||
on:click=handle_click
|
||||
on:keydown=handle_keydown
|
||||
on:scroll=handle_scroll
|
||||
>
|
||||
<span node_ref=measure_ref class="terminal-measure" aria-hidden="true">
|
||||
{MEASURE_SAMPLE_TEXT}
|
||||
</span>
|
||||
<div class="terminal-grid">
|
||||
{move || render_terminal_view(snapshot.get())}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
fn metrics_from_core(
|
||||
viewport: TerminalViewport,
|
||||
core: &mut TerminalCore,
|
||||
) -> ScrollMetrics {
|
||||
let snapshot = core.snapshot_live();
|
||||
ScrollMetrics {
|
||||
viewport_rows: usize::from(viewport.rows),
|
||||
scrollback_rows: snapshot.max_scrollback,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_for_model(
|
||||
core: &mut TerminalCore,
|
||||
model: ScrollbackModel,
|
||||
viewport: TerminalViewport,
|
||||
) -> TerminalSnapshot {
|
||||
let metrics = metrics_from_core(viewport, core);
|
||||
if model.is_live() {
|
||||
core.snapshot_live()
|
||||
} else {
|
||||
core.snapshot_for_top_row(model.top_row().min(metrics.max_top_row()))
|
||||
}
|
||||
}
|
||||
|
||||
fn render_terminal_view(snapshot: TerminalSnapshot) -> impl IntoView {
|
||||
let metrics = ScrollMetrics {
|
||||
viewport_rows: usize::from(snapshot.terminal_rows),
|
||||
scrollback_rows: snapshot.max_scrollback,
|
||||
};
|
||||
|
||||
let mut model = ScrollbackModel::default();
|
||||
if snapshot.scrollback_offset == 0 {
|
||||
model.enter_live(metrics);
|
||||
} else {
|
||||
let top_row = metrics
|
||||
.max_top_row()
|
||||
.saturating_sub(snapshot.scrollback_offset);
|
||||
model.apply_scroll_top(
|
||||
(top_row as f64 * SCROLL_LINE_HEIGHT).round() as i32,
|
||||
(metrics.total_rows() as f64 * SCROLL_LINE_HEIGHT).round() as i32,
|
||||
(metrics.viewport_rows as f64 * SCROLL_LINE_HEIGHT).round() as i32,
|
||||
SCROLL_LINE_HEIGHT,
|
||||
metrics,
|
||||
);
|
||||
}
|
||||
let plan = model.render_plan(metrics);
|
||||
|
||||
view! {
|
||||
<div
|
||||
class="terminal-scroll-spacer"
|
||||
style=format!("height:{}px;", plan.top_spacer_rows as f64 * SCROLL_LINE_HEIGHT)
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{snapshot
|
||||
.rows
|
||||
.into_iter()
|
||||
.map(render_terminal_row)
|
||||
.collect_view()}
|
||||
<div
|
||||
class="terminal-scroll-spacer"
|
||||
style=format!("height:{}px;", plan.bottom_spacer_rows as f64 * SCROLL_LINE_HEIGHT)
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_terminal_row(row: TerminalRow) -> impl IntoView {
|
||||
view! {
|
||||
<div class="terminal-row">
|
||||
{row
|
||||
.segments
|
||||
.into_iter()
|
||||
.map(render_terminal_segment)
|
||||
.collect_view()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_terminal_segment(segment: TerminalSegment) -> impl IntoView {
|
||||
let style = format!(
|
||||
"color:{};background:{};font-weight:{};font-style:{};text-decoration:{};",
|
||||
segment.style.color,
|
||||
segment.style.background,
|
||||
if segment.style.bold { "700" } else { "400" },
|
||||
if segment.style.italic { "italic" } else { "normal" },
|
||||
if segment.style.underline { "underline" } else { "none" },
|
||||
);
|
||||
|
||||
view! {
|
||||
<span
|
||||
class="terminal-segment"
|
||||
class:terminal-cursor=segment.is_cursor
|
||||
style=style
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
fn websocket_url() -> String {
|
||||
let window = window();
|
||||
let location = window.location();
|
||||
let protocol = location.protocol().unwrap_or_else(|_| "http:".to_owned());
|
||||
let host = location.host().unwrap_or_else(|_| "127.0.0.1:3100".to_owned());
|
||||
let scheme = if protocol == "https:" { "wss" } else { "ws" };
|
||||
format!("{scheme}://{host}{}/terminal/ws", SiteConfig::BASE_PATH)
|
||||
}
|
||||
|
||||
fn send_terminal_message(socket: &WebSocket, message: &ClientTerminalMessage) {
|
||||
if let Ok(payload) = serde_json::to_string(message) {
|
||||
let _ = socket.send_with_str(&payload);
|
||||
}
|
||||
}
|
||||
|
||||
fn send_resize_message(socket: &WebSocket, viewport: TerminalViewport) {
|
||||
send_terminal_message(
|
||||
socket,
|
||||
&ClientTerminalMessage::Resize {
|
||||
cols: viewport.cols,
|
||||
rows: viewport.rows,
|
||||
pixel_width: viewport.pixel_width,
|
||||
pixel_height: viewport.pixel_height,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn resize_terminal_core(core: &mut TerminalCore, viewport: TerminalViewport) {
|
||||
core.resize(viewport.rows, viewport.cols);
|
||||
}
|
||||
|
||||
fn measure_terminal_viewport(
|
||||
terminal_ref: NodeRef<html::Div>,
|
||||
measure_ref: NodeRef<html::Span>,
|
||||
) -> TerminalViewport {
|
||||
let Some(element) = terminal_ref.get_untracked() else {
|
||||
return TerminalViewport::default();
|
||||
};
|
||||
let Some(element) = element.dyn_ref::<HtmlElement>() else {
|
||||
return TerminalViewport::default();
|
||||
};
|
||||
|
||||
measure_terminal_viewport_from_rect(
|
||||
f64::from(element.client_width().max(0)),
|
||||
f64::from(element.client_height().max(0)),
|
||||
measure_ref,
|
||||
)
|
||||
}
|
||||
|
||||
fn measure_terminal_viewport_from_rect(
|
||||
width: f64,
|
||||
height: f64,
|
||||
measure_ref: NodeRef<html::Span>,
|
||||
) -> TerminalViewport {
|
||||
let (cell_width, cell_height) = measure_cell_size(measure_ref);
|
||||
let safe_width = (width - 2.0).max(cell_width);
|
||||
let safe_height = (height - 2.0).max(cell_height);
|
||||
let cols = ((safe_width / cell_width).floor().max(1.0) as u16).max(MIN_COLS);
|
||||
let rows = ((safe_height / cell_height).floor().max(1.0) as u16).max(MIN_ROWS);
|
||||
|
||||
TerminalViewport {
|
||||
cols,
|
||||
rows,
|
||||
pixel_width: safe_width.floor().max(1.0) as u16,
|
||||
pixel_height: safe_height.floor().max(1.0) as u16,
|
||||
}
|
||||
}
|
||||
|
||||
fn measure_cell_size(measure_ref: NodeRef<html::Span>) -> (f64, f64) {
|
||||
let Some(element) = measure_ref.get_untracked() else {
|
||||
return (DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT);
|
||||
};
|
||||
let Some(element) = element.dyn_ref::<HtmlElement>() else {
|
||||
return (DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT);
|
||||
};
|
||||
|
||||
let width = f64::from(element.offset_width().max(0));
|
||||
let height = f64::from(element.offset_height().max(0));
|
||||
|
||||
if width <= 0.0 || height <= 0.0 {
|
||||
return (DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT);
|
||||
}
|
||||
|
||||
(
|
||||
width / (MEASURE_SAMPLE_TEXT.chars().count() as f64),
|
||||
height,
|
||||
)
|
||||
}
|
||||
|
||||
fn focus_terminal(terminal_ref: NodeRef<html::Div>) {
|
||||
if let Some(element) = terminal_ref.get_untracked() {
|
||||
let _ = element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_scroll_to_model(
|
||||
terminal_ref: NodeRef<html::Div>,
|
||||
snapshot: TerminalSnapshot,
|
||||
model: ScrollbackModel,
|
||||
viewport: TerminalViewport,
|
||||
ignore_next_scroll: RwSignal<bool>,
|
||||
) {
|
||||
let callback = Closure::<dyn FnMut()>::wrap(Box::new(move || {
|
||||
let Some(element) = terminal_ref.get_untracked() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let metrics = ScrollMetrics {
|
||||
viewport_rows: usize::from(viewport.rows),
|
||||
scrollback_rows: snapshot.max_scrollback,
|
||||
};
|
||||
let scroll_top = model.target_scroll_top(
|
||||
element.scroll_height(),
|
||||
element.client_height(),
|
||||
SCROLL_LINE_HEIGHT,
|
||||
metrics,
|
||||
);
|
||||
ignore_next_scroll.set(true);
|
||||
element.set_scroll_top(scroll_top);
|
||||
}));
|
||||
|
||||
let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
callback.as_ref().unchecked_ref(),
|
||||
0,
|
||||
);
|
||||
callback.forget();
|
||||
}
|
||||
|
||||
fn map_key_to_terminal_input(event: &KeyboardEvent) -> Option<String> {
|
||||
let key = event.key();
|
||||
|
||||
if event.ctrl_key() && !event.alt_key() {
|
||||
return match key.as_str() {
|
||||
"c" | "C" => Some("\u{3}".to_owned()),
|
||||
"d" | "D" => Some("\u{4}".to_owned()),
|
||||
"l" | "L" => Some("\u{c}".to_owned()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
|
||||
match key.as_str() {
|
||||
"Enter" => Some("\r".to_owned()),
|
||||
"Backspace" => Some("\u{7f}".to_owned()),
|
||||
"Tab" => Some("\t".to_owned()),
|
||||
"ArrowUp" => Some("\u{1b}[A".to_owned()),
|
||||
"ArrowDown" => Some("\u{1b}[B".to_owned()),
|
||||
"ArrowRight" => Some("\u{1b}[C".to_owned()),
|
||||
"ArrowLeft" => Some("\u{1b}[D".to_owned()),
|
||||
"Escape" => Some("\u{1b}".to_owned()),
|
||||
_ if key.chars().count() == 1 && !event.meta_key() => Some(key),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
312
app/src/terminal/core.rs
Normal file
312
app/src/terminal/core.rs
Normal file
@@ -0,0 +1,312 @@
|
||||
use vt100::{Color, Parser};
|
||||
|
||||
pub const DEFAULT_ROWS: u16 = 24;
|
||||
pub const DEFAULT_COLS: u16 = 80;
|
||||
const DEFAULT_SCROLLBACK: usize = 2_000;
|
||||
|
||||
pub struct TerminalCore {
|
||||
parser: Parser,
|
||||
}
|
||||
|
||||
impl TerminalCore {
|
||||
pub fn new(rows: u16, cols: u16) -> Self {
|
||||
Self {
|
||||
parser: Parser::new(rows, cols, DEFAULT_SCROLLBACK),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn process(&mut self, bytes: &[u8]) {
|
||||
self.parser.process(bytes);
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, rows: u16, cols: u16) {
|
||||
self.parser.set_size(rows, cols);
|
||||
}
|
||||
|
||||
pub fn set_scrollback(&mut self, rows: usize) {
|
||||
self.parser.set_scrollback(rows);
|
||||
}
|
||||
|
||||
pub fn snapshot(&mut self) -> TerminalSnapshot {
|
||||
self.snapshot_live()
|
||||
}
|
||||
|
||||
pub fn snapshot_live(&mut self) -> TerminalSnapshot {
|
||||
self.snapshot_for_top_row_internal(None)
|
||||
}
|
||||
|
||||
pub fn snapshot_for_top_row(&mut self, top_row: usize) -> TerminalSnapshot {
|
||||
self.snapshot_for_top_row_internal(Some(top_row))
|
||||
}
|
||||
|
||||
fn snapshot_for_top_row_internal(
|
||||
&mut self,
|
||||
requested_top_row: Option<usize>,
|
||||
) -> TerminalSnapshot {
|
||||
self.parser.set_scrollback(usize::MAX);
|
||||
let max_scrollback = self.parser.screen().scrollback();
|
||||
let resolved_top_row =
|
||||
requested_top_row.unwrap_or(max_scrollback).min(max_scrollback);
|
||||
let current_scrollback = max_scrollback.saturating_sub(resolved_top_row);
|
||||
self.parser.set_scrollback(current_scrollback);
|
||||
|
||||
let screen = self.parser.screen();
|
||||
let (rows, cols) = screen.size();
|
||||
let (cursor_row, cursor_col) = screen.cursor_position();
|
||||
let hide_cursor = screen.hide_cursor();
|
||||
let mut rendered_rows = Vec::with_capacity(usize::from(rows));
|
||||
|
||||
for row in 0..rows {
|
||||
let mut segments = Vec::new();
|
||||
let mut current_style: Option<TerminalStyle> = None;
|
||||
let mut current_cursor = false;
|
||||
let mut current_text = String::new();
|
||||
|
||||
for col in 0..cols {
|
||||
let Some(cell) = screen.cell(row, col) else {
|
||||
continue;
|
||||
};
|
||||
if cell.is_wide_continuation() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_cursor =
|
||||
!hide_cursor && cursor_row == row && cursor_col == col;
|
||||
let style = style_from_cell(cell);
|
||||
let text = if cell.has_contents() {
|
||||
cell.contents()
|
||||
} else {
|
||||
" ".to_owned()
|
||||
};
|
||||
|
||||
if current_style.as_ref() == Some(&style)
|
||||
&& current_cursor == is_cursor
|
||||
{
|
||||
current_text.push_str(&text);
|
||||
} else {
|
||||
flush_segment(
|
||||
&mut segments,
|
||||
&mut current_style,
|
||||
&mut current_text,
|
||||
current_cursor,
|
||||
);
|
||||
current_style = Some(style);
|
||||
current_cursor = is_cursor;
|
||||
current_text = text;
|
||||
}
|
||||
}
|
||||
|
||||
if !hide_cursor && cursor_row == row && cursor_col >= cols {
|
||||
flush_segment(
|
||||
&mut segments,
|
||||
&mut current_style,
|
||||
&mut current_text,
|
||||
current_cursor,
|
||||
);
|
||||
segments.push(TerminalSegment {
|
||||
text: " ".to_owned(),
|
||||
style: TerminalStyle::default(),
|
||||
is_cursor: true,
|
||||
});
|
||||
} else {
|
||||
flush_segment(
|
||||
&mut segments,
|
||||
&mut current_style,
|
||||
&mut current_text,
|
||||
current_cursor,
|
||||
);
|
||||
}
|
||||
|
||||
if segments.is_empty() {
|
||||
segments.push(TerminalSegment {
|
||||
text: " ".to_owned(),
|
||||
style: TerminalStyle::default(),
|
||||
is_cursor: false,
|
||||
});
|
||||
}
|
||||
|
||||
rendered_rows.push(TerminalRow { segments });
|
||||
}
|
||||
|
||||
TerminalSnapshot {
|
||||
rows: rendered_rows,
|
||||
title: screen.title().to_owned(),
|
||||
cursor_row,
|
||||
cursor_col,
|
||||
terminal_rows: rows,
|
||||
terminal_cols: cols,
|
||||
hide_cursor,
|
||||
scrollback_offset: current_scrollback,
|
||||
max_scrollback,
|
||||
alternate_screen: screen.alternate_screen(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TerminalCore {
|
||||
fn default() -> Self {
|
||||
Self::new(DEFAULT_ROWS, DEFAULT_COLS)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct TerminalSnapshot {
|
||||
pub rows: Vec<TerminalRow>,
|
||||
pub title: String,
|
||||
pub cursor_row: u16,
|
||||
pub cursor_col: u16,
|
||||
pub terminal_rows: u16,
|
||||
pub terminal_cols: u16,
|
||||
pub hide_cursor: bool,
|
||||
pub scrollback_offset: usize,
|
||||
pub max_scrollback: usize,
|
||||
pub alternate_screen: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct TerminalRow {
|
||||
pub segments: Vec<TerminalSegment>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TerminalSegment {
|
||||
pub text: String,
|
||||
pub style: TerminalStyle,
|
||||
pub is_cursor: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TerminalStyle {
|
||||
pub color: String,
|
||||
pub background: String,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
pub underline: bool,
|
||||
}
|
||||
|
||||
impl Default for TerminalStyle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
color: "#dbeafe".to_owned(),
|
||||
background: "transparent".to_owned(),
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_segment(
|
||||
segments: &mut Vec<TerminalSegment>,
|
||||
current_style: &mut Option<TerminalStyle>,
|
||||
current_text: &mut String,
|
||||
is_cursor: bool,
|
||||
) {
|
||||
if current_text.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let style = current_style.clone().unwrap_or_default();
|
||||
let text = std::mem::take(current_text);
|
||||
segments.push(TerminalSegment {
|
||||
text,
|
||||
style,
|
||||
is_cursor,
|
||||
});
|
||||
}
|
||||
|
||||
fn style_from_cell(cell: &vt100::Cell) -> TerminalStyle {
|
||||
let color = if cell.inverse() {
|
||||
resolve_background(cell.bgcolor(), false)
|
||||
} else {
|
||||
resolve_foreground(cell.fgcolor())
|
||||
};
|
||||
let background = if cell.inverse() {
|
||||
resolve_foreground(cell.fgcolor())
|
||||
} else {
|
||||
resolve_background(cell.bgcolor(), true)
|
||||
};
|
||||
|
||||
TerminalStyle {
|
||||
color,
|
||||
background,
|
||||
bold: cell.bold(),
|
||||
italic: cell.italic(),
|
||||
underline: cell.underline(),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_foreground(color: Color) -> String {
|
||||
match color {
|
||||
Color::Default => "#dbeafe".to_owned(),
|
||||
Color::Idx(index) => indexed_color(index),
|
||||
Color::Rgb(red, green, blue) => {
|
||||
format!("#{red:02x}{green:02x}{blue:02x}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_background(color: Color, transparent_default: bool) -> String {
|
||||
match color {
|
||||
Color::Default => {
|
||||
if transparent_default {
|
||||
"transparent".to_owned()
|
||||
} else {
|
||||
"#0f172a".to_owned()
|
||||
}
|
||||
}
|
||||
Color::Idx(index) => indexed_color(index),
|
||||
Color::Rgb(red, green, blue) => {
|
||||
format!("#{red:02x}{green:02x}{blue:02x}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn indexed_color(index: u8) -> String {
|
||||
const ANSI_16: [&str; 16] = [
|
||||
"#1e293b",
|
||||
"#ef4444",
|
||||
"#22c55e",
|
||||
"#eab308",
|
||||
"#3b82f6",
|
||||
"#d946ef",
|
||||
"#06b6d4",
|
||||
"#e2e8f0",
|
||||
"#475569",
|
||||
"#f87171",
|
||||
"#4ade80",
|
||||
"#fde047",
|
||||
"#60a5fa",
|
||||
"#e879f9",
|
||||
"#67e8f9",
|
||||
"#f8fafc",
|
||||
];
|
||||
|
||||
match index {
|
||||
0..=15 => ANSI_16[usize::from(index)].to_owned(),
|
||||
16..=231 => {
|
||||
let base = index - 16;
|
||||
let red = base / 36;
|
||||
let green = (base % 36) / 6;
|
||||
let blue = base % 6;
|
||||
let convert = |value: u8| -> u8 {
|
||||
if value == 0 {
|
||||
0
|
||||
} else {
|
||||
value.saturating_mul(40).saturating_add(55)
|
||||
}
|
||||
};
|
||||
|
||||
format!(
|
||||
"#{:02x}{:02x}{:02x}",
|
||||
convert(red),
|
||||
convert(green),
|
||||
convert(blue)
|
||||
)
|
||||
}
|
||||
232..=255 => {
|
||||
let shade = 8_u8.saturating_add((index - 232).saturating_mul(10));
|
||||
format!("#{shade:02x}{shade:02x}{shade:02x}")
|
||||
}
|
||||
}
|
||||
}
|
||||
6
app/src/terminal/mod.rs
Normal file
6
app/src/terminal/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod component;
|
||||
pub mod core;
|
||||
pub mod protocol;
|
||||
pub mod scrollback;
|
||||
|
||||
pub use component::TerminalPanel;
|
||||
31
app/src/terminal/protocol.rs
Normal file
31
app/src/terminal/protocol.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ClientTerminalMessage {
|
||||
Input {
|
||||
data: String,
|
||||
},
|
||||
Resize {
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
pixel_width: u16,
|
||||
pixel_height: u16,
|
||||
},
|
||||
Ping,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ServerTerminalMessage {
|
||||
Output {
|
||||
data: String,
|
||||
},
|
||||
Exit {
|
||||
code: Option<i32>,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
Pong,
|
||||
}
|
||||
126
app/src/terminal/scrollback.rs
Normal file
126
app/src/terminal/scrollback.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
const LIVE_SCROLL_THRESHOLD_PX: i32 = 24;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ScrollMetrics {
|
||||
pub viewport_rows: usize,
|
||||
pub scrollback_rows: usize,
|
||||
}
|
||||
|
||||
impl ScrollMetrics {
|
||||
pub fn total_rows(self) -> usize {
|
||||
self.scrollback_rows.saturating_add(self.viewport_rows)
|
||||
}
|
||||
|
||||
pub fn max_top_row(self) -> usize {
|
||||
self.total_rows().saturating_sub(self.viewport_rows)
|
||||
}
|
||||
|
||||
pub fn max_scrollback_offset(self) -> usize {
|
||||
self.scrollback_rows
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ScrollRenderPlan {
|
||||
pub top_spacer_rows: usize,
|
||||
pub bottom_spacer_rows: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ScrollbackModel {
|
||||
live: bool,
|
||||
top_row: usize,
|
||||
}
|
||||
|
||||
impl Default for ScrollbackModel {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
live: true,
|
||||
top_row: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ScrollbackModel {
|
||||
pub fn is_live(self) -> bool {
|
||||
self.live
|
||||
}
|
||||
|
||||
pub fn top_row(self) -> usize {
|
||||
self.top_row
|
||||
}
|
||||
|
||||
pub fn enter_live(&mut self, metrics: ScrollMetrics) {
|
||||
self.live = true;
|
||||
self.top_row = metrics.max_top_row();
|
||||
}
|
||||
|
||||
pub fn normalize(&mut self, metrics: ScrollMetrics) {
|
||||
if self.live {
|
||||
self.top_row = metrics.max_top_row();
|
||||
} else {
|
||||
self.top_row = self.top_row.min(metrics.max_top_row());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_scroll_top(
|
||||
&mut self,
|
||||
scroll_top: i32,
|
||||
scroll_height: i32,
|
||||
client_height: i32,
|
||||
line_height_px: f64,
|
||||
metrics: ScrollMetrics,
|
||||
) {
|
||||
let distance_from_bottom =
|
||||
(scroll_height - client_height - scroll_top).max(0);
|
||||
if distance_from_bottom <= LIVE_SCROLL_THRESHOLD_PX {
|
||||
self.enter_live(metrics);
|
||||
return;
|
||||
}
|
||||
|
||||
self.live = false;
|
||||
self.top_row = scroll_top_to_row(scroll_top, line_height_px)
|
||||
.min(metrics.max_top_row());
|
||||
}
|
||||
|
||||
pub fn target_scroll_top(
|
||||
self,
|
||||
scroll_height: i32,
|
||||
client_height: i32,
|
||||
line_height_px: f64,
|
||||
metrics: ScrollMetrics,
|
||||
) -> i32 {
|
||||
if self.live {
|
||||
return (scroll_height - client_height).max(0);
|
||||
}
|
||||
|
||||
let max_scroll_top = (scroll_height - client_height).max(0);
|
||||
row_to_scroll_top(self.top_row.min(metrics.max_top_row()), line_height_px)
|
||||
.min(max_scroll_top)
|
||||
}
|
||||
|
||||
pub fn current_scrollback_offset(self, metrics: ScrollMetrics) -> usize {
|
||||
metrics.max_top_row().saturating_sub(self.top_row)
|
||||
}
|
||||
|
||||
pub fn render_plan(self, metrics: ScrollMetrics) -> ScrollRenderPlan {
|
||||
let top_spacer_rows = self.top_row.min(metrics.max_top_row());
|
||||
let bottom_spacer_rows = metrics
|
||||
.total_rows()
|
||||
.saturating_sub(top_spacer_rows)
|
||||
.saturating_sub(metrics.viewport_rows);
|
||||
|
||||
ScrollRenderPlan {
|
||||
top_spacer_rows,
|
||||
bottom_spacer_rows,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_top_to_row(scroll_top: i32, line_height_px: f64) -> usize {
|
||||
((f64::from(scroll_top.max(0)) / line_height_px.max(1.0)).round()) as usize
|
||||
}
|
||||
|
||||
pub fn row_to_scroll_top(row: usize, line_height_px: f64) -> i32 {
|
||||
((row as f64) * line_height_px).round() as i32
|
||||
}
|
||||
6
app_config/Cargo.toml
Normal file
6
app_config/Cargo.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "app_config"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
18
app_config/src/lib.rs
Normal file
18
app_config/src/lib.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
pub struct SiteConfig;
|
||||
|
||||
impl SiteConfig {
|
||||
pub const TITLE: &str = "BASE_PATH Demo";
|
||||
pub const DESCRIPTION: &str = "A small Leptos SSR app for validating sub-path deployment.";
|
||||
pub const BASE_URL: &str = "http://127.0.0.1:3100";
|
||||
pub const BASE_PATH: &str = "/rustui";
|
||||
|
||||
pub fn with_base_path(path: &str) -> String {
|
||||
if Self::BASE_PATH.is_empty() {
|
||||
path.to_owned()
|
||||
} else if path == "/" {
|
||||
Self::BASE_PATH.to_owned()
|
||||
} else {
|
||||
format!("{}{}", Self::BASE_PATH, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
7
package.json
Normal file
7
package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "4.3.0",
|
||||
"tailwindcss": "4.3.0"
|
||||
}
|
||||
}
|
||||
26
public/header.svg
Normal file
26
public/header.svg
Normal file
@@ -0,0 +1,26 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.72131 7.97323L7.77543 10.3299L16.0003 5.54456L24.2738 10.3582L28.3342 8.00521L16.0003 0.829102L3.72131 7.97323Z" fill="url(#paint0_linear_300_8367)"/>
|
||||
<path d="M13.8691 13.9176L16.0001 12.6777L18.1442 13.9252L21.9243 11.7157L16.0001 8.2689L10.0962 11.7039L13.8691 13.9176Z" fill="url(#paint1_linear_300_8367)"/>
|
||||
<path d="M25.2219 21.6404V12.0299L29.2742 9.68164V23.998L16.9482 31.1695V26.4541L25.2219 21.6404Z" fill="#1874FF"/>
|
||||
<path d="M19.0916 18.0737V15.6176L19.092 15.6323L22.8805 13.4055V20.2782L16.9482 23.7297V19.3208L19.0916 18.0737Z" fill="#1874FF"/>
|
||||
<path d="M15.0518 26.4536L6.77814 21.6399V11.9728L2.72583 9.61719V23.9975L15.0518 31.169V26.4536Z" fill="url(#paint2_linear_300_8367)"/>
|
||||
<path d="M9.11923 13.3376V20.2777L15.0518 23.7293V19.3205L12.9081 18.0732V15.55L9.11923 13.3376Z" fill="url(#paint3_linear_300_8367)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_300_8367" x1="14.104" y1="4.62169" x2="19.7265" y2="13.0418" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#06C3FF"/>
|
||||
<stop offset="1" stop-color="#0B60F3"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_300_8367" x1="14.104" y1="4.62169" x2="19.7265" y2="13.0418" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#06C3FF"/>
|
||||
<stop offset="1" stop-color="#0B60F3"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_300_8367" x1="10.311" y1="15.5246" x2="10.7851" y2="28.7987" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF8F90"/>
|
||||
<stop offset="1" stop-color="#F62F3F"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_300_8367" x1="10.311" y1="15.5246" x2="10.7851" y2="28.7987" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF8F90"/>
|
||||
<stop offset="1" stop-color="#F62F3F"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
9
public/project-card-cover.svg
Normal file
9
public/project-card-cover.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.6 MiB |
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow:
|
||||
3
rust-toolchain.toml
Normal file
3
rust-toolchain.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
targets = ["wasm32-unknown-unknown"]
|
||||
20
server/Cargo.toml
Normal file
20
server/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "server"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
app = { path = "../app", default-features = false, features = ["ssr"] }
|
||||
app_config.workspace = true
|
||||
axum.workspace = true
|
||||
futures-util.workspace = true
|
||||
leptos = { workspace = true, features = ["ssr"] }
|
||||
leptos_axum.workspace = true
|
||||
portable-pty.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tower.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
dotenvy = "0.15"
|
||||
93
server/src/main.rs
Normal file
93
server/src/main.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use app::app::App;
|
||||
use app::shell::shell;
|
||||
use app_config::SiteConfig;
|
||||
use axum::Router;
|
||||
use leptos::logging::log;
|
||||
use leptos::prelude::*;
|
||||
use leptos_axum::{LeptosRoutes, generate_route_list, handle_server_fns};
|
||||
|
||||
mod terminal;
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
load_env_files();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::from_default_env().add_directive("info".parse()?),
|
||||
)
|
||||
.init();
|
||||
|
||||
let conf = get_configuration(None).map_err(|err| {
|
||||
tracing::error!("Failed to get Leptos configuration: {err:?}");
|
||||
err
|
||||
})?;
|
||||
let addr = conf.leptos_options.site_addr;
|
||||
let leptos_options = conf.leptos_options;
|
||||
let routes = generate_route_list(App);
|
||||
|
||||
let leptos_router = Router::new()
|
||||
.leptos_routes(&leptos_options, routes, {
|
||||
let leptos_options = leptos_options.clone();
|
||||
move || shell(leptos_options.clone())
|
||||
})
|
||||
.fallback(leptos_axum::file_and_error_handler(shell))
|
||||
.with_state(leptos_options);
|
||||
|
||||
let terminal_routes = Router::new().route(
|
||||
"/terminal/ws",
|
||||
axum::routing::get(terminal::ws::terminal_ws_handler),
|
||||
);
|
||||
|
||||
let app = if SiteConfig::BASE_PATH.is_empty() {
|
||||
terminal_routes.merge(leptos_router)
|
||||
} else {
|
||||
Router::new()
|
||||
.route(
|
||||
"/rustui/api/{*fn_name}",
|
||||
axum::routing::post(handle_server_fns),
|
||||
)
|
||||
.route("/api/{*fn_name}", axum::routing::post(handle_server_fns))
|
||||
.merge(terminal_routes.clone())
|
||||
.nest(SiteConfig::BASE_PATH, terminal_routes.merge(leptos_router))
|
||||
};
|
||||
|
||||
log!(
|
||||
"BASE_PATH demo listening on http://{addr}{}",
|
||||
SiteConfig::BASE_PATH
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
axum::serve(listener, app.into_make_service()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_env_files() {
|
||||
for filename in [".env", ".env.local"] {
|
||||
let _ = dotenvy::from_filename(filename);
|
||||
}
|
||||
|
||||
let env = std::env::var("NEXT_PUBLIC_ENV").unwrap_or_else(|_| "development".to_owned());
|
||||
|
||||
for filename in [format!(".env.{env}"), format!(".env.{env}.local")] {
|
||||
let _ = dotenvy::from_filename(&filename);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn root_path_gets_base_path() {
|
||||
assert_eq!(SiteConfig::with_base_path("/"), "/rustui");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_path_gets_base_path() {
|
||||
assert_eq!(
|
||||
SiteConfig::with_base_path("/deep/nested"),
|
||||
"/rustui/deep/nested"
|
||||
);
|
||||
}
|
||||
}
|
||||
2
server/src/terminal/mod.rs
Normal file
2
server/src/terminal/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod pty_session;
|
||||
pub mod ws;
|
||||
149
server/src/terminal/pty_session.rs
Normal file
149
server/src/terminal/pty_session.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use portable_pty::{
|
||||
ChildKiller, CommandBuilder, MasterPty, PtySize, native_pty_system,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct TerminalSize {
|
||||
pub cols: u16,
|
||||
pub rows: u16,
|
||||
pub pixel_width: u16,
|
||||
pub pixel_height: u16,
|
||||
}
|
||||
|
||||
impl Default for TerminalSize {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
pixel_width: 720,
|
||||
pixel_height: 432,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalSize {
|
||||
fn into_pty_size(self) -> PtySize {
|
||||
PtySize {
|
||||
cols: self.cols,
|
||||
rows: self.rows,
|
||||
pixel_width: self.pixel_width,
|
||||
pixel_height: self.pixel_height,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PtySession {
|
||||
master: Box<dyn MasterPty + Send>,
|
||||
writer: Arc<Mutex<Box<dyn Write + Send>>>,
|
||||
killer: Arc<Mutex<Box<dyn ChildKiller + Send + Sync>>>,
|
||||
}
|
||||
|
||||
impl PtySession {
|
||||
pub fn spawn(
|
||||
size: TerminalSize,
|
||||
) -> Result<(Self, mpsc::UnboundedReceiver<Vec<u8>>), String> {
|
||||
let pty_system = native_pty_system();
|
||||
let pair = pty_system
|
||||
.openpty(size.into_pty_size())
|
||||
.map_err(|error| format!("failed to open pty: {error}"))?;
|
||||
|
||||
let shell = std::env::var("SHELL")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "bash".to_owned());
|
||||
|
||||
let command = CommandBuilder::new(shell);
|
||||
let child = pair
|
||||
.slave
|
||||
.spawn_command(command)
|
||||
.map_err(|error| format!("failed to spawn shell: {error}"))?;
|
||||
drop(pair.slave);
|
||||
|
||||
let mut reader = pair
|
||||
.master
|
||||
.try_clone_reader()
|
||||
.map_err(|error| format!("failed to clone pty reader: {error}"))?;
|
||||
let writer = pair
|
||||
.master
|
||||
.take_writer()
|
||||
.map_err(|error| format!("failed to take pty writer: {error}"))?;
|
||||
let killer = child.clone_killer();
|
||||
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let child_handle = Arc::new(Mutex::new(child));
|
||||
let child_for_thread = child_handle.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut buffer = [0_u8; 4096];
|
||||
|
||||
loop {
|
||||
match reader.read(&mut buffer) {
|
||||
Ok(0) => break,
|
||||
Ok(read) => {
|
||||
if tx.send(buffer[..read].to_vec()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = tx.send(
|
||||
format!("\r\n[terminal read error] {error}\r\n").into_bytes(),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let exit_code = child_for_thread
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut child| child.wait().ok())
|
||||
.map(|status| status.exit_code() as i32);
|
||||
|
||||
let payload = serde_json::to_vec(&app::terminal::protocol::ServerTerminalMessage::Exit {
|
||||
code: exit_code,
|
||||
})
|
||||
.unwrap_or_else(|_| {
|
||||
br#"{"type":"exit","code":null}"#.to_vec()
|
||||
});
|
||||
let _ = tx.send(payload);
|
||||
});
|
||||
|
||||
Ok((
|
||||
Self {
|
||||
master: pair.master,
|
||||
writer: Arc::new(Mutex::new(writer)),
|
||||
killer: Arc::new(Mutex::new(killer)),
|
||||
},
|
||||
rx,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn resize(&self, size: TerminalSize) -> Result<(), String> {
|
||||
self.master
|
||||
.resize(size.into_pty_size())
|
||||
.map_err(|error| format!("failed to resize pty: {error}"))
|
||||
}
|
||||
|
||||
pub fn write(&self, data: &str) -> Result<(), String> {
|
||||
let mut writer = self
|
||||
.writer
|
||||
.lock()
|
||||
.map_err(|_| "failed to lock pty writer".to_owned())?;
|
||||
writer
|
||||
.write_all(data.as_bytes())
|
||||
.map_err(|error| format!("failed to write to pty: {error}"))?;
|
||||
writer
|
||||
.flush()
|
||||
.map_err(|error| format!("failed to flush pty writer: {error}"))
|
||||
}
|
||||
|
||||
pub fn kill(&self) {
|
||||
if let Ok(mut killer) = self.killer.lock() {
|
||||
let _ = killer.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
103
server/src/terminal/ws.rs
Normal file
103
server/src/terminal/ws.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
use app::terminal::protocol::{ClientTerminalMessage, ServerTerminalMessage};
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::response::Response;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::pty_session::{PtySession, TerminalSize};
|
||||
|
||||
pub async fn terminal_ws_handler(ws: WebSocketUpgrade) -> Response {
|
||||
ws.on_upgrade(handle_terminal_socket)
|
||||
}
|
||||
|
||||
async fn handle_terminal_socket(socket: WebSocket) {
|
||||
let Ok((session, mut output_rx)) = PtySession::spawn(TerminalSize::default()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
let (server_tx, mut server_rx) = mpsc::unbounded_channel::<ServerTerminalMessage>();
|
||||
|
||||
let output_forwarder = {
|
||||
let server_tx = server_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(chunk) = output_rx.recv().await {
|
||||
if let Ok(message) = serde_json::from_slice::<ServerTerminalMessage>(&chunk) {
|
||||
if server_tx.send(message).is_err() {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let payload = String::from_utf8_lossy(&chunk).into_owned();
|
||||
if server_tx
|
||||
.send(ServerTerminalMessage::Output { data: payload })
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let send_task = tokio::spawn(async move {
|
||||
while let Some(message) = server_rx.recv().await {
|
||||
let Ok(payload) = serde_json::to_string(&message) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if sender.send(Message::Text(payload.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
while let Some(Ok(message)) = receiver.next().await {
|
||||
match message {
|
||||
Message::Text(text) => {
|
||||
let Ok(client_message) =
|
||||
serde_json::from_str::<ClientTerminalMessage>(text.as_str())
|
||||
else {
|
||||
let _ = server_tx.send(ServerTerminalMessage::Error {
|
||||
message: "invalid terminal message".to_owned(),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
|
||||
match client_message {
|
||||
ClientTerminalMessage::Input { data } => {
|
||||
if let Err(error) = session.write(&data) {
|
||||
let _ = server_tx
|
||||
.send(ServerTerminalMessage::Error { message: error });
|
||||
}
|
||||
}
|
||||
ClientTerminalMessage::Resize {
|
||||
cols,
|
||||
rows,
|
||||
pixel_width,
|
||||
pixel_height,
|
||||
} => {
|
||||
if let Err(error) = session.resize(TerminalSize {
|
||||
cols,
|
||||
rows,
|
||||
pixel_width,
|
||||
pixel_height,
|
||||
}) {
|
||||
let _ = server_tx
|
||||
.send(ServerTerminalMessage::Error { message: error });
|
||||
}
|
||||
}
|
||||
ClientTerminalMessage::Ping => {
|
||||
let _ = server_tx.send(ServerTerminalMessage::Pong);
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
session.kill();
|
||||
output_forwarder.abort();
|
||||
send_task.abort();
|
||||
}
|
||||
1296
style/tailwind.css
Normal file
1296
style/tailwind.css
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user