Add read-only repository viewer (UI + backend)
Implements a read-only repository viewer for remote Gitea repos and local clones. Adds UI/CSS for viewer panels, breadcrumb/branch controls, file table, code/Markdown preview, and readme rendering (frontend/css/components.css, frontend/js/app.js). Extends app state and wiring (state.js, app.js) with viewer actions, branch/content loading, local/remote navigation, and preview helpers (base64 decoding, markdown rendering, syntax highlighting, 256 KB preview limit). Adds Gitea API helpers to fetch repo branches and contents (frontend/js/gitea-api.js) and Tauri JS bindings for local repo operations (tauri-api.js). Implements Rust backend commands to list branches, tree entries, and file contents (with size/binary checks and helper utilities) and wires them into the Tauri command registry (src-tauri/src/lib.rs). Also updates README to mention the new read-only viewer.
This commit is contained in:
@@ -13,6 +13,7 @@ struct GitCommandResult {
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ServerConnectionResult {
|
||||
ok: bool,
|
||||
message: String,
|
||||
@@ -20,6 +21,32 @@ struct ServerConnectionResult {
|
||||
version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalRepoBranch {
|
||||
name: String,
|
||||
current: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalRepoEntry {
|
||||
name: String,
|
||||
path: String,
|
||||
entry_type: String,
|
||||
size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalRepoFile {
|
||||
path: String,
|
||||
size: u64,
|
||||
content: Option<String>,
|
||||
is_binary: bool,
|
||||
too_large: bool,
|
||||
}
|
||||
|
||||
fn normalize_api_base_url(server_url: &str) -> Result<String, String> {
|
||||
// Normalize user input so every backend consistently resolves to /api/v1.
|
||||
let trimmed = server_url.trim().trim_end_matches('/');
|
||||
@@ -72,6 +99,71 @@ fn run_git_command(
|
||||
})
|
||||
}
|
||||
|
||||
fn run_git_output(
|
||||
repo_path: &str,
|
||||
git_path: Option<String>,
|
||||
args: Vec<String>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let git_binary = resolve_git_binary(git_path);
|
||||
let path = repo_path.trim();
|
||||
if !Path::new(path).exists() {
|
||||
return Err(format!("Repository path does not exist: {path}"));
|
||||
}
|
||||
|
||||
let output = Command::new(&git_binary)
|
||||
.current_dir(path)
|
||||
.args(&args)
|
||||
.output()
|
||||
.map_err(|err| format!("Failed to run git command: {err}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(if stderr.is_empty() {
|
||||
format!("{git_binary} {} failed", args.join(" "))
|
||||
} else {
|
||||
stderr
|
||||
});
|
||||
}
|
||||
|
||||
Ok(output.stdout)
|
||||
}
|
||||
|
||||
fn normalize_repo_path(path: &str) -> Result<String, String> {
|
||||
let trimmed = path.trim().trim_matches('/');
|
||||
if trimmed.contains('\\') || trimmed.contains('\0') {
|
||||
return Err("Repository paths must use forward slashes.".to_string());
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
for part in trimmed.split('/') {
|
||||
if part.is_empty() || part == "." {
|
||||
continue;
|
||||
}
|
||||
if part == ".." {
|
||||
return Err("Repository paths cannot contain parent directory segments.".to_string());
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
|
||||
Ok(parts.join("/"))
|
||||
}
|
||||
|
||||
fn normalize_reference(reference: &str) -> Result<String, String> {
|
||||
let trimmed = reference.trim();
|
||||
if trimmed.is_empty() || trimmed.contains('\0') {
|
||||
return Err("Branch or reference is required.".to_string());
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn treeish(reference: &str, path: &str) -> String {
|
||||
if path.is_empty() {
|
||||
reference.to_string()
|
||||
} else {
|
||||
format!("{reference}:{path}")
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_clone(
|
||||
repo_url: String,
|
||||
@@ -124,6 +216,171 @@ fn git_branch(repo_path: String, git_path: Option<String>) -> Result<GitCommandR
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn local_repo_branches(
|
||||
repo_path: String,
|
||||
git_path: Option<String>,
|
||||
) -> Result<Vec<LocalRepoBranch>, String> {
|
||||
let current = run_git_output(
|
||||
repo_path.trim(),
|
||||
git_path.clone(),
|
||||
vec![
|
||||
"rev-parse".to_string(),
|
||||
"--abbrev-ref".to_string(),
|
||||
"HEAD".to_string(),
|
||||
],
|
||||
)
|
||||
.ok()
|
||||
.map(|output| String::from_utf8_lossy(&output).trim().to_string())
|
||||
.filter(|value| !value.is_empty() && value != "HEAD");
|
||||
|
||||
let output = run_git_output(
|
||||
repo_path.trim(),
|
||||
git_path,
|
||||
vec![
|
||||
"for-each-ref".to_string(),
|
||||
"--format=%(refname)".to_string(),
|
||||
"refs/heads".to_string(),
|
||||
"refs/remotes".to_string(),
|
||||
],
|
||||
)?;
|
||||
|
||||
let branches = String::from_utf8_lossy(&output)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.ends_with("/HEAD"))
|
||||
.filter_map(|ref_name| {
|
||||
ref_name
|
||||
.strip_prefix("refs/heads/")
|
||||
.or_else(|| ref_name.strip_prefix("refs/remotes/"))
|
||||
})
|
||||
.map(|name| LocalRepoBranch {
|
||||
name: name.to_string(),
|
||||
current: current.as_deref() == Some(name),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(branches)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn local_repo_tree(
|
||||
repo_path: String,
|
||||
reference: String,
|
||||
path: String,
|
||||
git_path: Option<String>,
|
||||
) -> Result<Vec<LocalRepoEntry>, String> {
|
||||
let reference = normalize_reference(&reference)?;
|
||||
let path = normalize_repo_path(&path)?;
|
||||
let output = run_git_output(
|
||||
repo_path.trim(),
|
||||
git_path,
|
||||
vec!["ls-tree".to_string(), "-l".to_string(), treeish(&reference, &path)],
|
||||
)?;
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for line in String::from_utf8_lossy(&output).lines() {
|
||||
let Some((meta, name)) = line.split_once('\t') else {
|
||||
continue;
|
||||
};
|
||||
let meta_parts: Vec<&str> = meta.split_whitespace().collect();
|
||||
if meta_parts.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry_type = meta_parts[1].to_string();
|
||||
let size = meta_parts[3].parse::<u64>().ok();
|
||||
let entry_path = if path.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{path}/{name}")
|
||||
};
|
||||
|
||||
entries.push(LocalRepoEntry {
|
||||
name: name.to_string(),
|
||||
path: entry_path,
|
||||
entry_type,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
let a_is_tree = a.entry_type == "tree";
|
||||
let b_is_tree = b.entry_type == "tree";
|
||||
b_is_tree
|
||||
.cmp(&a_is_tree)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn local_repo_file(
|
||||
repo_path: String,
|
||||
reference: String,
|
||||
path: String,
|
||||
git_path: Option<String>,
|
||||
) -> Result<LocalRepoFile, String> {
|
||||
const MAX_TEXT_PREVIEW_BYTES: u64 = 256 * 1024;
|
||||
|
||||
let reference = normalize_reference(&reference)?;
|
||||
let path = normalize_repo_path(&path)?;
|
||||
if path.is_empty() {
|
||||
return Err("File path is required.".to_string());
|
||||
}
|
||||
|
||||
let spec = treeish(&reference, &path);
|
||||
let object_type = run_git_output(
|
||||
repo_path.trim(),
|
||||
git_path.clone(),
|
||||
vec!["cat-file".to_string(), "-t".to_string(), spec.clone()],
|
||||
)?;
|
||||
if String::from_utf8_lossy(&object_type).trim() != "blob" {
|
||||
return Err("Selected path is not a file.".to_string());
|
||||
}
|
||||
|
||||
let size_output = run_git_output(
|
||||
repo_path.trim(),
|
||||
git_path.clone(),
|
||||
vec!["cat-file".to_string(), "-s".to_string(), spec.clone()],
|
||||
)?;
|
||||
let size = String::from_utf8_lossy(&size_output)
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.map_err(|_| "Unable to determine file size.".to_string())?;
|
||||
|
||||
if size > MAX_TEXT_PREVIEW_BYTES {
|
||||
return Ok(LocalRepoFile {
|
||||
path,
|
||||
size,
|
||||
content: None,
|
||||
is_binary: false,
|
||||
too_large: true,
|
||||
});
|
||||
}
|
||||
|
||||
let content = run_git_output(
|
||||
repo_path.trim(),
|
||||
git_path,
|
||||
vec!["show".to_string(), "--no-ext-diff".to_string(), spec],
|
||||
)?;
|
||||
let is_binary = content.contains(&0);
|
||||
let content = if is_binary {
|
||||
None
|
||||
} else {
|
||||
String::from_utf8(content).ok()
|
||||
};
|
||||
|
||||
Ok(LocalRepoFile {
|
||||
path,
|
||||
size,
|
||||
is_binary: content.is_none(),
|
||||
too_large: false,
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn test_gitea_connection(
|
||||
server_url: String,
|
||||
@@ -190,6 +447,9 @@ pub fn run() {
|
||||
git_push,
|
||||
git_status,
|
||||
git_branch,
|
||||
local_repo_branches,
|
||||
local_repo_tree,
|
||||
local_repo_file,
|
||||
test_gitea_connection
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
Reference in New Issue
Block a user