From 2d6bd90ac8762b061b4868361f6f00658caa30cf Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 19 Apr 2026 16:57:51 -0400 Subject: [PATCH 01/21] feat(tabs): adds custom tab schema and routing Components 1-6 of custom tabs implementation: - CustomTabSchema + BUILTIN_TAB_IDS + isBuiltinTab in schemas.ts - Config CRUD: addCustomTab, updateCustomTab, removeCustomTab, reorderCustomTab - View state: customTabFilters, expandedRepos widened to z.record, dynamic helpers - TabBar: TabId=string, custom tab rendering, + button, edit pencil - DashboardPage: custom tab routing, exclusiveOwnership, visibleIssues/PRs/Runs - Shared filter predicates: isIssueVisible, isPrVisible, isRunVisible - FilterBar: hideOrgRepo prop for custom tab context - 1863 tests (88 new), typecheck clean --- src/app/components/dashboard/ActionsTab.tsx | 13 +- .../components/dashboard/DashboardPage.tsx | 227 ++++++++++++-- src/app/components/dashboard/IssuesTab.tsx | 14 +- .../components/dashboard/PullRequestsTab.tsx | 12 +- src/app/components/layout/FilterBar.tsx | 95 +++--- src/app/components/layout/TabBar.tsx | 104 +++++-- src/app/lib/filters.ts | 41 +++ src/app/stores/config.ts | 61 +++- src/app/stores/view.ts | 100 ++++-- src/shared/schemas.ts | 24 +- tests/components/layout/TabBar.test.tsx | 135 +++++++- tests/lib/filters.test.ts | 236 ++++++++++++++ tests/stores/config.test.ts | 291 +++++++++++++++++- tests/stores/view.test.ts | 150 ++++++++- 14 files changed, 1332 insertions(+), 171 deletions(-) create mode 100644 src/app/lib/filters.ts create mode 100644 tests/lib/filters.test.ts diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index 577ea195..d4deed4f 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -2,6 +2,7 @@ import { createEffect, createMemo, For, Show } from "solid-js"; import { createStore } from "solid-js/store"; import type { WorkflowRun } from "../../services/api"; import { viewState, setViewState, setTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type ActionsFilterField } from "../../stores/view"; +import { isRunVisible } from "../../lib/filters"; import WorkflowSummaryCard from "./WorkflowSummaryCard"; import IgnoreBadge from "./IgnoreBadge"; import SkeletonRows from "../shared/SkeletonRows"; @@ -165,19 +166,13 @@ export default function ActionsTab(props: ActionsTabProps) { } const filteredRuns = createMemo(() => { - const { org, repo } = viewState.globalFilter; - const ignoredIds = new Set( - ignoredWorkflowRuns() - .map((i) => i.id) - ); + const ignoredIds = new Set(ignoredWorkflowRuns().map((i) => i.id)); + const globalFilter = viewState.globalFilter; const conclusionFilter = viewState.tabFilters.actions.conclusion; const eventFilter = viewState.tabFilters.actions.event; return props.workflowRuns.filter((run) => { - if (ignoredIds.has(run.id)) return false; - if (!viewState.showPrRuns && run.isPrRun) return false; - if (org && !run.repoFullName.startsWith(`${org}/`)) return false; - if (repo && run.repoFullName !== repo) return false; + if (!isRunVisible(run, { ignoredIds, showPrRuns: viewState.showPrRuns, globalFilter })) return false; if (conclusionFilter !== "all") { if (conclusionFilter === "running") { diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 140d562f..6d9f93ed 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -1,4 +1,4 @@ -import { createSignal, createMemo, createEffect, Show, Switch, Match, onMount, onCleanup } from "solid-js"; +import { createSignal, createMemo, createEffect, Show, Switch, Match, onMount, onCleanup, untrack } from "solid-js"; import { createStore, produce, unwrap } from "solid-js/store"; import Header from "../layout/Header"; import TabBar, { TabId } from "../layout/TabBar"; @@ -8,8 +8,8 @@ import IssuesTab from "./IssuesTab"; import PullRequestsTab from "./PullRequestsTab"; import TrackedTab from "./TrackedTab"; import PersonalSummaryStrip from "./PersonalSummaryStrip"; -import { config, setConfig, type TrackedUser } from "../../stores/config"; -import { viewState, updateViewState, setSortPreference, pruneClosedTrackedItems } from "../../stores/view"; +import { config, setConfig, getCustomTab, isBuiltinTab, type TrackedUser } from "../../stores/config"; +import { viewState, updateViewState, setSortPreference, pruneClosedTrackedItems, removeCustomTabState } from "../../stores/view"; import type { SortOption } from "../shared/SortDropdown"; import type { Issue, PullRequest, WorkflowRun } from "../../services/api"; import { fetchOrgs } from "../../services/api"; @@ -30,6 +30,7 @@ import { formatCount } from "../../lib/format"; import { setsEqual } from "../../lib/collections"; import { withScrollLock } from "../../lib/scroll"; import { Tooltip } from "../shared/Tooltip"; +import { isIssueVisible, isPrVisible, isRunVisible } from "../../lib/filters"; const globalSortOptions: SortOption[] = [ { label: "Repo", field: "repo", type: "text" }, @@ -303,17 +304,23 @@ export default function DashboardPage() { function resolveInitialTab(): TabId { const tab = config.rememberLastTab ? viewState.lastActiveTab : config.defaultTab; if (tab === "tracked" && !config.enableTracking) return "issues"; + // Validate custom tab still exists; fall back to "issues" if stale + if (!isBuiltinTab(tab) && !config.customTabs.some((t) => t.id === tab)) return "issues"; return tab; } const [activeTab, setActiveTab] = createSignal(resolveInitialTab()); function handleTabChange(tab: TabId) { + // Reject invalid tab IDs to prevent persisting stale state + if (!isBuiltinTab(tab) && !config.customTabs.some((t) => t.id === tab)) return; setActiveTab(tab); updateViewState({ lastActiveTab: tab }); } const [clockTick, setClockTick] = createSignal(0); + const [showCustomTabModal, setShowCustomTabModal] = createSignal(false); + const [editingTabId, setEditingTabId] = createSignal(null); // Redirect away from tracked tab when tracking is disabled at runtime createEffect(() => { @@ -322,6 +329,14 @@ export default function DashboardPage() { } }); + // Redirect away from a custom tab that was deleted while active + createEffect(() => { + const tab = activeTab(); + if (!isBuiltinTab(tab) && !config.customTabs.some((t) => t.id === tab)) { + handleTabChange("issues"); + } + }); + // Auto-prune tracked items that are closed/merged (absent from is:open results) createEffect(() => { // IMPORTANT: Access reactive store fields BEFORE early-return guards @@ -448,6 +463,67 @@ export default function DashboardPage() { const refreshTick = createMemo(() => (dashboardData.lastRefreshedAt?.getTime() ?? 0) + clockTick()); + // Eagerly compute scoped data for ALL custom tabs (not just the active one). + // exclusiveOwnership depends on customTabData for all exclusive tabs — lazy + // per-active-tab computation would break exclusivity. + const customTabData = createMemo(() => { + const result: Record = {}; + for (const tab of config.customTabs) { + const orgSet = tab.orgScope.length > 0 ? new Set(tab.orgScope.map((o) => o.toLowerCase())) : null; + const repoSet = tab.repoScope.length > 0 ? new Set(tab.repoScope.map((r) => r.fullName)) : null; + const matchesScope = (repoFullName: string) => { + if (repoSet && repoSet.has(repoFullName)) return true; + if (orgSet && orgSet.has(repoFullName.split("/")[0].toLowerCase())) return true; + return !orgSet && !repoSet; // no scope = all repos + }; + result[tab.id] = { + issues: dashboardData.issues.filter((i) => matchesScope(i.repoFullName)), + pullRequests: dashboardData.pullRequests.filter((p) => matchesScope(p.repoFullName)), + workflowRuns: dashboardData.workflowRuns.filter((w) => matchesScope(w.repoFullName)), + }; + } + return result; + }); + + // Item-level exclusive ownership: first exclusive tab claiming an item wins. + // Only claims items matching the tab's baseType (an exclusive Issues tab must + // not hide PRs or workflow runs from their respective tabs). + const exclusiveOwnership = createMemo(() => { + const issueOwner = new Map(); + const prOwner = new Map(); + const runOwner = new Map(); + for (const tab of config.customTabs) { + if (!tab.exclusive) continue; + const data = customTabData()[tab.id]; + if (!data) continue; + if (tab.baseType === "issues") { + for (const i of data.issues) if (!issueOwner.has(i.id)) issueOwner.set(i.id, tab.id); + } else if (tab.baseType === "pullRequests") { + for (const p of data.pullRequests) if (!prOwner.has(p.id)) prOwner.set(p.id, tab.id); + } else { + for (const w of data.workflowRuns) if (!runOwner.has(w.id)) runOwner.set(w.id, tab.id); + } + } + return { issues: issueOwner, pullRequests: prOwner, actions: runOwner }; + }); + + function isItemVisibleOnTab(ownerMap: Map, itemId: number, viewingTabId: string): boolean { + const owner = ownerMap.get(itemId); + if (!owner) return true; // not claimed by any exclusive tab + return owner === viewingTabId; // only visible on its owning tab + } + + // Visible data for built-in tabs — filters out exclusively-owned items + const visibleIssues = createMemo(() => + dashboardData.issues.filter((i) => isItemVisibleOnTab(exclusiveOwnership().issues, i.id, "issues")) + ); + const visiblePullRequests = createMemo(() => + dashboardData.pullRequests.filter((p) => isItemVisibleOnTab(exclusiveOwnership().pullRequests, p.id, "pullRequests")) + ); + const visibleWorkflowRuns = createMemo(() => + dashboardData.workflowRuns.filter((w) => isItemVisibleOnTab(exclusiveOwnership().actions, w.id, "actions")) + ); + const tabCounts = createMemo(() => { const { org, repo } = viewState.globalFilter; const ignoredByType = (type: string) => @@ -456,32 +532,66 @@ export default function DashboardPage() { const ignoredIssues = ignoredByType("issue"); const ignoredPRs = ignoredByType("pullRequest"); const ignoredRuns = ignoredByType("workflowRun"); + const ownership = exclusiveOwnership(); + + const builtinFilter = { org, repo }; + const customCounts: Record = {}; + for (const tab of config.customTabs) { + const data = customTabData()[tab.id]; + if (!data) continue; + if (tab.baseType === "issues") { + customCounts[tab.id] = data.issues.filter((i) => + isItemVisibleOnTab(ownership.issues, i.id, tab.id) && + isIssueVisible(i, { ignoredIds: ignoredIssues, hideDepDashboard: viewState.hideDepDashboard, globalFilter: null }) + ).length; + } else if (tab.baseType === "pullRequests") { + customCounts[tab.id] = data.pullRequests.filter((p) => + isItemVisibleOnTab(ownership.pullRequests, p.id, tab.id) && + isPrVisible(p, { ignoredIds: ignoredPRs, globalFilter: null }) + ).length; + } else { + customCounts[tab.id] = data.workflowRuns.filter((w) => + isItemVisibleOnTab(ownership.actions, w.id, tab.id) && + isRunVisible(w, { ignoredIds: ignoredRuns, showPrRuns: viewState.showPrRuns, globalFilter: null }) + ).length; + } + } return { - issues: dashboardData.issues.filter((i) => { - if (ignoredIssues.has(i.id)) return false; - if (viewState.hideDepDashboard && i.title === "Dependency Dashboard") return false; - if (repo && i.repoFullName !== repo) return false; - if (org && !i.repoFullName.startsWith(org + "/")) return false; - return true; - }).length, - pullRequests: dashboardData.pullRequests.filter((p) => { - if (ignoredPRs.has(p.id)) return false; - if (repo && p.repoFullName !== repo) return false; - if (org && !p.repoFullName.startsWith(org + "/")) return false; - return true; - }).length, - actions: dashboardData.workflowRuns.filter((w) => { - if (ignoredRuns.has(w.id)) return false; - if (!viewState.showPrRuns && w.isPrRun) return false; - if (repo && w.repoFullName !== repo) return false; - if (org && !w.repoFullName.startsWith(org + "/")) return false; - return true; - }).length, + issues: visibleIssues().filter((i) => + isIssueVisible(i, { ignoredIds: ignoredIssues, hideDepDashboard: viewState.hideDepDashboard, globalFilter: builtinFilter }) + ).length, + pullRequests: visiblePullRequests().filter((p) => + isPrVisible(p, { ignoredIds: ignoredPRs, globalFilter: builtinFilter }) + ).length, + actions: visibleWorkflowRuns().filter((w) => + isRunVisible(w, { ignoredIds: ignoredRuns, showPrRuns: viewState.showPrRuns, globalFilter: builtinFilter }) + ).length, ...(config.enableTracking ? { tracked: viewState.trackedItems.length } : {}), + ...customCounts, }; }); + // Reactive cleanup: prune orphaned view state when customTabs list changes + createEffect(() => { + const activeIds = new Set(config.customTabs.map((t) => t.id)); + const staleIds = untrack(() => { + const keys = new Set([ + ...Object.keys(viewState.customTabFilters), + ...Object.keys(viewState.expandedRepos).filter((k) => !isBuiltinTab(k)), + ]); + return [...keys].filter((id) => !activeIds.has(id)); + }); + for (const id of staleIds) removeCustomTabState(id); + }); + + // Memo for the active custom tab definition (null when a built-in tab is active) + const activeCustomTab = createMemo(() => { + const id = activeTab(); + if (isBuiltinTab(id)) return null; + return getCustomTab(id) ?? null; + }); + // Push dashboard data into the MCP relay snapshot on each full refresh. // Tracks lastRefreshedAt (always updated alongside data arrays in pollFetch). // Hot poll updates are intentionally excluded — relay reflects full-refresh data only. @@ -531,6 +641,9 @@ export default function DashboardPage() { onTabChange={handleTabChange} counts={tabCounts()} enableTracking={config.enableTracking} + customTabs={config.customTabs.map((t) => ({ id: t.id, name: t.name }))} + onAddTab={() => setShowCustomTabModal(true)} + onEditTab={(id) => { setEditingTabId(id); setShowCustomTabModal(true); }} /> setSortPreference(field, dir)} + hideOrgRepo={!isBuiltinTab(activeTab())} /> @@ -548,7 +662,7 @@ export default function DashboardPage() { + {/* TrackedTab intentionally receives unfiltered dashboardData — it bypasses exclusivity */} 0} configRepoNames={configRepoNames()} @@ -590,8 +705,68 @@ export default function DashboardPage() { hotPollingRunIds={hotPollingRunIds()} /> + + {(tab) => { + // Apply exclusivity: exclude items owned by OTHER exclusive tabs + const data = createMemo(() => { + const raw = customTabData()[tab().id]; + if (!raw) return { issues: [] as typeof dashboardData.issues, pullRequests: [] as typeof dashboardData.pullRequests, workflowRuns: [] as typeof dashboardData.workflowRuns }; + const ownership = exclusiveOwnership(); + return { + issues: raw.issues.filter((i) => isItemVisibleOnTab(ownership.issues, i.id, tab().id)), + pullRequests: raw.pullRequests.filter((p) => isItemVisibleOnTab(ownership.pullRequests, p.id, tab().id)), + workflowRuns: raw.workflowRuns.filter((w) => isItemVisibleOnTab(ownership.actions, w.id, tab().id)), + }; + }); + return ( + + + + + + + + + 0} + configRepoNames={configRepoNames()} + refreshTick={refreshTick()} + hotPollingRunIds={hotPollingRunIds()} + /> + + + ); + }} + + + {/* CustomTabModal — placeholder until Task 6 implements the component. + editingTabId() is read here to wire modal data; Task 6 replaces this div. */} + +
+