mirror of
https://gitlab.redox-os.org/redox-os/redox.git
synced 2026-06-27 07:14:18 +08:00
Merge branch 'web-index' into 'master'
web: Implement file browser See merge request redox-os/redox!2131
This commit is contained in:
commit
260f98467e
38
Cargo.lock
generated
38
Cargo.lock
generated
@ -885,6 +885,7 @@ dependencies = [
|
||||
"redoxer",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"strip-ansi-escapes",
|
||||
"termion",
|
||||
"toml",
|
||||
@ -1018,24 +1019,47 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.197"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.197"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
@ -1458,3 +1482,9 @@ dependencies = [
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
@ -42,12 +42,13 @@ redox-pkg = { git = "https://gitlab.redox-os.org/redox-os/pkgutils.git", default
|
||||
redox_installer = { git = "https://gitlab.redox-os.org/redox-os/installer.git", default-features = false }
|
||||
redoxer = { git = "https://gitlab.redox-os.org/redox-os/redoxer.git", default-features = false }
|
||||
regex = "1.11"
|
||||
serde = { version = "=1.0.197", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
termion = "4"
|
||||
toml = "0.8"
|
||||
walkdir = "2.3.1"
|
||||
ansi-to-tui = { version = "8", optional = true }
|
||||
strip-ansi-escapes = { version = "0.2.1", optional = true }
|
||||
serde_json = "1"
|
||||
|
||||
[dependencies.ratatui]
|
||||
version = "0.30"
|
||||
|
||||
20
src/web.rs
20
src/web.rs
@ -13,6 +13,7 @@ use crate::{
|
||||
};
|
||||
|
||||
pub mod html;
|
||||
pub mod search;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CliWebConfig {
|
||||
@ -49,6 +50,7 @@ impl CliWebConfig {
|
||||
}
|
||||
|
||||
const CSS: &str = include_str!("./web/style.css");
|
||||
const FILES: &str = include_str!("./web/files.html");
|
||||
|
||||
pub fn generate_web(all_packages: &Vec<String>, config: &CliWebConfig) {
|
||||
let repo_path = &config.out_dir.join(redoxer::target());
|
||||
@ -58,6 +60,7 @@ pub fn generate_web(all_packages: &Vec<String>, config: &CliWebConfig) {
|
||||
|
||||
let mut valid_packages = Vec::new();
|
||||
let mut dependents_map: HashMap<String, BTreeSet<String>> = HashMap::new();
|
||||
let mut files_map: BTreeMap<String, String> = BTreeMap::new();
|
||||
|
||||
for package_name in all_packages {
|
||||
let Ok(package_name) = PackageName::new(package_name) else {
|
||||
@ -108,6 +111,10 @@ pub fn generate_web(all_packages: &Vec<String>, config: &CliWebConfig) {
|
||||
&html_path,
|
||||
&config,
|
||||
);
|
||||
|
||||
if let Some(file_map) = stage_files {
|
||||
files_map.insert(package.name.to_string(), file_map);
|
||||
}
|
||||
}
|
||||
|
||||
let mut grouped_packages: BTreeMap<String, Vec<&(Package, CookRecipe)>> = BTreeMap::new();
|
||||
@ -120,7 +127,18 @@ pub fn generate_web(all_packages: &Vec<String>, config: &CliWebConfig) {
|
||||
let index_path = repo_path.join("index.html");
|
||||
let style_path = repo_path.join("style.css");
|
||||
generate_html_index(grouped_packages, &index_path, &config);
|
||||
fs::write(style_path, CSS).expect("Failed to write CSS file");
|
||||
fs::write(style_path, CSS).expect("Failed to write style.css");
|
||||
|
||||
let mut index_files = search::FileIndexBuilder::new();
|
||||
for (package, files) in &files_map {
|
||||
index_files.parse(package, files);
|
||||
}
|
||||
let files_json = repo_path.join("files.json");
|
||||
index_files
|
||||
.write(&files_json)
|
||||
.expect("Failed to write files.json");
|
||||
let files_path = repo_path.join("files.html");
|
||||
fs::write(files_path, FILES).expect("Failed to write files.html");
|
||||
}
|
||||
|
||||
pub(crate) fn get_category(dir: &Path) -> String {
|
||||
|
||||
401
src/web/files.html
Normal file
401
src/web/files.html
Normal file
@ -0,0 +1,401 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Package File Browser</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1 class="category-title">Package File Browser</h1>
|
||||
|
||||
<div class="card">
|
||||
<input type="text" id="searchInput" class="search-box" placeholder="Type file name" autocomplete="off">
|
||||
|
||||
<div class="browser-panels">
|
||||
<div class="panel">
|
||||
<div class="panel-header" id="dirHeader">/</div>
|
||||
<div class="panel-body" id="dirPanel">
|
||||
<div style="padding: 15px; color: #777;">Loading files...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-header" id="fileHeader">Files</div>
|
||||
<div class="panel-body" id="filePanel"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-header" id="contentHeader">File Content</div>
|
||||
<div class="panel-body" id="contentPanel" style="padding: 15px;">
|
||||
<div style="color: #777; text-align: center; margin-top: 50px;">
|
||||
Select a file to reveal details.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// @ts-check
|
||||
|
||||
/** @type {DB} */
|
||||
let db = {};
|
||||
/** @type {DB} */
|
||||
let activeDb = {};
|
||||
/** @type {string[]} */
|
||||
let pathStack = ["/"];
|
||||
/** @type {string[][]} */
|
||||
let historyStack = [];
|
||||
let currentSearch = "";
|
||||
/** @type {string[]|null} */
|
||||
let lockedFileViewPath = null;
|
||||
|
||||
|
||||
const searchInput = document.getElementById('searchInput') || (() => { throw new Error("missing html"); })();
|
||||
const dirHeader = document.getElementById('dirHeader') || (() => { throw new Error("missing html"); })();
|
||||
const dirPanel = document.getElementById('dirPanel') || (() => { throw new Error("missing html"); })();
|
||||
const fileHeader = document.getElementById('fileHeader') || (() => { throw new Error("missing html"); })();
|
||||
const filePanel = document.getElementById('filePanel') || (() => { throw new Error("missing html"); })();
|
||||
const contentHeader = document.getElementById('contentHeader') || (() => { throw new Error("missing html"); })();
|
||||
const contentPanel = document.getElementById('contentPanel') || (() => { throw new Error("missing html"); })();
|
||||
|
||||
/**
|
||||
* @typedef {Record<string, any>} DB
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {DB} node
|
||||
* @param {string} query
|
||||
* @param {boolean} parentMatched
|
||||
* @returns {DB|null}
|
||||
*/
|
||||
function filterDb(node, query, parentMatched = false) {
|
||||
/** @type {DB} */
|
||||
let result = {};
|
||||
let filesCount = 0;
|
||||
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (typeof value === 'object') {
|
||||
const isMatch = parentMatched || key.toLowerCase().includes(query);
|
||||
const subTree = filterDb(value, query, isMatch);
|
||||
if (subTree !== null) {
|
||||
result[key] = subTree;
|
||||
filesCount++;
|
||||
}
|
||||
} else {
|
||||
if (parentMatched || key.toLowerCase().includes(query)) {
|
||||
result[key] = value;
|
||||
filesCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return filesCount > 0 ? result : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DB} node
|
||||
* @returns {DB}
|
||||
*/
|
||||
function cascadeTree(node) {
|
||||
if (typeof node !== 'object' || node === null) return node;
|
||||
|
||||
/** @type {DB} */
|
||||
let newNode = {};
|
||||
/** @type {Record<string, string>} */
|
||||
let files = {};
|
||||
/** @type {Record<string, DB>} */
|
||||
let dirs = {};
|
||||
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (typeof value === 'object') {
|
||||
dirs[key] = cascadeTree(value);
|
||||
} else {
|
||||
files[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [dirKey, dirNode] of Object.entries(dirs)) {
|
||||
let childHasFiles = false;
|
||||
|
||||
for (const val of Object.values(dirNode)) {
|
||||
if (typeof val !== 'object') {
|
||||
childHasFiles = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!childHasFiles && Object.keys(dirNode).length > 0) {
|
||||
for (const [subKey, subValue] of Object.entries(dirNode)) {
|
||||
newNode[`${dirKey}/${subKey}`] = subValue;
|
||||
}
|
||||
} else {
|
||||
newNode[dirKey] = dirNode;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [fileKey, fileVal] of Object.entries(files)) {
|
||||
newNode[fileKey] = fileVal;
|
||||
}
|
||||
|
||||
return newNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} pathArray
|
||||
*/
|
||||
function getNodeByPath(pathArray) {
|
||||
let node = activeDb;
|
||||
if (!node) return null;
|
||||
for (let i = 1; i < pathArray.length; i++) {
|
||||
if (node[pathArray[i]]) {
|
||||
node = node[pathArray[i]];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function getCurrentNode() {
|
||||
return getNodeByPath(pathStack);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {DB|null} node
|
||||
* @param {string[]} basePathArray
|
||||
*/
|
||||
function renderFiles(node, basePathArray, isPeeking = false) {
|
||||
filePanel.innerHTML = '';
|
||||
|
||||
if (isPeeking) {
|
||||
const peekName = basePathArray[basePathArray.length - 1];
|
||||
fileHeader.innerHTML = `Files <span style="color:#777; font-weight:normal; font-size:0.9em;">(Peeking: ${peekName})</span>`;
|
||||
} else if (lockedFileViewPath) {
|
||||
const lockName = lockedFileViewPath[lockedFileViewPath.length - 1];
|
||||
fileHeader.innerHTML = `Files <span style="color:#ddd; font-weight:normal; font-size:0.9em;">(Selected: ${lockName})</span>`;
|
||||
} else {
|
||||
fileHeader.innerHTML = `Files`;
|
||||
}
|
||||
|
||||
if (!node) {
|
||||
filePanel.innerHTML = `<div style="padding: 15px; color: #777;">No files available</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let files = [];
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (typeof value === 'string') {
|
||||
files.push({ name: key, pkgs: value });
|
||||
}
|
||||
}
|
||||
files.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
files.forEach(file => {
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className = 'list-item';
|
||||
const pkgSpans = file.pkgs.split(',').map(p => `<span class="pkg-badge">${p}</span>`).join(' ');
|
||||
fileDiv.innerHTML = `<span style="margin-right: auto">${file.name}</span> <div>${pkgSpans}</div>`;
|
||||
|
||||
fileDiv.onclick = () => {
|
||||
Array.from(filePanel.children).forEach(c => c.classList.remove('selected'));
|
||||
fileDiv.classList.add('selected');
|
||||
showFileContent(file.name, file.pkgs, basePathArray);
|
||||
};
|
||||
filePanel.appendChild(fileDiv);
|
||||
});
|
||||
|
||||
if (files.length === 0) {
|
||||
filePanel.innerHTML = `<div style="padding: 15px; color: #777;">No files in this directory</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
* @param {DB} node
|
||||
* @param {number} currentDepth
|
||||
* @param {number} maxDepth
|
||||
* @param {string[]} currentPathArray
|
||||
*/
|
||||
function renderDirTree(container, node, currentDepth, maxDepth, currentPathArray) {
|
||||
if (currentDepth >= maxDepth || !node) return;
|
||||
|
||||
let dirs = [];
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (typeof value === 'object') dirs.push(key);
|
||||
}
|
||||
dirs.sort();
|
||||
|
||||
dirs.forEach(dir => {
|
||||
const dirDiv = document.createElement('div');
|
||||
dirDiv.className = 'list-item';
|
||||
|
||||
dirDiv.style.paddingLeft = `${15 + (currentDepth * 20)}px`;
|
||||
|
||||
if (currentDepth > 0) {
|
||||
dirDiv.style.fontSize = '0.95em';
|
||||
dirDiv.style.borderLeft = "1px solid #eee";
|
||||
}
|
||||
|
||||
const nextPathArray = [...currentPathArray, dir];
|
||||
|
||||
let hasSubDirs = false;
|
||||
for (const val of Object.values(node[dir])) {
|
||||
if (typeof val === 'object') {
|
||||
hasSubDirs = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (lockedFileViewPath && lockedFileViewPath.join('/') === nextPathArray.join('/')) {
|
||||
dirDiv.classList.add('selected');
|
||||
}
|
||||
|
||||
dirDiv.innerHTML = `<span>${dir}</span>`;
|
||||
|
||||
dirDiv.addEventListener('mouseenter', () => {
|
||||
renderFiles(node[dir], nextPathArray, true);
|
||||
});
|
||||
dirDiv.addEventListener('mouseleave', () => {
|
||||
const targetPath = lockedFileViewPath || pathStack;
|
||||
renderFiles(getNodeByPath(targetPath), targetPath, false);
|
||||
});
|
||||
dirDiv.onclick = () => {
|
||||
if (hasSubDirs) {
|
||||
historyStack.push([...pathStack]);
|
||||
pathStack = nextPathArray;
|
||||
lockedFileViewPath = null;
|
||||
} else {
|
||||
if (lockedFileViewPath && lockedFileViewPath.join('/') === nextPathArray.join('/')) {
|
||||
lockedFileViewPath = null;
|
||||
} else {
|
||||
lockedFileViewPath = nextPathArray;
|
||||
}
|
||||
}
|
||||
clearContentPanel();
|
||||
render();
|
||||
};
|
||||
|
||||
container.appendChild(dirDiv);
|
||||
|
||||
renderDirTree(container, node[dir], currentDepth + 1, maxDepth, nextPathArray);
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
let node = getCurrentNode();
|
||||
|
||||
if (!node) {
|
||||
// reset to root
|
||||
pathStack = ["/"];
|
||||
historyStack = [];
|
||||
node = getCurrentNode();
|
||||
lockedFileViewPath = null;
|
||||
if (!node) return;
|
||||
}
|
||||
|
||||
dirHeader.innerText = pathStack.join('/').replace('//', '/');
|
||||
|
||||
dirPanel.innerHTML = '';
|
||||
|
||||
if (historyStack.length > 0) {
|
||||
const backDiv = document.createElement('div');
|
||||
backDiv.className = 'list-item up-icon';
|
||||
backDiv.innerHTML = `<span>Back</span>`;
|
||||
backDiv.onclick = () => {
|
||||
pathStack = historyStack.pop() || ['/'];
|
||||
lockedFileViewPath = null;
|
||||
clearContentPanel();
|
||||
render();
|
||||
};
|
||||
dirPanel.appendChild(backDiv);
|
||||
}
|
||||
|
||||
renderDirTree(dirPanel, node, 0, 3, pathStack);
|
||||
|
||||
if (dirPanel.children.length === 0 || (dirPanel.children.length === 1 && pathStack.length > 1)) {
|
||||
const emptyMsg = document.createElement('div');
|
||||
emptyMsg.style.padding = '15px';
|
||||
emptyMsg.style.color = '#777';
|
||||
emptyMsg.innerText = 'No subdirectories';
|
||||
dirPanel.appendChild(emptyMsg);
|
||||
}
|
||||
|
||||
renderFiles(node, pathStack, false);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {string} fileName
|
||||
* @param {string} packages
|
||||
* @param {string[]} basePathArray
|
||||
*/
|
||||
function showFileContent(fileName, packages, basePathArray) {
|
||||
const fullPath = (basePathArray.join('/') + '/' + fileName).replace('//', '/');
|
||||
contentHeader.innerText = fileName;
|
||||
contentPanel.innerHTML = `
|
||||
<div style="margin-bottom: 15px;">
|
||||
<strong>Path:</strong> <br>
|
||||
<code>${fullPath}</code>
|
||||
</div>
|
||||
<div style="margin-bottom: 15px;">
|
||||
<strong>Provided by:</strong> <br>
|
||||
${packages.split(',').map(p => `<code>${p}</code>`).join(', ')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function clearContentPanel() {
|
||||
contentHeader.innerText = 'File Content';
|
||||
contentPanel.innerHTML = `
|
||||
<div style="color: #777; text-align: center; margin-top: 50px;">
|
||||
Select a file to reveal details.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
/** @type {HTMLInputElement|null} */
|
||||
// @ts-ignore
|
||||
let el = e.currentTarget;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
currentSearch = el.value.trim().toLowerCase();
|
||||
|
||||
if (currentSearch === "") {
|
||||
activeDb = cascadeTree(db);
|
||||
} else {
|
||||
activeDb = cascadeTree(filterDb(db, currentSearch) || {});
|
||||
}
|
||||
|
||||
lockedFileViewPath = null;
|
||||
clearContentPanel();
|
||||
render();
|
||||
});
|
||||
|
||||
fetch('files.json')
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
db = data;
|
||||
activeDb = cascadeTree(db);
|
||||
render();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Failed to load files.json:", error);
|
||||
dirPanel.innerHTML = `
|
||||
<div style="padding: 15px; color: #f00;">
|
||||
<strong>Error loading files.json</strong><br><br>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
113
src/web/search.rs
Normal file
113
src/web/search.rs
Normal file
@ -0,0 +1,113 @@
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{Result, wrap_io_err};
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
#[serde(untagged)]
|
||||
enum FsNode {
|
||||
Dir(BTreeMap<String, FsNode>),
|
||||
File(String),
|
||||
}
|
||||
|
||||
pub struct FileIndexBuilder {
|
||||
inner: BTreeMap<String, FsNode>,
|
||||
}
|
||||
impl FileIndexBuilder {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
inner: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
fn insert_node(&mut self, path_stack: &[String], name: &str, pkg_name: Option<&str>) {
|
||||
let mut current = &mut self.inner;
|
||||
for dir in path_stack {
|
||||
let node = current
|
||||
.entry(dir.clone())
|
||||
.or_insert_with(|| FsNode::Dir(BTreeMap::new()));
|
||||
|
||||
if let FsNode::File(_) = node {
|
||||
// previously a file, silently replace to dir
|
||||
*node = FsNode::Dir(BTreeMap::new());
|
||||
}
|
||||
|
||||
match node {
|
||||
FsNode::Dir(map) => current = map,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pkg) = pkg_name {
|
||||
current
|
||||
.entry(name.to_string())
|
||||
.and_modify(|node| {
|
||||
if let FsNode::File(existing_pkgs) = node {
|
||||
existing_pkgs.push(',');
|
||||
existing_pkgs.push_str(pkg);
|
||||
} else {
|
||||
*node = FsNode::File(pkg.to_string());
|
||||
}
|
||||
})
|
||||
.or_insert_with(|| FsNode::File(pkg.to_string()));
|
||||
} else {
|
||||
current
|
||||
.entry(name.to_string())
|
||||
.or_insert_with(|| FsNode::Dir(BTreeMap::new()));
|
||||
}
|
||||
}
|
||||
pub fn parse(&mut self, pkg: &str, content: &str) {
|
||||
let mut path_stack: Vec<String> = Vec::new();
|
||||
|
||||
for line in content.lines() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut prefix_chars: usize = 0;
|
||||
let mut name_start_byte = 0;
|
||||
|
||||
for (idx, c) in line.char_indices() {
|
||||
if "│├└─ ".contains(c) {
|
||||
prefix_chars += 1;
|
||||
} else {
|
||||
name_start_byte = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if name_start_byte == 0 && prefix_chars == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let depth = (prefix_chars / 4).saturating_sub(1);
|
||||
let remainder = line[name_start_byte..].trim();
|
||||
|
||||
path_stack.truncate(depth);
|
||||
|
||||
if remainder.ends_with('/') {
|
||||
let name = remainder.trim_end_matches('/');
|
||||
self.insert_node(&path_stack, name, None);
|
||||
path_stack.push(name.to_string());
|
||||
} else {
|
||||
let name = if let Some(paren_idx) = remainder.rfind('(') {
|
||||
// Strip size
|
||||
remainder[..paren_idx].trim()
|
||||
} else {
|
||||
remainder
|
||||
};
|
||||
self.insert_node(&path_stack, name, Some(pkg));
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn write(&self, path: &Path) -> Result<()> {
|
||||
// JSON: because javascript have them natively
|
||||
let json_output = serde_json::to_string(&self.inner).unwrap();
|
||||
let mut file = File::create(path).map_err(wrap_io_err!(path, "Opening file"))?;
|
||||
file.write_all(json_output.as_bytes())
|
||||
.map_err(wrap_io_err!(path, "Writing file"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -43,11 +43,9 @@ body {
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin: 10px;
|
||||
|
||||
display: inline-block;
|
||||
width: 30%;
|
||||
vertical-align: top;
|
||||
|
||||
display: flex;
|
||||
flex: 0 1 280px;
|
||||
flex-direction: column;
|
||||
@ -162,7 +160,7 @@ code {
|
||||
}
|
||||
|
||||
.install-action {
|
||||
display: inline-block;
|
||||
display: inline-block;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
@ -191,7 +189,7 @@ code {
|
||||
}
|
||||
|
||||
.pkg-main, .pkg-meta {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@ -245,13 +243,90 @@ th {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 100%;
|
||||
padding: 12px 15px;
|
||||
font-size: 1.1rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.browser-panels {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
height: 80vh;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
font-weight: 600;
|
||||
padding: 10px 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
background-color: #f9f9f9;
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
padding: 8px 15px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
background-color: #f1f8ff;
|
||||
}
|
||||
|
||||
.list-item.selected {
|
||||
background-color: #0366d6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.list-item.selected .pkg-badge {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.pkg-badge {
|
||||
font-size: 0.75rem;
|
||||
color: #6a737d;
|
||||
background-color: #eee;
|
||||
padding: 2px 6px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.up-icon::before {
|
||||
content: "↵ ";
|
||||
margin-right: 0.2em;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.package-card, .card, .pkg-header, .index-header {
|
||||
.package-card, .card, .panel, .pkg-header, .index-header {
|
||||
background-color: #111;
|
||||
border-color: #333;
|
||||
}
|
||||
@ -289,4 +364,30 @@ th {
|
||||
th, td, .pkg-deps li, .pkg-dependents li {
|
||||
border-bottom-color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.search-box {
|
||||
background-color: #000;
|
||||
color: #ccc;
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
background-color: #1a1a1a;
|
||||
border-bottom-color: #333;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
background-color: #222;
|
||||
}
|
||||
|
||||
.list-item.selected {
|
||||
background-color: #1f6feb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pkg-badge {
|
||||
background-color: #333;
|
||||
color: #8b949e;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user