diff --git a/desktop/src-tauri/src/commands/project_git.rs b/desktop/src-tauri/src/commands/project_git.rs index bd11a751d3..d01c75cbd5 100644 --- a/desktop/src-tauri/src/commands/project_git.rs +++ b/desktop/src-tauri/src/commands/project_git.rs @@ -23,6 +23,7 @@ pub struct ProjectRepoFileInfo { pub kind: String, pub size: Option, pub preview_content: Option, + pub last_changed_at: Option, } #[derive(Serialize)] @@ -161,7 +162,29 @@ fn read_preview_content( String::from_utf8(bytes).ok() } -fn parse_ls_tree(repo_dir: &std::path::Path, output: &str) -> Vec { +fn parse_last_changed_at(output: &str) -> std::collections::HashMap { + let mut current_timestamp = None; + let mut result = std::collections::HashMap::new(); + + for line in output.lines().filter(|line| !line.trim().is_empty()) { + if let Ok(timestamp) = line.parse::() { + current_timestamp = Some(timestamp); + continue; + } + + if let Some(timestamp) = current_timestamp { + result.entry(line.to_string()).or_insert(timestamp); + } + } + + result +} + +fn parse_ls_tree( + repo_dir: &std::path::Path, + output: &str, + last_changed_by_path: &std::collections::HashMap, +) -> Vec { output .lines() .filter_map(|line| { @@ -181,6 +204,7 @@ fn parse_ls_tree(repo_dir: &std::path::Path, output: &str) -> Vec Proje .ok() .and_then(|output| parse_latest_commit(&output)); let files = if latest_commit.is_some() { + let last_changed_by_path = run_git( + &[ + "log", + "--format=%ct", + "--name-only", + "--diff-filter=ACMRT", + "--", + ], + Some(repo_dir), + auth, + ) + .map(|output| parse_last_changed_at(&output)) + .unwrap_or_default(); + run_git(&["ls-tree", "-r", "--long", "HEAD"], Some(repo_dir), auth) - .map(|output| parse_ls_tree(repo_dir, &output)) + .map(|output| parse_ls_tree(repo_dir, &output, &last_changed_by_path)) .unwrap_or_default() } else { Vec::new() diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index 2db1b47945..44fe8e453a 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -19,7 +19,6 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { type Project, - type ProjectRepoFile, type ProjectRepoSnapshot, useProjectIssuesQuery, useProjectQuery, @@ -37,6 +36,11 @@ import { Button } from "@/shared/ui/button"; import { Card } from "@/shared/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { + findReadmeFile, + ReadmePanel, + RepositoryFilesPanel, +} from "./ProjectRepositoryPanel"; function CloneUrlRow({ url }: { url: string }) { const [copied, setCopied] = React.useState(false); @@ -123,138 +127,6 @@ function projectPeople(project: Project, issues: ProjectIssue[]) { ]; } -function formatFileSize(size: number | null) { - if (size === null) return "—"; - if (size < 1024) return `${size} B`; - if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; - return `${(size / (1024 * 1024)).toFixed(1)} MB`; -} - -function baseName(path: string) { - return path.split("/").pop() || path; -} - -function dirName(path: string) { - const index = path.lastIndexOf("/"); - return index >= 0 ? path.slice(0, index) : "/"; -} - -function FileBrowser({ - files, - selectedFile, - onSelectFile, -}: { - files: ProjectRepoFile[]; - selectedFile: ProjectRepoFile | null; - onSelectFile: (file: ProjectRepoFile) => void; -}) { - if (files.length === 0) { - return ( -
- No files have been pushed yet. -
- ); - } - - return ( -
- {files.slice(0, 200).map((file) => { - const isSelected = selectedFile?.path === file.path; - return ( - - ); - })} -
- ); -} - -function FilePreview({ file }: { file: ProjectRepoFile | null }) { - if (!file) { - return ( -
- Select a file to inspect its path and contents. -
- ); - } - - return ( -
-
- - - {baseName(file.path)} - - - {formatFileSize(file.size)} - -
-
- {file.previewContent ? ( -
-            {file.previewContent}
-          
- ) : ( -
-
-
-

- Path -

-

- {file.path} -

-
-
-
-

- File -

-

- {baseName(file.path)} -

-
-
-

- Folder -

-

- {dirName(file.path)} -

-
-
-

- Size -

-

- {formatFileSize(file.size)} -

-
-
-
-

- Preview unavailable for this file. Large and binary files only - show metadata. -

-
- )} -
-
- ); -} - function LatestCommitPanel({ snapshot, isLoading, @@ -282,7 +154,7 @@ function LatestCommitPanel({ return (
-
+

@@ -292,7 +164,7 @@ function LatestCommitPanel({ {latestCommit.authorName} · {compactDate(latestCommit.timestamp)}

- + {latestCommit.shortHash}
@@ -396,7 +268,7 @@ function IssuesPanel({
{issues.slice(0, 10).map((issue) => (
@@ -410,7 +282,7 @@ function IssuesPanel({

) : null}
- + {issue.status}
@@ -451,98 +323,68 @@ function WorkspaceTabs({ issuesLoading: boolean; }) { const files = snapshot?.files ?? []; - const [selectedPath, setSelectedPath] = React.useState(null); - const selectedFile = - files.find((file) => file.path === selectedPath) ?? files[0] ?? null; - - React.useEffect(() => { - if (files.length > 0 && !files.some((file) => file.path === selectedPath)) { - setSelectedPath(files[0].path); - } - }, [files, selectedPath]); + const readmeFile = React.useMemo(() => findReadmeFile(files), [files]); return ( -
- -
-
- - - Workspace - -
- - - - Files - - - - Activity - - - - Issues - - - - Branches - - -
+ + + + + Files + + + + Activity + + + + Issues + + + + Branches + + - -
- - -
-
+ + + + - - - + + + - - - + + + - - - -
-
+ + + + ); } diff --git a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx new file mode 100644 index 0000000000..c49817f849 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx @@ -0,0 +1,481 @@ +import { + ArrowLeft, + BookOpen, + ChevronRight, + FileDiff, + FolderGit2, +} from "lucide-react"; +import * as React from "react"; + +import type { + ProjectRepoFile, + ProjectRepoSnapshot, +} from "@/features/projects/hooks"; +import { Button } from "@/shared/ui/button"; +import { Markdown, SyntaxHighlightedCode } from "@/shared/ui/markdown"; + +function compactDate(createdAt: number) { + return new Date(createdAt * 1_000).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +function formatFileSize(size: number | null) { + if (size === null) return "—"; + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / (1024 * 1024)).toFixed(1)} MB`; +} + +function baseName(path: string) { + return path.split("/").pop() || path; +} + +const FILE_LANGUAGE_BY_EXTENSION: Record = { + c: "c", + cc: "cpp", + cpp: "cpp", + cs: "csharp", + css: "css", + dart: "dart", + go: "go", + h: "c", + hpp: "cpp", + html: "html", + java: "java", + js: "javascript", + json: "json", + jsx: "jsx", + kt: "kotlin", + kts: "kotlin", + md: "markdown", + mjs: "javascript", + mts: "typescript", + py: "python", + rb: "ruby", + rs: "rust", + sh: "bash", + sql: "sql", + swift: "swift", + toml: "toml", + ts: "typescript", + tsx: "tsx", + yaml: "yaml", + yml: "yaml", + zig: "zig", +}; + +function languageForPath(path: string) { + const fileName = baseName(path).toLowerCase(); + if (fileName === "dockerfile") return "dockerfile"; + if (fileName === "makefile") return "make"; + const extension = fileName.split(".").pop(); + return extension ? FILE_LANGUAGE_BY_EXTENSION[extension] : undefined; +} + +type RepositoryFileEntry = { + file?: ProjectRepoFile; + fileCount?: number; + lastChangedAt: number | null; + name: string; + path: string; + type: "directory" | "file"; +}; + +function repositoryEntries( + files: ProjectRepoFile[], + currentPath: string, +): RepositoryFileEntry[] { + const directories = new Map(); + const entries: RepositoryFileEntry[] = []; + const prefix = currentPath ? `${currentPath}/` : ""; + + for (const file of files) { + if (currentPath && !file.path.startsWith(prefix)) continue; + + const relativePath = currentPath + ? file.path.slice(prefix.length) + : file.path; + const [name, ...rest] = relativePath.split("/"); + if (!name) continue; + + if (rest.length > 0) { + const path = currentPath ? `${currentPath}/${name}` : name; + const existing = directories.get(path); + if (existing) { + existing.fileCount = (existing.fileCount ?? 0) + 1; + existing.lastChangedAt = Math.max( + existing.lastChangedAt ?? 0, + file.lastChangedAt ?? 0, + ); + } else { + directories.set(path, { + fileCount: 1, + lastChangedAt: file.lastChangedAt, + name, + path, + type: "directory", + }); + } + continue; + } + + entries.push({ + file, + lastChangedAt: file.lastChangedAt, + name, + path: file.path, + type: "file", + }); + } + + return [...directories.values(), ...entries].sort((left, right) => { + if (left.type !== right.type) return left.type === "directory" ? -1 : 1; + return left.name.localeCompare(right.name); + }); +} + +export function findReadmeFile(files: ProjectRepoFile[]) { + const readmes = files.filter((file) => + /^readme(?:\.(?:md|markdown|mdx|txt))?$/i.test(baseName(file.path)), + ); + + return readmes.find((file) => !file.path.includes("/")) ?? readmes[0] ?? null; +} + +function decodeHtmlEntities(value: string) { + return value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'"); +} + +function htmlInlineToMarkdown(value: string): string { + return decodeHtmlEntities(value) + .replace(//gi, "\n") + .replace(/]*)>/gi, (_match: string, attrs: string) => { + const src = attrs.match(/\bsrc=["']([^"']+)["']/i)?.[1]; + const alt = attrs.match(/\balt=["']([^"']*)["']/i)?.[1] ?? ""; + return src ? `![${alt}](${src})` : ""; + }) + .replace( + /]*\bhref=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, + (_match: string, href: string, label: string) => + `[${htmlInlineToMarkdown(label).trim()}](${href})`, + ) + .replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, "**$2**") + .replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/\1>/gi, "*$2*") + .replace(/]*>([\s\S]*?)<\/code>/gi, "`$1`") + .replace(/]*>([\s\S]*?)<\/sub>/gi, "$1") + .replace(/]*>([\s\S]*?)<\/span>/gi, "$1") + .replace(/<[^>]+>/g, "") + .trim(); +} + +function normalizeReadmeMarkdown(content: string) { + return content + .replace( + /]*>([\s\S]*?)<\/h\1>/gi, + (_match, depth: string, value: string) => + `${"#".repeat(Number(depth))} ${htmlInlineToMarkdown(value)}\n\n`, + ) + .replace( + /]*>([\s\S]*?)<\/p>/gi, + (_match, value: string) => `${htmlInlineToMarkdown(value)}\n\n`, + ) + .replace( + /]*>([\s\S]*?)<\/div>/gi, + (_match, value: string) => `${htmlInlineToMarkdown(value)}\n\n`, + ) + .replace( + /]*>([\s\S]*?)<\/center>/gi, + (_match, value: string) => `${htmlInlineToMarkdown(value)}\n\n`, + ) + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function BreadcrumbButton({ + children, + onClick, +}: { + children: React.ReactNode; + onClick: () => void; +}) { + return ( + + ); +} + +function FileContentPanel({ + file, + onBack, +}: { + file: ProjectRepoFile; + onBack: () => void; +}) { + const language = languageForPath(file.path); + + return ( +
+
+ + + + {file.path} + + + {formatFileSize(file.size)} + +
+ {file.previewContent ? ( +
+          {language ? (
+            
+          ) : (
+            
+              {file.previewContent}
+            
+          )}
+        
+ ) : ( +
+ Preview unavailable for this file. Large and binary files only show + metadata. +
+ )} +
+ ); +} + +export function RepositoryFilesPanel({ + files, + snapshot, + isLoading, + error, +}: { + files: ProjectRepoFile[]; + snapshot: ProjectRepoSnapshot | null | undefined; + isLoading: boolean; + error: unknown; +}) { + const [currentPath, setCurrentPath] = React.useState(""); + const [selectedFile, setSelectedFile] = + React.useState(null); + const entries = React.useMemo( + () => repositoryEntries(files, currentPath), + [currentPath, files], + ); + const latestCommit = snapshot?.latestCommit ?? null; + const pathSegments = currentPath ? currentPath.split("/") : []; + + const filesKey = React.useMemo( + () => files.map((file) => file.path).join("\0"), + [files], + ); + + React.useEffect(() => { + if (!filesKey) return; + setCurrentPath(""); + setSelectedFile(null); + }, [filesKey]); + + if (isLoading) { + return ( +
+ Loading repository files… +
+ ); + } + + if (error) { + return ( +
+ Could not load the repository file tree. +
+ ); + } + + if (files.length === 0) { + return ( +
+ No files have been pushed yet. +
+ ); + } + + if (selectedFile) { + return ( + setSelectedFile(null)} + /> + ); + } + + return ( +
+
+
+

+ {latestCommit?.subject ?? "Repository files"} +

+ {latestCommit ? ( +

+ {latestCommit.authorName} committed{" "} + {compactDate(latestCommit.timestamp)} +

+ ) : ( +

+ {files.length} tracked files +

+ )} +
+ {latestCommit ? ( + + {latestCommit.shortHash} + + ) : null} +
+ +
+ setCurrentPath("")}> + Files + + {pathSegments.map((segment, index) => { + const nextPath = pathSegments.slice(0, index + 1).join("/"); + return ( + + + setCurrentPath(nextPath)}> + {segment} + + + ); + })} +
+ +
+ {currentPath ? ( + + ) : null} + {entries.slice(0, 200).map((entry) => { + const Icon = entry.type === "directory" ? FolderGit2 : FileDiff; + const meta = + entry.type === "directory" + ? `${entry.fileCount ?? 0} files` + : formatFileSize(entry.file?.size ?? null); + + return ( + + ); + })} +
+
+ ); +} + +export function ReadmePanel({ file }: { file: ProjectRepoFile | null }) { + if (!file?.previewContent) { + return ( +
+ Add a README to this repository to describe setup, usage, and project + context. +
+ ); + } + + const language = languageForPath(file.path); + const isMarkdown = /\.(?:md|markdown|mdx)$/i.test(file.path); + const readmeContent = isMarkdown + ? normalizeReadmeMarkdown(file.previewContent) + : file.previewContent; + + return ( +
+
+ + + {baseName(file.path)} + +
+
+ {isMarkdown ? ( + + ) : language ? ( +
+            
+          
+ ) : ( +
+            
+              {file.previewContent}
+            
+          
+ )} +
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index e7a8748812..e0373dbb22 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -556,7 +556,7 @@ function ProjectGridCard({ }) { return ( @@ -602,9 +602,9 @@ function ProjectGridCard({
-
+
-

+

{getActivityLabel(summary)}

@@ -646,7 +646,7 @@ function ProjectListRow({ }) { return ( @@ -685,8 +685,8 @@ function ProjectListRow({
-
-

+

+

{getActivityLabel(summary)}