mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-10-31 06:31:58 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaef5d6e5c | ||
|
|
604ff3b6b3 |
No files matched your search
@@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Raised the dependency floors for the advisories published on 2026-09-08: Astro 7.2.8 (remote code execution through AVIF image optimization, and an authorization bypass when stripping the configured base), @xmldom/xmldom 0.8.15 (eight parser and serializer issues), sharp 0.35.4 (libheif), svgo 4.1.0 (removeScripts sanitization) and js-yaml 4.3.2 (merge-key CPU use). Applied to both the application and the documentation site.
|
||||
|
||||
### Added
|
||||
- List view for the Organizations page (#428)
|
||||
- A cards/list switch in the toolbar; the list shows one row per organization with its role, destination, repository counts, last mirrored time, status, the same actions as the cards and the source and destination links, so a few dozen organizations fit on one screen
|
||||
- The choice is remembered per browser, next to the theme and time format preferences
|
||||
- Incremental issue and pull request sync (#449)
|
||||
- After a complete pass, the issues and pull request passes ask GitHub only for items updated since the last one (with a 10 minute margin), so comments, pull request details, commits and files are fetched only for what changed instead of for every item on every sync
|
||||
- The watermark is stored per repository in the metadata state and only moves when a pass finishes with no failed item
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: gitea-mirror
|
||||
description: Kubernetes helm chart for gitea-mirror
|
||||
type: application
|
||||
version: 3.39.0
|
||||
appVersion: "3.39.0"
|
||||
version: 3.40.0
|
||||
appVersion: "3.40.0"
|
||||
icon: https://github.com/RayLabsHQ/gitea-mirror/blob/main/.github/assets/logo.png
|
||||
keywords:
|
||||
- git
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.39.0",
|
||||
"version": "3.40.0",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
|
||||
@@ -44,6 +44,11 @@ interface MirrorDestinationEditorProps {
|
||||
destinationLabel?: string;
|
||||
isUpdating?: boolean;
|
||||
className?: string;
|
||||
/**
|
||||
* Show only the destination and the edit button. The list view (#428)
|
||||
* already names the organization in its first column.
|
||||
*/
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const MAX_LISTED = 8;
|
||||
@@ -88,6 +93,7 @@ export function MirrorDestinationEditor({
|
||||
destinationLabel = "Gitea",
|
||||
isUpdating = false,
|
||||
className,
|
||||
compact = false,
|
||||
}: MirrorDestinationEditorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [editValue, setEditValue] = useState(currentDestination || "");
|
||||
@@ -185,14 +191,25 @@ export function MirrorDestinationEditor({
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 w-full", className)}>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0 flex-1">
|
||||
<Building2 className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="font-medium truncate">{organizationName}</span>
|
||||
<ArrowRight className="h-3 w-3 flex-shrink-0" />
|
||||
<span className={cn(
|
||||
"font-medium truncate",
|
||||
hasOverride && "text-orange-600 dark:text-orange-400"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"flex items-center gap-1.5 text-muted-foreground min-w-0 flex-1",
|
||||
compact ? "text-sm" : "text-xs"
|
||||
)}>
|
||||
{!compact && (
|
||||
<>
|
||||
<Building2 className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="font-medium truncate">{organizationName}</span>
|
||||
<ArrowRight className="h-3 w-3 flex-shrink-0" />
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium truncate",
|
||||
compact && "text-foreground",
|
||||
hasOverride && "text-orange-600 dark:text-orange-400"
|
||||
)}
|
||||
title={effectiveDestination}
|
||||
>
|
||||
{effectiveDestination}
|
||||
</span>
|
||||
{hasOverride && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, RefreshCw, FlipHorizontal, Filter, LoaderCircle, Trash2, Download } from "lucide-react";
|
||||
import { Search, RefreshCw, FlipHorizontal, Filter, LoaderCircle, Trash2, Download, LayoutGrid, List } from "lucide-react";
|
||||
import type { MirrorJob, Organization } from "@/lib/db/schema";
|
||||
import { OrganizationList } from "./OrganizationsList";
|
||||
import AddOrganizationDialog from "./AddOrganizationDialog";
|
||||
@@ -30,6 +30,9 @@ import { invalidateConfigCache, useConfigStatus } from "@/hooks/useConfigStatus"
|
||||
import { useNavigation } from "@/components/layout/MainLayout";
|
||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import { useOrganizationsView } from "@/hooks/useOrganizationsView";
|
||||
import type { OrganizationsViewMode } from "@/lib/utils/organizations-view";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -53,6 +56,8 @@ export function Organization() {
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
||||
// Cards or the compact list (#428); remembered per browser.
|
||||
const { view, setView } = useOrganizationsView();
|
||||
const { user } = useAuth();
|
||||
const { sourceProvider, sourceUrl, sources } = useConfigStatus();
|
||||
const { navigationKey } = useNavigation();
|
||||
@@ -778,6 +783,8 @@ export function Organization() {
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<OrganizationsViewToggle view={view} onChange={setView} compact />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@@ -938,6 +945,8 @@ export function Organization() {
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<OrganizationsViewToggle view={view} onChange={setView} />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@@ -991,6 +1000,7 @@ export function Organization() {
|
||||
sourceProvider={sourceProvider}
|
||||
sourceUrl={sourceUrl}
|
||||
sources={sources}
|
||||
view={view}
|
||||
/>
|
||||
|
||||
<AddOrganizationDialog
|
||||
@@ -1066,3 +1076,73 @@ export function Organization() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cards or list switch for the Organizations page (#428). A segmented
|
||||
* pair of icon buttons so it sits next to the other toolbar icons. The
|
||||
* compact form is one button that flips to the other view, for the
|
||||
* narrow toolbar where every icon costs search box width.
|
||||
*/
|
||||
function OrganizationsViewToggle({
|
||||
view,
|
||||
onChange,
|
||||
compact = false,
|
||||
}: {
|
||||
view: OrganizationsViewMode;
|
||||
onChange: (next: OrganizationsViewMode) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const options: { value: OrganizationsViewMode; label: string; Icon: typeof LayoutGrid }[] = [
|
||||
{ value: "cards", label: "Cards", Icon: LayoutGrid },
|
||||
{ value: "list", label: "List", Icon: List },
|
||||
];
|
||||
if (compact) {
|
||||
const next = options.find((option) => option.value !== view) ?? options[0];
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onChange(next.value)}
|
||||
title={`Switch to ${next.label.toLowerCase()} view`}
|
||||
aria-label={`Switch to ${next.label.toLowerCase()} view`}
|
||||
className="h-10 w-10 shrink-0"
|
||||
data-testid="organizations-view-toggle"
|
||||
>
|
||||
<next.Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Organizations view"
|
||||
className="flex items-center h-10 rounded-md border border-input bg-background shrink-0"
|
||||
data-testid="organizations-view-toggle"
|
||||
>
|
||||
{options.map(({ value, label, Icon }, index) => (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
role="radio"
|
||||
aria-checked={view === value}
|
||||
aria-label={`${label} view`}
|
||||
title={`${label} view`}
|
||||
data-state={view === value ? "on" : "off"}
|
||||
onClick={() => onChange(value)}
|
||||
className={cn(
|
||||
"h-full w-10 rounded-none",
|
||||
index === 0 ? "rounded-l-md" : "rounded-r-md border-l border-input",
|
||||
view === value
|
||||
? "bg-muted text-foreground hover:bg-muted"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Ban, MoreVertical, RefreshCw, SlidersHorizontal, Trash2 } from "lucide-react";
|
||||
import type { Organization } from "@/lib/db/schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface OrganizationActionsMenuProps {
|
||||
org: Organization;
|
||||
disabled?: boolean;
|
||||
onSync?: ({ orgId }: { orgId: string }) => Promise<void>;
|
||||
onIgnore?: ({ orgId, ignore }: { orgId: string; ignore: boolean }) => Promise<void>;
|
||||
onDelete?: (orgId: string) => void;
|
||||
onEditMirrorOptions: (org: Organization) => void;
|
||||
triggerClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The secondary actions of an organization, shared by the cards and the
|
||||
* list (#428). Hidden while the organization is being mirrored, like the
|
||||
* primary action.
|
||||
*/
|
||||
export function OrganizationActionsMenu({
|
||||
org,
|
||||
disabled = false,
|
||||
onSync,
|
||||
onIgnore,
|
||||
onDelete,
|
||||
onEditMirrorOptions,
|
||||
triggerClassName,
|
||||
}: OrganizationActionsMenuProps) {
|
||||
if (org.status === "mirroring") return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
className={cn(triggerClassName)}
|
||||
title="More actions"
|
||||
aria-label={`More actions for ${org.name}`}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{onSync && (org.status === "mirrored" || org.status === "failed") && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => org.id && onSync({ orgId: org.id })}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Sync Organization
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onEditMirrorOptions(org)}>
|
||||
<SlidersHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror Options
|
||||
</DropdownMenuItem>
|
||||
{org.status !== "ignored" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore Organization
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDelete && (
|
||||
<>
|
||||
{org.status !== "ignored" && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => org.id && onDelete(org.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete from Mirror
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Layers } from "lucide-react";
|
||||
import type { SourceApiRecord } from "@/types/config";
|
||||
import { SOURCE_PROVIDER_ICONS } from "@/lib/source-providers/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
// Radix reserves the empty string as a Select value, so unpinned
|
||||
// organizations use this sentinel instead.
|
||||
const EVERY_SOURCE_VALUE = "every-source";
|
||||
|
||||
export interface OrganizationSourceEditorProps {
|
||||
sources: SourceApiRecord[];
|
||||
value?: string | null;
|
||||
disabled?: boolean;
|
||||
onUpdateSource: (sourceId: string | null) => Promise<void>;
|
||||
/** Drop the "Source" label; the list view puts it in the column header. */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin an organization to one connected source, or let it follow every
|
||||
* source. Shown by the cards and the list only when more than one source
|
||||
* is connected.
|
||||
*/
|
||||
export function OrganizationSourceEditor({
|
||||
sources,
|
||||
value,
|
||||
disabled = false,
|
||||
onUpdateSource,
|
||||
compact = false,
|
||||
className,
|
||||
}: OrganizationSourceEditorProps) {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const handleValueChange = async (next: string) => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await onUpdateSource(next === EVERY_SOURCE_VALUE ? null : next);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to update source");
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 w-full", className)}>
|
||||
{!compact && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
|
||||
<Layers className="h-3 w-3" />
|
||||
Source
|
||||
</span>
|
||||
)}
|
||||
<Select
|
||||
value={value ?? EVERY_SOURCE_VALUE}
|
||||
onValueChange={(next) => void handleValueChange(next)}
|
||||
disabled={disabled || isUpdating}
|
||||
>
|
||||
<SelectTrigger className="h-8 min-w-0 flex-1 text-xs" aria-label="Source">
|
||||
<SelectValue placeholder="Source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={EVERY_SOURCE_VALUE}>Every source</SelectItem>
|
||||
{sources.map((source) => {
|
||||
const SourceIcon = SOURCE_PROVIDER_ICONS[source.provider];
|
||||
return (
|
||||
<SelectItem key={source.id} value={source.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
<SourceIcon className="h-3.5 w-3.5" />
|
||||
{source.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,7 @@ import { useMemo, useState } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, MoreVertical, Ban, SlidersHorizontal, Trash2, Layers } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, Ban, Layers } from "lucide-react";
|
||||
import type { MirrorOverrides, Organization } from "@/lib/db/schema";
|
||||
import type { FilterParams } from "@/types/filter";
|
||||
import type { SourceApiRecord } from "@/types/config";
|
||||
@@ -26,21 +25,11 @@ import {
|
||||
type SourceProviderKind,
|
||||
} from "@/lib/source-providers/kinds";
|
||||
import { SOURCE_PROVIDER_ICONS } from "@/lib/source-providers/icons";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { OrganizationsViewMode } from "@/lib/utils/organizations-view";
|
||||
import { OrganizationSourceEditor } from "./OrganizationSourceEditor";
|
||||
import { OrganizationActionsMenu } from "./OrganizationActionsMenu";
|
||||
import { OrganizationsTable } from "./OrganizationsTable";
|
||||
import { getOrganizationStatusBadge, resolveOrganizationSource } from "./organization-presentation";
|
||||
|
||||
interface OrganizationListProps {
|
||||
organizations: Organization[];
|
||||
@@ -64,87 +53,18 @@ interface OrganizationListProps {
|
||||
sourceUrl?: string;
|
||||
/** Connected sources; with more than one, each card shows and edits its own source. */
|
||||
sources?: SourceApiRecord[];
|
||||
/** Cards (default) or the compact list (#428). */
|
||||
view?: OrganizationsViewMode;
|
||||
}
|
||||
|
||||
const STATUS_ICONS = { check: Check, alert: AlertCircle, clock: Clock, ban: Ban } as const;
|
||||
|
||||
// Helper function to get status badge variant and icon
|
||||
const getStatusBadge = (status: string | null) => {
|
||||
switch (status) {
|
||||
case "imported":
|
||||
return { variant: "secondary" as const, label: "Not Mirrored", icon: null };
|
||||
case "mirroring":
|
||||
return { variant: "outline" as const, label: "Mirroring", icon: Clock };
|
||||
case "mirrored":
|
||||
return { variant: "default" as const, label: "Mirrored", icon: Check };
|
||||
case "failed":
|
||||
return { variant: "destructive" as const, label: "Failed", icon: AlertCircle };
|
||||
case "ignored":
|
||||
return { variant: "outline" as const, label: "Ignored", icon: Ban };
|
||||
default:
|
||||
return { variant: "secondary" as const, label: "Unknown", icon: null };
|
||||
}
|
||||
const badge = getOrganizationStatusBadge(status);
|
||||
return { ...badge, icon: badge.icon ? STATUS_ICONS[badge.icon] : null };
|
||||
};
|
||||
|
||||
// Radix reserves the empty string as a Select value, so unpinned
|
||||
// organizations use this sentinel instead.
|
||||
const EVERY_SOURCE_VALUE = "every-source";
|
||||
|
||||
function OrganizationSourceEditor({
|
||||
sources,
|
||||
value,
|
||||
disabled = false,
|
||||
onUpdateSource,
|
||||
}: {
|
||||
sources: SourceApiRecord[];
|
||||
value?: string | null;
|
||||
disabled?: boolean;
|
||||
onUpdateSource: (sourceId: string | null) => Promise<void>;
|
||||
}) {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const handleValueChange = async (next: string) => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await onUpdateSource(next === EVERY_SOURCE_VALUE ? null : next);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to update source");
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
|
||||
<Layers className="h-3 w-3" />
|
||||
Source
|
||||
</span>
|
||||
<Select
|
||||
value={value ?? EVERY_SOURCE_VALUE}
|
||||
onValueChange={(next) => void handleValueChange(next)}
|
||||
disabled={disabled || isUpdating}
|
||||
>
|
||||
<SelectTrigger className="h-8 min-w-0 flex-1 text-xs">
|
||||
<SelectValue placeholder="Source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={EVERY_SOURCE_VALUE}>Every source</SelectItem>
|
||||
{sources.map((source) => {
|
||||
const SourceIcon = SOURCE_PROVIDER_ICONS[source.provider];
|
||||
return (
|
||||
<SelectItem key={source.id} value={source.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
<SourceIcon className="h-3.5 w-3.5" />
|
||||
{source.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrganizationList({
|
||||
organizations,
|
||||
isLoading,
|
||||
@@ -160,6 +80,7 @@ export function OrganizationList({
|
||||
sourceProvider = "github",
|
||||
sourceUrl: sourceUrlProp,
|
||||
sources,
|
||||
view = "cards",
|
||||
}: OrganizationListProps) {
|
||||
const { giteaConfig, mirrorOptions, advancedOptions } = useGiteaConfig();
|
||||
const [overridesTarget, setOverridesTarget] = useState<Organization | null>(null);
|
||||
@@ -339,12 +260,43 @@ export function OrganizationList({
|
||||
return result;
|
||||
}, [organizations, filter]);
|
||||
|
||||
const overridesDialog = (
|
||||
<MirrorOverridesDialog
|
||||
open={!!overridesTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setOverridesTarget(null);
|
||||
}}
|
||||
targetKind="organization"
|
||||
targetName={overridesTarget?.name ?? ""}
|
||||
value={overridesTarget?.mirrorOverrides ?? null}
|
||||
destinationProvider={giteaConfig?.provider}
|
||||
inheritedFrom={{
|
||||
...mirrorOptionsToFlags(mirrorOptions),
|
||||
skipForks: !!advancedOptions?.skipForks,
|
||||
}}
|
||||
inheritedLabel="global settings"
|
||||
onSave={async (overrides) => {
|
||||
if (overridesTarget?.id) {
|
||||
await handleUpdateMirrorOverrides(overridesTarget.id, overrides);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return isLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(27rem,1fr))] gap-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[11.25rem] w-full" />
|
||||
))}
|
||||
</div>
|
||||
view === "list" ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(27rem,1fr))] gap-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[11.25rem] w-full" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : filteredOrganizations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
@@ -373,6 +325,29 @@ export function OrganizationList({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : view === "list" ? (
|
||||
<>
|
||||
<OrganizationsTable
|
||||
organizations={filteredOrganizations}
|
||||
loadingOrgIds={loadingOrgIds}
|
||||
sources={sources}
|
||||
sourceProvider={sourceProvider}
|
||||
sourceUrl={sourceUrl}
|
||||
destination={destination}
|
||||
getDestinationUrl={getGiteaOrgUrl}
|
||||
destinationLinkTooltip={destinationLinkTooltip}
|
||||
defaultDestinationFor={defaultDestinationFor}
|
||||
onUpdateDestination={handleUpdateDestination}
|
||||
onMoveMirrors={destination.isPushTarget ? undefined : handleMoveMirrors}
|
||||
onUpdateSource={handleUpdateSource}
|
||||
onMirror={onMirror}
|
||||
onSync={onSync}
|
||||
onIgnore={onIgnore}
|
||||
onDelete={onDelete}
|
||||
onEditMirrorOptions={setOverridesTarget}
|
||||
/>
|
||||
{overridesDialog}
|
||||
</>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(27rem,1fr))] gap-4 pb-20 sm:pb-0">
|
||||
{filteredOrganizations.map((org, index) => {
|
||||
@@ -380,16 +355,12 @@ export function OrganizationList({
|
||||
const statusBadge = getStatusBadge(org.status);
|
||||
const StatusIcon = statusBadge.icon;
|
||||
const orgSources = sources ?? [];
|
||||
const pinnedSource =
|
||||
orgSources.find((source) => source.id === org.sourceId) ?? null;
|
||||
const isPublicOnlySource = pinnedSource?.token === "";
|
||||
const orgProvider: SourceProviderKind = pinnedSource?.provider ?? sourceProvider;
|
||||
const orgSourceUrl = pinnedSource
|
||||
? normalizeSourceUrl(pinnedSource.url, pinnedSource.provider)
|
||||
: sourceUrl;
|
||||
const orgSourceLabel = SOURCE_PROVIDER_LABELS[orgProvider];
|
||||
const orgSourceShortLabel = orgProvider === "gitea" ? "Gitea" : orgSourceLabel;
|
||||
const OrgSourceIcon = SOURCE_PROVIDER_ICONS[orgProvider];
|
||||
const orgSource = resolveOrganizationSource(org, sources, sourceProvider, sourceUrl);
|
||||
const { pinnedSource, isPublicOnlySource } = orgSource;
|
||||
const orgSourceUrl = orgSource.sourceUrl;
|
||||
const orgSourceLabel = orgSource.label;
|
||||
const orgSourceShortLabel = orgSource.shortLabel;
|
||||
const OrgSourceIcon = SOURCE_PROVIDER_ICONS[orgSource.provider];
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -724,51 +695,15 @@ export function OrganizationList({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dropdown menu for additional actions */}
|
||||
{org.status !== "mirroring" && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" disabled={isLoading} className="h-10 w-10">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{onSync && (org.status === "mirrored" || org.status === "failed") && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => org.id && onSync({ orgId: org.id })}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Sync Organization
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => setOverridesTarget(org)}>
|
||||
<SlidersHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror Options
|
||||
</DropdownMenuItem>
|
||||
{org.status !== "ignored" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore Organization
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDelete && (
|
||||
<>
|
||||
{org.status !== "ignored" && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => org.id && onDelete(org.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete from Mirror
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<OrganizationActionsMenu
|
||||
org={org}
|
||||
disabled={isLoading}
|
||||
onSync={onSync}
|
||||
onIgnore={onIgnore}
|
||||
onDelete={onDelete}
|
||||
onEditMirrorOptions={setOverridesTarget}
|
||||
triggerClassName="h-10 w-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
@@ -884,51 +819,14 @@ export function OrganizationList({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dropdown menu for additional actions */}
|
||||
{org.status !== "mirroring" && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" disabled={isLoading}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{onSync && (org.status === "mirrored" || org.status === "failed") && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => org.id && onSync({ orgId: org.id })}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Sync Organization
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => setOverridesTarget(org)}>
|
||||
<SlidersHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror Options
|
||||
</DropdownMenuItem>
|
||||
{org.status !== "ignored" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
Ignore Organization
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => org.id && onDelete(org.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete from Mirror
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<OrganizationActionsMenu
|
||||
org={org}
|
||||
disabled={isLoading}
|
||||
onSync={onSync}
|
||||
onIgnore={onIgnore}
|
||||
onDelete={onDelete}
|
||||
onEditMirrorOptions={setOverridesTarget}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -988,26 +886,7 @@ export function OrganizationList({
|
||||
);
|
||||
})}
|
||||
|
||||
<MirrorOverridesDialog
|
||||
open={!!overridesTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setOverridesTarget(null);
|
||||
}}
|
||||
targetKind="organization"
|
||||
targetName={overridesTarget?.name ?? ""}
|
||||
value={overridesTarget?.mirrorOverrides ?? null}
|
||||
destinationProvider={giteaConfig?.provider}
|
||||
inheritedFrom={{
|
||||
...mirrorOptionsToFlags(mirrorOptions),
|
||||
skipForks: !!advancedOptions?.skipForks,
|
||||
}}
|
||||
inheritedLabel="global settings"
|
||||
onSave={async (overrides) => {
|
||||
if (overridesTarget?.id) {
|
||||
await handleUpdateMirrorOverrides(overridesTarget.id, overrides);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{overridesDialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { AlertCircle, Ban, Check, Clock, RefreshCw } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { Organization } from "@/lib/db/schema";
|
||||
import type { SourceApiRecord } from "@/types/config";
|
||||
import type { OrganizationMoveResult } from "@/lib/destination-transfer";
|
||||
import type { SourceProviderKind } from "@/lib/source-providers/kinds";
|
||||
import { SOURCE_PROVIDER_ICONS } from "@/lib/source-providers/icons";
|
||||
import { DestinationIcon, type destinationInfo } from "@/components/destination/DestinationIcon";
|
||||
import { cn, formatLastSyncTime } from "@/lib/utils";
|
||||
import { hasMirrorOverrides } from "@/lib/utils/mirror-overrides";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
import { MirrorDestinationEditor } from "./MirrorDestinationEditor";
|
||||
import { OrganizationSourceEditor } from "./OrganizationSourceEditor";
|
||||
import { OrganizationActionsMenu } from "./OrganizationActionsMenu";
|
||||
import {
|
||||
describeRepositoryBreakdown,
|
||||
getOrganizationStatusBadge,
|
||||
resolveOrganizationSource,
|
||||
} from "./organization-presentation";
|
||||
|
||||
const STATUS_ICONS = {
|
||||
check: Check,
|
||||
alert: AlertCircle,
|
||||
clock: Clock,
|
||||
ban: Ban,
|
||||
} as const;
|
||||
|
||||
export interface OrganizationsTableProps {
|
||||
organizations: Organization[];
|
||||
loadingOrgIds: Set<string>;
|
||||
sources?: SourceApiRecord[];
|
||||
sourceProvider: SourceProviderKind;
|
||||
sourceUrl: string;
|
||||
destination: ReturnType<typeof destinationInfo>;
|
||||
getDestinationUrl: (org: Organization) => string | null;
|
||||
destinationLinkTooltip: (org: Organization, url: string | null) => string;
|
||||
defaultDestinationFor: (orgName: string) => string;
|
||||
onUpdateDestination: (orgId: string, destination: string | null) => Promise<void>;
|
||||
onMoveMirrors?: (
|
||||
orgId: string,
|
||||
destination: string | null,
|
||||
dryRun: boolean
|
||||
) => Promise<OrganizationMoveResult>;
|
||||
onUpdateSource: (orgId: string, sourceId: string | null) => Promise<void>;
|
||||
onMirror: ({ orgId }: { orgId: string }) => Promise<void>;
|
||||
onSync?: ({ orgId }: { orgId: string }) => Promise<void>;
|
||||
onIgnore?: ({ orgId, ignore }: { orgId: string; ignore: boolean }) => Promise<void>;
|
||||
onDelete?: (orgId: string) => void;
|
||||
onEditMirrorOptions: (org: Organization) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The compact list view of the Organizations page (#428): one row per
|
||||
* organization so a few dozen fit on one screen. Same actions as the cards,
|
||||
* with the secondary ones behind the menu.
|
||||
*/
|
||||
export function OrganizationsTable({
|
||||
organizations,
|
||||
loadingOrgIds,
|
||||
sources,
|
||||
sourceProvider,
|
||||
sourceUrl,
|
||||
destination,
|
||||
getDestinationUrl,
|
||||
destinationLinkTooltip,
|
||||
defaultDestinationFor,
|
||||
onUpdateDestination,
|
||||
onMoveMirrors,
|
||||
onUpdateSource,
|
||||
onMirror,
|
||||
onSync,
|
||||
onIgnore,
|
||||
onDelete,
|
||||
onEditMirrorOptions,
|
||||
}: OrganizationsTableProps) {
|
||||
const hasMultipleSources = !!sources && sources.length > 1;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto pb-20 sm:pb-0" data-testid="organizations-table">
|
||||
<div className="min-w-[64rem] border rounded-md">
|
||||
{/* Header */}
|
||||
<div className="h-[45px] flex items-center border-b bg-muted/50 text-sm font-medium">
|
||||
<div className="h-full p-3 flex items-center flex-[2.2] sticky left-0 z-10 bg-background before:absolute before:inset-0 before:-z-10 before:bg-muted/50">
|
||||
Organization
|
||||
</div>
|
||||
{hasMultipleSources && (
|
||||
<div className="h-full p-3 flex items-center flex-[1.1]">Source</div>
|
||||
)}
|
||||
<div className="h-full p-3 flex items-center flex-[1.6]">Destination</div>
|
||||
<div className="h-full p-3 flex items-center flex-[1]">Repositories</div>
|
||||
<div className="h-full p-3 flex items-center flex-[1]">Last Mirrored</div>
|
||||
<div className="h-full p-3 flex items-center flex-[0.9]">Status</div>
|
||||
<div className="h-full p-3 flex items-center flex-[1.3]">Actions</div>
|
||||
<div className="h-full p-3 flex items-center justify-center flex-[0.6]">Links</div>
|
||||
</div>
|
||||
|
||||
{organizations.map((org) => {
|
||||
const orgId = org.id ?? "";
|
||||
const isLoading = loadingOrgIds.has(orgId);
|
||||
const statusBadge = getOrganizationStatusBadge(org.status);
|
||||
const StatusIcon = statusBadge.icon ? STATUS_ICONS[statusBadge.icon] : null;
|
||||
const source = resolveOrganizationSource(org, sources, sourceProvider, sourceUrl);
|
||||
const SourceIcon = SOURCE_PROVIDER_ICONS[source.provider];
|
||||
const breakdown = describeRepositoryBreakdown(org, "long");
|
||||
const destinationUrl = getDestinationUrl(org);
|
||||
const destinationTooltip = destinationLinkTooltip(org, destinationUrl);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={org.id ?? org.name}
|
||||
data-testid="organization-row"
|
||||
className={cn(
|
||||
"group min-h-[48px] flex items-center border-b last:border-b-0 hover:bg-muted/50",
|
||||
isLoading && "opacity-75"
|
||||
)}
|
||||
>
|
||||
{/* Organization. Pinned to the left edge so the row stays
|
||||
identifiable while a narrow screen scrolls the rest. */}
|
||||
<div className="h-full px-3 py-2 flex items-center gap-2 flex-[2.2] min-w-0 sticky left-0 z-10 bg-background before:absolute before:inset-0 before:-z-10 group-hover:before:bg-muted/50">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<a
|
||||
href={`${withBase("/repositories")}?organization=${encodeURIComponent(org.name || "")}`}
|
||||
className="font-medium hover:underline truncate"
|
||||
title={`Show the repositories of ${org.name}`}
|
||||
>
|
||||
{org.name}
|
||||
</a>
|
||||
<Badge
|
||||
variant={org.membershipRole === "member" ? "secondary" : "default"}
|
||||
className="capitalize shrink-0 px-1.5 text-[11px] font-normal"
|
||||
>
|
||||
{org.membershipRole.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
{source.isPublicOnlySource && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-1.5 font-normal text-[11px]"
|
||||
title="Mirrors public repositories only; its source has no account"
|
||||
>
|
||||
Public only
|
||||
</Badge>
|
||||
)}
|
||||
{hasMirrorOverrides(org.mirrorOverrides) && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 px-1.5 text-[11px] font-normal text-amber-600 dark:text-amber-400 border-amber-500/40"
|
||||
title="This organization overrides the global mirror options"
|
||||
>
|
||||
Custom
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{org.status === "failed" && org.errorMessage && (
|
||||
<p
|
||||
className="text-xs text-destructive truncate"
|
||||
title={org.errorMessage}
|
||||
>
|
||||
{org.errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source (multi-source only) */}
|
||||
{hasMultipleSources && (
|
||||
<div className="h-full px-3 py-2 flex items-center flex-[1.1] min-w-0">
|
||||
<OrganizationSourceEditor
|
||||
sources={sources ?? []}
|
||||
value={source.pinnedSource?.id ?? null}
|
||||
disabled={isLoading}
|
||||
onUpdateSource={(sourceId) => onUpdateSource(orgId, sourceId)}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Destination */}
|
||||
<div className="h-full px-3 py-2 flex items-center flex-[1.6] min-w-0">
|
||||
<MirrorDestinationEditor
|
||||
organizationId={orgId}
|
||||
organizationName={org.name!}
|
||||
currentDestination={org.destinationOrg ?? undefined}
|
||||
defaultDestination={defaultDestinationFor(org.name!)}
|
||||
onUpdate={(next) => onUpdateDestination(orgId, next)}
|
||||
onMoveMirrors={
|
||||
onMoveMirrors
|
||||
? (next, dryRun) => onMoveMirrors(orgId, next, dryRun)
|
||||
: undefined
|
||||
}
|
||||
destinationLabel={destination.label}
|
||||
isUpdating={isLoading}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Repositories */}
|
||||
<div className="h-full px-3 py-2 flex items-center flex-[1] min-w-0">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm">
|
||||
<span className="font-semibold tabular-nums">{org.repositoryCount}</span>
|
||||
<span className="text-muted-foreground ml-1">
|
||||
{org.repositoryCount === 1 ? "repository" : "repositories"}
|
||||
</span>
|
||||
</p>
|
||||
{breakdown.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground truncate" title={breakdown.join(", ")}>
|
||||
{breakdown.join(" | ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last Mirrored */}
|
||||
<div className="h-full px-3 py-2 flex items-center flex-[1]">
|
||||
<p className="text-sm">{formatLastSyncTime(org.lastMirrored ?? null)}</p>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="h-full px-3 py-2 flex items-center flex-[0.9]">
|
||||
{org.status === "failed" && org.errorMessage ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="destructive" className="cursor-help flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{statusBadge.label}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="text-sm">{org.errorMessage}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<Badge variant={statusBadge.variant} className="flex items-center gap-1">
|
||||
{StatusIcon && (
|
||||
<StatusIcon
|
||||
className={cn("h-3 w-3", org.status === "mirroring" && "animate-pulse")}
|
||||
/>
|
||||
)}
|
||||
{statusBadge.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="h-full px-3 py-2 flex items-center gap-1 flex-[1.3]">
|
||||
{org.status === "ignored" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => orgId && onIgnore && onIgnore({ orgId, ignore: false })}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1.5" />
|
||||
Include
|
||||
</Button>
|
||||
) : org.status === "imported" ? (
|
||||
<Button size="sm" onClick={() => orgId && onMirror({ orgId })} disabled={isLoading}>
|
||||
<RefreshCw className={cn("h-4 w-4 mr-1.5", isLoading && "animate-spin")} />
|
||||
{isLoading ? "Starting" : "Mirror"}
|
||||
</Button>
|
||||
) : org.status === "mirroring" ? (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<RefreshCw className="h-4 w-4 mr-1.5 animate-spin" />
|
||||
Mirroring
|
||||
</Button>
|
||||
) : org.status === "failed" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => orgId && onMirror({ orgId })}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCw className="h-4 w-4 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 mr-1.5" />
|
||||
)}
|
||||
{isLoading ? "Retrying" : "Retry"}
|
||||
</Button>
|
||||
) : org.status === "mirrored" && onSync ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => orgId && onSync({ orgId })}
|
||||
disabled={isLoading}
|
||||
title="Re-sync this organization now"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4 mr-1.5", isLoading && "animate-spin")} />
|
||||
Sync
|
||||
</Button>
|
||||
) : null}
|
||||
<OrganizationActionsMenu
|
||||
org={org}
|
||||
disabled={isLoading}
|
||||
onSync={onSync}
|
||||
onIgnore={onIgnore}
|
||||
onDelete={onDelete}
|
||||
onEditMirrorOptions={onEditMirrorOptions}
|
||||
triggerClassName="h-8 w-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="h-full px-3 py-2 flex items-center justify-center gap-x-1 flex-[0.6]">
|
||||
{destinationUrl ? (
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<a
|
||||
href={destinationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={destinationTooltip}
|
||||
>
|
||||
<DestinationIcon provider={destination.provider} className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="icon" disabled title={destinationTooltip} className="h-8 w-8">
|
||||
<DestinationIcon provider={destination.provider} className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<a
|
||||
href={`${source.sourceUrl}/${org.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`View on ${source.label}`}
|
||||
>
|
||||
<SourceIcon className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Organization } from "@/lib/db/schema";
|
||||
import type { SourceApiRecord } from "@/types/config";
|
||||
import {
|
||||
SOURCE_PROVIDER_LABELS,
|
||||
normalizeSourceUrl,
|
||||
type SourceProviderKind,
|
||||
} from "@/lib/source-providers/kinds";
|
||||
|
||||
export interface OrganizationSourceInfo {
|
||||
/** The source this organization is pinned to, if any. */
|
||||
pinnedSource: SourceApiRecord | null;
|
||||
/** A pinned source with no account mirrors public repositories only. */
|
||||
isPublicOnlySource: boolean;
|
||||
provider: SourceProviderKind;
|
||||
/** Normalized instance URL of the source the organization lives on. */
|
||||
sourceUrl: string;
|
||||
/** Full provider label, for tooltips. */
|
||||
label: string;
|
||||
/** Shorter label for buttons: Gitea and Forgejo sources both read "Gitea". */
|
||||
shortLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which source an organization belongs to, for the cards and the list.
|
||||
* Unpinned organizations follow the configured (or first) source.
|
||||
*/
|
||||
export function resolveOrganizationSource(
|
||||
org: Pick<Organization, "sourceId">,
|
||||
sources: SourceApiRecord[] | undefined,
|
||||
fallbackProvider: SourceProviderKind,
|
||||
fallbackUrl: string
|
||||
): OrganizationSourceInfo {
|
||||
const pinnedSource = (sources ?? []).find((source) => source.id === org.sourceId) ?? null;
|
||||
const provider: SourceProviderKind = pinnedSource?.provider ?? fallbackProvider;
|
||||
const label = SOURCE_PROVIDER_LABELS[provider];
|
||||
return {
|
||||
pinnedSource,
|
||||
isPublicOnlySource: pinnedSource?.token === "",
|
||||
provider,
|
||||
sourceUrl: pinnedSource
|
||||
? normalizeSourceUrl(pinnedSource.url, pinnedSource.provider)
|
||||
: fallbackUrl,
|
||||
label,
|
||||
shortLabel: provider === "gitea" ? "Gitea" : label,
|
||||
};
|
||||
}
|
||||
|
||||
export type OrganizationStatusBadge = {
|
||||
variant: "default" | "secondary" | "outline" | "destructive";
|
||||
label: string;
|
||||
icon: "check" | "alert" | "clock" | "ban" | null;
|
||||
};
|
||||
|
||||
/** Badge variant, label and icon for an organization status. */
|
||||
export function getOrganizationStatusBadge(status: string | null | undefined): OrganizationStatusBadge {
|
||||
switch (status) {
|
||||
case "imported":
|
||||
return { variant: "secondary", label: "Not Mirrored", icon: null };
|
||||
case "mirroring":
|
||||
return { variant: "outline", label: "Mirroring", icon: "clock" };
|
||||
case "mirrored":
|
||||
return { variant: "default", label: "Mirrored", icon: "check" };
|
||||
case "failed":
|
||||
return { variant: "destructive", label: "Failed", icon: "alert" };
|
||||
case "ignored":
|
||||
return { variant: "outline", label: "Ignored", icon: "ban" };
|
||||
default:
|
||||
return { variant: "secondary", label: "Unknown", icon: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** "3 public | 2 private | 1 fork" style breakdown, only the non-zero parts. */
|
||||
export function describeRepositoryBreakdown(
|
||||
org: Pick<Organization, "publicRepositoryCount" | "privateRepositoryCount" | "forkRepositoryCount">,
|
||||
style: "long" | "short" = "long"
|
||||
): string[] {
|
||||
const parts: string[] = [];
|
||||
const pub = org.publicRepositoryCount ?? 0;
|
||||
const priv = org.privateRepositoryCount ?? 0;
|
||||
const forks = org.forkRepositoryCount ?? 0;
|
||||
if (pub > 0) parts.push(style === "long" ? `${pub} public` : `${pub} pub`);
|
||||
if (priv > 0) parts.push(style === "long" ? `${priv} private` : `${priv} priv`);
|
||||
if (forks > 0) {
|
||||
parts.push(
|
||||
style === "long" ? `${forks} ${forks === 1 ? "fork" : "forks"}` : `${forks} fork`
|
||||
);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import {
|
||||
DEFAULT_ORGANIZATIONS_VIEW,
|
||||
getOrganizationsView,
|
||||
setOrganizationsView,
|
||||
subscribeToOrganizationsViewChange,
|
||||
type OrganizationsViewMode,
|
||||
} from "@/lib/utils/organizations-view";
|
||||
|
||||
const getServerSnapshot = (): OrganizationsViewMode => DEFAULT_ORGANIZATIONS_VIEW;
|
||||
|
||||
/**
|
||||
* Cards or list on the Organizations page (#428). The choice is kept in
|
||||
* localStorage and shared across tabs.
|
||||
*/
|
||||
export function useOrganizationsView() {
|
||||
const view = useSyncExternalStore(
|
||||
subscribeToOrganizationsViewChange,
|
||||
getOrganizationsView,
|
||||
getServerSnapshot
|
||||
);
|
||||
|
||||
const setView = useCallback((next: OrganizationsViewMode) => {
|
||||
setOrganizationsView(next);
|
||||
}, []);
|
||||
|
||||
return { view, setView };
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import {
|
||||
DEFAULT_ORGANIZATIONS_VIEW,
|
||||
ORGANIZATIONS_VIEW_CHANGE_EVENT,
|
||||
ORGANIZATIONS_VIEW_STORAGE_KEY,
|
||||
getOrganizationsView,
|
||||
isOrganizationsViewMode,
|
||||
setOrganizationsView,
|
||||
subscribeToOrganizationsViewChange,
|
||||
} from "./organizations-view";
|
||||
|
||||
// A minimal window and localStorage so the helper can be exercised
|
||||
// without a DOM library. Both are removed again afterwards.
|
||||
function installBrowserGlobals() {
|
||||
const store = new Map<string, string>();
|
||||
const listeners = new Map<string, Set<(event: any) => void>>();
|
||||
(globalThis as any).localStorage = {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void store.set(key, value),
|
||||
removeItem: (key: string) => void store.delete(key),
|
||||
};
|
||||
(globalThis as any).window = {
|
||||
addEventListener: (type: string, cb: (event: any) => void) => {
|
||||
if (!listeners.has(type)) listeners.set(type, new Set());
|
||||
listeners.get(type)!.add(cb);
|
||||
},
|
||||
removeEventListener: (type: string, cb: (event: any) => void) => {
|
||||
listeners.get(type)?.delete(cb);
|
||||
},
|
||||
dispatchEvent: (event: any) => {
|
||||
listeners.get(event.type)?.forEach((cb) => cb(event));
|
||||
return true;
|
||||
},
|
||||
};
|
||||
if (typeof (globalThis as any).CustomEvent === "undefined") {
|
||||
(globalThis as any).CustomEvent = class CustomEvent {
|
||||
type: string;
|
||||
detail: unknown;
|
||||
constructor(type: string, init?: { detail?: unknown }) {
|
||||
this.type = type;
|
||||
this.detail = init?.detail;
|
||||
}
|
||||
};
|
||||
}
|
||||
return { store, listeners };
|
||||
}
|
||||
|
||||
describe("organizations view preference", () => {
|
||||
const hadWindow = typeof (globalThis as any).window !== "undefined";
|
||||
const hadStorage = typeof (globalThis as any).localStorage !== "undefined";
|
||||
const savedWindow = (globalThis as any).window;
|
||||
const savedStorage = (globalThis as any).localStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
delete (globalThis as any).window;
|
||||
delete (globalThis as any).localStorage;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (hadWindow) (globalThis as any).window = savedWindow;
|
||||
else delete (globalThis as any).window;
|
||||
if (hadStorage) (globalThis as any).localStorage = savedStorage;
|
||||
else delete (globalThis as any).localStorage;
|
||||
});
|
||||
|
||||
it("falls back to cards without a browser", () => {
|
||||
expect(getOrganizationsView()).toBe(DEFAULT_ORGANIZATIONS_VIEW);
|
||||
expect(DEFAULT_ORGANIZATIONS_VIEW).toBe("cards");
|
||||
// Setting without a window must not throw either.
|
||||
expect(() => setOrganizationsView("list")).not.toThrow();
|
||||
});
|
||||
|
||||
it("only accepts the two known modes", () => {
|
||||
expect(isOrganizationsViewMode("cards")).toBe(true);
|
||||
expect(isOrganizationsViewMode("list")).toBe(true);
|
||||
expect(isOrganizationsViewMode("table")).toBe(false);
|
||||
expect(isOrganizationsViewMode(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("persists the choice and ignores garbage in storage", () => {
|
||||
const { store } = installBrowserGlobals();
|
||||
expect(getOrganizationsView()).toBe("cards");
|
||||
|
||||
setOrganizationsView("list");
|
||||
expect(store.get(ORGANIZATIONS_VIEW_STORAGE_KEY)).toBe("list");
|
||||
expect(getOrganizationsView()).toBe("list");
|
||||
|
||||
store.set(ORGANIZATIONS_VIEW_STORAGE_KEY, "sideways");
|
||||
expect(getOrganizationsView()).toBe("cards");
|
||||
});
|
||||
|
||||
it("notifies subscribers in this tab and from the storage event", () => {
|
||||
const { listeners } = installBrowserGlobals();
|
||||
let calls = 0;
|
||||
const unsubscribe = subscribeToOrganizationsViewChange(() => {
|
||||
calls += 1;
|
||||
});
|
||||
|
||||
setOrganizationsView("list");
|
||||
expect(calls).toBe(1);
|
||||
|
||||
// Another tab changed it.
|
||||
listeners.get("storage")?.forEach((cb) => cb({ key: ORGANIZATIONS_VIEW_STORAGE_KEY }));
|
||||
expect(calls).toBe(2);
|
||||
// An unrelated key does nothing.
|
||||
listeners.get("storage")?.forEach((cb) => cb({ key: "timeFormat" }));
|
||||
expect(calls).toBe(2);
|
||||
|
||||
unsubscribe();
|
||||
(globalThis as any).window.dispatchEvent(new CustomEvent(ORGANIZATIONS_VIEW_CHANGE_EVENT));
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("swallows a storage that throws", () => {
|
||||
installBrowserGlobals();
|
||||
(globalThis as any).localStorage = {
|
||||
getItem: () => {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
};
|
||||
expect(getOrganizationsView()).toBe("cards");
|
||||
expect(() => setOrganizationsView("list")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Cards or list on the Organizations page (#428).
|
||||
*
|
||||
* A pure display preference, so it lives in localStorage next to the theme
|
||||
* and time format preferences rather than in the database. Reading is safe
|
||||
* during SSR and in tests, where localStorage does not exist.
|
||||
*/
|
||||
|
||||
export type OrganizationsViewMode = "cards" | "list";
|
||||
|
||||
export const ORGANIZATIONS_VIEW_STORAGE_KEY = "organizationsView";
|
||||
export const ORGANIZATIONS_VIEW_CHANGE_EVENT = "gitea-mirror:organizations-view-change";
|
||||
|
||||
export const DEFAULT_ORGANIZATIONS_VIEW: OrganizationsViewMode = "cards";
|
||||
|
||||
export function isOrganizationsViewMode(value: unknown): value is OrganizationsViewMode {
|
||||
return value === "cards" || value === "list";
|
||||
}
|
||||
|
||||
export function getOrganizationsView(): OrganizationsViewMode {
|
||||
if (typeof localStorage === "undefined") return DEFAULT_ORGANIZATIONS_VIEW;
|
||||
try {
|
||||
const stored = localStorage.getItem(ORGANIZATIONS_VIEW_STORAGE_KEY);
|
||||
return isOrganizationsViewMode(stored) ? stored : DEFAULT_ORGANIZATIONS_VIEW;
|
||||
} catch {
|
||||
return DEFAULT_ORGANIZATIONS_VIEW;
|
||||
}
|
||||
}
|
||||
|
||||
export function setOrganizationsView(mode: OrganizationsViewMode): void {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem(ORGANIZATIONS_VIEW_STORAGE_KEY, mode);
|
||||
}
|
||||
} catch {
|
||||
// Persisting is best effort; listeners are still told.
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent(ORGANIZATIONS_VIEW_CHANGE_EVENT, { detail: mode }));
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to changes from this tab and from other tabs. */
|
||||
export function subscribeToOrganizationsViewChange(callback: () => void): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === null || event.key === ORGANIZATIONS_VIEW_STORAGE_KEY) callback();
|
||||
};
|
||||
window.addEventListener(ORGANIZATIONS_VIEW_CHANGE_EVENT, callback);
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
window.removeEventListener(ORGANIZATIONS_VIEW_CHANGE_EVENT, callback);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user