Merge pull request #2935 from nekochanfood/feat/reorder-user-repos

feat: リポジトリ一覧で並び替えができるようにする
This commit is contained in:
anatawa12 2026-05-30 21:48:44 +09:00 committed by GitHub
commit a31c947c1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 849 additions and 171 deletions

View file

@ -11,6 +11,7 @@ The format is based on [Keep a Changelog].
- The package list can show hidden packages. `#2731`
- Build-time option to disable auto updater `#2759`
- Please read README for new build instruction.
- User repositories can now be reordered by drag and drop `#2935`
### Changed
- The "Clear Selection" button in the package management screen is now red (destructive style) to distinguish it from the "Install Selected" button [`#2803`](https://github.com/vrc-get/vrc-get/pull/2803)

View file

@ -1,5 +1,26 @@
"use client";
import {
type CollisionDetection,
closestCenter,
DndContext,
type DragEndEvent,
type DragOverEvent,
DragOverlay,
type DragStartEvent,
defaultDropAnimation,
defaultDropAnimationSideEffects,
type Modifier,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import {
queryOptions,
useMutation,
@ -7,8 +28,16 @@ import {
useQueryClient,
} from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { ChevronDown, CircleX } from "lucide-react";
import { Suspense, useCallback, useEffect, useId, useMemo } from "react";
import { ChevronDown, CircleX, GripVertical } from "lucide-react";
import {
Suspense,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { HNavBar, VStack } from "@/components/layout";
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import { Button } from "@/components/ui/button";
@ -41,6 +70,8 @@ export const Route = createFileRoute("/_main/packages/repositories/")({
component: Page,
});
type UserRepoWithListId = TauriUserRepository & { listId: string };
function Page() {
return (
<Suspense>
@ -49,11 +80,99 @@ function Page() {
);
}
const restrictToVerticalAxis: Modifier = ({ transform }) => ({
...transform,
x: 0,
});
const DRAG_OVERLAY_MODIFIERS = [restrictToVerticalAxis];
const customDropAnimation: typeof defaultDropAnimation = {
...defaultDropAnimation,
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: { opacity: "0" },
},
}),
};
const TABLE_HEAD = [
"", // checkbox
"general:name",
"vpm repositories:url",
"", // actions
"", // grip handle
] as const;
const environmentRepositoriesInfo = queryOptions({
queryKey: ["environmentRepositoriesInfo"],
queryFn: commands.environmentRepositoriesInfo,
});
// Scrolls the given viewport element when the pointer is near the top or bottom
// edge during drag. dnd-kit's built-in autoscroll is disabled because it causes
// jitter with Radix UI ScrollArea (wrong container detection + double-smoothing).
function useDragAutoScroll(
viewportRef: React.RefObject<HTMLElement | null>,
isActive: boolean,
): void {
useEffect(() => {
if (!isActive) return;
const THRESHOLD = 80; // px from edge to begin scrolling
const MAX_SPEED = 15; // px/frame at the very edge
let pointerY = 0;
const onPointerMove = (e: PointerEvent) => {
pointerY = e.clientY;
};
window.addEventListener("pointermove", onPointerMove, { passive: true });
let rafId: number;
const tick = () => {
const viewport = viewportRef.current;
if (viewport) {
const { top, bottom } = viewport.getBoundingClientRect();
const distFromTop = pointerY - top;
const distFromBottom = bottom - pointerY;
let delta = 0;
if (distFromTop >= 0 && distFromTop < THRESHOLD) {
delta = -MAX_SPEED * (1 - distFromTop / THRESHOLD);
} else if (distFromBottom >= 0 && distFromBottom < THRESHOLD) {
delta = MAX_SPEED * (1 - distFromBottom / THRESHOLD);
}
if (delta !== 0) {
viewport.scrollTo({
top: viewport.scrollTop + delta,
behavior: "instant",
});
}
}
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => {
window.removeEventListener("pointermove", onPointerMove);
cancelAnimationFrame(rafId);
};
}, [isActive, viewportRef]);
}
function computeSlotKey(repo: TauriUserRepository, used: Set<string>): string {
const base = `${repo.id} ${repo.url ?? ""}`;
let key = base;
let counter = 0;
while (used.has(key)) {
counter++;
key = `${base} ${counter}`;
}
used.add(key);
return key;
}
function PageBody() {
const result = useQuery(environmentRepositoriesInfo);
@ -95,140 +214,110 @@ function PageBody() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const bodyAnimation = usePrevPathName().startsWith("/packages")
? "slide-right"
: "";
const guiAnimation = useQuery({
queryKey: ["environmentGuiAnimation"],
queryFn: commands.environmentGuiAnimation,
initialData: true,
}).data;
return (
<VStack>
<HNavBar
className="shrink-0"
leading={<HeadingPageName pageType={"/packages/repositories"} />}
trailing={
<DropdownMenu>
<div className={"flex divide-x"}>
<Button
className={"rounded-r-none compact:h-10"}
onClick={() => openAddRepositoryDialog()}
>
{tc("vpm repositories:button:add repository")}
</Button>
<DropdownMenuTrigger
asChild
className={"rounded-l-none pl-2 pr-2 compact:h-10"}
>
<Button>
<ChevronDown className={"w-4 h-4"} />
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => importRepositoriesMutation.mutate()}
>
{tc("vpm repositories:button:import repositories")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportRepositories.mutate()}>
{tc("vpm repositories:button:export repositories")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
/>
<main
className={`shrink overflow-hidden flex w-full h-full ${bodyAnimation}`}
>
<ScrollableCardTable className={"h-full w-full"}>
<RepositoryTableBody
userRepos={result.data?.user_repositories || []}
hiddenUserRepos={hiddenUserRepos}
/>
</ScrollableCardTable>
</main>
</VStack>
const userRepos = result.data?.user_repositories;
const listIdMapRef = useRef<Map<string, string>>(new Map());
const augmentedUserRepos = useMemo<UserRepoWithListId[]>(() => {
if (!userRepos) {
listIdMapRef.current = new Map();
return [];
}
const prev = listIdMapRef.current;
const next = new Map<string, string>();
const usedKeys = new Set<string>();
const result: UserRepoWithListId[] = [];
for (const r of userRepos) {
const key = computeSlotKey(r, usedKeys);
const listId = prev.get(key) ?? crypto.randomUUID();
next.set(key, listId);
result.push({ ...r, listId });
}
listIdMapRef.current = next;
return result;
}, [userRepos]);
const [orderedListIds, setOrderedListIds] = useState<string[]>(() =>
augmentedUserRepos.map((r) => r.listId),
);
}
function RepositoryTableBody({
userRepos,
hiddenUserRepos,
}: {
userRepos: TauriUserRepository[];
hiddenUserRepos: Set<string>;
}) {
const TABLE_HEAD = [
"", // checkbox
"general:name",
"vpm repositories:url",
"", // actions
];
useEffect(() => {
setOrderedListIds(augmentedUserRepos.map((r) => r.listId));
}, [augmentedUserRepos]);
return (
<>
<thead>
<tr>
{TABLE_HEAD.map((head, index) => (
<th
// biome-ignore lint/suspicious/noArrayIndexKey: static array
key={index}
className={
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground px-2.5 py-1.5"
}
>
<small className="font-normal leading-none">{tc(head)}</small>
</th>
))}
</tr>
</thead>
<tbody>
<RepositoryRow
repoId={"com.vrchat.repos.official"}
url={"https://packages.vrchat.com/official?download"}
displayName={tt("vpm repositories:source:official")}
hiddenUserRepos={hiddenUserRepos}
canRemove={false}
/>
<RepositoryRow
repoId={"com.vrchat.repos.curated"}
url={"https://packages.vrchat.com/curated?download"}
displayName={tt("vpm repositories:source:curated")}
hiddenUserRepos={hiddenUserRepos}
className={"border-b border-primary/10"}
canRemove={false}
/>
{userRepos.map((repo) => (
<RepositoryRow
key={repo.id}
repoId={repo.id}
displayName={repo.display_name}
url={repo.url}
hiddenUserRepos={hiddenUserRepos}
/>
))}
</tbody>
</>
const userRepoByListId = useMemo(
() => new Map(augmentedUserRepos.map((r) => [r.listId, r])),
[augmentedUserRepos],
);
}
function RepositoryRow({
repoId,
displayName,
url,
hiddenUserRepos,
className,
canRemove = true,
}: {
repoId: TauriUserRepository["id"];
displayName: TauriUserRepository["display_name"];
url: TauriUserRepository["url"];
hiddenUserRepos: Set<string>;
className?: string;
canRemove?: boolean;
}) {
const cellClass = "p-2.5 compact:py-1";
const id = useId();
const userRepoByListIdRef =
useRef<Map<string, UserRepoWithListId>>(userRepoByListId);
useEffect(() => {
userRepoByListIdRef.current = userRepoByListId;
}, [userRepoByListId]);
const [activeId, setActiveId] = useState<string | null>(null);
const [overId, setOverId] = useState<string | null>(null);
const [columnWidths, setColumnWidths] = useState<number[]>([]);
const theadRowRef = useRef<HTMLTableRowElement>(null);
const scrollViewportRef = useRef<HTMLDivElement>(null);
const sensors = useSensors(useSensor(PointerSensor));
const orderedListIdsSet = useMemo(
() => new Set(orderedListIds),
[orderedListIds],
);
const collisionDetection = useCallback<CollisionDetection>(
(args) =>
closestCenter({
...args,
droppableContainers: args.droppableContainers.filter((c) =>
orderedListIdsSet.has(c.id as string),
),
}),
[orderedListIdsSet],
);
const queryClient = useQueryClient();
const reorderMutation = useMutation({
mutationFn: (listIds: string[]) => {
const repos = listIds
.map((lid) => userRepoByListId.get(lid))
.filter((r): r is UserRepoWithListId => r !== undefined)
.map((r) => ({ index: r.index, id: r.id }));
return commands.environmentReorderRepositories(repos);
},
// Pin listIds to the new positions so duplicate-keyed rows don't swap their listIds on refetch.
onMutate: (newListIds: string[]) => {
const prevMap = new Map(listIdMapRef.current);
const rebuilt = new Map<string, string>();
const usedKeys = new Set<string>();
for (const lid of newListIds) {
const repo = userRepoByListIdRef.current.get(lid);
if (!repo) continue;
const key = computeSlotKey(repo, usedKeys);
rebuilt.set(key, lid);
}
listIdMapRef.current = rebuilt;
return { prevMap };
},
onSettled: () => queryClient.invalidateQueries(environmentRepositoriesInfo),
onError: (e, _newListIds, ctx) => {
if (ctx?.prevMap) listIdMapRef.current = ctx.prevMap;
toastThrownError(e);
},
});
const setHideRepository = useMutation({
mutationFn: async ({ id, shown }: { id: string; shown: boolean }) => {
if (shown) {
@ -273,72 +362,483 @@ function RepositoryRow({
},
});
const activeVisualIndex = useMemo(() => {
if (!activeId) return 0;
const effectiveId = overId ?? activeId;
return orderedListIds.indexOf(effectiveId) + 2; // +2 for the 2 fixed rows
}, [activeId, overId, orderedListIds]);
function handleDragStart(event: DragStartEvent) {
setActiveId(event.active.id as string);
if (theadRowRef.current) {
const widths = Array.from(
theadRowRef.current.querySelectorAll("th"),
(th) => th.getBoundingClientRect().width,
);
setColumnWidths(widths);
}
}
function handleDragOver(event: DragOverEvent) {
setOverId((event.over?.id as string | null) ?? null);
}
function handleDragEnd(event: DragEndEvent) {
setActiveId(null);
setOverId(null);
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = orderedListIds.indexOf(active.id as string);
const newIndex = orderedListIds.indexOf(over.id as string);
const newListIds = arrayMove(orderedListIds, oldIndex, newIndex);
setOrderedListIds(newListIds);
reorderMutation.mutate(newListIds);
}
}
function handleDragCancel() {
setActiveId(null);
setOverId(null);
}
useDragAutoScroll(scrollViewportRef, activeId !== null);
const bodyAnimation = usePrevPathName().startsWith("/packages")
? "slide-right"
: "";
return (
<DndContext
sensors={sensors}
collisionDetection={collisionDetection}
autoScroll={false}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<VStack>
<div style={activeId !== null ? { pointerEvents: "none" } : undefined}>
<HNavBar
className="shrink-0"
leading={<HeadingPageName pageType={"/packages/repositories"} />}
trailing={
<DropdownMenu>
<div className={"flex divide-x"}>
<Button
className={"rounded-r-none compact:h-10"}
onClick={() => openAddRepositoryDialog()}
>
{tc("vpm repositories:button:add repository")}
</Button>
<DropdownMenuTrigger
asChild
className={"rounded-l-none pl-2 pr-2 compact:h-10"}
>
<Button>
<ChevronDown className={"w-4 h-4"} />
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => importRepositoriesMutation.mutate()}
>
{tc("vpm repositories:button:import repositories")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportRepositories.mutate()}>
{tc("vpm repositories:button:export repositories")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
/>
</div>
<main
className={`shrink overflow-hidden flex w-full h-full ${bodyAnimation}`}
>
<ScrollableCardTable
className={"h-full w-full"}
viewportRef={scrollViewportRef}
>
<RepositoryTableBody
orderedListIds={orderedListIds}
userRepoByListId={userRepoByListId}
hiddenUserRepos={hiddenUserRepos}
theadRowRef={theadRowRef}
guiAnimation={guiAnimation}
onToggleVisibility={(id, shown) =>
setHideRepository.mutate({ id, shown })
}
isDragActive={activeId !== null}
/>
</ScrollableCardTable>
</main>
</VStack>
<DragOverlay
modifiers={DRAG_OVERLAY_MODIFIERS}
dropAnimation={guiAnimation ? customDropAnimation : null}
>
{activeId ? (
<RepositoryDragOverlay
repo={userRepoByListId.get(activeId)}
selected={
!hiddenUserRepos.has(userRepoByListId.get(activeId)?.id ?? "")
}
columnWidths={columnWidths}
visualIndex={activeVisualIndex}
guiAnimation={guiAnimation}
/>
) : null}
</DragOverlay>
</DndContext>
);
}
function RepositoryTableBody({
orderedListIds,
userRepoByListId,
hiddenUserRepos,
theadRowRef,
guiAnimation,
onToggleVisibility,
isDragActive,
}: {
orderedListIds: string[];
userRepoByListId: Map<string, UserRepoWithListId>;
hiddenUserRepos: Set<string>;
theadRowRef: React.RefObject<HTMLTableRowElement | null>;
guiAnimation: boolean;
onToggleVisibility: (id: string, shown: boolean) => void;
isDragActive: boolean;
}) {
return (
<>
<thead>
<tr ref={theadRowRef}>
{TABLE_HEAD.map((head, index) => (
<th
// biome-ignore lint/suspicious/noArrayIndexKey: static array
key={index}
className={
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground px-2.5 py-1.5"
}
>
<small className="font-normal leading-none">{tc(head)}</small>
</th>
))}
</tr>
</thead>
<tbody>
<RepositoryRow
repoId={"com.vrchat.repos.official"}
url={"https://packages.vrchat.com/official?download"}
displayName={tt("vpm repositories:source:official")}
hiddenUserRepos={hiddenUserRepos}
canRemove={false}
rowIndex={0}
guiAnimation={guiAnimation}
onToggleVisibility={onToggleVisibility}
isDragActive={isDragActive}
/>
<RepositoryRow
repoId={"com.vrchat.repos.curated"}
url={"https://packages.vrchat.com/curated?download"}
displayName={tt("vpm repositories:source:curated")}
hiddenUserRepos={hiddenUserRepos}
className={"border-b border-primary/10"}
canRemove={false}
rowIndex={1}
guiAnimation={guiAnimation}
onToggleVisibility={onToggleVisibility}
isDragActive={isDragActive}
/>
<SortableContext
items={orderedListIds}
strategy={verticalListSortingStrategy}
>
{orderedListIds.map((listId, index) => {
const repo = userRepoByListId.get(listId);
if (!repo) return null;
return (
<RepositoryRow
key={listId}
listId={listId}
repoId={repo.id}
repoIndex={repo.index}
displayName={repo.display_name}
url={repo.url}
hiddenUserRepos={hiddenUserRepos}
rowIndex={2 + index}
guiAnimation={guiAnimation}
onToggleVisibility={onToggleVisibility}
isDragActive={isDragActive}
/>
);
})}
</SortableContext>
</tbody>
</>
);
}
const CELL_CLASS = "p-2.5 compact:py-1 align-middle";
function RepositoryRowCells({
labelId,
displayName,
url,
canRemove,
selected,
onCheckedChange,
onRemove,
dragListeners,
dragAttributes,
}: {
labelId?: string;
displayName: string;
url: string | null | undefined;
canRemove: boolean;
selected: boolean;
onCheckedChange?: (shown: boolean) => void;
onRemove?: () => void;
dragListeners?: ReturnType<typeof useSortable>["listeners"];
dragAttributes?: ReturnType<typeof useSortable>["attributes"];
}) {
const interactive = onCheckedChange !== undefined;
return (
<>
<td className={CELL_CLASS}>
{interactive ? (
<div className="flex">
<Checkbox
id={labelId}
checked={selected}
onCheckedChange={(x) => onCheckedChange(x === true)}
/>
</div>
) : (
<div className="pointer-events-none flex">
<Checkbox checked={selected} />
</div>
)}
</td>
<td className={CELL_CLASS}>
{interactive ? (
<label htmlFor={labelId}>
<p className="font-normal">{displayName}</p>
</label>
) : (
<p className="font-normal">{displayName}</p>
)}
</td>
<td className={CELL_CLASS}>
<p className="font-normal">{url}</p>
</td>
<td className={`${CELL_CLASS} w-0`}>
{interactive ? (
<Tooltip>
<TooltipTrigger asChild={canRemove}>
<Button
disabled={!canRemove}
onClick={onRemove}
variant={"ghost"}
size={"icon"}
>
<CircleX className={"size-5 text-destructive"} />
</Button>
</TooltipTrigger>
<TooltipContent>
{canRemove
? tc("vpm repositories:remove repository")
: tc(
"vpm repositories:tooltip:remove curated or official repository",
)}
</TooltipContent>
</Tooltip>
) : (
<Button variant={"ghost"} size={"icon"} disabled>
<CircleX className={"size-5 text-destructive"} />
</Button>
)}
</td>
<td
className={cn(
CELL_CLASS,
"w-0",
canRemove ? "cursor-move" : "cursor-not-allowed",
)}
{...(canRemove ? dragListeners : undefined)}
{...(canRemove ? dragAttributes : undefined)}
>
<GripVertical
className={cn(
"size-5 text-muted-foreground",
!canRemove && "opacity-50",
)}
/>
</td>
</>
);
}
function RepositoryRow({
listId,
repoId,
repoIndex,
displayName,
url,
hiddenUserRepos,
className,
canRemove = true,
rowIndex,
guiAnimation,
onToggleVisibility,
isDragActive,
}: {
listId?: string;
repoId: TauriUserRepository["id"];
repoIndex?: number;
displayName: TauriUserRepository["display_name"];
url: TauriUserRepository["url"];
hiddenUserRepos: Set<string>;
className?: string;
canRemove?: boolean;
rowIndex: number;
guiAnimation: boolean;
onToggleVisibility: (id: string, shown: boolean) => void;
isDragActive: boolean;
}) {
const labelId = useId();
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: listId ?? repoId, disabled: !canRemove });
const visualIndex = useMemo(() => {
if (isDragging) return rowIndex;
const dy = transform?.y ?? 0;
if (dy < 0) return rowIndex - 1;
if (dy > 0) return rowIndex + 1;
return rowIndex;
}, [rowIndex, transform?.y, isDragging]);
const dragStyle = useMemo<React.CSSProperties>(
() => ({
transform: transform ? `translateY(${transform.y}px)` : undefined,
transition: guiAnimation
? [transition, isDragActive ? undefined : "background-color 200ms ease"]
.filter(Boolean)
.join(", ") || undefined
: undefined,
opacity: isDragging ? 0 : 1,
position: "relative",
}),
[transform, transition, isDragging, guiAnimation, isDragActive],
);
const selected = !hiddenUserRepos.has(repoId);
return (
<tr className={cn("even:bg-secondary/30", className)}>
<td className={cellClass}>
<Checkbox
id={id}
checked={selected}
onCheckedChange={(x) =>
setHideRepository.mutate({ id: repoId, shown: x === true })
}
/>
</td>
<td className={cellClass}>
<label htmlFor={id}>
<p className="font-normal">{displayName}</p>
</label>
</td>
<td className={cellClass}>
<p className="font-normal">{url}</p>
</td>
<td className={`${cellClass} w-0`}>
<Tooltip>
<TooltipTrigger asChild={canRemove}>
<Button
disabled={!canRemove}
onClick={() => {
void openSingleDialog(RemoveRepositoryDialog, {
displayName,
id: repoId,
});
}}
variant={"ghost"}
size={"icon"}
>
<CircleX className={"size-5 text-destructive"} />
</Button>
</TooltipTrigger>
<TooltipContent>
{canRemove
? tc("vpm repositories:remove repository")
: tc(
"vpm repositories:tooltip:remove curated or official repository",
)}
</TooltipContent>
</Tooltip>
</td>
<tr
ref={setNodeRef}
style={dragStyle}
className={cn(visualIndex % 2 === 1 ? "bg-secondary/30" : "", className)}
>
<RepositoryRowCells
labelId={labelId}
displayName={displayName}
url={url}
canRemove={canRemove}
selected={selected}
onCheckedChange={(shown) => onToggleVisibility(repoId, shown)}
onRemove={() =>
void openSingleDialog(RemoveRepositoryDialog, {
displayName,
index: repoIndex ?? 0,
id: repoId,
})
}
dragListeners={listeners}
dragAttributes={attributes}
/>
</tr>
);
}
function RepositoryDragOverlay({
repo,
selected,
columnWidths,
visualIndex,
guiAnimation,
}: {
repo: TauriUserRepository | undefined;
selected: boolean;
columnWidths: number[];
visualIndex: number;
guiAnimation: boolean;
}) {
const style = useMemo<React.CSSProperties>(
() => ({
transition: guiAnimation ? "background-color 200ms ease" : undefined,
}),
[guiAnimation],
);
if (!repo) return null;
return (
<table
className={cn(
"w-full table-fixed text-left",
visualIndex % 2 === 1 ? "bg-secondary/30" : "",
)}
style={style}
>
{columnWidths.length > 0 && (
<colgroup>
{columnWidths.map((w, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: fixed column order
<col key={i} style={{ width: w }} />
))}
</colgroup>
)}
<tbody>
<tr>
<RepositoryRowCells
displayName={repo.display_name}
url={repo.url}
canRemove={true}
selected={selected}
/>
</tr>
</tbody>
</table>
);
}
function RemoveRepositoryDialog({
dialog,
displayName,
index,
id,
}: {
dialog: DialogContext<void>;
displayName: string;
index: number;
id: string;
}) {
const queryClient = useQueryClient();
const removeRepository = useMutation({
mutationFn: async (id: string) =>
await commands.environmentRemoveRepository(id),
onMutate: async (id) => {
mutationFn: async (args: { index: number; id: string }) =>
await commands.environmentRemoveRepository(args.index, args.id),
onMutate: async ({ index }) => {
await queryClient.cancelQueries(environmentRepositoriesInfo);
const data = queryClient.getQueryData(
environmentRepositoriesInfo.queryKey,
@ -346,10 +846,18 @@ function RemoveRepositoryDialog({
if (data !== undefined) {
queryClient.setQueryData(environmentRepositoriesInfo.queryKey, {
...data,
user_repositories: data.user_repositories.filter((x) => x.id !== id),
user_repositories: data.user_repositories.filter(
(x) => x.index !== index,
),
});
}
return data;
},
onError: (e, _args, ctx) => {
queryClient.setQueryData(environmentRepositoriesInfo.queryKey, ctx);
toastThrownError(e);
},
onSettled: () => queryClient.invalidateQueries(environmentRepositoriesInfo),
});
return (
@ -369,7 +877,7 @@ function RemoveRepositoryDialog({
<Button
onClick={() => {
dialog.close();
removeRepository.mutate(id);
removeRepository.mutate({ index, id });
}}
className={"ml-2"}
>

View file

@ -303,6 +303,17 @@ html {
@apply pe-2.5;
}
/*
* Add padding end for the content area of scrollable card if vertical scroll bar is visible
* This prevents the table / items from being hidden behind the vertical scroll bar
*/
.vrc-get-scrollable-card:has(
> .vrc-get-scrollable-card-vertical-bar
) > div[data-radix-scroll-area-viewport]
> div {
@apply pe-2.5;
}
.vrc-get-sidebar-hostname-warning-container {
contain-intrinsic-size: 0 7em;
contain: size;

View file

@ -40,7 +40,8 @@ export const commands = {
environmentSetHideLocalUserPackages: (value: boolean) => __TAURI_INVOKE<null>("environment_set_hide_local_user_packages", { value }),
environmentDownloadRepository: (url: string, headers: { [key in string]: string }) => __TAURI_INVOKE<TauriDownloadRepository>("environment_download_repository", { url, headers }),
environmentAddRepository: (url: string, headers: { [key in string]: string }) => __TAURI_INVOKE<TauriAddRepositoryResult>("environment_add_repository", { url, headers }),
environmentRemoveRepository: (id: string) => __TAURI_INVOKE<null>("environment_remove_repository", { id }),
environmentRemoveRepository: (index: number, expectedId: string) => __TAURI_INVOKE<null>("environment_remove_repository", { index, expectedId }),
environmentReorderRepositories: (repos: TauriUserRepositoryRef[]) => __TAURI_INVOKE<null>("environment_reorder_repositories", { repos }),
environmentImportRepositoryPick: () => __TAURI_INVOKE<TauriImportRepositoryPickResult>("environment_import_repository_pick"),
environmentImportDownloadRepositories: (channel: string, repositories: TauriRepositoryDescriptor[]) => __TAURI_INVOKE<AsyncCallResult<number, ([TauriRepositoryDescriptor, TauriDownloadRepository])[]>>("environment_import_download_repositories", { channel, repositories }),
environmentImportAddRepositories: (repositories: TauriRepositoryDescriptor[]) => __TAURI_INVOKE<null>("environment_import_add_repositories", { repositories }),
@ -404,11 +405,17 @@ export type TauriUserPackage = {
};
export type TauriUserRepository = {
index: number,
id: string,
url: string | null,
display_name: string,
};
export type TauriUserRepositoryRef = {
index: number,
id: string,
};
export type TauriVersion = {
major: number,
minor: number,

View file

@ -8,6 +8,9 @@
"name": "vrc-get-gui",
"version": "0.1.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-accordion": "^1",
"@radix-ui/react-checkbox": "^1",
"@radix-ui/react-dialog": "^1",
@ -508,6 +511,59 @@
"node": ">=14.21.3"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",

View file

@ -14,6 +14,9 @@
"lint": "tsc && biome lint"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-accordion": "^1",
"@radix-ui/react-checkbox": "^1",
"@radix-ui/react-dialog": "^1",

View file

@ -96,6 +96,7 @@ pub(crate) fn handlers() -> impl Fn(Invoke) -> bool + Send + Sync + 'static {
environment::packages::environment_download_repository,
environment::packages::environment_add_repository,
environment::packages::environment_remove_repository,
environment::packages::environment_reorder_repositories,
environment::packages::environment_import_repository_pick,
environment::packages::environment_import_download_repositories,
environment::packages::environment_import_add_repositories,
@ -204,6 +205,7 @@ pub(crate) fn export_ts() {
environment::packages::environment_download_repository,
environment::packages::environment_add_repository,
environment::packages::environment_remove_repository,
environment::packages::environment_reorder_repositories,
environment::packages::environment_import_repository_pick,
environment::packages::environment_import_download_repositories,
environment::packages::environment_import_add_repositories,

View file

@ -1,6 +1,6 @@
use crate::commands::async_command::{AsyncCallResult, With, async_command};
use crate::commands::prelude::*;
use futures::future::{join_all, try_join_all};
use futures::future::try_join_all;
use indexmap::IndexMap;
use itertools::Itertools;
use log::info;
@ -18,7 +18,7 @@ use vrc_get_vpm::environment::{
use vrc_get_vpm::io::{DefaultEnvironmentIo, IoTrait};
use vrc_get_vpm::repositories_file::RepositoriesFile;
use vrc_get_vpm::repository::RemoteRepository;
use vrc_get_vpm::{HttpClient, VersionSelector};
use vrc_get_vpm::{HttpClient, UserRepoSetting, VersionSelector};
#[tauri::command]
#[specta::specta]
@ -58,6 +58,7 @@ pub async fn environment_packages(
#[derive(Serialize, specta::Type)]
struct TauriUserRepository {
index: usize,
id: String,
url: Option<String>,
display_name: String,
@ -87,9 +88,11 @@ pub async fn environment_repositories_info(
let user_repositories = settings
.get_user_repos()
.iter()
.map(|x| {
.enumerate()
.map(|(index, x)| {
let id = x.id().or(x.url().map(Url::as_str)).unwrap();
TauriUserRepository {
index,
id: id.to_string(),
url: x.url().map(|x| x.to_string()),
display_name: x.name().unwrap_or(id).to_string(),
@ -345,24 +348,48 @@ pub async fn environment_add_repository(
Ok(TauriAddRepositoryResult::Success)
}
// Verifies that the repo at `index` in the freshly-loaded settings still has
// the `expected_id` the frontend last saw. Guards against silent corruption
// from external writes to settings.json between fetch and mutation.
fn verify_repo_at_index(
repos: &[UserRepoSetting],
index: usize,
expected_id: &str,
) -> Result<(), RustError> {
let Some(repo) = repos.get(index) else {
return Err(RustError::unrecoverable_str(format!(
"Repository index {index} out of range (expected id {expected_id}). \
settings.json was likely modified externally; please refresh."
)));
};
let actual = repo.id().or(repo.url().map(Url::as_str));
if actual != Some(expected_id) {
return Err(RustError::unrecoverable_str(format!(
"Repository at index {index} changed (expected id {expected_id}, found {actual:?}). \
settings.json was likely modified externally; please refresh."
)));
}
Ok(())
}
#[tauri::command]
#[specta::specta]
pub async fn environment_remove_repository(
settings: State<'_, SettingsState>,
packages: State<'_, PackagesState>,
io: State<'_, DefaultEnvironmentIo>,
id: String,
index: usize,
expected_id: String,
) -> Result<(), RustError> {
let mut settings = settings.load_mut(io.inner()).await?;
let removed = settings.remove_repo(|r| r.id() == Some(id.as_str()));
verify_repo_at_index(settings.get_user_repos(), index, &expected_id)?;
join_all(
removed
.iter()
.map(|x| async { io.remove_file(x.local_path()).await.ok() }),
)
.await;
let removed = settings.remove_repo_at_index(index);
if let Some(repo) = &removed {
io.remove_file(repo.local_path()).await.ok();
}
settings.save().await?;
@ -390,6 +417,35 @@ pub struct TauriRepositoryDescriptor {
pub headers: Headers,
}
#[derive(Deserialize, specta::Type)]
pub struct TauriUserRepositoryRef {
pub index: usize,
pub id: String,
}
#[tauri::command]
#[specta::specta]
pub async fn environment_reorder_repositories(
settings: State<'_, SettingsState>,
packages: State<'_, PackagesState>,
io: State<'_, DefaultEnvironmentIo>,
repos: Vec<TauriUserRepositoryRef>,
) -> Result<(), RustError> {
let mut settings = settings.load_mut(io.inner()).await?;
log::debug!("reorder user repositories: {} entries", repos.len());
let user_repos = settings.get_user_repos();
for r in &repos {
verify_repo_at_index(user_repos, r.index, &r.id)?;
}
let indices: Vec<usize> = repos.into_iter().map(|r| r.index).collect();
settings.reorder_user_repos_by_indices(&indices);
settings.save().await?;
packages.clear_cache();
Ok(())
}
#[tauri::command]
#[specta::specta]
pub async fn environment_import_repository_pick(

View file

@ -269,6 +269,14 @@ impl Settings {
self.vpm.retain_user_repos(|x| !condition(x))
}
pub fn remove_repo_at_index(&mut self, index: usize) -> Option<UserRepoSetting> {
self.vpm.remove_user_repo_at_index(index)
}
pub fn reorder_user_repos_by_indices(&mut self, indices: &[usize]) {
self.vpm.reorder_user_repos_by_indices(indices);
}
// auto configurations
/// Removes id-duplicated repositories

View file

@ -155,6 +155,32 @@ impl VpmSettings {
.collect::<Vec<_>>()
}
pub fn remove_user_repo_at_index(&mut self, index: usize) -> Option<UserRepoSetting> {
let repos = &mut self.parsed.user_repos;
if index < repos.len() {
Some(repos.remove(index))
} else {
None
}
}
pub fn reorder_user_repos_by_indices(&mut self, indices: &[usize]) {
let mut pool: Vec<Option<UserRepoSetting>> = std::mem::take(&mut self.parsed.user_repos)
.into_iter()
.map(Some)
.collect();
let mut result = Vec::with_capacity(pool.len());
for &idx in indices {
if let Some(slot) = pool.get_mut(idx)
&& let Some(repo) = slot.take()
{
result.push(repo);
}
}
result.extend(pool.into_iter().flatten());
self.parsed.user_repos = result;
}
pub(crate) fn add_user_repo(&mut self, repo: UserRepoSetting) {
self.parsed.user_repos.push(repo);
}