diff --git a/crates/buzz-relay/CHANGELOG.md b/crates/buzz-relay/CHANGELOG.md index e3802bee3..4f7ab940d 100644 --- a/crates/buzz-relay/CHANGELOG.md +++ b/crates/buzz-relay/CHANGELOG.md @@ -3,4 +3,3 @@ ## relay-v0.1.1 - Initial release - diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 5c431eba7..57053834f 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -45,6 +45,8 @@ export default defineConfig({ "**/identity-archive.spec.ts", "**/identity-archive-hide.spec.ts", "**/relay-connectivity-screenshots.spec.ts", + "**/history-icons-screenshots.spec.ts", + "**/projects-avatar-screenshot.spec.ts", "**/unread-pill-screenshots.spec.ts", "**/sidebar-more-unread-overlap.spec.ts", "**/home-collapsed-top-chrome-screenshots.spec.ts", diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index a8bce1081..e20468832 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -21,6 +21,7 @@ pub mod pairing; mod personas; mod prevent_sleep; mod profile; +mod project_git; mod relay_members; mod relay_reconnect; mod social; @@ -49,6 +50,7 @@ pub use pairing::*; pub use personas::*; pub use prevent_sleep::*; pub use profile::*; +pub use project_git::*; pub use relay_members::*; pub use relay_reconnect::*; pub use social::*; diff --git a/desktop/src-tauri/src/commands/project_git.rs b/desktop/src-tauri/src/commands/project_git.rs new file mode 100644 index 000000000..d01c75cbd --- /dev/null +++ b/desktop/src-tauri/src/commands/project_git.rs @@ -0,0 +1,320 @@ +use std::process::Command; + +use nostr::ToBech32; +use serde::Serialize; +use tauri::State; +use url::Url; + +use crate::{app_state::AppState, managed_agents::resolve_command}; + +#[derive(Serialize)] +pub struct ProjectRepoCommitInfo { + pub hash: String, + pub short_hash: String, + pub author_name: String, + pub author_email: String, + pub timestamp: i64, + pub subject: String, +} + +#[derive(Serialize)] +pub struct ProjectRepoFileInfo { + pub path: String, + pub kind: String, + pub size: Option, + pub preview_content: Option, + pub last_changed_at: Option, +} + +#[derive(Serialize)] +pub struct ProjectRepoSnapshotInfo { + pub latest_commit: Option, + pub files: Vec, +} + +struct GitAuthConfig { + git_path: std::path::PathBuf, + credential_helper: Option, + nsec: String, +} + +fn run_git( + args: &[&str], + cwd: Option<&std::path::Path>, + auth: &GitAuthConfig, +) -> Result { + let mut command = Command::new(&auth.git_path); + command.args(args); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + configure_git_auth(&mut command, auth); + + let output = command + .output() + .map_err(|error| format!("failed to run git: {error}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if stderr.is_empty() { + format!("git exited with status {}", output.status) + } else { + stderr + }); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn configure_git_auth(command: &mut Command, auth: &GitAuthConfig) { + command.env("GIT_TERMINAL_PROMPT", "0"); + command.env("GIT_CONFIG_NOSYSTEM", "1"); + command.env("GIT_CONFIG_GLOBAL", "/dev/null"); + + // Clear inherited/global helpers first. Otherwise Git may authenticate with + // git-credential-nostr successfully, then call a system helper such as + // git-credential-osxkeychain to `store` the ephemeral NIP-98 credential; + // that helper can fail and make an otherwise-successful read look broken. + command.env("GIT_CONFIG_COUNT", "1"); + command.env("GIT_CONFIG_KEY_0", "credential.helper"); + command.env("GIT_CONFIG_VALUE_0", ""); + + let Some(cred_helper) = &auth.credential_helper else { + return; + }; + + command.env("NOSTR_PRIVATE_KEY", &auth.nsec); + command.env("GIT_CONFIG_COUNT", "3"); + command.env("GIT_CONFIG_KEY_1", "credential.helper"); + command.env("GIT_CONFIG_VALUE_1", cred_helper.display().to_string()); + command.env("GIT_CONFIG_KEY_2", "credential.useHttpPath"); + command.env("GIT_CONFIG_VALUE_2", "true"); +} + +fn build_git_auth_config(state: &AppState) -> Result { + let git_path = resolve_command("git").ok_or_else(|| "git was not found on PATH".to_string())?; + let credential_helper = resolve_command("git-credential-nostr"); + let nsec = { + let keys = state.keys.lock().map_err(|error| error.to_string())?; + keys.secret_key() + .to_bech32() + .map_err(|error| format!("encode identity key: {error}"))? + }; + + Ok(GitAuthConfig { + git_path, + credential_helper, + nsec, + }) +} + +fn validate_clone_url(clone_url: &str) -> Result<(), String> { + let parsed = Url::parse(clone_url).map_err(|error| format!("invalid clone URL: {error}"))?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err("clone URL must be http or https".into()); + } + if !parsed.path().contains("/git/") { + return Err("clone URL must point at a Buzz git repository".into()); + } + Ok(()) +} + +fn parse_latest_commit(output: &str) -> Option { + let line = output.lines().next()?; + let mut parts = line.split('\0'); + let hash = parts.next()?.to_string(); + let short_hash = parts.next()?.to_string(); + let author_name = parts.next()?.to_string(); + let author_email = parts.next()?.to_string(); + let timestamp = parts.next()?.parse::().ok()?; + let subject = parts.next().unwrap_or_default().to_string(); + + Some(ProjectRepoCommitInfo { + hash, + short_hash, + author_name, + author_email, + timestamp, + subject, + }) +} + +fn read_preview_content( + repo_dir: &std::path::Path, + path: &str, + size: Option, +) -> Option { + 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_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| { + 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::().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: last_changed_by_path.get(path).copied(), + }) + }) + .take(250) + .collect() +} + +fn snapshot_from_repo(repo_dir: &std::path::Path, auth: &GitAuthConfig) -> ProjectRepoSnapshotInfo { + let latest_commit = run_git( + &["log", "-1", "--format=%H%x00%h%x00%an%x00%ae%x00%at%x00%s"], + Some(repo_dir), + auth, + ) + .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, &last_changed_by_path)) + .unwrap_or_default() + } else { + Vec::new() + }; + + ProjectRepoSnapshotInfo { + latest_commit, + files, + } +} + +fn current_checkout_snapshot(auth: &GitAuthConfig) -> Option { + if !cfg!(debug_assertions) { + return None; + } + + let cwd = std::env::current_dir().ok()?; + let root = run_git(&["rev-parse", "--show-toplevel"], Some(&cwd), auth).ok()?; + let root = std::path::PathBuf::from(root.trim()); + Some(snapshot_from_repo(&root, auth)) +} + +#[tauri::command] +pub async fn get_project_repo_snapshot( + clone_url: String, + default_branch: Option, + state: State<'_, AppState>, +) -> Result { + validate_clone_url(&clone_url)?; + let auth = build_git_auth_config(&state)?; + let branch = default_branch + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + tauri::async_runtime::spawn_blocking(move || { + let temp_dir = tempfile::tempdir().map_err(|error| format!("create temp dir: {error}"))?; + let repo_dir = temp_dir.path().join("repo"); + let repo_path = repo_dir + .to_str() + .ok_or_else(|| "temporary repository path is not UTF-8".to_string())?; + + let mut clone_args = vec!["clone", "--depth=1", "--filter=blob:none"]; + if let Some(ref branch) = branch { + clone_args.push("--branch"); + clone_args.push(branch.as_str()); + } + clone_args.push(clone_url.as_str()); + clone_args.push(repo_path); + + if run_git(&clone_args, None, &auth).is_err() && branch.is_some() { + // A newly-announced Buzz git repo can be empty even when the project + // metadata says its default branch is `main`. In that state, + // `git clone --branch main` fails because the ref does not exist yet. + // Retry without the branch selector so we can still render a clean + // "no commits/files yet" state instead of an error card. + run_git( + &[ + "clone", + "--depth=1", + "--filter=blob:none", + clone_url.as_str(), + repo_path, + ], + None, + &auth, + )?; + } + + let snapshot = snapshot_from_repo(&repo_dir, &auth); + if snapshot.latest_commit.is_none() && snapshot.files.is_empty() { + if let Some(local_snapshot) = current_checkout_snapshot(&auth) { + return Ok(local_snapshot); + } + } + + Ok(snapshot) + }) + .await + .map_err(|error| format!("repo snapshot task failed: {error}"))? +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index b713fce13..2aae1597a 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -747,6 +747,7 @@ pub fn run() { get_user_profile, get_users_batch, get_user_notes, + get_project_repo_snapshot, search_users, get_presence, get_default_relay_url, diff --git a/desktop/src/app/AppTopChrome.tsx b/desktop/src/app/AppTopChrome.tsx index 974fa73a8..79fd4bbba 100644 --- a/desktop/src/app/AppTopChrome.tsx +++ b/desktop/src/app/AppTopChrome.tsx @@ -21,6 +21,8 @@ type AppTopChromeProps = { const TOP_CHROME_ICON_BUTTON_CLASS = "h-7 w-7 rounded-[4px] text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&_svg]:size-4"; +const HISTORY_ICON_BUTTON_CLASS = + "h-7 w-6 rounded-[4px] text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&_svg]:size-4"; function TopChromeSidebarTrigger() { const sidebar = useOptionalSidebar(); @@ -73,7 +75,7 @@ export function AppTopChrome({ + ) : null} {project.description ? (

{project.description}

) : null} + +
+ + + + +
{project.cloneUrls.length > 0 ? ( @@ -182,32 +590,48 @@ export function ProjectDetailScreen({ projectId }: ProjectDetailScreenProps) { ) : null} - {project.contributors.length > 0 ? ( + + + {peoplePubkeys.length > 0 ? (
-

- - - Contributors ({project.contributors.length}) - +

+ + Involved ({peoplePubkeys.length})

-
- {project.contributors.map((pubkey) => { +
+ {peoplePubkeys.map((pubkey) => { + const profile = profiles?.[normalizePubkey(pubkey)]; const label = resolveUserLabel({ pubkey, profiles }); - const avatarUrl = - profiles?.[pubkey.toLowerCase()]?.avatarUrl ?? null; + const isOwner = + normalizePubkey(pubkey) === normalizePubkey(project.owner); return (
- - {label} - +
+

+ {label} +

+

+ {isOwner ? "Project owner" : "Contributor"} +

+
); })} @@ -215,12 +639,35 @@ export function ProjectDetailScreen({ projectId }: ProjectDetailScreenProps) {
) : null} +
+ +

+ + Agent Work +

+

+ Start agents from project issues so their summaries, branches, + patches, and review notes stay attached to this project. +

+
+ +

+ + Code Discussion +

+

+ Diff messages and NIP-34 patches render in the linked discussion + channel, giving humans and agents a shared review surface. +

+
+
+

Details

-

Created: {createdDate}

+

Repo: {project.repoAddress}

Owner: {resolveUserLabel({ pubkey: project.owner, profiles })}

diff --git a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx new file mode 100644 index 000000000..c49817f84 --- /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 0b8d030f0..e0373dbb2 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -1,9 +1,315 @@ -import { ExternalLink, FolderGit2, GitFork, Users } from "lucide-react"; +import { + Bot, + CalendarDays, + FolderGit2, + GitBranch, + GitFork, + LayoutGrid, + List, + MessageSquare, + MoreHorizontal, + Trash2, + Users, +} from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { useProjectsQuery } from "@/features/projects/hooks"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { + type Project, + type ProjectActivitySummary, + useDeleteProjectMutation, + useProjectActivitySummariesQuery, + useProjectsQuery, +} from "@/features/projects/hooks"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { topChromeInset } from "@/shared/layout/chromeLayout"; +import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; import { Button } from "@/shared/ui/button"; import { Card } from "@/shared/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +type ProjectsViewMode = "grid" | "list"; +type ProjectsFilter = "all" | "mine" | "agents" | "users"; +type ProjectsSort = "updated" | "created" | "name"; + +const PROJECTS_VIEW_MODE_STORAGE_KEY = "buzz.projects.viewMode"; +const PROJECTS_FILTER_STORAGE_KEY = "buzz.projects.filter"; +const PROJECTS_SORT_STORAGE_KEY = "buzz.projects.sort"; +const MANY_PROJECTS_THRESHOLD = 12; + +function readStoredViewMode(): ProjectsViewMode | null { + try { + const value = globalThis.localStorage?.getItem( + PROJECTS_VIEW_MODE_STORAGE_KEY, + ); + return value === "grid" || value === "list" ? value : null; + } catch { + return null; + } +} + +function writeStoredViewMode(viewMode: ProjectsViewMode) { + try { + globalThis.localStorage?.setItem(PROJECTS_VIEW_MODE_STORAGE_KEY, viewMode); + } catch { + // Persistence is best-effort; the in-memory toggle still works. + } +} + +function readStoredFilter(): ProjectsFilter { + try { + const value = globalThis.localStorage?.getItem(PROJECTS_FILTER_STORAGE_KEY); + return value === "mine" || value === "agents" || value === "users" + ? value + : "all"; + } catch { + return "all"; + } +} + +function writeStoredFilter(filter: ProjectsFilter) { + try { + globalThis.localStorage?.setItem(PROJECTS_FILTER_STORAGE_KEY, filter); + } catch { + // Persistence is best-effort; the in-memory toggle still works. + } +} + +function readStoredSort(): ProjectsSort { + try { + const value = globalThis.localStorage?.getItem(PROJECTS_SORT_STORAGE_KEY); + return value === "created" || value === "name" ? value : "updated"; + } catch { + return "updated"; + } +} + +function writeStoredSort(sort: ProjectsSort) { + try { + globalThis.localStorage?.setItem(PROJECTS_SORT_STORAGE_KEY, sort); + } catch { + // Persistence is best-effort; the in-memory toggle still works. + } +} + +function pluralize(count: number, singular: string, plural = `${singular}s`) { + return `${count} ${count === 1 ? singular : plural}`; +} + +function formatCreatedDate(createdAt: number) { + return new Date(createdAt * 1_000).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +function projectPeople( + project: Project, + summary?: ProjectActivitySummary, +): string[] { + return [ + ...new Set( + [ + project.owner, + ...project.contributors, + ...(summary?.participantPubkeys ?? []), + ].map(normalizePubkey), + ), + ]; +} + +function getCloneLabel(project: Project) { + return project.cloneUrls[0] ?? "Internal git clone URL pending"; +} + +function getDiscussionLabel(project: Project) { + return project.projectChannelId ? "Discussion linked" : "No discussion"; +} + +function getActivityLabel(summary: ProjectActivitySummary | undefined) { + if (!summary || summary.activityCount === 0) { + return "No activity yet"; + } + + return `${pluralize(summary.issueCount, "issue")} · ${pluralize( + summary.activityCount, + "event", + )}`; +} + +function getProjectUpdatedAt( + project: Project, + summary: ProjectActivitySummary | undefined, +) { + return summary?.updatedAt ?? project.createdAt; +} + +function isProjectMine(project: Project, currentPubkey: string | undefined) { + if (!currentPubkey) return false; + const normalizedCurrentPubkey = normalizePubkey(currentPubkey); + return ( + normalizePubkey(project.owner) === normalizedCurrentPubkey || + project.contributors.some( + (pubkey) => normalizePubkey(pubkey) === normalizedCurrentPubkey, + ) + ); +} + +function isProjectOwnedByCurrentUser( + project: Project, + currentPubkey: string | undefined, +) { + return currentPubkey + ? normalizePubkey(project.owner) === normalizePubkey(currentPubkey) + : false; +} + +function projectHasAgent( + project: Project, + people: string[], + profiles: UserProfileLookup | undefined, +) { + const projectPubkeys = [project.owner, ...people]; + return projectPubkeys.some( + (pubkey) => profiles?.[normalizePubkey(pubkey)]?.isAgent === true, + ); +} + +function projectOwnerIsUser( + project: Project, + profiles: UserProfileLookup | undefined, +) { + return profiles?.[normalizePubkey(project.owner)]?.isAgent !== true; +} + +function ProjectPeopleStack({ + pubkeys, + profiles, + workOwnerPubkey, +}: { + pubkeys: string[]; + profiles?: UserProfileLookup; + workOwnerPubkey: string; +}) { + const visible = pubkeys.slice(0, 4); + const remaining = pubkeys.length - visible.length; + + if (visible.length === 0) { + return null; + } + + return ( +
+ {visible.map((pubkey) => { + const profile = profiles?.[normalizePubkey(pubkey)]; + const label = resolveUserLabel({ pubkey, profiles }); + return ( + + ); + })} + {remaining > 0 ? ( + + +{remaining} + + ) : null} +
+ ); +} + +function StatusPill({ status }: { status: string }) { + if (status === "active") { + return null; + } + + return ( + + {status} + + ); +} + +function MetadataItem({ + icon: Icon, + children, +}: { + icon: React.ComponentType<{ className?: string }>; + children: React.ReactNode; +}) { + return ( + + + {children} + + ); +} + +function ProjectsViewModeToggle({ + viewMode, + onViewModeChange, +}: { + viewMode: ProjectsViewMode; + onViewModeChange: (viewMode: ProjectsViewMode) => void; +}) { + return ( +
+ Project layout + + +
+ ); +} function EmptyState() { return ( @@ -19,10 +325,496 @@ function EmptyState() { ); } +function EmptyFilteredState() { + return ( +
+ +
+

+ No matching projects +

+

+ Try another owner filter or sort mode. +

+
+
+ ); +} + +function ProjectsToolbar({ + filter, + onFilterChange, + onSortChange, + onViewModeChange, + projectCount, + sort, + totalProjectCount, + viewMode, +}: { + filter: ProjectsFilter; + onFilterChange: (filter: ProjectsFilter) => void; + onSortChange: (sort: ProjectsSort) => void; + onViewModeChange: (viewMode: ProjectsViewMode) => void; + projectCount: number; + sort: ProjectsSort; + totalProjectCount: number; + viewMode: ProjectsViewMode; +}) { + const filterOptions: Array<{ label: string; value: ProjectsFilter }> = [ + { label: "All", value: "all" }, + { label: "Mine", value: "mine" }, + { label: "Agents", value: "agents" }, + { label: "Users", value: "users" }, + ]; + + return ( +
+
+
+
+

Projects

+ + {pluralize(projectCount, "project")} + {projectCount !== totalProjectCount + ? ` of ${totalProjectCount}` + : ""} + +
+

+ Internal git projects bring code, issues, discussion, and agent work + into one shared space. +

+
+ +
+ +
+
+ Project owner filter + {filterOptions.map((option) => ( + + ))} +
+ + +
+
+ ); +} + +function ProjectCardButton({ + project, + onOpen, +}: { + project: Project; + onOpen: (project: Project) => void; +}) { + return ( + + ); +} + +function ProjectActionsMenu({ + project, + canDelete, + disabled, + onDelete, +}: { + project: Project; + canDelete: boolean; + disabled: boolean; + onDelete: (project: Project) => Promise | void; +}) { + const [confirmOpen, setConfirmOpen] = React.useState(false); + + return ( + + + + + + + { + event.preventDefault(); + event.stopPropagation(); + if (canDelete && !disabled) { + setConfirmOpen(true); + } + }} + > + + Delete branch + + + + + + Delete branch? + + Delete {project.name} from Projects for everyone. This can only be + done for branches you own and cannot be undone. + + + + + + + + + + + + + ); +} + +function ProjectGridCard({ + project, + people, + profiles, + summary, + canDelete, + deleteDisabled, + onDelete, + onOpen, +}: { + project: Project; + people: string[]; + profiles?: UserProfileLookup; + summary: ProjectActivitySummary | undefined; + canDelete: boolean; + deleteDisabled: boolean; + onDelete: (project: Project) => Promise | void; + onOpen: (project: Project) => void; +}) { + return ( + + +
+
+
+
+ + + +
+ + {project.name} + +

+ {project.dtag} +

+
+
+
+
+ + +
+
+ +

+ {project.description || "A shared space for internal git work."} +

+ +
+ {project.defaultBranch} + + {pluralize(people.length, "person", "people")} + + + {formatCreatedDate(project.createdAt)} + +
+ +
+
+

+ {getActivityLabel(summary)} +

+
+ +
+
+
+ + {getCloneLabel(project)} +
+
+
+
+ ); +} + +function ProjectListRow({ + project, + people, + profiles, + summary, + canDelete, + deleteDisabled, + onDelete, + onOpen, +}: { + project: Project; + people: string[]; + profiles?: UserProfileLookup; + summary: ProjectActivitySummary | undefined; + canDelete: boolean; + deleteDisabled: boolean; + onDelete: (project: Project) => Promise | void; + onOpen: (project: Project) => void; +}) { + return ( + + +
+
+
+ + + {project.name} + + +
+

+ {project.description || "A shared space for internal git work."} +

+
+ +
+
+ + {project.defaultBranch} + + + {pluralize(people.length, "person", "people")} + + + {getDiscussionLabel(project)} + + + {formatCreatedDate(project.createdAt)} + +
+
+ + {getCloneLabel(project)} +
+
+ +
+

+ {getActivityLabel(summary)} +

+ + +
+
+
+ ); +} + export function ProjectsView() { const { goProject } = useAppNavigation(); const projectsQuery = useProjectsQuery(); + const identityQuery = useIdentityQuery(); const projects = projectsQuery.data ?? []; + const activitySummariesQuery = useProjectActivitySummariesQuery(projects); + const [storedViewMode, setStoredViewMode] = + React.useState(() => readStoredViewMode()); + const [filter, setFilter] = React.useState(() => + readStoredFilter(), + ); + const [sort, setSort] = React.useState(() => readStoredSort()); + const viewMode = + storedViewMode ?? + (projects.length > MANY_PROJECTS_THRESHOLD ? "list" : "grid"); + + const projectPubkeys = React.useMemo( + () => [ + ...new Set( + projects.flatMap((project) => + projectPeople( + project, + activitySummariesQuery.data?.[project.repoAddress], + ), + ), + ), + ], + [activitySummariesQuery.data, projects], + ); + const profilesQuery = useUsersBatchQuery(projectPubkeys, { + enabled: projectPubkeys.length > 0, + }); + const profiles = profilesQuery.data?.profiles; + const deleteProjectMutation = useDeleteProjectMutation(); + const currentPubkey = identityQuery.data?.pubkey; + + const handleViewModeChange = React.useCallback( + (nextViewMode: ProjectsViewMode) => { + setStoredViewMode(nextViewMode); + writeStoredViewMode(nextViewMode); + }, + [], + ); + + const handleFilterChange = React.useCallback((nextFilter: ProjectsFilter) => { + setFilter(nextFilter); + writeStoredFilter(nextFilter); + }, []); + + const handleSortChange = React.useCallback((nextSort: ProjectsSort) => { + setSort(nextSort); + writeStoredSort(nextSort); + }, []); + + const visibleProjects = React.useMemo(() => { + return projects + .filter((project) => { + const summary = activitySummariesQuery.data?.[project.repoAddress]; + const people = projectPeople(project, summary); + if (filter === "mine") return isProjectMine(project, currentPubkey); + if (filter === "agents") { + return projectHasAgent(project, people, profiles); + } + if (filter === "users") return projectOwnerIsUser(project, profiles); + return true; + }) + .sort((left, right) => { + const leftSummary = activitySummariesQuery.data?.[left.repoAddress]; + const rightSummary = activitySummariesQuery.data?.[right.repoAddress]; + if (sort === "name") { + return left.name.localeCompare(right.name); + } + if (sort === "created") { + return right.createdAt - left.createdAt; + } + return ( + getProjectUpdatedAt(right, rightSummary) - + getProjectUpdatedAt(left, leftSummary) + ); + }); + }, [ + activitySummariesQuery.data, + currentPubkey, + filter, + profiles, + projects, + sort, + ]); + + const handleOpenProject = React.useCallback( + (project: Project) => { + void goProject(project.dtag); + }, + [goProject], + ); + + const handleDeleteProject = React.useCallback( + async (project: Project) => { + try { + await deleteProjectMutation.mutateAsync(project); + toast.success("Branch deleted"); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to delete branch", + ); + } + }, + [deleteProjectMutation], + ); if (projectsQuery.isLoading) { return null; @@ -48,66 +840,64 @@ export function ProjectsView() { } return ( -
-
-

- {projects.length} {projects.length === 1 ? "project" : "projects"} -

-
+
+ -
- {projects.map((project) => ( - - -
-
-
- - - {project.name} - -
- {project.description ? ( -

- {project.description} -

- ) : null} -
- {project.cloneUrls.length > 0 ? ( - - - {project.cloneUrls[0]} - - ) : null} - {project.contributors.length > 0 ? ( - - - {project.contributors.length} - - ) : null} - {project.webUrl ? ( - - - Web - - ) : null} -
-
-
-
- ))} -
+ {visibleProjects.length === 0 ? ( + + ) : viewMode === "grid" ? ( +
+ {visibleProjects.map((project) => { + const summary = activitySummariesQuery.data?.[project.repoAddress]; + return ( + + ); + })} +
+ ) : ( +
+ {visibleProjects.map((project) => { + const summary = activitySummariesQuery.data?.[project.repoAddress]; + return ( + + ); + })} +
+ )}
); } diff --git a/desktop/src/shared/api/projectGit.ts b/desktop/src/shared/api/projectGit.ts new file mode 100644 index 000000000..461cd4406 --- /dev/null +++ b/desktop/src/shared/api/projectGit.ts @@ -0,0 +1,62 @@ +import type { ProjectRepoSnapshot } from "@/shared/api/types"; +import { invokeTauri } from "@/shared/api/tauri"; + +type RawProjectRepoCommit = { + hash: string; + short_hash: string; + author_name: string; + author_email: string; + timestamp: number; + subject: string; +}; + +type RawProjectRepoFile = { + path: string; + kind: string; + size: number | null; + preview_content: string | null; + last_changed_at: number | null; +}; + +type RawProjectRepoSnapshot = { + latest_commit: RawProjectRepoCommit | null; + files: RawProjectRepoFile[]; +}; + +function fromRawProjectRepoSnapshot( + snapshot: RawProjectRepoSnapshot, +): ProjectRepoSnapshot { + return { + latestCommit: snapshot.latest_commit + ? { + hash: snapshot.latest_commit.hash, + shortHash: snapshot.latest_commit.short_hash, + authorName: snapshot.latest_commit.author_name, + authorEmail: snapshot.latest_commit.author_email, + timestamp: snapshot.latest_commit.timestamp, + subject: snapshot.latest_commit.subject, + } + : null, + files: snapshot.files.map((file) => ({ + path: file.path, + kind: file.kind, + size: file.size, + previewContent: file.preview_content, + lastChangedAt: file.last_changed_at, + })), + }; +} + +export async function getProjectRepoSnapshot(input: { + cloneUrl: string; + defaultBranch?: string | null; +}): Promise { + const snapshot = await invokeTauri( + "get_project_repo_snapshot", + { + cloneUrl: input.cloneUrl, + defaultBranch: input.defaultBranch ?? null, + }, + ); + return fromRawProjectRepoSnapshot(snapshot); +} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index ef59385aa..fb8d12b19 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -158,6 +158,28 @@ export type UserStatus = { export type UserStatusLookup = Record; +export type ProjectRepoCommit = { + hash: string; + shortHash: string; + authorName: string; + authorEmail: string; + timestamp: number; + subject: string; +}; + +export type ProjectRepoFile = { + path: string; + kind: string; + size: number | null; + previewContent: string | null; + lastChangedAt: number | null; +}; + +export type ProjectRepoSnapshot = { + latestCommit: ProjectRepoCommit | null; + files: ProjectRepoFile[]; +}; + export type RelayEvent = { id: string; /** Local-only render identity for optimistic events that are later acknowledged. */ diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index 15199b8dc..56bd3c166 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -41,6 +41,15 @@ export const KIND_MESH_CONNECT_REQUEST = 24621; export const KIND_MESH_CALL_ME_NOW = 24622; export const KIND_EVENT_REMINDER = 30300; export const KIND_REPO_ANNOUNCEMENT = 30617; +export const KIND_REPO_STATE = 30618; +export const KIND_GIT_PATCH = 1617; +export const KIND_GIT_PULL_REQUEST = 1618; +export const KIND_GIT_PR_UPDATE = 1619; +export const KIND_GIT_ISSUE = 1621; +export const KIND_GIT_STATUS_OPEN = 1630; +export const KIND_GIT_STATUS_MERGED = 1631; +export const KIND_GIT_STATUS_CLOSED = 1632; +export const KIND_GIT_STATUS_DRAFT = 1633; // NIP-DV: relay-signed per-viewer DM visibility snapshot (d=viewer pubkey, // h-tags = currently-hidden DM channel ids). export const KIND_DM_VISIBILITY = 30622; diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 3c9f2bb4b..6f3dbb0f9 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -1519,9 +1519,10 @@ function FileCard({ ); } -function SyntaxHighlightedCode({ +export function SyntaxHighlightedCode({ code, language, + className, ...props }: { code: string; @@ -1597,7 +1598,7 @@ function SyntaxHighlightedCode({ } }, [code, language, themeName, loadedKey]); - const codeClassName = CODE_BLOCK_CLASS; + const codeClassName = cn(CODE_BLOCK_CLASS, className); if (!tokens) { const lines = code.split("\n"); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index ba2148d25..7af2de9db 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -6593,6 +6593,47 @@ export function maybeInstallE2eTauriMocks() { }, activeConfig, ); + case "get_project_repo_snapshot": + return { + latest_commit: { + hash: "0123456789abcdef0123456789abcdef01234567", + short_hash: "0123456", + author_name: "Brain", + author_email: "brain@example.com", + timestamp: Math.floor(Date.now() / 1000) - 600, + subject: "Add Trello board workflow details", + }, + files: [ + { + path: "desktop/src/features/projects/ui/ProjectDetailScreen.tsx", + kind: "blob", + size: 18420, + preview_content: + 'export function ProjectDetailScreen() {\n return ;\n}\n', + }, + { + path: "desktop/src/features/projects/ui/ProjectsView.tsx", + kind: "blob", + size: 16412, + preview_content: + "export function ProjectsView() {\n return ;\n}\n", + }, + { + path: "desktop/src/features/projects/hooks.ts", + kind: "blob", + size: 9520, + preview_content: + "export function useProjectRepoSnapshotQuery(project) {\n return useQuery({ queryKey: [project.id, 'repo-snapshot'] });\n}\n", + }, + { + path: "crates/buzz-relay/src/api/git/transport.rs", + kind: "blob", + size: 33120, + preview_content: + "// Smart HTTP git transport\n// Handles upload-pack and receive-pack for Buzz git repos.\n", + }, + ], + }; case "get_relay_ws_url": return getRelayWsUrl(activeConfig); case "get_default_relay_url": diff --git a/desktop/tests/e2e/history-icons-screenshots.spec.ts b/desktop/tests/e2e/history-icons-screenshots.spec.ts new file mode 100644 index 000000000..c57ff0854 --- /dev/null +++ b/desktop/tests/e2e/history-icons-screenshots.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge } from "../helpers/bridge"; + +const SHOTS = "test-results/history-icons"; + +test.describe("top chrome history controls", () => { + test.use({ viewport: { width: 1280, height: 720 } }); + + test("back and forward buttons read as a compact pair", async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + await expect(page.getByTestId("home-inbox-list")).toBeVisible(); + + const back = page.getByTestId("global-back"); + const forward = page.getByTestId("global-forward"); + await expect(back).toBeVisible(); + await expect(forward).toBeVisible(); + + await expect + .poll(async () => + page.evaluate(() => { + const backRect = document + .querySelector('[data-testid="global-back"]') + ?.getBoundingClientRect(); + const forwardRect = document + .querySelector('[data-testid="global-forward"]') + ?.getBoundingClientRect(); + if (!backRect || !forwardRect) return null; + + const backCenter = backRect.left + backRect.width / 2; + const forwardCenter = forwardRect.left + forwardRect.width / 2; + return Math.round(forwardCenter - backCenter); + }), + ) + .toBeLessThanOrEqual(28); + + await waitForAnimations(page); + await page.screenshot({ + path: `${SHOTS}/01-history-icons-compact-unit.png`, + clip: { x: 64, y: 0, width: 130, height: 48 }, + }); + }); +}); diff --git a/desktop/tests/e2e/projects-avatar-screenshot.spec.ts b/desktop/tests/e2e/projects-avatar-screenshot.spec.ts new file mode 100644 index 000000000..2cc7b0110 --- /dev/null +++ b/desktop/tests/e2e/projects-avatar-screenshot.spec.ts @@ -0,0 +1,240 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge } from "../helpers/bridge"; + +const SHOTS = "test-results/projects-avatar"; +const BRAIN_PUBKEY = + "1d4f144e07e4c289490acf6d51b50e5450820ee0555783972a22a3074fb1d8bf"; +const THOMAS_PUBKEY = + "29ddeb07aec92535a5b38b7ea1d731bc641fd97ffcf59080ab9a2584d3cbe5c6"; +const BRAIN_AVATAR = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop stop-color='%238b5cf6'/%3E%3Cstop offset='1' stop-color='%2306b6d4'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='32' fill='url(%23g)'/%3E%3Ctext x='32' y='39' text-anchor='middle' font-size='24' font-family='Inter,Arial' fill='white' font-weight='700'%3EB%3C/text%3E%3C/svg%3E"; + +const PROJECT_ID = `${BRAIN_PUBKEY}:git-ticket-trello`; +const PROJECT = { + id: PROJECT_ID, + dtag: "git-ticket-trello", + name: "Git Ticket Trello Board", + description: "Trello-style workflow for moving git tickets back and forth.", + cloneUrls: [ + `https://sprout-oss.stage.blox.sqprod.co/git/${BRAIN_PUBKEY}/git-ticket-trello.git`, + ], + webUrl: null, + owner: BRAIN_PUBKEY, + contributors: [THOMAS_PUBKEY], + createdAt: 1_782_389_983, + projectChannelId: "f147ef69-9ec1-48cf-8e0e-524fb3b33cee", + status: "active", + defaultBranch: "main", + repoAddress: `30617:${BRAIN_PUBKEY}:git-ticket-trello`, +}; + +const SECOND_PROJECT = { + ...PROJECT, + id: `${BRAIN_PUBKEY}:agent-review-queue`, + dtag: "agent-review-queue", + name: "Agent Review Queue", + description: "Track branches, patches, and review notes across agent work.", + cloneUrls: [ + `https://sprout-oss.stage.blox.sqprod.co/git/${BRAIN_PUBKEY}/agent-review-queue.git`, + ], + repoAddress: `30617:${BRAIN_PUBKEY}:agent-review-queue`, + projectChannelId: null, + createdAt: 1_782_300_000, +}; + +const THIRD_PROJECT = { + ...PROJECT, + id: `${BRAIN_PUBKEY}:workflow-sandbox`, + dtag: "workflow-sandbox", + name: "Workflow Sandbox", + description: "Prototype board automations before promoting them to staging.", + cloneUrls: [ + `https://sprout-oss.stage.blox.sqprod.co/git/${BRAIN_PUBKEY}/workflow-sandbox.git`, + ], + repoAddress: `30617:${BRAIN_PUBKEY}:workflow-sandbox`, + status: "draft", + createdAt: 1_782_200_000, +}; + +async function seedProjects(page: Page) { + await page.evaluate( + ({ brainPubkey, project, secondProject, thomasPubkey, thirdProject }) => { + window.__BUZZ_E2E_QUERY_CLIENT__?.setQueryData?.( + ["projects"], + [project, secondProject, thirdProject], + ); + window.__BUZZ_E2E_QUERY_CLIENT__?.setQueryData?.( + ["project", project.dtag], + project, + ); + window.__BUZZ_E2E_QUERY_CLIENT__?.setQueryData?.( + ["project", project.id, "issues"], + [ + { + id: "a".repeat(64), + title: "Move git tickets between Trello columns", + content: + "Persist movement through NIP-34 status events and keep history auditable.", + author: thomasPubkey, + createdAt: 1_782_389_990, + repoAddress: project.repoAddress, + labels: ["feature", "projects"], + recipients: [brainPubkey], + status: "In Progress", + statusEventId: null, + updatedAt: 1_782_390_100, + }, + { + id: "b".repeat(64), + title: "Render agent avatar in project cards", + content: "Show Brain's avatar directly inside the agent pill.", + author: brainPubkey, + createdAt: 1_782_389_995, + repoAddress: project.repoAddress, + labels: ["ui"], + recipients: [thomasPubkey], + status: "Done", + statusEventId: null, + updatedAt: 1_782_390_200, + }, + ], + ); + window.__BUZZ_E2E_QUERY_CLIENT__?.setQueryData?.( + ["project", project.id, "repo-state"], + { + branches: [ + { + name: "main", + commit: "0123456789abcdef0123456789abcdef01234567", + }, + { + name: "feature/trello-board", + commit: "fedcba9876543210fedcba9876543210fedcba98", + }, + ], + tags: [], + head: "refs/heads/main", + updatedAt: 1_782_390_300, + }, + ); + window.__BUZZ_E2E_QUERY_CLIENT__?.setQueryData?.( + [ + "projects", + "activity-summaries", + [ + project.repoAddress, + secondProject.repoAddress, + thirdProject.repoAddress, + ].sort(), + ], + { + [project.repoAddress]: { + repoAddress: project.repoAddress, + issueCount: 2, + activityCount: 5, + updatedAt: 1_782_390_300, + participantPubkeys: [brainPubkey, thomasPubkey], + }, + [secondProject.repoAddress]: { + repoAddress: secondProject.repoAddress, + issueCount: 1, + activityCount: 2, + updatedAt: 1_782_300_100, + participantPubkeys: [brainPubkey], + }, + [thirdProject.repoAddress]: { + repoAddress: thirdProject.repoAddress, + issueCount: 0, + activityCount: 0, + updatedAt: 0, + participantPubkeys: [], + }, + }, + ); + }, + { + brainPubkey: BRAIN_PUBKEY, + project: PROJECT, + secondProject: SECOND_PROJECT, + thomasPubkey: THOMAS_PUBKEY, + thirdProject: THIRD_PROJECT, + }, + ); +} + +test.describe("project cards", () => { + test.use({ viewport: { width: 1280, height: 720 } }); + + test("show grid/list modes, agent avatar, delete action, and detail view", async ({ + page, + }) => { + await installMockBridge(page, { + searchProfiles: [ + { + pubkey: BRAIN_PUBKEY, + displayName: "Brain", + avatarUrl: BRAIN_AVATAR, + isAgent: true, + ownerPubkey: THOMAS_PUBKEY, + }, + { + pubkey: THOMAS_PUBKEY, + displayName: "Thomas P", + avatarUrl: null, + }, + ], + }); + + await page.goto("/"); + await page.waitForFunction(() => Boolean(window.__BUZZ_E2E_QUERY_CLIENT__)); + await page.getByTestId("open-projects-view").click(); + await seedProjects(page); + + const card = page.getByTestId("project-card-git-ticket-trello"); + await expect(card).toBeVisible(); + await expect(card.getByText("Agent: Brain")).toBeVisible(); + await expect( + card.getByTestId("project-work-owner-avatar-image"), + ).toBeVisible(); + + await card.hover(); + await expect( + page.getByLabel("Delete Git Ticket Trello Board"), + ).toBeVisible(); + + await waitForAnimations(page); + await card.screenshot({ path: `${SHOTS}/01-project-grid-card.png` }); + + await page.getByRole("button", { name: "List" }).click(); + const row = page.getByTestId("project-row-git-ticket-trello"); + await expect(row).toBeVisible(); + await waitForAnimations(page); + await row.screenshot({ path: `${SHOTS}/02-project-list-row.png` }); + + await row.click(); + await expect(page.getByRole("tab", { name: "Files" })).toBeVisible(); + await expect( + page.getByRole("button", { + name: "desktop/src/features/projects/ui/ProjectDetailScreen.tsx", + }), + ).toBeVisible(); + await expect(page.getByText("return