Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export default defineConfig({
"**/inbox-edit.spec.ts",
"**/send-channel-binding.spec.ts",
"**/project-commit-detail.spec.ts",
"**/project-file-tree.spec.ts",
"**/project-inbox.spec.ts",
"**/project-issue-comments.spec.ts",
"**/project-pr-review.spec.ts",
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ mod project_git;
mod project_git_branches;
mod project_git_diff;
mod project_git_exec;
mod project_git_files;
mod project_git_merge_error;
mod project_git_push;
mod project_git_workflow;
Expand Down
114 changes: 10 additions & 104 deletions desktop/src-tauri/src/commands/project_git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ use super::project_git_exec::{
build_git_auth_config, clean_branch, clean_target_ref, run_git, validate_workspace_clone_url,
GitAuthConfig,
};
use super::project_git_files::{parse_ls_tree, parse_worktree_files, ParsedProjectRepoFiles};
use super::project_git_push::push_project_local_repository_blocking;
use super::project_repo_paths::{canonical_repos_roots, find_local_repo_dir};
use crate::app_state::AppState;
use serde::Serialize;
use std::time::UNIX_EPOCH;
use tauri::State;
#[derive(Clone, Serialize)]
pub struct ProjectRepoCommitInfo {
Expand Down Expand Up @@ -38,6 +38,8 @@ pub struct ProjectRepoSnapshotInfo {
pub latest_commit: Option<ProjectRepoCommitInfo>,
pub commits: Vec<ProjectRepoCommitInfo>,
pub files: Vec<ProjectRepoFileInfo>,
/// Complete file count before the snapshot payload is capped.
pub total_file_count: usize,
pub contributors: Vec<ProjectRepoContributorInfo>,
}
#[derive(Serialize)]
Expand Down Expand Up @@ -134,30 +136,6 @@ fn has_untracked_files(output: &str) -> bool {
output.lines().any(|line| line.starts_with("??"))
}

fn read_preview_content(
repo_dir: &std::path::Path,
path: &str,
size: Option<u64>,
) -> Option<String> {
const MAX_PREVIEW_BYTES: u64 = 64 * 1024;
if size.is_some_and(|value| value > MAX_PREVIEW_BYTES) {
return None;
}

let full_path = repo_dir.join(path);
let normalized = full_path.canonicalize().ok()?;
let repo_root = repo_dir.canonicalize().ok()?;
if !normalized.starts_with(repo_root) {
return None;
}

let bytes = std::fs::read(normalized).ok()?;
if bytes.contains(&0) {
return None;
}
String::from_utf8(bytes).ok()
}

fn parse_commits(output: &str) -> Vec<ProjectRepoCommitInfo> {
output
.lines()
Expand Down Expand Up @@ -235,46 +213,6 @@ fn parse_latest_commit_by_path(
result
}

fn path_modified_at(path: &std::path::Path) -> Option<i64> {
let modified = std::fs::metadata(path).ok()?.modified().ok()?;
modified
.duration_since(UNIX_EPOCH)
.ok()
.and_then(|duration| i64::try_from(duration.as_secs()).ok())
}

fn parse_worktree_files(
repo_dir: &std::path::Path,
output: &str,
latest_commit_by_path: &std::collections::HashMap<String, ProjectRepoCommitInfo>,
) -> Vec<ProjectRepoFileInfo> {
output
.split('\0')
.filter(|path| !path.trim().is_empty())
.filter_map(|path| {
let full_path = repo_dir.join(path);
let metadata = std::fs::metadata(&full_path).ok()?;
if !metadata.is_file() {
return None;
}
let size = Some(metadata.len());
let latest_commit = latest_commit_by_path.get(path).cloned();
Some(ProjectRepoFileInfo {
path: path.to_string(),
kind: "blob".to_string(),
size,
preview_content: read_preview_content(repo_dir, path, size),
last_changed_at: latest_commit
.as_ref()
.map(|commit| commit.timestamp)
.or_else(|| path_modified_at(&full_path)),
latest_commit,
})
})
.take(250)
.collect()
}

fn normalize_branch_name(branch: &str) -> &str {
branch
.trim()
Expand Down Expand Up @@ -309,40 +247,6 @@ fn branch_activity_range(
Some(format!("origin/{base_branch}..HEAD"))
}

fn parse_ls_tree(
repo_dir: &std::path::Path,
output: &str,
latest_commit_by_path: &std::collections::HashMap<String, ProjectRepoCommitInfo>,
) -> Vec<ProjectRepoFileInfo> {
output
.lines()
.filter_map(|line| {
let (meta, path) = line.split_once('\t')?;
let mut parts = meta.split_whitespace();
let _mode = parts.next()?;
let kind = parts.next()?.to_string();
let _object = parts.next()?;
let size = parts.next().and_then(|value| value.parse::<u64>().ok());
let preview_content = if kind == "blob" {
read_preview_content(repo_dir, path, size)
} else {
None
};
Some(ProjectRepoFileInfo {
path: path.to_string(),
kind,
size,
preview_content,
last_changed_at: latest_commit_by_path
.get(path)
.map(|commit| commit.timestamp),
latest_commit: latest_commit_by_path.get(path).cloned(),
})
})
.take(250)
.collect()
}

fn snapshot_from_repo(
repo_dir: &std::path::Path,
auth: &GitAuthConfig,
Expand Down Expand Up @@ -383,7 +287,7 @@ fn snapshot_from_repo(
(Vec::new(), Vec::new())
};

let files = if latest_commit.is_some() {
let parsed_files = if latest_commit.is_some() {
let latest_commit_by_path = run_git(
&[
"log",
Expand All @@ -402,13 +306,14 @@ fn snapshot_from_repo(
.map(|output| parse_ls_tree(repo_dir, &output, &latest_commit_by_path))
.unwrap_or_default()
} else {
Vec::new()
ParsedProjectRepoFiles::default()
};

ProjectRepoSnapshotInfo {
latest_commit,
commits,
files,
files: parsed_files.files,
total_file_count: parsed_files.total_file_count,
contributors,
}
}
Expand Down Expand Up @@ -466,7 +371,7 @@ fn snapshot_from_worktree(
(Vec::new(), Vec::new(), std::collections::HashMap::new())
};

let files = run_git(
let parsed_files = run_git(
&[
"ls-files",
"--cached",
Expand All @@ -483,7 +388,8 @@ fn snapshot_from_worktree(
ProjectRepoSnapshotInfo {
latest_commit,
commits,
files,
files: parsed_files.files,
total_file_count: parsed_files.total_file_count,
contributors,
}
}
Expand Down
152 changes: 152 additions & 0 deletions desktop/src-tauri/src/commands/project_git_files.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
use super::project_git::{ProjectRepoCommitInfo, ProjectRepoFileInfo};
use std::{collections::HashMap, path::Path, time::UNIX_EPOCH};

const MAX_REPOSITORY_FILE_PAYLOAD: usize = 250;

#[derive(Default)]
pub(super) struct ParsedProjectRepoFiles {
pub(super) files: Vec<ProjectRepoFileInfo>,
pub(super) total_file_count: usize,
}

fn read_preview_content(repo_dir: &Path, path: &str, size: Option<u64>) -> Option<String> {
const MAX_PREVIEW_BYTES: u64 = 64 * 1024;
if size.is_some_and(|value| value > MAX_PREVIEW_BYTES) {
return None;
}

let normalized = repo_dir.join(path).canonicalize().ok()?;
let repo_root = repo_dir.canonicalize().ok()?;
if !normalized.starts_with(repo_root) {
return None;
}

let bytes = std::fs::read(normalized).ok()?;
if bytes.contains(&0) {
return None;
}
String::from_utf8(bytes).ok()
}

fn path_modified_at(path: &Path) -> Option<i64> {
let modified = std::fs::metadata(path).ok()?.modified().ok()?;
modified
.duration_since(UNIX_EPOCH)
.ok()
.and_then(|duration| i64::try_from(duration.as_secs()).ok())
}

pub(super) fn parse_worktree_files(
repo_dir: &Path,
output: &str,
latest_commit_by_path: &HashMap<String, ProjectRepoCommitInfo>,
) -> ParsedProjectRepoFiles {
let mut parsed = ParsedProjectRepoFiles::default();

for path in output.split('\0').filter(|path| !path.trim().is_empty()) {
let full_path = repo_dir.join(path);
let Ok(metadata) = std::fs::metadata(&full_path) else {
continue;
};
if !metadata.is_file() {
continue;
}

parsed.total_file_count += 1;
if parsed.files.len() >= MAX_REPOSITORY_FILE_PAYLOAD {
continue;
}

let size = Some(metadata.len());
let latest_commit = latest_commit_by_path.get(path).cloned();
parsed.files.push(ProjectRepoFileInfo {
path: path.to_string(),
kind: "blob".to_string(),
size,
preview_content: read_preview_content(repo_dir, path, size),
last_changed_at: latest_commit
.as_ref()
.map(|commit| commit.timestamp)
.or_else(|| path_modified_at(&full_path)),
latest_commit,
});
}

parsed
}

pub(super) fn parse_ls_tree(
repo_dir: &Path,
output: &str,
latest_commit_by_path: &HashMap<String, ProjectRepoCommitInfo>,
) -> ParsedProjectRepoFiles {
let mut parsed = ParsedProjectRepoFiles::default();

for line in output.lines() {
let Some((meta, path)) = line.split_once('\t') else {
continue;
};
let mut parts = meta.split_whitespace();
let (Some(_mode), Some(kind), Some(_object)) = (parts.next(), parts.next(), parts.next())
else {
continue;
};
let size = parts.next().and_then(|value| value.parse::<u64>().ok());

parsed.total_file_count += 1;
if parsed.files.len() >= MAX_REPOSITORY_FILE_PAYLOAD {
continue;
}

let preview_content = (kind == "blob")
.then(|| read_preview_content(repo_dir, path, size))
.flatten();
parsed.files.push(ProjectRepoFileInfo {
path: path.to_string(),
kind: kind.to_string(),
size,
preview_content,
last_changed_at: latest_commit_by_path
.get(path)
.map(|commit| commit.timestamp),
latest_commit: latest_commit_by_path.get(path).cloned(),
});
}

parsed
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn ls_tree_reports_the_full_count_when_the_payload_is_capped() {
let output = (0..251)
.map(|index| format!("100644 blob {} 1\tfile-{index:03}.txt", "0".repeat(40)))
.collect::<Vec<_>>()
.join("\n");

let parsed = parse_ls_tree(Path::new("/missing-repository"), &output, &HashMap::new());

assert_eq!(parsed.files.len(), 250);
assert_eq!(parsed.total_file_count, 251);
}

#[test]
fn worktree_reports_the_full_count_when_the_payload_is_capped() {
let repo = tempfile::tempdir().expect("create temporary repository");
let mut paths = Vec::new();
for index in 0..251 {
let path = format!("file-{index:03}.txt");
std::fs::write(repo.path().join(&path), "x").expect("write fixture file");
paths.push(path);
}
let output = format!("{}\0", paths.join("\0"));

let parsed = parse_worktree_files(repo.path(), &output, &HashMap::new());

assert_eq!(parsed.files.len(), 250);
assert_eq!(parsed.total_file_count, 251);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import { projectFileTreeSummary } from "./projectFileTreeSummary.ts";

test("reports a complete repository tree normally", () => {
assert.deepEqual(projectFileTreeSummary(12, 12), {
countLabel: "12 files",
truncationNotice: null,
});
});

test("reports the loaded and total counts when the repository tree is truncated", () => {
assert.deepEqual(projectFileTreeSummary(250, 661), {
countLabel: "250 of 661 files",
truncationNotice:
"Showing the first 250 of 661 files. Some files and folders are not included.",
});
});

test("never reports fewer total files than were loaded", () => {
assert.deepEqual(projectFileTreeSummary(3, 0), {
countLabel: "3 files",
truncationNotice: null,
});
});
Loading