feat: add rust browser terminal prototype

This commit is contained in:
zhangheng
2026-06-08 16:34:13 +08:00
commit fd056ce502
43 changed files with 10312 additions and 0 deletions

1
app/src/api/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod project;

490
app/src/api/project.rs Normal file
View 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
View 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
View 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()
}
}
}

View 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>
}
}

View 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()
}
}}
}
}

View File

@@ -0,0 +1,7 @@
mod breadcrumb;
mod header;
mod shell;
mod sidebar;
pub use breadcrumb::Breadcrumb;
pub use shell::AdminShell;

View 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>
}
}

View 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(&current_path.get(), item)
});
let child_active = Memo::new(move |_| {
is_child_route_active(&current_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(&current_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(&current_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>
}
}

View File

@@ -0,0 +1 @@
pub mod layout;

19
app/src/lib.rs Normal file
View 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
View 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
View File

@@ -0,0 +1,2 @@
pub mod project_list;
pub mod terminal;

File diff suppressed because it is too large Load Diff

16
app/src/pages/terminal.rs Normal file
View 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
View 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>
}
}

View 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
View 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
View File

@@ -0,0 +1,6 @@
pub mod component;
pub mod core;
pub mod protocol;
pub mod scrollback;
pub use component::TerminalPanel;

View 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,
}

View 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
}