Compare commits

...
2 Commits
Author SHA1 Message Date
Arunavo Ray aaef5d6e5c chore: bump version to 3.40.0 2026-09-23 23:35:06 +05:30
ARUNAVO RAY 604ff3b6b3 feat: list view for the Organizations page (#428) (#451)
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 stored in localStorage next to the theme and time format
preferences.

The actions dropdown that existed twice in the card is now one shared
component used by the cards and the list, and the status badge, source
resolution and repository breakdown helpers moved to a shared module.
2026-09-23 23:35:01 +05:30
13 changed files with 1033 additions and 229 deletions

No files matched your search

+3
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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 && (
+81 -1
View File
@@ -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;
}
+28
View File
@@ -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 };
}
+127
View File
@@ -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();
});
});
+55
View File
@@ -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);
};
}