refactor: 合并为单 crate,前端改为内嵌 React 控制台

合并 stash-cli / stash-core 为根目录单 crate(lib + bin),新增 React + Vite + TS 控制台替换 Askama 模板。
build.rs 用 walkdir 把 stash-web/dist 同步到 $OUT_DIR/web,由 include_dir! 编译期内嵌,产出单文件二进制。
路由改为 / 与 /assets/** 走内嵌前端,其余交给 ServeDir,缺失路径返回内置 404。同步更新依赖、.gitignore 与 README。
This commit is contained in:
2026-10-21 17:59:08 +08:00
parent 92c510e959
commit e522a98f6f
48 changed files with 5113 additions and 273 deletions
+160
View File
@@ -0,0 +1,160 @@
use serde::Serialize;
use std::{fmt::Write as _, path::Path};
#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
pub struct AppProject {
pub name: String,
pub route_path: String,
}
impl AppProject {
pub fn initial(&self) -> String {
self.name
.chars()
.next()
.unwrap_or('A')
.to_uppercase()
.to_string()
}
}
pub fn scan_projects(root: &Path) -> Vec<AppProject> {
let mut projects = Vec::new();
let Ok(read_dir) = std::fs::read_dir(root) else {
return projects;
};
for item in read_dir.flatten() {
let path = item.path();
let name = item.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
if path.is_dir() {
let has_index = path.join("index.html").exists() || path.join("index.htm").exists();
if has_index {
let route_path = format!("/{}/", encode_path_segment(&name));
projects.push(AppProject { name, route_path });
}
}
}
projects.sort_by(|a, b| {
a.name
.to_lowercase()
.cmp(&b.name.to_lowercase())
.then_with(|| a.name.cmp(&b.name))
});
projects
}
fn encode_path_segment(segment: &str) -> String {
let mut encoded = String::with_capacity(segment.len());
for byte in segment.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
encoded.push(char::from(byte));
} else {
write!(&mut encoded, "%{byte:02X}").expect("写入字符串不会失败");
}
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
};
static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
struct TestDir {
path: PathBuf,
}
impl TestDir {
fn new() -> Self {
let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("stash-core-test-{}-{id}", std::process::id()));
fs::create_dir_all(&path).expect("应能创建测试目录");
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
fn add_project(&self, name: &str, index_file: &str) {
let project = self.path.join(name);
fs::create_dir_all(&project).expect("应能创建项目目录");
fs::write(project.join(index_file), "<!doctype html>").expect("应能创建入口文件");
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
#[test]
fn scan_projects_detects_only_immediate_visible_projects() {
let root = TestDir::new();
root.add_project("with-html", "index.html");
root.add_project("with-htm", "index.htm");
root.add_project(".hidden", "index.html");
root.add_project("no-index", "readme.txt");
root.add_project("parent/child", "index.html");
fs::create_dir_all(root.path().join("plain-directory")).expect("应能创建普通目录");
let names: Vec<_> = scan_projects(root.path())
.into_iter()
.map(|project| project.name)
.collect();
assert_eq!(names, vec!["with-htm", "with-html"]);
}
#[test]
fn scan_projects_sorts_names_case_insensitively() {
let root = TestDir::new();
root.add_project("zeta", "index.html");
root.add_project("ALPHA", "index.html");
root.add_project("middle", "index.html");
let names: Vec<_> = scan_projects(root.path())
.into_iter()
.map(|project| project.name)
.collect();
assert_eq!(names, vec!["ALPHA", "middle", "zeta"]);
}
#[test]
fn scan_projects_url_encodes_route_segments() {
let root = TestDir::new();
root.add_project("space name", "index.html");
root.add_project("中文", "index.html");
let projects = scan_projects(root.path());
assert_eq!(projects[0].name, "space name");
assert_eq!(projects[0].route_path, "/space%20name/");
assert_eq!(projects[1].name, "中文");
assert_eq!(projects[1].route_path, "/%E4%B8%AD%E6%96%87/");
}
#[test]
fn path_segment_encoding_preserves_unreserved_characters() {
assert_eq!(encode_path_segment("aZ09-_.~/?#%"), "aZ09-_.~%2F%3F%23%25");
}
}