mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): add Projects as first-class top-level view (#546)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
||||
Bot,
|
||||
CircleDot,
|
||||
FileText,
|
||||
FolderGit2,
|
||||
Hash,
|
||||
Home,
|
||||
Lock,
|
||||
@@ -20,7 +21,7 @@ type ChatHeaderProps = {
|
||||
description: string;
|
||||
channelType?: ChannelType;
|
||||
visibility?: ChannelVisibility;
|
||||
mode?: "home" | "channel" | "agents" | "workflows" | "pulse";
|
||||
mode?: "home" | "channel" | "agents" | "workflows" | "pulse" | "projects";
|
||||
overlaysContent?: boolean;
|
||||
statusBadge?: React.ReactNode;
|
||||
};
|
||||
@@ -34,7 +35,7 @@ function ChannelIcon({
|
||||
}: {
|
||||
channelType?: ChannelType;
|
||||
visibility?: ChannelVisibility;
|
||||
mode?: "home" | "channel" | "agents" | "workflows" | "pulse";
|
||||
mode?: "home" | "channel" | "agents" | "workflows" | "pulse" | "projects";
|
||||
}) {
|
||||
if (mode === "home") {
|
||||
return <Home className={HEADER_ICON_CLASS} />;
|
||||
@@ -52,6 +53,10 @@ function ChannelIcon({
|
||||
return <Activity className={HEADER_ICON_CLASS} />;
|
||||
}
|
||||
|
||||
if (mode === "projects") {
|
||||
return <FolderGit2 className={HEADER_ICON_CLASS} />;
|
||||
}
|
||||
|
||||
if (channelType === "dm") {
|
||||
return <CircleDot className={HEADER_ICON_CLASS} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FileDiff, Maximize2 } from "lucide-react";
|
||||
|
||||
import { isSafeUrl } from "@/shared/lib/url";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { DiffViewer } from "./DiffViewer";
|
||||
@@ -14,16 +15,6 @@ type DiffMessageProps = {
|
||||
onExpand?: () => void;
|
||||
};
|
||||
|
||||
function isSafeUrl(url: string | undefined): url is string {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getHostname(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { KIND_REPO_ANNOUNCEMENT } from "@/shared/constants/kinds";
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
|
||||
export type Project = {
|
||||
id: string;
|
||||
dtag: string;
|
||||
name: string;
|
||||
description: string;
|
||||
cloneUrls: string[];
|
||||
webUrl: string | null;
|
||||
owner: string;
|
||||
contributors: string[];
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
function getTag(event: RelayEvent, name: string): string | undefined {
|
||||
return event.tags.find((t) => t[0] === name)?.[1];
|
||||
}
|
||||
|
||||
function getAllTags(event: RelayEvent, name: string): string[] {
|
||||
return event.tags.filter((t) => t[0] === name).map((t) => t[1]);
|
||||
}
|
||||
|
||||
function getCloneUrls(event: RelayEvent): string[] {
|
||||
const tag = event.tags.find((t) => t[0] === "clone");
|
||||
return tag ? tag.slice(1) : [];
|
||||
}
|
||||
|
||||
function eventToProject(event: RelayEvent): Project {
|
||||
const d = getTag(event, "d") ?? event.id;
|
||||
const name = getTag(event, "name") || d;
|
||||
const description = getTag(event, "description") || event.content || "";
|
||||
const cloneUrls = getCloneUrls(event);
|
||||
const webUrl = getTag(event, "web") ?? null;
|
||||
const contributors = getAllTags(event, "p");
|
||||
|
||||
return {
|
||||
id: `${event.pubkey}:${d}`,
|
||||
dtag: d,
|
||||
name,
|
||||
description,
|
||||
cloneUrls,
|
||||
webUrl,
|
||||
owner: event.pubkey,
|
||||
contributors,
|
||||
createdAt: event.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function dedup(events: RelayEvent[]): RelayEvent[] {
|
||||
const best = new Map<string, RelayEvent>();
|
||||
|
||||
for (const e of events) {
|
||||
const d = getTag(e, "d") ?? "";
|
||||
const key = `${e.pubkey}:${e.kind}:${d}`;
|
||||
const prev = best.get(key);
|
||||
|
||||
if (!prev || e.created_at > prev.created_at) {
|
||||
best.set(key, e);
|
||||
}
|
||||
}
|
||||
|
||||
return [...best.values()];
|
||||
}
|
||||
|
||||
async function fetchProjects(): Promise<Project[]> {
|
||||
const events = await relayClient.fetchEvents({
|
||||
kinds: [KIND_REPO_ANNOUNCEMENT],
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
return dedup(events)
|
||||
.map(eventToProject)
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
async function fetchProject(projectId: string): Promise<Project | null> {
|
||||
const events = await relayClient.fetchEvents({
|
||||
kinds: [KIND_REPO_ANNOUNCEMENT],
|
||||
"#d": [projectId],
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const deduped = dedup(events);
|
||||
return deduped.length > 0 ? eventToProject(deduped[0]) : null;
|
||||
}
|
||||
|
||||
export const projectsQueryKey = ["projects"] as const;
|
||||
|
||||
export function useProjectsQuery() {
|
||||
return useQuery({
|
||||
queryKey: projectsQueryKey,
|
||||
queryFn: fetchProjects,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useProjectQuery(projectId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["project", projectId],
|
||||
queryFn: () => fetchProject(projectId),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FolderGit2,
|
||||
GitFork,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
|
||||
import { useProjectQuery } from "@/features/projects/hooks";
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { resolveUserLabel } from "@/features/profile/lib/identity";
|
||||
import { isSafeUrl } from "@/shared/lib/url";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
|
||||
function CloneUrlRow({ url }: { url: string }) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const handleCopy = React.useCallback(() => {
|
||||
void navigator.clipboard.writeText(url).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2_000);
|
||||
});
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-3 py-2">
|
||||
<GitFork className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<code className="min-w-0 flex-1 truncate text-xs">{url}</code>
|
||||
<Button
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={handleCopy}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ProjectDetailScreenProps = {
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export function ProjectDetailScreen({ projectId }: ProjectDetailScreenProps) {
|
||||
const { goProjects } = useAppNavigation();
|
||||
const projectQuery = useProjectQuery(projectId);
|
||||
const project = projectQuery.data;
|
||||
|
||||
const allPubkeys = React.useMemo(
|
||||
() =>
|
||||
project ? [project.owner, ...project.contributors].filter(Boolean) : [],
|
||||
[project],
|
||||
);
|
||||
const profilesQuery = useUsersBatchQuery(allPubkeys);
|
||||
const profiles = profilesQuery.data?.profiles;
|
||||
|
||||
if (projectQuery.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (projectQuery.isError) {
|
||||
return (
|
||||
<>
|
||||
<ChatHeader
|
||||
description=""
|
||||
mode="projects"
|
||||
overlaysContent
|
||||
title="Error"
|
||||
/>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-16 text-center">
|
||||
<FolderGit2 className="h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="text-sm text-red-400">Failed to load project</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => void projectQuery.refetch()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void goProjects();
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
|
||||
Back to Projects
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<>
|
||||
<ChatHeader
|
||||
description=""
|
||||
mode="projects"
|
||||
overlaysContent
|
||||
title="Project not found"
|
||||
/>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-16 text-center">
|
||||
<FolderGit2 className="h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This project could not be found.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void goProjects();
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
|
||||
Back to Projects
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const createdDate = new Date(project.createdAt * 1_000).toLocaleDateString(
|
||||
undefined,
|
||||
{ year: "numeric", month: "long", day: "numeric" },
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatHeader
|
||||
description={project.description}
|
||||
mode="projects"
|
||||
overlaysContent
|
||||
title={project.name}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-4 pt-14">
|
||||
<div className="mb-4">
|
||||
<Button
|
||||
className="gap-1.5 text-muted-foreground"
|
||||
onClick={() => {
|
||||
void goProjects();
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back to Projects
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full max-w-2xl space-y-6">
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderGit2 className="h-5 w-5 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold">{project.name}</h2>
|
||||
</div>
|
||||
{project.description ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.description}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{project.cloneUrls.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Clone
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{project.cloneUrls.map((url) => (
|
||||
<CloneUrlRow key={url} url={url} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{project.webUrl && isSafeUrl(project.webUrl) ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Web
|
||||
</h3>
|
||||
<a
|
||||
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
|
||||
href={project.webUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{project.webUrl}
|
||||
</a>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{project.contributors.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
Contributors ({project.contributors.length})
|
||||
</span>
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{project.contributors.map((pubkey) => {
|
||||
const label = resolveUserLabel({ pubkey, profiles });
|
||||
const avatarUrl =
|
||||
profiles?.[pubkey.toLowerCase()]?.avatarUrl ?? null;
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-md bg-muted/30 px-3 py-1.5"
|
||||
key={pubkey}
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
displayName={label}
|
||||
size="xs"
|
||||
/>
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Details
|
||||
</h3>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>Created: {createdDate}</p>
|
||||
<p className="truncate">
|
||||
Owner: {resolveUserLabel({ pubkey: project.owner, profiles })}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
|
||||
import { ProjectsView } from "@/features/projects/ui/ProjectsView";
|
||||
|
||||
export function ProjectsScreen() {
|
||||
return (
|
||||
<>
|
||||
<ChatHeader
|
||||
description="Repositories and projects on this relay."
|
||||
mode="projects"
|
||||
overlaysContent
|
||||
title="Projects"
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<ProjectsView />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { ExternalLink, FolderGit2, GitFork, Users } from "lucide-react";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useProjectsQuery } from "@/features/projects/hooks";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card } from "@/shared/ui/card";
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-16 text-center">
|
||||
<FolderGit2 className="h-10 w-10 text-muted-foreground/40" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">No projects yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Projects published to this relay will appear here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectsView() {
|
||||
const { goProject } = useAppNavigation();
|
||||
const projectsQuery = useProjectsQuery();
|
||||
const projects = projectsQuery.data ?? [];
|
||||
|
||||
if (projectsQuery.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (projectsQuery.isError) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<p className="text-sm text-red-400">Failed to load projects</p>
|
||||
<Button
|
||||
onClick={() => void projectsQuery.refetch()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (projects.length === 0) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto p-4 pt-14">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">
|
||||
{projects.length} {projects.length === 1 ? "project" : "projects"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{projects.map((project) => (
|
||||
<Card
|
||||
className="relative p-4 transition-colors hover:bg-muted/50"
|
||||
key={project.id}
|
||||
>
|
||||
<button
|
||||
className="absolute inset-0 rounded-lg"
|
||||
onClick={() => {
|
||||
void goProject(project.dtag);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="sr-only">View {project.name}</span>
|
||||
</button>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderGit2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-semibold">
|
||||
{project.name}
|
||||
</span>
|
||||
</div>
|
||||
{project.description ? (
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{project.description}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground/70">
|
||||
{project.cloneUrls.length > 0 ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<GitFork className="h-3 w-3" />
|
||||
{project.cloneUrls[0]}
|
||||
</span>
|
||||
) : null}
|
||||
{project.contributors.length > 0 ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{project.contributors.length}
|
||||
</span>
|
||||
) : null}
|
||||
{project.webUrl ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Web
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Activity,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
FolderGit2,
|
||||
Home,
|
||||
PenSquare,
|
||||
Plus,
|
||||
@@ -88,7 +89,13 @@ type AppSidebarProps = {
|
||||
selfPresenceStatus: PresenceStatus;
|
||||
errorMessage?: string;
|
||||
selectedChannelId: string | null;
|
||||
selectedView: "home" | "channel" | "agents" | "workflows" | "pulse";
|
||||
selectedView:
|
||||
| "home"
|
||||
| "channel"
|
||||
| "agents"
|
||||
| "workflows"
|
||||
| "pulse"
|
||||
| "projects";
|
||||
unreadChannelIds: Set<string>;
|
||||
workspaces: Workspace[];
|
||||
onAddWorkspace: (workspace: Workspace) => void;
|
||||
@@ -119,6 +126,7 @@ type AppSidebarProps = {
|
||||
) => void;
|
||||
onRemoveWorkspace: (id: string) => void;
|
||||
onSelectAgents: () => void;
|
||||
onSelectProjects: () => void;
|
||||
onSelectPulse: () => void;
|
||||
onSelectWorkflows: () => void;
|
||||
onSelectHome: () => void;
|
||||
@@ -309,6 +317,7 @@ export function AppSidebar({
|
||||
onUpdateWorkspace,
|
||||
onRemoveWorkspace,
|
||||
onSelectAgents,
|
||||
onSelectProjects,
|
||||
onSelectPulse,
|
||||
onSelectWorkflows,
|
||||
onSelectHome,
|
||||
@@ -481,6 +490,18 @@ export function AppSidebar({
|
||||
<span>Pulse</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-testid="open-projects-view"
|
||||
isActive={selectedView === "projects"}
|
||||
onClick={onSelectProjects}
|
||||
tooltip="Projects"
|
||||
type="button"
|
||||
>
|
||||
<FolderGit2 className="h-4 w-4" />
|
||||
<span>Projects</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-testid="open-agents-view"
|
||||
|
||||
Reference in New Issue
Block a user