mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): refine repository project navigation
Make project repository states, branch actions, loading feedback, split-pane chrome, and persistent sidebar expansion behave consistently across the workspace. Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
@@ -101,6 +101,13 @@ export function AppTopChrome({
|
||||
)}
|
||||
data-tauri-drag-region
|
||||
data-testid="app-top-chrome"
|
||||
style={
|
||||
{
|
||||
"--app-top-chrome-center-offset": hasCommunityRail
|
||||
? "-1.75rem"
|
||||
: "0rem",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className={cn("flex items-center gap-0.5", navRowAlignmentClass)}>
|
||||
<TopChromeSidebarTrigger />
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { projectExternalRefUrl } from "./projectExternalUrl.ts";
|
||||
|
||||
test("opens the selected GitHub branch", () => {
|
||||
assert.equal(
|
||||
projectExternalRefUrl(
|
||||
"https://github.com/block/buzz",
|
||||
"fix/agent-profile-about-preserve",
|
||||
),
|
||||
"https://github.com/block/buzz/tree/fix%2Fagent-profile-about-preserve",
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizes clone URLs before adding the selected ref", () => {
|
||||
assert.equal(
|
||||
projectExternalRefUrl("https://github.com/block/buzz.git/", "main"),
|
||||
"https://github.com/block/buzz/tree/main",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps unsupported and unscoped URLs unchanged", () => {
|
||||
assert.equal(
|
||||
projectExternalRefUrl("https://gitlab.com/block/buzz", "main"),
|
||||
"https://gitlab.com/block/buzz",
|
||||
);
|
||||
assert.equal(
|
||||
projectExternalRefUrl("https://github.com/block/buzz", null),
|
||||
"https://github.com/block/buzz",
|
||||
);
|
||||
assert.equal(projectExternalRefUrl("not a URL", "main"), "not a URL");
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Builds a GitHub repository URL scoped to the selected branch or tag. */
|
||||
export function projectExternalRefUrl(
|
||||
externalUrl: string | null | undefined,
|
||||
ref: string | null | undefined,
|
||||
): string | null {
|
||||
if (!externalUrl) return null;
|
||||
const selectedRef = ref?.trim();
|
||||
if (!selectedRef) return externalUrl;
|
||||
|
||||
try {
|
||||
const url = new URL(externalUrl);
|
||||
if (
|
||||
url.protocol !== "https:" ||
|
||||
url.hostname.toLowerCase() !== "github.com"
|
||||
) {
|
||||
return externalUrl;
|
||||
}
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
if (segments.length !== 2) return externalUrl;
|
||||
const repository = segments[1]?.replace(/\.git$/i, "");
|
||||
if (!segments[0] || !repository) return externalUrl;
|
||||
return `${url.origin}/${segments[0]}/${repository}/tree/${encodeURIComponent(selectedRef)}`;
|
||||
} catch {
|
||||
return externalUrl;
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
getMentionTagPubkey,
|
||||
resolveMentionProps,
|
||||
} from "@/shared/lib/resolveMentionNames";
|
||||
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { useProjectConversationPanel } from "./ProjectConversationPanelContext";
|
||||
@@ -491,11 +492,7 @@ export function DiscussionChannelsPanel({
|
||||
const profiles = profilesQuery.data?.profiles;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<p className="px-4 py-6 text-sm text-muted-foreground">
|
||||
Searching channel discussions…
|
||||
</p>
|
||||
);
|
||||
return <BuzzLoadingState label="Loading channel discussions" />;
|
||||
}
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
|
||||
@@ -40,6 +40,7 @@ export function ProjectConversationPanel({
|
||||
onClose,
|
||||
onResetWidth,
|
||||
onResizeStart,
|
||||
sharedHeaderBackdrop,
|
||||
widthPx,
|
||||
}: {
|
||||
canResetWidth: boolean;
|
||||
@@ -47,6 +48,7 @@ export function ProjectConversationPanel({
|
||||
onClose: () => void;
|
||||
onResetWidth: () => void;
|
||||
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
|
||||
sharedHeaderBackdrop?: boolean;
|
||||
widthPx: number;
|
||||
}) {
|
||||
const { goChannel } = useAppNavigation();
|
||||
@@ -204,7 +206,7 @@ export function ProjectConversationPanel({
|
||||
showBackButton: false,
|
||||
splitPaneClamp: false,
|
||||
testId: isOverlay ? "project-conversation-panel" : "message-thread-panel",
|
||||
transparentChrome: false,
|
||||
transparentChrome: sharedHeaderBackdrop,
|
||||
};
|
||||
|
||||
const handleSend = React.useCallback(
|
||||
|
||||
@@ -86,6 +86,7 @@ export function ProjectConversationPanelController({
|
||||
onClose={() => setHit(null)}
|
||||
onResetWidth={onResetWidth}
|
||||
onResizeStart={onResizeStart}
|
||||
sharedHeaderBackdrop={sharedHeaderBackdrop}
|
||||
widthPx={widthPx}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -42,7 +42,10 @@ export function ProjectDetailChrome({
|
||||
>
|
||||
<nav
|
||||
aria-label="Project breadcrumb"
|
||||
className="absolute left-1/2 flex max-w-[50%] min-w-0 -translate-x-1/2 -translate-y-px items-center gap-0.5 text-xs text-sidebar-foreground/65"
|
||||
className="absolute flex max-w-[50%] min-w-0 -translate-x-1/2 -translate-y-px items-center gap-0.5 text-xs text-sidebar-foreground/65"
|
||||
style={{
|
||||
left: "calc(50% + var(--app-top-chrome-center-offset, 0rem))",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md px-1 py-1 font-medium transition-colors hover:text-sidebar-accent-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
|
||||
import type { ProjectRepoCommit } from "@/shared/api/types";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
@@ -241,14 +242,7 @@ export function ActivityPanel({
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<p
|
||||
className={PROJECT_DETAIL_PANEL_MESSAGE_CLASS}
|
||||
data-project-detail-panel
|
||||
>
|
||||
Loading activity…
|
||||
</p>
|
||||
);
|
||||
return <BuzzLoadingState label="Loading activity" />;
|
||||
}
|
||||
|
||||
if (commits.length === 0) {
|
||||
|
||||
@@ -479,19 +479,18 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
}
|
||||
}, [projectPending, repository]);
|
||||
React.useEffect(() => {
|
||||
setRepoSource((currentSource) => {
|
||||
if (selectedTag) return "remote";
|
||||
if (currentSource === "local" && !hasLocalCheckout) return "remote";
|
||||
if (
|
||||
currentSource === "remote" &&
|
||||
!hasRemoteSnapshot &&
|
||||
hasLocalCheckout
|
||||
) {
|
||||
return "local";
|
||||
}
|
||||
return currentSource;
|
||||
});
|
||||
}, [hasLocalCheckout, hasRemoteSnapshot, selectedTag]);
|
||||
if (selectedTag) {
|
||||
if (repoSource !== "remote") setRepoSource("remote");
|
||||
return;
|
||||
}
|
||||
if (repoSource === "local" && !hasLocalCheckout) {
|
||||
setRepoSource("remote");
|
||||
return;
|
||||
}
|
||||
if (repoSource === "remote" && !hasRemoteSnapshot && hasLocalCheckout) {
|
||||
setRepoSource("local");
|
||||
}
|
||||
}, [hasLocalCheckout, hasRemoteSnapshot, repoSource, selectedTag]);
|
||||
const {
|
||||
contributorActivityCounts,
|
||||
contributorPubkeys,
|
||||
@@ -809,12 +808,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
/>
|
||||
);
|
||||
const sharedHeaderBackdrop =
|
||||
repositoryPanel.mode === "chat" &&
|
||||
!repositoryPanel.collapsed &&
|
||||
!profilePanelPubkey &&
|
||||
!selectedPullRequestId &&
|
||||
!selectedIssueId &&
|
||||
!selectedCommitHash;
|
||||
!selectedPullRequestId && !selectedIssueId && !selectedCommitHash;
|
||||
const handleRepositoryChange = (nextRepositoryId: string) => {
|
||||
applyRepositorySearch({
|
||||
repositoryId: nextRepositoryId,
|
||||
@@ -985,6 +979,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
onViewChange={handleProfilePanelViewChange}
|
||||
pubkey={profilePanelPubkey}
|
||||
tab={profilePanelTab}
|
||||
transparentChrome={sharedHeaderBackdrop}
|
||||
view={profilePanelView}
|
||||
widthPx={threadPanelWidth.widthPx}
|
||||
/>
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "@/features/projects/projectTaskCategories";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import type { ChannelMember } from "@/shared/api/types";
|
||||
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { IssueAssigneeFacepile, IssueAssigneesRow } from "./IssueAssigneesRow";
|
||||
import { DiscussedInChannels } from "./DiscussionChannels";
|
||||
@@ -416,7 +417,7 @@ export function ProjectIssuesPanel({
|
||||
issues.find((issue) => issue.id === selectedIssueId) ?? null;
|
||||
|
||||
if (issuesQuery.isLoading) {
|
||||
return <p className="p-4 text-sm text-muted-foreground">Loading tasks…</p>;
|
||||
return <BuzzLoadingState label="Loading tasks" />;
|
||||
}
|
||||
|
||||
if (issues.length === 0) {
|
||||
|
||||
@@ -39,6 +39,7 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import type { ProjectRepoDiff, ProjectRepoDiffFile } from "@/shared/api/types";
|
||||
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
|
||||
import { PROJECT_DETAIL_PANEL_CLASS } from "./projectPanelStyles";
|
||||
import { ProjectPullRequestInlineCommentThread } from "./ProjectPullRequestInlineComments";
|
||||
|
||||
@@ -814,14 +815,7 @@ export function ProjectDiffFilesPanel({
|
||||
}, [filteredFiles, selectedPath]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={cn("p-4 text-sm text-muted-foreground", outerBorderClass)}
|
||||
data-project-detail-panel={embedded ? undefined : true}
|
||||
>
|
||||
Loading changed files…
|
||||
</div>
|
||||
);
|
||||
return <BuzzLoadingState label="Loading changed files" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -33,6 +33,7 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import type { ChannelMember } from "@/shared/api/types";
|
||||
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
|
||||
import {
|
||||
ProjectFeedRow,
|
||||
ProjectFeedRowCluster,
|
||||
@@ -874,9 +875,7 @@ export function PullRequestsPanel({
|
||||
}, [onSelectedPullRequestIdChange, pullRequests, selectedPullRequestId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<p className="p-4 text-sm text-muted-foreground">Loading reviews…</p>
|
||||
);
|
||||
return <BuzzLoadingState label="Loading reviews" />;
|
||||
}
|
||||
|
||||
if (pullRequests.length === 0) {
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import type { ProjectRepoFile } from "@/features/projects/hooks";
|
||||
import { projectExternalRefUrl } from "@/features/projects/lib/projectExternalUrl";
|
||||
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
|
||||
import { Markdown, SyntaxHighlightedCode } from "@/shared/ui/markdown";
|
||||
import {
|
||||
baseName,
|
||||
@@ -117,6 +119,10 @@ export function ReadmePanel({
|
||||
/** Branch picker + remote/local toggle rendered in the panel header. */
|
||||
sourceControls?: RepoSourceHeaderControls;
|
||||
}) {
|
||||
const externalOpenUrl = projectExternalRefUrl(
|
||||
externalUrl,
|
||||
sourceControls?.selectedTag ?? sourceControls?.branch,
|
||||
);
|
||||
// Two header rows, mirroring the files panel: controls on top, then the
|
||||
// file identity row.
|
||||
const header = hideHeader ? null : (
|
||||
@@ -161,10 +167,7 @@ export function ReadmePanel({
|
||||
return (
|
||||
<section className="overflow-hidden">
|
||||
{header}
|
||||
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading repository…
|
||||
</div>
|
||||
<BuzzLoadingState label="Loading repository" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -204,14 +207,14 @@ export function ReadmePanel({
|
||||
Clone this repository locally to explore its files, commits, and
|
||||
contributors in Buzz.
|
||||
</p>
|
||||
{externalUrl ? (
|
||||
{externalOpenUrl ? (
|
||||
<a
|
||||
className="mt-2 max-w-lg truncate font-mono text-xs text-primary hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
href={externalUrl}
|
||||
href={externalOpenUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{externalUrl}
|
||||
{externalOpenUrl}
|
||||
</a>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
|
||||
@@ -229,9 +232,9 @@ export function ReadmePanel({
|
||||
{sourceControls.clonePending ? "Cloning…" : "Clone locally"}
|
||||
</Button>
|
||||
) : null}
|
||||
{externalUrl ? (
|
||||
{externalOpenUrl ? (
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={externalUrl} rel="noreferrer" target="_blank">
|
||||
<a href={externalOpenUrl} rel="noreferrer" target="_blank">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open on {externalHost}
|
||||
</a>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
languageForPath,
|
||||
topLanguagesFromCounts,
|
||||
} from "@/features/projects/lib/projectLanguages";
|
||||
import { projectExternalRefUrl } from "@/features/projects/lib/projectExternalUrl";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
@@ -148,6 +149,10 @@ export function ProjectRepositoryActionsPanel({
|
||||
: undefined;
|
||||
const pullAction = sourceControls.canPull ? sourceControls.onPull : undefined;
|
||||
const pushAction = sourceControls.canPush ? sourceControls.onPush : undefined;
|
||||
const externalOpenUrl = projectExternalRefUrl(
|
||||
sourceControls.externalUrl,
|
||||
sourceControls.selectedTag ?? sourceControls.branch,
|
||||
);
|
||||
|
||||
return (
|
||||
<RightAuxiliaryPane
|
||||
@@ -157,7 +162,7 @@ export function ProjectRepositoryActionsPanel({
|
||||
testId="project-repository-actions-panel"
|
||||
widthPx={widthPx}
|
||||
>
|
||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto p-5">
|
||||
<div className="relative z-30 min-h-0 flex-1 space-y-6 overflow-y-auto p-5">
|
||||
<RepositoryPanelSection title="Working copy">
|
||||
<div className="grid gap-2 [&_button]:w-full [&_button]:justify-between [&_button]:text-sm">
|
||||
<RepoSourceDropdown controls={sourceControls} />
|
||||
@@ -258,18 +263,14 @@ export function ProjectRepositoryActionsPanel({
|
||||
<SquareTerminal className="h-3.5 w-3.5" />
|
||||
Terminal
|
||||
</RepositoryActionButton>
|
||||
{sourceControls.externalUrl ? (
|
||||
{externalOpenUrl ? (
|
||||
<Button
|
||||
asChild
|
||||
className="h-8 justify-start gap-2 text-sm"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<a
|
||||
href={sourceControls.externalUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<a href={externalOpenUrl} rel="noreferrer" target="_blank">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Open
|
||||
</a>
|
||||
|
||||
@@ -32,6 +32,7 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { UserSearchResult } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
|
||||
import { SyntaxHighlightedCode } from "@/shared/ui/markdown";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import {
|
||||
@@ -694,15 +695,43 @@ export function RepositoryFilesPanel({
|
||||
|
||||
// Loading/error/empty states keep the header controls visible — the
|
||||
// remote/local toggle must stay reachable when one source fails to load.
|
||||
const stateMessage = isLoading
|
||||
? "Loading repository files…"
|
||||
: unavailableMessage
|
||||
? unavailableMessage
|
||||
: error
|
||||
? "Could not load the repository file tree."
|
||||
: files.length === 0
|
||||
? "No files have been pushed yet."
|
||||
: null;
|
||||
if (isLoading) {
|
||||
if (!sourceControls) {
|
||||
return <BuzzLoadingState label="Loading repository files" />;
|
||||
}
|
||||
return (
|
||||
<div className={PROJECT_DETAIL_PANEL_CLASS} data-project-detail-panel>
|
||||
<div className="flex min-h-14 min-w-0 items-center gap-1 border-border/50 border-b px-4 py-3">
|
||||
<RepoSourceDropdown controls={sourceControls} />
|
||||
<RepositoryBranchDropdown
|
||||
branch={sourceControls.branch}
|
||||
branchOptions={sourceControls.branchOptions}
|
||||
createBranchDisabled={sourceControls.createBranchDisabled}
|
||||
createBranchTitle={sourceControls.createBranchTitle}
|
||||
deleteBranchDisabled={sourceControls.deleteBranchDisabled}
|
||||
deleteBranchTitle={sourceControls.deleteBranchTitle}
|
||||
onBranchChange={sourceControls.onBranchChange}
|
||||
onCreateBranch={sourceControls.onCreateBranch}
|
||||
onDeleteBranch={sourceControls.onDeleteBranch}
|
||||
onTagChange={sourceControls.onTagChange}
|
||||
selectedTag={sourceControls.selectedTag}
|
||||
tagOptions={sourceControls.tagOptions}
|
||||
/>
|
||||
<div className="ml-auto flex shrink-0 items-center">
|
||||
<RepoSyncActionButton controls={sourceControls} />
|
||||
</div>
|
||||
</div>
|
||||
<BuzzLoadingState label="Loading repository files" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const stateMessage = unavailableMessage
|
||||
? unavailableMessage
|
||||
: error
|
||||
? "Could not load the repository file tree."
|
||||
: files.length === 0
|
||||
? "No files have been pushed yet."
|
||||
: null;
|
||||
if (stateMessage) {
|
||||
if (!sourceControls) {
|
||||
return (
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { projectExternalRefUrl } from "@/features/projects/lib/projectExternalUrl";
|
||||
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -74,12 +75,15 @@ export function RepositoryBranchDropdown({
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className={PROJECT_PICKER_TRIGGER_CLASS}
|
||||
data-testid="project-repository-branch-trigger"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<RefIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{selectedTag ?? branch}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-left">
|
||||
{selectedTag ?? branch}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -98,7 +102,7 @@ export function RepositoryBranchDropdown({
|
||||
{selectableBranches.map((option) => (
|
||||
<DropdownMenuRadioItem key={option} value={`branch:${option}`}>
|
||||
<GitBranch className="mr-1.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="truncate">{option}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{option}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
{tagOptions.length > 0 ? (
|
||||
@@ -111,7 +115,7 @@ export function RepositoryBranchDropdown({
|
||||
value={`tag:${option.name}`}
|
||||
>
|
||||
<Tag className="mr-1.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="truncate">{option.name}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{option.name}</span>
|
||||
<span className="ml-auto font-mono text-xs text-muted-foreground">
|
||||
{option.commit.slice(0, 7)}
|
||||
</span>
|
||||
@@ -233,7 +237,7 @@ export function RepoSourceDropdown({
|
||||
variant="outline"
|
||||
>
|
||||
<SourceIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
<span className="min-w-0 flex-1 truncate text-left">
|
||||
{isLocal ? controls.localLabel : controls.remoteLabel}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
@@ -289,8 +293,12 @@ export function RepoSyncActionButton({
|
||||
}: {
|
||||
controls: RepoSourceHeaderControls;
|
||||
}) {
|
||||
const externalOpenUrl = projectExternalRefUrl(
|
||||
controls.externalUrl,
|
||||
controls.selectedTag ?? controls.branch,
|
||||
);
|
||||
if (controls.remoteKind === "external") {
|
||||
return controls.externalUrl ? (
|
||||
return externalOpenUrl ? (
|
||||
<Button
|
||||
asChild
|
||||
className={PROJECT_PANEL_ACTION_BUTTON_CLASS}
|
||||
@@ -298,7 +306,7 @@ export function RepoSyncActionButton({
|
||||
title={`Open repository on ${controls.remoteLabel}`}
|
||||
variant="ghost"
|
||||
>
|
||||
<a href={controls.externalUrl} rel="noreferrer" target="_blank">
|
||||
<a href={externalOpenUrl} rel="noreferrer" target="_blank">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open
|
||||
</a>
|
||||
|
||||
@@ -32,6 +32,7 @@ import { projectRepoUnavailableReason } from "@/features/projects/lib/projectRep
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
|
||||
import { Tabs, TabsContent } from "@/shared/ui/tabs";
|
||||
import { findReadmeFile } from "./ProjectReadmePanel";
|
||||
import { RepositoryFilesPanel } from "./ProjectRepositoryPanel";
|
||||
@@ -551,13 +552,17 @@ export function WorkspaceTabs({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="m-0" value="contributors">
|
||||
<ContributorsPanel
|
||||
activityCounts={contributorActivityCounts}
|
||||
contributorPubkeys={contributorPubkeys}
|
||||
contributorPubkeysByGitIdentity={contributorPubkeysByGitIdentity}
|
||||
profiles={profiles}
|
||||
repoContributors={displayedContributors}
|
||||
/>
|
||||
{displayedSnapshotLoading ? (
|
||||
<BuzzLoadingState label="Loading contributors" />
|
||||
) : (
|
||||
<ContributorsPanel
|
||||
activityCounts={contributorActivityCounts}
|
||||
contributorPubkeys={contributorPubkeys}
|
||||
contributorPubkeysByGitIdentity={contributorPubkeysByGitIdentity}
|
||||
profiles={profiles}
|
||||
repoContributors={displayedContributors}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</div>
|
||||
{createPullRequestAction && createPullRequestOpen ? (
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "@/features/projects/projectPullRequests.mjs";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import {
|
||||
@@ -431,19 +432,7 @@ export function ProjectsActivityFeed(props: ProjectsActivityFeedProps) {
|
||||
const items = buildActivityItems(props);
|
||||
|
||||
if (props.isLoading && items.length === 0) {
|
||||
return (
|
||||
<div className={cn(props.compact ? "space-y-2.5" : "space-y-3")}>
|
||||
{["first", "second", "third"].map((key) => (
|
||||
<div
|
||||
className={cn(
|
||||
"animate-pulse rounded-xl border border-border/60 bg-muted/20",
|
||||
props.compact ? "h-24" : "h-28",
|
||||
)}
|
||||
key={key}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return <BuzzLoadingState label="Loading project activity" />;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "@/features/profile/lib/identity";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
|
||||
import { Card } from "@/shared/ui/card";
|
||||
import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
|
||||
import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem";
|
||||
@@ -318,16 +319,7 @@ export function ProjectsIssuesList({
|
||||
viewMode,
|
||||
}: ProjectsIssuesListProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-12 text-center text-sm text-muted-foreground",
|
||||
!embedded && "border border-border/60",
|
||||
)}
|
||||
>
|
||||
Loading tasks...
|
||||
</div>
|
||||
);
|
||||
return <BuzzLoadingState label="Loading tasks" />;
|
||||
}
|
||||
|
||||
const loadNotice = (
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
|
||||
import { Card } from "@/shared/ui/card";
|
||||
import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
|
||||
import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem";
|
||||
@@ -317,16 +318,7 @@ export function ProjectsPullRequestsList({
|
||||
viewMode,
|
||||
}: ProjectsPullRequestsListProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-12 text-center text-sm text-muted-foreground",
|
||||
!embedded && "border border-border/60",
|
||||
)}
|
||||
>
|
||||
Loading reviews...
|
||||
</div>
|
||||
);
|
||||
return <BuzzLoadingState label="Loading reviews" />;
|
||||
}
|
||||
|
||||
const loadNotice = (
|
||||
|
||||
@@ -7,7 +7,7 @@ export const PROJECT_PANEL_ACTION_BUTTON_CLASS =
|
||||
* they read as one consistent control family in the workspace header.
|
||||
*/
|
||||
export const PROJECT_PICKER_TRIGGER_CLASS =
|
||||
"h-7 max-w-full shrink-0 gap-1.5 rounded-md px-3 text-sm font-medium hover:border-input";
|
||||
"h-7 min-w-0 max-w-full gap-1.5 rounded-md px-3 text-sm font-medium hover:border-input";
|
||||
|
||||
/** Bordered shell that lets the project page surface show through. */
|
||||
export const PROJECT_DETAIL_PANEL_CLASS =
|
||||
|
||||
@@ -597,7 +597,7 @@ export function AppSidebar({
|
||||
) : null}
|
||||
|
||||
<SidebarContent
|
||||
className="buzz-sidebar-scrollbar overscroll-none"
|
||||
className="buzz-sidebar-scrollbar overscroll-none [overflow-anchor:none]"
|
||||
data-sidebar-background
|
||||
ref={scrollRef}
|
||||
>
|
||||
|
||||
@@ -3,12 +3,54 @@ import { test } from "node:test";
|
||||
|
||||
import {
|
||||
listSidebarProjects,
|
||||
readSidebarProjectExpansion,
|
||||
selectedProjectRouteId,
|
||||
writeSidebarProjectExpansion,
|
||||
} from "./listSidebarProjects.ts";
|
||||
|
||||
const OWNER = "a".repeat(64);
|
||||
const VIEWER = "b".repeat(64);
|
||||
|
||||
test("project expansion persists independently per relay and viewer", () => {
|
||||
const values = new Map();
|
||||
const previousLocalStorage = globalThis.localStorage;
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
},
|
||||
});
|
||||
try {
|
||||
writeSidebarProjectExpansion(
|
||||
{ "project:one": true, "project:two": false },
|
||||
"https://relay.example",
|
||||
VIEWER,
|
||||
);
|
||||
assert.deepEqual(
|
||||
readSidebarProjectExpansion("https://relay.example", VIEWER),
|
||||
{ "project:one": true, "project:two": false },
|
||||
);
|
||||
assert.deepEqual(
|
||||
readSidebarProjectExpansion("https://other.example", VIEWER),
|
||||
{},
|
||||
);
|
||||
assert.deepEqual(
|
||||
readSidebarProjectExpansion("https://relay.example", OWNER),
|
||||
{},
|
||||
);
|
||||
} finally {
|
||||
if (previousLocalStorage === undefined) {
|
||||
delete globalThis.localStorage;
|
||||
} else {
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: previousLocalStorage,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function makeProject(overrides = {}) {
|
||||
return {
|
||||
createdAt: 0,
|
||||
|
||||
@@ -89,11 +89,14 @@ import {
|
||||
} from "@/features/sidebar/ui/sidebarSectionStyles";
|
||||
import {
|
||||
listSidebarProjects,
|
||||
readSidebarProjectExpansion,
|
||||
readSidebarProjectsFilter,
|
||||
readSidebarProjectsSort,
|
||||
selectedProjectRouteId,
|
||||
type SidebarProjectExpansionState,
|
||||
type SidebarProjectsFilter,
|
||||
type SidebarProjectsSort,
|
||||
writeSidebarProjectExpansion,
|
||||
writeSidebarProjectsFilter,
|
||||
writeSidebarProjectsSort,
|
||||
} from "@/features/sidebar/ui/listSidebarProjects";
|
||||
@@ -144,6 +147,10 @@ function SidebarProjectsSectionContent() {
|
||||
const [sort, setSort] = React.useState<SidebarProjectsSort>(
|
||||
readSidebarProjectsSort,
|
||||
);
|
||||
const [projectExpansion, setProjectExpansion] =
|
||||
React.useState<SidebarProjectExpansionState>(() =>
|
||||
readSidebarProjectExpansion(relayOrigin, currentPubkey),
|
||||
);
|
||||
const [addedProjectAddresses, setAddedProjectAddresses] = React.useState<
|
||||
string[]
|
||||
>(() => readProjectSidebarMembership(relayOrigin, currentPubkey));
|
||||
@@ -160,6 +167,11 @@ function SidebarProjectsSectionContent() {
|
||||
return () =>
|
||||
globalThis.removeEventListener(PROJECT_SIDEBAR_MEMBERSHIP_EVENT, refresh);
|
||||
}, [currentPubkey, relayOrigin]);
|
||||
React.useEffect(() => {
|
||||
setProjectExpansion(
|
||||
readSidebarProjectExpansion(relayOrigin, currentPubkey),
|
||||
);
|
||||
}, [currentPubkey, relayOrigin]);
|
||||
const addedProjectAddressSet = React.useMemo(
|
||||
() => new Set(addedProjectAddresses),
|
||||
[addedProjectAddresses],
|
||||
@@ -175,6 +187,23 @@ function SidebarProjectsSectionContent() {
|
||||
}),
|
||||
[addedProjectAddressSet, currentPubkey, filter, projectsQuery.data, sort],
|
||||
);
|
||||
React.useEffect(() => {
|
||||
if (!routeProjectId) return;
|
||||
const selectedProject = projects.find((project) =>
|
||||
projectMatchesRouteId(project, routeProjectId),
|
||||
);
|
||||
if (
|
||||
!selectedProject ||
|
||||
projectExpansion[selectedProject.projectAddress] !== undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setProjectExpansion((current) => {
|
||||
const next = { ...current, [selectedProject.projectAddress]: true };
|
||||
writeSidebarProjectExpansion(next, relayOrigin, currentPubkey);
|
||||
return next;
|
||||
});
|
||||
}, [currentPubkey, projectExpansion, projects, relayOrigin, routeProjectId]);
|
||||
|
||||
const handleFilterChange = (next: SidebarProjectsFilter) => {
|
||||
setFilter(next);
|
||||
@@ -184,6 +213,13 @@ function SidebarProjectsSectionContent() {
|
||||
setSort(next);
|
||||
writeSidebarProjectsSort(next);
|
||||
};
|
||||
const setProjectExpanded = (project: Project, expanded: boolean) => {
|
||||
setProjectExpansion((current) => {
|
||||
const next = { ...current, [project.projectAddress]: expanded };
|
||||
writeSidebarProjectExpansion(next, relayOrigin, currentPubkey);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const handleAdd = (project: Project) => {
|
||||
addProjectToSidebar(project.projectAddress, relayOrigin, currentPubkey);
|
||||
};
|
||||
@@ -282,6 +318,8 @@ function SidebarProjectsSectionContent() {
|
||||
const selectedRepository = isActive
|
||||
? selectProjectRepository(project, routeRepositoryId)
|
||||
: null;
|
||||
const isExpanded =
|
||||
projectExpansion[project.projectAddress] ?? isActive;
|
||||
|
||||
return (
|
||||
<React.Fragment key={project.id}>
|
||||
@@ -292,12 +330,20 @@ function SidebarProjectsSectionContent() {
|
||||
)}
|
||||
deleteDisabled={deleteProjectMutation.isPending}
|
||||
isActive={isActive}
|
||||
isExpanded={isExpanded}
|
||||
onDelete={() => setProjectToDelete(project)}
|
||||
onOpen={() => goProject(project.id)}
|
||||
onOpen={() => {
|
||||
if (isActive) {
|
||||
setProjectExpanded(project, !isExpanded);
|
||||
return;
|
||||
}
|
||||
setProjectExpanded(project, true);
|
||||
void goProject(project.id);
|
||||
}}
|
||||
onRemove={() => handleRemove(project)}
|
||||
project={project}
|
||||
/>
|
||||
{isActive
|
||||
{isExpanded
|
||||
? project.repositories.map((repository) => (
|
||||
<SidebarMenuItem key={repository.id}>
|
||||
<SidebarMenuButton
|
||||
@@ -529,6 +575,7 @@ function SidebarProjectRow({
|
||||
canDelete,
|
||||
deleteDisabled,
|
||||
isActive,
|
||||
isExpanded,
|
||||
onDelete,
|
||||
onOpen,
|
||||
onRemove,
|
||||
@@ -537,19 +584,21 @@ function SidebarProjectRow({
|
||||
canDelete: boolean;
|
||||
deleteDisabled: boolean;
|
||||
isActive: boolean;
|
||||
isExpanded: boolean;
|
||||
onDelete: () => void;
|
||||
onOpen: () => void;
|
||||
onRemove: () => void;
|
||||
project: Project;
|
||||
}) {
|
||||
const shareLink = projectShareLink(project);
|
||||
const ProjectIcon = isActive ? FolderOpen : Folders;
|
||||
const ProjectIcon = isExpanded ? FolderOpen : Folders;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
aria-expanded={isExpanded}
|
||||
className="data-[active=true]:!bg-transparent data-[active=true]:font-normal data-[active=true]:text-sidebar-foreground data-[active=true]:shadow-none data-[active=true]:hover:!bg-transparent data-[active=true]:hover:text-sidebar-foreground data-[active=true]:active:!bg-transparent"
|
||||
data-testid={`sidebar-project-${project.dtag}`}
|
||||
isActive={isActive}
|
||||
|
||||
@@ -3,9 +3,55 @@ import { isProjectOwnedByCurrentUser } from "@/features/projects/lib/projectsVie
|
||||
|
||||
const SIDEBAR_PROJECTS_FILTER_KEY = "buzz.sidebar.projects.filter";
|
||||
const SIDEBAR_PROJECTS_SORT_KEY = "buzz.sidebar.projects.sort";
|
||||
const SIDEBAR_PROJECTS_EXPANDED_KEY = "buzz.sidebar.projects.expanded";
|
||||
|
||||
export type SidebarProjectsFilter = "added" | "owned";
|
||||
export type SidebarProjectsSort = "name" | "created";
|
||||
export type SidebarProjectExpansionState = Record<string, boolean>;
|
||||
|
||||
function expandedProjectsStorageKey(
|
||||
relayOrigin: string | null,
|
||||
currentPubkey?: string,
|
||||
) {
|
||||
return `${SIDEBAR_PROJECTS_EXPANDED_KEY}:${encodeURIComponent(relayOrigin ?? "unknown")}:${currentPubkey ?? "anonymous"}`;
|
||||
}
|
||||
|
||||
export function readSidebarProjectExpansion(
|
||||
relayOrigin: string | null,
|
||||
currentPubkey?: string,
|
||||
): SidebarProjectExpansionState {
|
||||
try {
|
||||
const value = globalThis.localStorage?.getItem(
|
||||
expandedProjectsStorageKey(relayOrigin, currentPubkey),
|
||||
);
|
||||
if (!value) return {};
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
||||
return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).filter((entry): entry is [string, boolean] => {
|
||||
return typeof entry[1] === "boolean";
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSidebarProjectExpansion(
|
||||
expansion: SidebarProjectExpansionState,
|
||||
relayOrigin: string | null,
|
||||
currentPubkey?: string,
|
||||
) {
|
||||
try {
|
||||
globalThis.localStorage?.setItem(
|
||||
expandedProjectsStorageKey(relayOrigin, currentPubkey),
|
||||
JSON.stringify(expansion),
|
||||
);
|
||||
} catch {
|
||||
// Persistence is best-effort; the in-memory toggle still works.
|
||||
}
|
||||
}
|
||||
|
||||
export function selectedProjectRouteId(pathname: string): string | undefined {
|
||||
if (!pathname.startsWith("/projects/")) return undefined;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import BuzzLogoAnimation from "@/shared/ui/buzz-logo/BuzzLogoAnimation";
|
||||
|
||||
/** Centered, low-emphasis loading state for page and panel fetches. */
|
||||
export function BuzzLoadingState({
|
||||
className,
|
||||
fill = false,
|
||||
label = "Loading",
|
||||
}: {
|
||||
className?: string;
|
||||
fill?: boolean;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-center text-muted-foreground/45",
|
||||
fill ? "min-h-0 flex-1" : "min-h-[calc(100dvh-7rem)]",
|
||||
className,
|
||||
)}
|
||||
data-testid="buzz-loading-state"
|
||||
role="status"
|
||||
>
|
||||
<BuzzLogoAnimation
|
||||
ariaLabel={label}
|
||||
className="buzz-logo--scale-pulse"
|
||||
fullScreen={false}
|
||||
showBackground={false}
|
||||
style={{ width: "2rem" }}
|
||||
textured={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Card } from "@/shared/ui/card";
|
||||
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { channelChrome } from "@/shared/layout/chromeLayout";
|
||||
@@ -402,7 +403,9 @@ export function ViewLoadingFallback({
|
||||
{shouldShowChannelHeader ? <LoadingHeaderSkeleton /> : null}
|
||||
{kind === "agents" ? <AgentsLoadingBody /> : null}
|
||||
{kind === "workflows" ? <CardListLoadingBody /> : null}
|
||||
{kind === "projects" ? <CardListLoadingBody /> : null}
|
||||
{kind === "projects" ? (
|
||||
<BuzzLoadingState fill label="Loading projects" />
|
||||
) : null}
|
||||
{kind === "channel" ? (
|
||||
<ChannelLoadingBody hasHeader={shouldShowChannelHeader} />
|
||||
) : null}
|
||||
|
||||
@@ -56,6 +56,21 @@
|
||||
animation: buzz-logo-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes buzz-logo-scale-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
}
|
||||
|
||||
.buzz-logo--scale-pulse .buzz-logo__mark {
|
||||
animation: buzz-logo-scale-pulse 1.8s ease-in-out infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.buzz-logo animate {
|
||||
display: none;
|
||||
@@ -65,4 +80,9 @@
|
||||
animation: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.buzz-logo--scale-pulse .buzz-logo__mark {
|
||||
animation: none;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +192,8 @@ type E2eConfig = {
|
||||
projectAccessChannelId?: string;
|
||||
/** Make remote project snapshots fail with this git-facing message. */
|
||||
projectRepoSnapshotError?: string;
|
||||
/** Delay remote repository snapshots so project loading UI is observable. */
|
||||
projectRepoSnapshotDelayMs?: number;
|
||||
/** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */
|
||||
builderlabAuth?: {
|
||||
email?: string;
|
||||
@@ -1260,6 +1262,8 @@ declare global {
|
||||
__BUZZ_E2E_REJECT_PROJECT_QUERY_KINDS__?: number[];
|
||||
/** Captured aggregate project-history filters for request-count assertions. */
|
||||
__BUZZ_E2E_PROJECT_QUERY_FILTERS__?: MockFilter[];
|
||||
/** Optional local repository snapshot returned for project branch tests. */
|
||||
__BUZZ_E2E_PROJECT_LOCAL_REPO_SNAPSHOT__?: unknown;
|
||||
__BUZZ_E2E_PROJECT_REPO_SYNC_STATUS__?: {
|
||||
local_path: string | null;
|
||||
local_branch: string | null;
|
||||
@@ -5364,6 +5368,7 @@ const MOCK_PROJECT_SEEDS = [
|
||||
description:
|
||||
"Relay, desktop, and mobile clients for the Buzz community platform.",
|
||||
cloneUrl: `${DEFAULT_RELAY_HTTP_URL}/git/${MOCK_IDENTITY_PUBKEY}/buzz`,
|
||||
webUrl: null,
|
||||
owner: MOCK_IDENTITY_PUBKEY,
|
||||
contributors: [ALICE_PUBKEY, BOB_PUBKEY, CHARLIE_PUBKEY],
|
||||
activityLevel: 4,
|
||||
@@ -5373,6 +5378,7 @@ const MOCK_PROJECT_SEEDS = [
|
||||
name: "relay-tools",
|
||||
description: "Operator tooling and admin CLI for relay deployments.",
|
||||
cloneUrl: "https://github.com/block/relay-tools.git",
|
||||
webUrl: "https://github.com/block/relay-tools",
|
||||
owner: ALICE_PUBKEY,
|
||||
contributors: [MOCK_IDENTITY_PUBKEY, BOB_PUBKEY],
|
||||
activityLevel: 2,
|
||||
@@ -5382,6 +5388,7 @@ const MOCK_PROJECT_SEEDS = [
|
||||
name: "design-system",
|
||||
description: "Shared UI tokens, typography ramps, and component library.",
|
||||
cloneUrl: `${DEFAULT_RELAY_HTTP_URL}/git/${BOB_PUBKEY}/design-system`,
|
||||
webUrl: null,
|
||||
owner: BOB_PUBKEY,
|
||||
contributors: [ALICE_PUBKEY],
|
||||
activityLevel: 1,
|
||||
@@ -5485,6 +5492,7 @@ function buildMockProjectEvents(): RelayEvent[] {
|
||||
"9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
||||
],
|
||||
["clone", seed.cloneUrl],
|
||||
...(seed.webUrl ? [["web", seed.webUrl]] : []),
|
||||
...seed.contributors.map((pubkey) => ["p", pubkey]),
|
||||
],
|
||||
owner,
|
||||
@@ -11543,6 +11551,14 @@ export function maybeInstallE2eTauriMocks() {
|
||||
// viewer-identity avatar attribution is exercised in e2e.
|
||||
return { name: "Thomas P", email: "thomasp@example.com" };
|
||||
case "get_project_repo_snapshot":
|
||||
if (activeConfig?.mock?.projectRepoSnapshotDelayMs) {
|
||||
await new Promise((resolve) =>
|
||||
window.setTimeout(
|
||||
resolve,
|
||||
activeConfig.mock?.projectRepoSnapshotDelayMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (activeConfig?.mock?.projectRepoSnapshotError) {
|
||||
throw new Error(activeConfig.mock.projectRepoSnapshotError);
|
||||
}
|
||||
@@ -11641,7 +11657,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
],
|
||||
};
|
||||
case "get_project_local_repo_snapshot":
|
||||
return null;
|
||||
return window.__BUZZ_E2E_PROJECT_LOCAL_REPO_SNAPSHOT__ ?? null;
|
||||
case "get_project_repo_diff":
|
||||
return {
|
||||
additions: 27,
|
||||
|
||||
@@ -437,8 +437,74 @@ test("multi-repository projects switch the active repository", async ({
|
||||
const relayToolsRepository = page.getByTestId(
|
||||
"sidebar-project-repository-relay-tools",
|
||||
);
|
||||
const projectRow = page.getByTestId("sidebar-project-buzz");
|
||||
await expect(projectRow).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(primaryRepository).toHaveAttribute("data-active", "true");
|
||||
await expect(relayToolsRepository).toBeVisible();
|
||||
|
||||
await projectRow.click();
|
||||
await expect(projectRow).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(relayToolsRepository).toBeHidden();
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await addProjectToSidebar(page, "buzz");
|
||||
await expect(projectRow).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(relayToolsRepository).toBeHidden();
|
||||
|
||||
await projectRow.click();
|
||||
await expect(projectRow).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(relayToolsRepository).toBeVisible();
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(projectRow).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(relayToolsRepository).toBeVisible();
|
||||
const sidebarScrollContent = page.getByTestId("sidebar-scroll-content");
|
||||
const channelSidebarMetrics = await sidebarScrollContent.evaluate(
|
||||
(element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return {
|
||||
clientWidth: element.clientWidth,
|
||||
left: bounds.left,
|
||||
top: bounds.top,
|
||||
width: bounds.width,
|
||||
};
|
||||
},
|
||||
);
|
||||
await projectRow.click();
|
||||
await expect(page).toHaveURL(/\/projects\//);
|
||||
await expect(relayToolsRepository).toBeVisible();
|
||||
const projectSidebarMetrics = await sidebarScrollContent.evaluate(
|
||||
(element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return {
|
||||
clientWidth: element.clientWidth,
|
||||
left: bounds.left,
|
||||
top: bounds.top,
|
||||
width: bounds.width,
|
||||
};
|
||||
},
|
||||
);
|
||||
expect(projectSidebarMetrics).toEqual(channelSidebarMetrics);
|
||||
|
||||
await projectRow.click();
|
||||
await expect(projectRow).toHaveAttribute("aria-expanded", "false");
|
||||
await page.getByTestId("channel-general").click();
|
||||
const sidebarScroller = page.locator('[data-sidebar="content"]');
|
||||
const anchoredScrollTop = await sidebarScroller.evaluate((element) => {
|
||||
element.scrollTop = Math.min(
|
||||
20,
|
||||
Math.max(0, element.scrollHeight - element.clientHeight),
|
||||
);
|
||||
return element.scrollTop;
|
||||
});
|
||||
await projectRow.click();
|
||||
await expect(page).toHaveURL(/\/projects\//);
|
||||
await expect(projectRow).toHaveAttribute("aria-expanded", "true");
|
||||
await expect
|
||||
.poll(() =>
|
||||
sidebarScroller.evaluate((element) => Math.round(element.scrollTop)),
|
||||
)
|
||||
.toBe(Math.round(anchoredScrollTop));
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/04-multi-repository-picker.png`,
|
||||
|
||||
@@ -32,6 +32,17 @@ async function openBuzzProject(page: import("@playwright/test").Page) {
|
||||
await projectEntry.click();
|
||||
}
|
||||
|
||||
async function addProjectToSidebar(
|
||||
page: import("@playwright/test").Page,
|
||||
dtag: string,
|
||||
) {
|
||||
await page.getByTestId("sidebar-projects-section-label").hover();
|
||||
await page.getByTestId("sidebar-projects-create").click();
|
||||
const browser = page.getByTestId("project-browser-dialog");
|
||||
await browser.getByRole("searchbox", { name: "Search projects" }).fill(dtag);
|
||||
await browser.getByTestId(`project-browser-result-${dtag}`).click();
|
||||
}
|
||||
|
||||
test("same-second request changes supersedes approval", async ({ page }) => {
|
||||
await enableProjectsFeature(page);
|
||||
await page.addInitScript(() => {
|
||||
@@ -1276,6 +1287,123 @@ test("project branches can be deleted but the default branch cannot", async ({
|
||||
expect(commands).toContain("delete_project_remote_branch");
|
||||
});
|
||||
|
||||
test("external repositories stay on local source after a branch round trip", async ({
|
||||
page,
|
||||
}) => {
|
||||
await enableProjectsFeature(page);
|
||||
await page.addInitScript(() => {
|
||||
const commit = "0123456789abcdef0123456789abcdef01234567";
|
||||
const localBranch =
|
||||
"wintermute/entity-link-recipient-cards-with-a-long-branch-name";
|
||||
window.sessionStorage.setItem(
|
||||
"buzz-e2e-project-branches",
|
||||
JSON.stringify({ "relay-tools": { [localBranch]: commit } }),
|
||||
);
|
||||
window.__BUZZ_E2E_PROJECT_REPO_SYNC_STATUS__ = {
|
||||
local_path: "/tmp/buzz/REPOS/relay-tools",
|
||||
local_branch: localBranch,
|
||||
local_branches: ["main", localBranch],
|
||||
local_head: commit,
|
||||
local_short_head: commit.slice(0, 7),
|
||||
remote_branch: localBranch,
|
||||
remote_head: commit,
|
||||
remote_short_head: commit.slice(0, 7),
|
||||
merge_base: commit,
|
||||
ahead_count: 0,
|
||||
behind_count: 0,
|
||||
has_uncommitted_changes: false,
|
||||
has_untracked_files: false,
|
||||
can_push: false,
|
||||
push_block_reason: "Local branch is already pushed.",
|
||||
can_pull: false,
|
||||
pull_block_reason: "Local branch is up to date.",
|
||||
};
|
||||
window.__BUZZ_E2E_PROJECT_LOCAL_REPO_SNAPSHOT__ = {
|
||||
path: "/tmp/buzz/REPOS/relay-tools",
|
||||
snapshot: {
|
||||
latest_commit: null,
|
||||
commits: [],
|
||||
contributors: [],
|
||||
files: [
|
||||
{
|
||||
path: "README.md",
|
||||
kind: "text",
|
||||
size: 21,
|
||||
preview_content: "# Local branch README",
|
||||
last_changed_at: null,
|
||||
latest_commit: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
await installMockBridge(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await addProjectToSidebar(page, "buzz");
|
||||
await page.getByTestId("sidebar-project-repository-relay-tools").click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Local branch README" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Local", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Open", exact: true }),
|
||||
).toHaveAttribute("href", "https://github.com/block/relay-tools/tree/main");
|
||||
|
||||
await page.getByRole("button", { name: /main/ }).click();
|
||||
await page
|
||||
.getByRole("menuitemradio", {
|
||||
name: "wintermute/entity-link-recipient-cards-with-a-long-branch-name",
|
||||
})
|
||||
.click();
|
||||
const branchTrigger = page.getByTestId("project-repository-branch-trigger");
|
||||
await expect(
|
||||
page.getByRole("button", {
|
||||
name: /wintermute\/entity-link-recipient-cards-with-a-long-branch-name/,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
branchTrigger.evaluate(
|
||||
(element) => element.scrollWidth <= element.clientWidth,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(() =>
|
||||
branchTrigger
|
||||
.locator("span")
|
||||
.evaluate((element) => element.scrollWidth > element.clientWidth),
|
||||
)
|
||||
.toBe(true);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Local", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Open", exact: true }),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/block/relay-tools/tree/wintermute%2Fentity-link-recipient-cards-with-a-long-branch-name",
|
||||
);
|
||||
|
||||
await branchTrigger.click();
|
||||
await page.getByRole("menuitemradio", { name: "main" }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: /main/ })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Local", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Local branch README" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Open", exact: true }),
|
||||
).toHaveAttribute("href", "https://github.com/block/relay-tools/tree/main");
|
||||
await expect(page.getByText("Code hosted on github.com")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("pushed local branch can open a pull request", async ({ page }) => {
|
||||
await enableProjectsFeature(page);
|
||||
await page.addInitScript(() => {
|
||||
|
||||
@@ -113,6 +113,27 @@ test("restricted repositories keep event work visible and offer access help", as
|
||||
await expect(chatPanel.getByTestId("message-composer")).toBeVisible();
|
||||
});
|
||||
|
||||
test("repository pages show a centered Buzz loader while fetching", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, { projectRepoSnapshotDelayMs: 750 });
|
||||
await openBuzzProject(page);
|
||||
|
||||
const loader = page.getByTestId("buzz-loading-state");
|
||||
await expect(loader).toBeVisible();
|
||||
await expect(
|
||||
loader.getByRole("img", { name: "Loading repository" }),
|
||||
).toBeVisible();
|
||||
const animatedMark = loader.locator(".buzz-logo__mark");
|
||||
await expect(animatedMark).toHaveCSS(
|
||||
"animation-name",
|
||||
"buzz-logo-scale-pulse",
|
||||
);
|
||||
await expect(animatedMark).toHaveCSS("opacity", "1");
|
||||
await expect(loader).toHaveCSS("justify-content", "center");
|
||||
await expect(loader).toBeHidden({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
// Walks the Projects v3 workspace through its headline states so PR
|
||||
// screenshots capture distinct pixels per feature (overview box, tab-strip
|
||||
// plus, issue detail with inline copy link + avatar timeline, PR detail).
|
||||
@@ -177,6 +198,31 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
|
||||
).toBeGreaterThan(13);
|
||||
expect((await filesTab.boundingBox())?.height).toBe(28);
|
||||
await expect(repositoryActionsPanel).toBeVisible();
|
||||
const sharedHeaderBackdrop = page.getByTestId(
|
||||
"project-shared-header-backdrop",
|
||||
);
|
||||
await expect(sharedHeaderBackdrop).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
sharedHeaderBackdrop.evaluate(
|
||||
(element) => getComputedStyle(element).backdropFilter,
|
||||
),
|
||||
)
|
||||
.not.toBe("none");
|
||||
const [sharedHeaderBackdropBounds, repositoryActionsPanelBounds] =
|
||||
await Promise.all([
|
||||
sharedHeaderBackdrop.boundingBox(),
|
||||
repositoryActionsPanel.boundingBox(),
|
||||
]);
|
||||
expect(sharedHeaderBackdropBounds).not.toBeNull();
|
||||
expect(repositoryActionsPanelBounds).not.toBeNull();
|
||||
expect(
|
||||
(sharedHeaderBackdropBounds?.x ?? 0) +
|
||||
(sharedHeaderBackdropBounds?.width ?? 0),
|
||||
).toBeGreaterThanOrEqual(
|
||||
(repositoryActionsPanelBounds?.x ?? 0) +
|
||||
(repositoryActionsPanelBounds?.width ?? 0),
|
||||
);
|
||||
const repositoryPanelTab = page.getByTestId(
|
||||
"project-right-panel-repository-tab",
|
||||
);
|
||||
@@ -228,19 +274,6 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
|
||||
(tabMenuHeaderBounds?.height ?? 0) - (agentContextBounds?.height ?? 0),
|
||||
),
|
||||
).toBeLessThanOrEqual(1);
|
||||
const sharedHeaderBackdrop = page.getByTestId(
|
||||
"project-shared-header-backdrop",
|
||||
);
|
||||
await expect(sharedHeaderBackdrop).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
sharedHeaderBackdrop.evaluate(
|
||||
(element) => getComputedStyle(element).backdropFilter,
|
||||
),
|
||||
)
|
||||
.not.toBe("none");
|
||||
const sharedHeaderBackdropBounds = await sharedHeaderBackdrop.boundingBox();
|
||||
expect(sharedHeaderBackdropBounds).not.toBeNull();
|
||||
expect(sharedHeaderBackdropBounds?.x).toBeLessThanOrEqual(
|
||||
tabMenuHeaderBounds?.x ?? 0,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user