From 230a426ec7def62af1a277985c6570f2273d8872 Mon Sep 17 00:00:00 2001 From: Taylor Bombay Date: Wed, 9 Sep 2026 22:33:01 +0000 Subject: [PATCH] feat(repositories): add organization and filtering controls - Add persistent repository groups, aliases, pinning, and hidden state - Add search, project sections, context-menu actions, and organization dialog - Cover persistence, validation, storage errors, hidden repositories, and reordering --- .../App.repositoryOrganization.test.tsx | 136 ++++++++ src/renderer/App.tsx | 174 ++++++---- src/renderer/AppTestHarness.tsx | 2 +- src/renderer/RepositoryOrganizationDialog.tsx | 296 ++++++++++++++++++ src/renderer/repositoryOrganization.test.ts | 128 ++++++++ src/renderer/repositoryOrganization.ts | 239 ++++++++++++++ src/renderer/styles.css | 67 ++++ 7 files changed, 978 insertions(+), 64 deletions(-) create mode 100644 src/renderer/App.repositoryOrganization.test.tsx create mode 100644 src/renderer/RepositoryOrganizationDialog.tsx create mode 100644 src/renderer/repositoryOrganization.test.ts create mode 100644 src/renderer/repositoryOrganization.ts diff --git a/src/renderer/App.repositoryOrganization.test.tsx b/src/renderer/App.repositoryOrganization.test.tsx new file mode 100644 index 0000000..727f41b --- /dev/null +++ b/src/renderer/App.repositoryOrganization.test.tsx @@ -0,0 +1,136 @@ +// @vitest-environment jsdom +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { + createSummary, + githead, + repoPath, + repositoryRecents, + waitForRepositoryWorkspace, +} from "./AppTestHarness"; +import { App } from "./App"; +import { readRepositoryOrganization, REPOSITORY_ORGANIZATION_KEY } from "./repositoryOrganization"; + +const other = "D:\\Work\\Other"; +const third = "D:\\Archive\\Other"; +beforeEach(() => { + window.localStorage.removeItem(REPOSITORY_ORGANIZATION_KEY); + vi.mocked(githead.getRepoRecents).mockResolvedValue(repositoryRecents(repoPath, other, third)); +}); +afterEach(() => window.localStorage.removeItem(REPOSITORY_ORGANIZATION_KEY)); +const repositories = () => within(screen.getByRole("region", { name: "Repositories" })); + +describe("Repository organization", { timeout: 10_000 }, () => { + it("saves groups, aliases, pins and hidden state and restores them after remount", async () => { + const user = userEvent.setup(); + const mounted = render(); + await waitForRepositoryWorkspace(); + await user.click(screen.getByRole("button", { name: "Organize repositories" })); + const dialog = within(screen.getByRole("dialog")); + await user.type(dialog.getByRole("textbox", { name: "New group name" }), "Tools"); + await user.click(dialog.getByRole("button", { name: "Add group" })); + const aliases = dialog.getAllByRole("textbox", { name: "Display alias" }); + await user.type(aliases[1]!, "Builder"); + await user.selectOptions( + dialog.getAllByRole("combobox", { name: "Project group" })[1]!, + "Tools", + ); + await user.click(dialog.getAllByRole("checkbox", { name: "Pinned" })[0]!); + await user.click(dialog.getAllByRole("checkbox", { name: "Hidden" })[2]!); + await user.click(dialog.getByRole("button", { name: "Save changes" })); + expect(repositories().getByText("Pinned")).toBeTruthy(); + expect(repositories().getByText("Builder")).toBeTruthy(); + expect(repositories().queryByRole("button", { name: `Switch to ${third}` })).toBeNull(); + await user.click(repositories().getByRole("button", { name: "Tools, 1 repositories" })); + expect(repositories().queryByText("Builder")).toBeNull(); + mounted.unmount(); + render(); + await waitForRepositoryWorkspace(); + expect( + repositories() + .getByRole("button", { name: "Tools, 1 repositories" }) + .getAttribute("aria-expanded"), + ).toBe("false"); + await user.type( + repositories().getByRole("searchbox", { name: "Search repositories" }), + "builder", + ); + expect(repositories().getByText("Builder")).toBeTruthy(); + await user.clear(repositories().getByRole("searchbox", { name: "Search repositories" })); + expect(repositories().queryByText("Builder")).toBeNull(); + await user.click(repositories().getByRole("button", { name: "Show hidden (1)" })); + expect(repositories().getByRole("button", { name: `Switch to ${third}` })).toBeTruthy(); + }); + + it("searches hidden paths and switches without removing them", async () => { + window.localStorage.setItem( + REPOSITORY_ORGANIZATION_KEY, + JSON.stringify({ version: 1, repositories: { [third]: { hidden: true } } }), + ); + vi.mocked(githead.getRepoSummary).mockImplementation(async (path) => + createSummary({ repoPath: path }), + ); + render(); + await waitForRepositoryWorkspace(); + await userEvent.setup().type(repositories().getByRole("searchbox"), "archive"); + await userEvent + .setup() + .click(repositories().getByRole("button", { name: `Switch to ${third}` })); + await waitFor(() => expect(githead.addRepoRecent).toHaveBeenCalledWith({ repoPath: third })); + expect(githead.removeRepoRecent).not.toHaveBeenCalled(); + }); + + it("cancels organization edits and does not save an invalid group name", async () => { + const user = userEvent.setup(); + render(); + await waitForRepositoryWorkspace(); + await user.click(screen.getByRole("button", { name: "Organize repositories" })); + await user.type(screen.getByRole("textbox", { name: "New group name" }), "Temporary"); + await user.click(screen.getByRole("button", { name: "Add group" })); + await user.clear(screen.getByRole("textbox", { name: "Group name 1" })); + expect(screen.getByRole("button", { name: "Save changes" }).hasAttribute("disabled")).toBe( + true, + ); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(readRepositoryOrganization().projects).toEqual([]); + }); + + it("reports storage failures and keeps the saved organization unchanged", async () => { + const user = userEvent.setup(); + render(); + await waitForRepositoryWorkspace(); + await user.click(screen.getByRole("button", { name: "Organize repositories" })); + await user.type(screen.getAllByRole("textbox", { name: "Display alias" })[0]!, "Daily"); + const write = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("Storage full"); + }); + await user.click(screen.getByRole("button", { name: "Save changes" })); + expect(within(screen.getByRole("dialog")).getByRole("alert").textContent).toContain( + "Unable to save", + ); + expect(readRepositoryOrganization().repositories).toEqual({}); + write.mockRestore(); + await user.click(screen.getByRole("button", { name: "Save changes" })); + expect(repositories().getByText("Daily")).toBeTruthy(); + }); + + it("reorders within the visible project across unrelated repositories", async () => { + window.localStorage.setItem( + REPOSITORY_ORGANIZATION_KEY, + JSON.stringify({ + version: 1, + projects: [{ id: "tools", name: "Tools" }], + repositories: { [repoPath]: { projectId: "tools" }, [third]: { projectId: "tools" } }, + }), + ); + render(); + await waitForRepositoryWorkspace(); + await act(async () => + fireEvent.keyDown(repositories().getByRole("button", { name: `Reorder ${repoPath}` }), { + key: "ArrowDown", + }), + ); + expect(githead.reorderRepoRecents).toHaveBeenCalledWith([other, third, repoPath]); + }); +}); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 748e27c..2add99a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,3 +1,5 @@ +import { RepositoryOrganizationDialog, RepositoryOrganizationMenu } from "./RepositoryOrganizationDialog"; +import { repositoryName as getRepoDisplayName, organizeRepositories, repositoryLabels, repositoryPreference, useRepositoryOrganization, type RepositoryPreference } from "./repositoryOrganization"; import { CheckoutTagDialog } from "./CheckoutTagDialog"; import type { GitTagCheckoutRequest } from "../shared/types"; import { @@ -35,6 +37,10 @@ import { RotateCcw, Save, SearchX, + Search, + SlidersHorizontal, + Pin, + EyeOff, Settings, ShieldAlert, Sparkles, @@ -8696,6 +8702,9 @@ interface RepositoryListProps { } interface RecentRepositoryRowProps { + label?: { name: string; detail: string }; + organizationMenu?: ReactNode; + hidden?: boolean; active?: boolean; disabled: boolean; dropPosition: RepositoryDropPosition | null; @@ -8743,7 +8752,20 @@ function RepositoryList({ const repositoryRowsRef = useRef(new Map()); const [draggedRepoPath, setDraggedRepoPath] = useState(null); const [dropTarget, setDropTarget] = useState<{ repoPath: string; position: RepositoryDropPosition } | null>(null); - const [expandedGroupIds, setExpandedGroupIds] = useState>(new Set()); + const { organization, update: updateOrganization, saveError } = useRepositoryOrganization(); + const [query, setQuery] = useState(""); + const [showHidden, setShowHidden] = useState(false); + const [organizerQuery, setOrganizerQuery] = useState(null); + const orderedPaths = useMemo(() => groups?.length ? groups.map((group) => group.anchorPath) : repoPaths, [groups, repoPaths]); + const groupsByPath = useMemo(() => new Map(groups?.map((group) => [getRepoPathKey(group.anchorPath), group]) ?? []), [groups]); + const labels = useMemo(() => repositoryLabels(orderedPaths, organization), [orderedPaths, organization]); + const sections = useMemo(() => organizeRepositories(orderedPaths, groups ?? [], organization, query, showHidden, repoPath), [orderedPaths, groups, organization, query, showHidden, repoPath]); + const visiblePaths = sections.flatMap((section) => section.collapsed ? [] : section.paths); + const hiddenCount = orderedPaths.filter((path) => repositoryPreference(organization, path).hidden).length; + const changePreference = (path: string, patch: Partial) => updateOrganization((current) => ({ + ...current, repositories: { ...current.repositories, [getRepoPathKey(path)]: { ...repositoryPreference(current, path), ...patch } } + })); + const organizationMenu = (path: string) => { changePreference(path, patch); }} onOrganize={() => setOrganizerQuery(path)} />; const [removeTarget, setRemoveTarget] = useState(null); const [recoveryTarget, setRecoveryTarget] = useState<{ repoPath: string; reason: string } | null>(null); const [recoveryError, setRecoveryError] = useState(""); @@ -8762,33 +8784,27 @@ function RepositoryList({ }); }, [repoPath, repoPaths]); - const moveRepository = useCallback((fromRepoPath: string, toRepoPath: string, position: RepositoryDropPosition): void => { + const moveRepository = (fromRepoPath: string, toRepoPath: string, position: RepositoryDropPosition): void => { if (isSameRepoPath(fromRepoPath, toRepoPath)) { return; } - const next = moveRepoPath(repoPaths, fromRepoPath, toRepoPath, position); - if (!areRepoPathListsEqual(repoPaths, next)) { - onReorder(next); - } - }, [onReorder, repoPaths]); - - const moveRepositoryByKeyboard = useCallback((moveRepoPathValue: string, direction: RepositoryMoveDirection): void => { - const index = repoPaths.findIndex((candidate) => isSameRepoPath(candidate, moveRepoPathValue)); - const targetIndex = direction === "up" ? index - 1 : index + 1; - if (index < 0 || targetIndex < 0 || targetIndex >= repoPaths.length) { - return; - } - - const next = [...repoPaths]; - const [moved] = next.splice(index, 1); - if (!moved) { - return; + const targetPreference = repositoryPreference(organization, toRepoPath); + const sourcePreference = repositoryPreference(organization, fromRepoPath); + if (sourcePreference.pinned !== targetPreference.pinned || (!targetPreference.pinned && sourcePreference.projectId !== targetPreference.projectId)) { + if (!changePreference(fromRepoPath, { pinned: targetPreference.pinned, ...(!targetPreference.pinned ? { projectId: targetPreference.projectId } : {}) })) return; } + const next = moveRepoPath(repoPaths, fromRepoPath, toRepoPath, position); + if (!areRepoPathListsEqual(repoPaths, next)) onReorder(next); + }; - next.splice(targetIndex, 0, moved); - onReorder(next); - }, [onReorder, repoPaths]); + const moveRepositoryByKeyboard = (path: string, direction: RepositoryMoveDirection): void => { + const section = sections.find((item) => item.paths.some((candidate) => isSameRepoPath(candidate, path))); + if (!section) return; + const index = section.paths.findIndex((candidate) => isSameRepoPath(candidate, path)); + const target = section.paths[direction === "up" ? index - 1 : index + 1]; + if (target) moveRepository(path, target, direction === "up" ? "before" : "after"); + }; const startDrag = (event: DragEvent, dragRepoPath: string): void => { event.dataTransfer.effectAllowed = "move"; @@ -8854,46 +8870,29 @@ function RepositoryList({ moveRepository(sourceRepoPath, target.repoPath, getDropPosition(event.clientY, target.element)); }; - return ( - <> -
-
-

Repositories

- {headingAction} -
-
{ - const target = getDragTarget(event); - if (target) { - updateDropTarget(event, target.repoPath, target.element); - } - }} - onDrop={(event) => { - const target = getDragTarget(event); - if (target) { - dropRepository(event, target.repoPath, target.element); - } - }} - > - {groups?.length ? groups.map((group) => { + const renderRepository = (recentRepoPath: string): ReactNode => { + const group = groupsByPath.get(getRepoPathKey(recentRepoPath)); + if (group) { const key = getRepoPathKey(group.anchorPath); const active = group.worktrees.some((worktree) => isSameRepoPath(worktree.path, repoPath)) || isSameRepoPath(group.anchorPath, repoPath); const currentDropPosition = dropTarget && isSameRepoPath(dropTarget.repoPath, group.anchorPath) ? dropTarget.position : null; return
{group.anchorPath}{organizationMenu} onOpenRepositorySettings(group.lastUsedPath)}>Repository Settings… onShowInExplorer(group.anchorPath)}>Show in ExplorerRemove Repository {worktrees.map((worktree) => { const workspaceActive = isSameRepoPath(worktree.path, activeRepoPath); const unavailable = worktree.isBare || worktree.prunable; @@ -9079,6 +9127,9 @@ function RepositoryGroupRow({ group, activeRepoPath, active, expanded, disabled, } function RecentRepositoryRow({ + label, + organizationMenu, + hidden, active = false, disabled, dropPosition, @@ -9098,7 +9149,7 @@ function RecentRepositoryRow({ onShowInExplorer, onOpenRepositorySettings }: RecentRepositoryRowProps): ReactNode { - const displayName = getRepoDisplayName(repoPath); + const displayName = label?.name ?? getRepoDisplayName(repoPath); const syncDescription = formatRepoSyncStatusDescription(syncStatus); const rowClassName = [ "repo-recent-row", @@ -9158,7 +9209,8 @@ function RecentRepositoryRow({ > {syncStatus?.isValid ? : null} - {displayName} + {displayName}{label?.detail ? {label.detail} : null} + {hidden ? : null} @@ -9170,6 +9222,7 @@ function RecentRepositoryRow({ {repoPath} + {organizationMenu} onOpenRepositorySettings(repoPath)}> Repository Settings… @@ -9628,7 +9681,7 @@ function RepositoryPanel({ }; return ( -