From 1f7c2aad48f44ab296c47922c987dbb09b469224 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 19 Apr 2026 13:24:00 -0400 Subject: [PATCH 1/4] fix(view): moveLockedRepo skips invisible repos during reorder Add optional visibleRepoNames parameter to moveLockedRepo. When provided, scans in the given direction to find the next visible locked repo and repositions the source adjacent to it. Falls back to adjacent swap when the parameter is omitted (backward compatible). --- src/app/stores/view.ts | 37 ++++++++++++++++++---- tests/stores/view-lock.test.ts | 58 ++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/app/stores/view.ts b/src/app/stores/view.ts index 63029567..ff5f9b42 100644 --- a/src/app/stores/view.ts +++ b/src/app/stores/view.ts @@ -341,17 +341,42 @@ export function unlockRepo(repoFullName: string): void { export function moveLockedRepo( repoFullName: string, - direction: "up" | "down" + direction: "up" | "down", + visibleRepoNames?: ReadonlySet ): void { setViewState(produce((draft) => { const arr = draft.lockedRepos; const idx = arr.indexOf(repoFullName); if (idx === -1) return; - const targetIdx = direction === "up" ? idx - 1 : idx + 1; - if (targetIdx < 0 || targetIdx >= arr.length) return; - const tmp = arr[idx]; - arr[idx] = arr[targetIdx]; - arr[targetIdx] = tmp; + + if (!visibleRepoNames) { + // Backward-compatible adjacent swap + const targetIdx = direction === "up" ? idx - 1 : idx + 1; + if (targetIdx < 0 || targetIdx >= arr.length) return; + const tmp = arr[idx]; + arr[idx] = arr[targetIdx]; + arr[targetIdx] = tmp; + return; + } + + // Find the next visible repo in the given direction + const step = direction === "up" ? -1 : 1; + let targetIdx = -1; + for (let i = idx + step; i >= 0 && i < arr.length; i += step) { + if (visibleRepoNames.has(arr[i])) { + targetIdx = i; + break; + } + } + if (targetIdx === -1) return; + + // Remove from old position and insert adjacent to target + arr.splice(idx, 1); + // After removal, targetIdx shifts if source was before it + const insertIdx = direction === "up" + ? targetIdx - (idx < targetIdx ? 1 : 0) + : targetIdx - (idx < targetIdx ? 1 : 0) + 1; + arr.splice(insertIdx, 0, repoFullName); })); } diff --git a/tests/stores/view-lock.test.ts b/tests/stores/view-lock.test.ts index 3d0370ae..e93f889c 100644 --- a/tests/stores/view-lock.test.ts +++ b/tests/stores/view-lock.test.ts @@ -90,6 +90,64 @@ describe("view lock store", () => { moveLockedRepo("org/repo-z", "up"); expect(viewState.lockedRepos).toEqual(["org/repo-a"]); }); + + describe("with visibleRepoNames", () => { + it("skips one invisible repo when moving up", () => { + lockRepo("org/a"); + lockRepo("org/hidden"); + lockRepo("org/c"); + moveLockedRepo("org/c", "up", new Set(["org/a", "org/c"])); + expect(viewState.lockedRepos).toEqual(["org/c", "org/a", "org/hidden"]); + }); + + it("skips one invisible repo when moving down", () => { + lockRepo("org/a"); + lockRepo("org/hidden"); + lockRepo("org/c"); + moveLockedRepo("org/a", "down", new Set(["org/a", "org/c"])); + expect(viewState.lockedRepos).toEqual(["org/hidden", "org/c", "org/a"]); + }); + + it("skips multiple invisible repos", () => { + lockRepo("org/a"); + lockRepo("org/h1"); + lockRepo("org/h2"); + lockRepo("org/d"); + moveLockedRepo("org/d", "up", new Set(["org/a", "org/d"])); + expect(viewState.lockedRepos).toEqual(["org/d", "org/a", "org/h1", "org/h2"]); + }); + + it("no-op when already first visible and moving up", () => { + lockRepo("org/h1"); + lockRepo("org/a"); + lockRepo("org/b"); + moveLockedRepo("org/a", "up", new Set(["org/a", "org/b"])); + expect(viewState.lockedRepos).toEqual(["org/h1", "org/a", "org/b"]); + }); + + it("no-op when already last visible and moving down", () => { + lockRepo("org/a"); + lockRepo("org/b"); + lockRepo("org/h1"); + moveLockedRepo("org/b", "down", new Set(["org/a", "org/b"])); + expect(viewState.lockedRepos).toEqual(["org/a", "org/b", "org/h1"]); + }); + + it("falls back to adjacent swap when visibleRepoNames is undefined", () => { + lockRepo("org/a"); + lockRepo("org/b"); + lockRepo("org/c"); + moveLockedRepo("org/c", "up"); + expect(viewState.lockedRepos).toEqual(["org/a", "org/c", "org/b"]); + }); + + it("no-op when visibleRepoNames is empty set", () => { + lockRepo("org/a"); + lockRepo("org/b"); + moveLockedRepo("org/a", "down", new Set()); + expect(viewState.lockedRepos).toEqual(["org/a", "org/b"]); + }); + }); }); describe("pruneLockedRepos", () => { From 09185326973fd492d5d600e6b7a372f7397105e1 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 19 Apr 2026 13:24:45 -0400 Subject: [PATCH 2/4] fix(ui): RepoLockControls uses visible locked repos for reorder Add visibleLockedRepos prop to RepoLockControls. isFirst/isLast are now computed from the visible subset, so buttons correctly disable when hidden repos are at the boundary. Each tab derives visibleLockedRepos from its repoGroups memo intersected with viewState.lockedRepos. --- src/app/components/dashboard/ActionsTab.tsx | 6 +- src/app/components/dashboard/IssuesTab.tsx | 6 +- .../components/dashboard/PullRequestsTab.tsx | 6 +- .../components/shared/RepoLockControls.tsx | 15 ++--- .../shared/RepoLockControls.test.tsx | 63 ++++++++++++++----- 5 files changed, 72 insertions(+), 24 deletions(-) diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index 577ea195..6d587ed5 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -204,6 +204,10 @@ export default function ActionsTab(props: ActionsTabProps) { const repoGroups = createMemo(() => orderRepoGroups(groupRuns(filteredRuns()), viewState.lockedRepos) ); + const visibleLockedRepos = createMemo(() => { + const rendered = new Set(repoGroups().map(g => g.repoFullName)); + return viewState.lockedRepos.filter(name => rendered.has(name)); + }); createEffect(() => { const names = activeRepoNames(); @@ -302,7 +306,7 @@ export default function ActionsTab(props: ActionsTabProps) { trailing={ <> - + } collapsedSummary={ diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 60013914..d688f16b 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -194,6 +194,10 @@ export default function IssuesTab(props: IssuesTabProps) { const repoGroups = createMemo(() => orderRepoGroups(groupByRepo(filteredSorted()), viewState.lockedRepos) ); + const visibleLockedRepos = createMemo(() => { + const rendered = new Set(repoGroups().map(g => g.repoFullName)); + return viewState.lockedRepos.filter(name => rendered.has(name)); + }); const pageLayout = createMemo(() => computePageLayout(repoGroups(), config.itemsPerPage)); const pageCount = createMemo(() => pageLayout().pageCount); const pageGroups = createMemo(() => @@ -367,7 +371,7 @@ export default function IssuesTab(props: IssuesTabProps) { trailing={ <> - + } collapsedSummary={ diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 094b7f1e..89ef011c 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -291,6 +291,10 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { const repoGroups = createMemo(() => orderRepoGroups(groupByRepo(filteredSorted()), viewState.lockedRepos) ); + const visibleLockedRepos = createMemo(() => { + const rendered = new Set(repoGroups().map(g => g.repoFullName)); + return viewState.lockedRepos.filter(name => rendered.has(name)); + }); const pageLayout = createMemo(() => computePageLayout(repoGroups(), config.itemsPerPage)); const pageCount = createMemo(() => pageLayout().pageCount); const pageGroups = createMemo(() => @@ -474,7 +478,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { trailing={ <> - + } collapsedSummary={ diff --git a/src/app/components/shared/RepoLockControls.tsx b/src/app/components/shared/RepoLockControls.tsx index be6cbf25..7ff6967e 100644 --- a/src/app/components/shared/RepoLockControls.tsx +++ b/src/app/components/shared/RepoLockControls.tsx @@ -5,16 +5,17 @@ import { withFlipAnimation } from "../../lib/scroll"; interface RepoLockControlsProps { repoFullName: string; + visibleLockedRepos: string[]; } export default function RepoLockControls(props: RepoLockControlsProps) { const lockInfo = createMemo(() => { - const list = viewState.lockedRepos; - const idx = list.indexOf(props.repoFullName); + const isLocked = viewState.lockedRepos.indexOf(props.repoFullName) !== -1; + const visIdx = props.visibleLockedRepos.indexOf(props.repoFullName); return { - isLocked: idx !== -1, - isFirst: idx === 0, - isLast: idx !== -1 && idx === list.length - 1, + isLocked, + isFirst: visIdx === 0, + isLast: visIdx !== -1 && visIdx === props.visibleLockedRepos.length - 1, }; }); @@ -52,7 +53,7 @@ export default function RepoLockControls(props: RepoLockControlsProps) {