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:
2026-05-09 18:45:51 +12:00
parent 17c0174e7d
commit f0358dbdfe
7 changed files with 1276 additions and 5 deletions
+260
View File
@@ -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!())