From 7726d1b9f8768f70a1f4c3756a9a87743fa88fe5 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Tue, 23 Jun 2026 16:11:03 -0400 Subject: [PATCH 1/5] Tighten history button spacing Co-authored-by: Thomas Petersen Signed-off-by: Thomas Petersen --- desktop/playwright.config.ts | 1 + desktop/src/app/AppTopChrome.tsx | 6 ++- .../e2e/history-icons-screenshots.spec.ts | 45 +++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 desktop/tests/e2e/history-icons-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 7f7ccce4f..bd244110f 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -45,6 +45,7 @@ export default defineConfig({ "**/identity-archive.spec.ts", "**/identity-archive-hide.spec.ts", "**/relay-connectivity-screenshots.spec.ts", + "**/history-icons-screenshots.spec.ts", "**/unread-pill-screenshots.spec.ts", "**/sidebar-more-unread-overlap.spec.ts", "**/thread-unread-screenshots.spec.ts", diff --git a/desktop/src/app/AppTopChrome.tsx b/desktop/src/app/AppTopChrome.tsx index 256bd8d8e..6ecb6fca9 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-muted-foreground/70 hover:bg-border/45 hover:text-foreground [&_svg]:size-4"; +const HISTORY_ICON_BUTTON_CLASS = + "h-7 w-6 rounded-[4px] text-muted-foreground/70 hover:bg-border/45 hover:text-foreground [&_svg]:size-4"; const TOP_CHROME_WHEEL_GUARD_HEIGHT = 40; function TopChromeSidebarTrigger() { @@ -91,7 +93,7 @@ export function AppTopChrome({ + ); + })} + + ); +} + +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, + error, +}: { + snapshot: ProjectRepoSnapshot | null | undefined; + isLoading: boolean; + error: unknown; +}) { + const latestCommit = snapshot?.latestCommit ?? null; + + if (isLoading) { + return

Loading commit…

; + } + + if (!latestCommit) { + return ( +

+ {error + ? "Could not load repository activity from git." + : "No commits are available yet."} +

+ ); + } + + return ( +
+
+
+
+

+ {latestCommit.subject} +

+

+ {latestCommit.authorName} · {compactDate(latestCommit.timestamp)} +

+
+ + {latestCommit.shortHash} + +
+
+
+ + +
+
+ ); +} + +function BranchesPanel({ + project, + repoState, + isLoading, +}: { + project: Project; + repoState: ReturnType["data"]; + isLoading: boolean; +}) { + if (isLoading) { + return ( +

Loading branches…

+ ); + } + + if (!repoState) { + return ( +

+ No branch refs have been published yet. +

+ ); + } + + return ( +
+
+ + + +
+
+ {repoState.branches.slice(0, 12).map((branch) => ( +
+ {branch.name} + + {branch.commit.slice(0, 8)} + +
+ ))} +
+
+ ); +} + +function IssuesPanel({ + issues, + isLoading, +}: { + issues: ProjectIssue[]; + isLoading: boolean; +}) { + if (isLoading) { + return

Loading issues…

; + } + + if (issues.length === 0) { + return ( +

+ No issues yet. Git issues for this project will appear here with their + workflow status. +

+ ); + } + + return ( +
+ {issues.slice(0, 10).map((issue) => ( + +
+
+

+ {issue.title} +

+ {issue.content ? ( +

+ {issue.content} +

+ ) : null} +
+ + {issue.status} + +
+
+ Updated {compactDate(issue.updatedAt)} + {issue.labels.map((label) => ( + + {label} + + ))} +
+
+ ))} +
+ ); +} + +function WorkspaceTabs({ + project, + snapshot, + snapshotError, + snapshotLoading, + repoState, + repoStateLoading, + issues, + issuesLoading, +}: { + project: Project; + snapshot: ProjectRepoSnapshot | null | undefined; + snapshotError: unknown; + snapshotLoading: boolean; + repoState: ReturnType["data"]; + repoStateLoading: boolean; + issues: ProjectIssue[]; + 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]); + + return ( +
+ +
+
+ + + Workspace + +
+ + + + Files + + + + Activity + + + + Issues + + + + Branches + + +
+ + +
+ + +
+
+ + + + + + + + + + + + +
+
+ ); +} + type ProjectDetailScreenProps = { projectId: string; }; export function ProjectDetailScreen({ projectId }: ProjectDetailScreenProps) { - const { goProjects } = useAppNavigation(); + const { goChannel, goProjects } = useAppNavigation(); const projectQuery = useProjectQuery(projectId); const project = projectQuery.data; + const repoStateQuery = useRepoStateQuery(project); + const repoSnapshotQuery = useProjectRepoSnapshotQuery(project); + const issuesQuery = useProjectIssuesQuery(project); + const issues = issuesQuery.data ?? []; - const allPubkeys = React.useMemo( - () => - project ? [project.owner, ...project.contributors].filter(Boolean) : [], - [project], + const peoplePubkeys = React.useMemo( + () => (project ? projectPeople(project, issues) : []), + [issues, project], ); - const profilesQuery = useUsersBatchQuery(allPubkeys); + const profilesQuery = useUsersBatchQuery(peoplePubkeys, { + enabled: peoplePubkeys.length > 0, + }); const profiles = profilesQuery.data?.profiles; if (projectQuery.isLoading) { @@ -119,10 +621,8 @@ export function ProjectDetailScreen({ projectId }: ProjectDetailScreenProps) { ); } - const createdDate = new Date(project.createdAt * 1_000).toLocaleDateString( - undefined, - { year: "numeric", month: "long", day: "numeric" }, - ); + const ownerProfile = profiles?.[normalizePubkey(project.owner)]; + const ownerLabel = resolveUserLabel({ pubkey: project.owner, profiles }); return (
@@ -146,17 +646,76 @@ export function ProjectDetailScreen({ projectId }: ProjectDetailScreenProps) {
-
-
-
- -

{project.name}

+
+
+
+
+ +
+
+

+ {project.name} +

+ + {project.status} + +
+

+ Work by {ownerLabel} · Created{" "} + {formatCreatedDate(project.createdAt)} +

+
+
+ {project.projectChannelId ? ( + + ) : null}
{project.description ? (

{project.description}

) : null} + +
+ + + + +
{project.cloneUrls.length > 0 ? ( @@ -189,32 +748,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"} +

+
); })} @@ -222,12 +797,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/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index 8ca16846e..8f4ffc541 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -1,11 +1,232 @@ -import { ExternalLink, FolderGit2, GitFork, Users } from "lucide-react"; +import { + CalendarDays, + FolderGit2, + GitBranch, + GitFork, + LayoutGrid, + List, + MessageSquare, + 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 { topChromeInset } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { Card } from "@/shared/ui/card"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +type ProjectsViewMode = "grid" | "list"; + +const PROJECTS_VIEW_MODE_STORAGE_KEY = "buzz.projects.viewMode"; +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 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 WorkOwnerBadge({ + avatarUrl, + isAgent, + label, +}: { + avatarUrl: string | null; + isAgent: boolean; + label: string; +}) { + return ( + + + + {isAgent ? "Agent" : "Work by"}: {label} + + + ); +} + +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 }) { + 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 ( @@ -21,10 +242,324 @@ function EmptyState() { ); } +function ProjectsToolbar({ + projectCount, + viewMode, + onViewModeChange, +}: { + projectCount: number; + viewMode: ProjectsViewMode; + onViewModeChange: (viewMode: ProjectsViewMode) => void; +}) { + return ( +
+
+
+

Projects

+ + {pluralize(projectCount, "project")} + +
+

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

+
+ +
+ ); +} + +function ProjectCardButton({ + project, + onOpen, +}: { + project: Project; + onOpen: (project: Project) => void; +}) { + return ( + + ); +} + +function ProjectDeleteButton({ + project, + disabled, + onDelete, +}: { + project: Project; + disabled: boolean; + onDelete: (project: Project) => void; +}) { + return ( + + ); +} + +function ProjectGridCard({ + project, + people, + profiles, + summary, + onDelete, + onOpen, + deleteDisabled, +}: { + project: Project; + people: string[]; + profiles?: UserProfileLookup; + summary: ProjectActivitySummary | undefined; + onDelete: (project: Project) => void; + onOpen: (project: Project) => void; + deleteDisabled: boolean; +}) { + const ownerProfile = profiles?.[normalizePubkey(project.owner)]; + const ownerLabel = resolveUserLabel({ pubkey: project.owner, profiles }); + + return ( + + +
+
+
+
+ + + {project.name} + +
+

+ {project.dtag} +

+
+ +
+ + + +

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

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

+ {getActivityLabel(summary)} +

+
+ + +
+
+
+ + {getCloneLabel(project)} +
+
+
+
+ ); +} + +function ProjectListRow({ + project, + people, + profiles, + summary, + onDelete, + onOpen, + deleteDisabled, +}: { + project: Project; + people: string[]; + profiles?: UserProfileLookup; + summary: ProjectActivitySummary | undefined; + onDelete: (project: Project) => void; + onOpen: (project: Project) => void; + deleteDisabled: boolean; +}) { + const ownerProfile = profiles?.[normalizePubkey(project.owner)]; + const ownerLabel = resolveUserLabel({ pubkey: project.owner, profiles }); + + 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 projects = projectsQuery.data ?? []; + const activitySummariesQuery = useProjectActivitySummariesQuery(projects); + const [storedViewMode, setStoredViewMode] = + React.useState(() => readStoredViewMode()); + 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 handleViewModeChange = React.useCallback( + (nextViewMode: ProjectsViewMode) => { + setStoredViewMode(nextViewMode); + writeStoredViewMode(nextViewMode); + }, + [], + ); + + const handleOpenProject = React.useCallback( + (project: Project) => { + void goProject(project.dtag); + }, + [goProject], + ); + + const handleDeleteProject = React.useCallback( + async (project: Project) => { + const confirmed = window.confirm(`Delete ${project.name}?`); + if (!confirmed) return; + + try { + await deleteProjectMutation.mutateAsync(project); + toast.success("Project card deleted"); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to delete project card", + ); + } + }, + [deleteProjectMutation], + ); if (projectsQuery.isLoading) { return null; @@ -56,65 +591,53 @@ export function ProjectsView() { topChromeInset.padding, )} > -
-

- {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} -
-
-
-
- ))} -
+ {viewMode === "grid" ? ( +
+ {projects.map((project) => { + const summary = activitySummariesQuery.data?.[project.repoAddress]; + return ( + + void handleDeleteProject(nextProject) + } + onOpen={handleOpenProject} + people={projectPeople(project, summary)} + profiles={profiles} + project={project} + summary={summary} + /> + ); + })} +
+ ) : ( +
+ {projects.map((project) => { + const summary = activitySummariesQuery.data?.[project.repoAddress]; + return ( + + void handleDeleteProject(nextProject) + } + onOpen={handleOpenProject} + people={projectPeople(project, summary)} + profiles={profiles} + project={project} + summary={summary} + /> + ); + })} +
+ )}
); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 04f8bfe98..8ad959d1a 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -6506,6 +6506,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/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 Date: Thu, 25 Jun 2026 14:01:28 -0400 Subject: [PATCH 4/5] Refine project UI surfaces Co-authored-by: Thomas Petersen Signed-off-by: Thomas Petersen --- desktop/src/features/projects/hooks.ts | 18 +- .../projects/ui/ProjectDetailScreen.tsx | 12 +- .../src/features/projects/ui/ProjectsView.tsx | 253 +++++++++++------- 3 files changed, 158 insertions(+), 125 deletions(-) diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index 819c46019..02d4f1a33 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -95,19 +95,6 @@ function readHiddenProjectCards(): string[] { } } -function hideProjectCard(project: Project): void { - if (typeof window === "undefined") { - return; - } - - const hidden = new Set(readHiddenProjectCards()); - hidden.add(projectCoordinate(project)); - window.localStorage.setItem( - HIDDEN_PROJECT_CARDS_KEY, - JSON.stringify([...hidden]), - ); -} - function isHiddenLocally(project: Project): boolean { return readHiddenProjectCards().includes(projectCoordinate(project)); } @@ -306,13 +293,12 @@ async function fetchProjectActivitySummaries( async function deleteProject(project: Project): Promise { const identity = await getIdentity(); if (identity.pubkey.toLowerCase() !== project.owner.toLowerCase()) { - hideProjectCard(project); - return; + throw new Error("Only branch owners can delete branches."); } const event = await signRelayEvent({ kind: KIND_DELETION, - content: `Delete project ${project.name}`, + content: `Delete branch ${project.name}`, tags: [["a", project.repoAddress]], }); diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index 8cb6c95ce..2db1b4794 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -282,7 +282,7 @@ function LatestCommitPanel({ return (
-
+

@@ -395,8 +395,8 @@ function IssuesPanel({ return (

{issues.slice(0, 10).map((issue) => ( -
@@ -418,14 +418,14 @@ function IssuesPanel({ Updated {compactDate(issue.updatedAt)} {issue.labels.map((label) => ( {label} ))}
-
+ ))}
); @@ -646,7 +646,7 @@ export function ProjectDetailScreen({ projectId }: ProjectDetailScreenProps) {
-
+
diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index 86d19634c..e7a874881 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -7,6 +7,7 @@ import { LayoutGrid, List, MessageSquare, + MoreHorizontal, Trash2, Users, } from "lucide-react"; @@ -30,8 +31,24 @@ 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"; @@ -161,6 +178,15 @@ function isProjectMine(project: Project, currentPubkey: string | undefined) { ); } +function isProjectOwnedByCurrentUser( + project: Project, + currentPubkey: string | undefined, +) { + return currentPubkey + ? normalizePubkey(project.owner) === normalizePubkey(currentPubkey) + : false; +} + function projectHasAgent( project: Project, people: string[], @@ -179,31 +205,6 @@ function projectOwnerIsUser( return profiles?.[normalizePubkey(project.owner)]?.isAgent !== true; } -function WorkOwnerBadge({ - avatarUrl, - isAgent, - label, -}: { - avatarUrl: string | null; - isAgent: boolean; - label: string; -}) { - return ( - - - - {isAgent ? "Agent" : "Work by"}: {label} - - - ); -} - function ProjectPeopleStack({ pubkeys, profiles, @@ -248,8 +249,12 @@ function ProjectPeopleStack({ } function StatusPill({ status }: { status: string }) { + if (status === "active") { + return null; + } + return ( - + {status} ); @@ -447,30 +452,86 @@ function ProjectCardButton({ ); } -function ProjectDeleteButton({ +function ProjectActionsMenu({ project, + canDelete, disabled, onDelete, }: { project: Project; + canDelete: boolean; disabled: boolean; - onDelete: (project: Project) => void; + 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. + + + + + + + + + + + + ); } @@ -479,67 +540,69 @@ function ProjectGridCard({ people, profiles, summary, + canDelete, + deleteDisabled, onDelete, onOpen, - deleteDisabled, }: { project: Project; people: string[]; profiles?: UserProfileLookup; summary: ProjectActivitySummary | undefined; - onDelete: (project: Project) => void; - onOpen: (project: Project) => void; + canDelete: boolean; deleteDisabled: boolean; + onDelete: (project: Project) => Promise | void; + onOpen: (project: Project) => void; }) { - const ownerProfile = profiles?.[normalizePubkey(project.owner)]; - const ownerLabel = resolveUserLabel({ pubkey: project.owner, profiles }); - return (
-
+
- - - {project.name} + + +
+ + {project.name} + +

+ {project.dtag} +

+
-

- {project.dtag} -

- +
+ + +
- - -

+

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

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

{getActivityLabel(summary)} @@ -550,11 +613,6 @@ function ProjectGridCard({ pubkeys={people} workOwnerPubkey={project.owner} /> -

@@ -572,24 +630,23 @@ function ProjectListRow({ people, profiles, summary, + canDelete, + deleteDisabled, onDelete, onOpen, - deleteDisabled, }: { project: Project; people: string[]; profiles?: UserProfileLookup; summary: ProjectActivitySummary | undefined; - onDelete: (project: Project) => void; - onOpen: (project: Project) => void; + canDelete: boolean; deleteDisabled: boolean; + onDelete: (project: Project) => Promise | void; + onOpen: (project: Project) => void; }) { - const ownerProfile = profiles?.[normalizePubkey(project.owner)]; - const ownerLabel = resolveUserLabel({ pubkey: project.owner, profiles }); - return ( @@ -605,11 +662,6 @@ function ProjectListRow({

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

-
@@ -642,7 +694,8 @@ function ProjectListRow({ pubkeys={people} workOwnerPubkey={project.owner} /> - { @@ -707,14 +761,14 @@ export function ProjectsView() { }, []); const visibleProjects = React.useMemo(() => { - const currentPubkey = identityQuery.data?.pubkey; 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") + if (filter === "agents") { return projectHasAgent(project, people, profiles); + } if (filter === "users") return projectOwnerIsUser(project, profiles); return true; }) @@ -734,8 +788,8 @@ export function ProjectsView() { }); }, [ activitySummariesQuery.data, + currentPubkey, filter, - identityQuery.data?.pubkey, profiles, projects, sort, @@ -750,17 +804,12 @@ export function ProjectsView() { const handleDeleteProject = React.useCallback( async (project: Project) => { - const confirmed = window.confirm(`Delete ${project.name}?`); - if (!confirmed) return; - try { await deleteProjectMutation.mutateAsync(project); - toast.success("Project card deleted"); + toast.success("Branch deleted"); } catch (error) { toast.error( - error instanceof Error - ? error.message - : "Failed to delete project card", + error instanceof Error ? error.message : "Failed to delete branch", ); } }, @@ -816,11 +865,10 @@ export function ProjectsView() { const summary = activitySummariesQuery.data?.[project.repoAddress]; return ( - void handleDeleteProject(nextProject) - } + onDelete={handleDeleteProject} onOpen={handleOpenProject} people={projectPeople(project, summary)} profiles={profiles} @@ -836,11 +884,10 @@ export function ProjectsView() { const summary = activitySummariesQuery.data?.[project.repoAddress]; return ( - void handleDeleteProject(nextProject) - } + onDelete={handleDeleteProject} onOpen={handleOpenProject} people={projectPeople(project, summary)} profiles={profiles} From 2a2be6b1a5788a3f70c4589e73c558914d33e36d Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Thu, 25 Jun 2026 18:00:05 -0400 Subject: [PATCH 5/5] Improve project repository browser Co-authored-by: Thomas Petersen Signed-off-by: Thomas Petersen --- desktop/src-tauri/src/commands/project_git.rs | 42 +- .../projects/ui/ProjectDetailScreen.tsx | 290 +++-------- .../projects/ui/ProjectRepositoryPanel.tsx | 481 ++++++++++++++++++ .../src/features/projects/ui/ProjectsView.tsx | 12 +- desktop/src/shared/api/projectGit.ts | 2 + desktop/src/shared/api/types.ts | 1 + desktop/src/shared/ui/markdown.tsx | 5 +- 7 files changed, 599 insertions(+), 234 deletions(-) create mode 100644 desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx diff --git a/desktop/src-tauri/src/commands/project_git.rs b/desktop/src-tauri/src/commands/project_git.rs index bd11a751d..d01c75cbd 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 2db1b4794..44fe8e453 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 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 e7a874881..e0373dbb2 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)}