diff --git a/README.md b/README.md index 89e73cde..14de0e0f 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,10 @@ Shimmer animations on items being updated by the hot poll, flash highlights when Star counts appear in repo group headers, fetched as part of the standard data refresh. +### Custom Tabs + +Create named filtered views over the existing Issues, PRs, and Actions data. Each custom tab has a name, a base type (Issues, PRs, or Actions), an optional org/repo scope, and optional filter presets. An "exclusive" toggle hides matching items from the standard tabs so they only appear in the custom tab. Up to 10 custom tabs can be created. Manage them via the "+" button in the tab bar or in **Settings > Custom Tabs**. + ### Themes 9 themes: auto (follows system), corporate, cupcake, light, nord, dim, dracula, dark, forest. Theme is applied immediately on selection with no page reload. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 4af99436..a3ad1d28 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -11,6 +11,7 @@ GitHub Tracker is a dashboard that aggregates open issues, pull requests, and Gi - [Organization Access](#organization-access) - [Dashboard Overview](#dashboard-overview) - [Tab Structure](#tab-structure) + - [Custom Tabs](#custom-tabs) - [Personal Summary Strip](#personal-summary-strip) - [Repo Grouping and Expand/Collapse](#repo-grouping-and-expandcollapse) - [Scope Filter](#scope-filter) @@ -73,7 +74,7 @@ OAuth sign-in uses your existing GitHub org memberships. If a private organizati ### Tab Structure -The dashboard has three tabs by default, with an optional fourth: +The dashboard has three built-in tabs by default, with optional additional tabs: | Tab | Contents | |-----|----------| @@ -81,9 +82,25 @@ The dashboard has three tabs by default, with an optional fourth: | **Pull Requests** | Open PRs where you are the author, reviewer, or assignee | | **Actions** | Recent workflow runs for your selected repos | | **Tracked** | Manually pinned issues and PRs (opt-in via Settings) | +| **Custom tabs** | Named filtered views you define (up to 10, see [Custom Tabs](#custom-tabs)) | The active tab is remembered across page loads by default. You can set a fixed default tab in Settings. +### Custom Tabs + +Custom tabs let you create named, filtered views over the Issues, PRs, or Actions data. For example, you could create a "My PRs" tab showing only PRs you authored, or a "Needs review" tab scoped to a single org. + +**Creating a tab:** Click the **+** button at the right end of the tab bar (desktop) or go to **Settings > Custom Tabs** (mobile or desktop). Each tab requires: + +- **Name** — displayed in the tab bar +- **Base type** — Issues, Pull Requests, or Actions +- **Scope** (optional) — restrict to a specific org or repo +- **Filter presets** (optional) — pre-apply one or more filters (e.g., Role: Author, Checks: Failing). Filters use the same options as the corresponding built-in tab. The value `_self` in user-based filters resolves to your authenticated login at runtime. + +**Exclusive toggle:** When enabled, items that match the custom tab's scope and filters are hidden from the standard Issues, Pull Requests, or Actions tab. They appear only in the custom tab. Items in the Tracked tab are never hidden by exclusivity. + +**Managing tabs:** In **Settings > Custom Tabs** you can edit, reorder, and delete custom tabs. Up to 10 custom tabs are supported. + ### Personal Summary Strip A summary strip appears directly below the tab bar whenever there is actionable activity. It shows counts for: diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index e6d09434..062c306d 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -1,11 +1,13 @@ 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 { viewState, setViewState, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, ActionsFiltersSchema } from "../../stores/view"; +import { createTabFilterHandlers, mergeActiveFilters } from "../../lib/tabFilters"; +import { isRunVisible } from "../../lib/filters"; import WorkflowSummaryCard from "./WorkflowSummaryCard"; import IgnoreBadge from "./IgnoreBadge"; import SkeletonRows from "../shared/SkeletonRows"; -import type { FilterChipGroupDef } from "../shared/filterTypes"; +import { actionsFilterGroups, KNOWN_CONCLUSIONS, KNOWN_EVENTS } from "../shared/filterTypes"; import FilterToolbar from "../shared/FilterToolbar"; import RepoGroupHeader from "../shared/RepoGroupHeader"; import ExpandCollapseButtons from "../shared/ExpandCollapseButtons"; @@ -23,6 +25,8 @@ interface ActionsTabProps { configRepoNames?: string[]; refreshTick?: number; hotPollingRunIds?: ReadonlySet; + customTabId?: string; + filterPreset?: Record; } interface WorkflowGroup { @@ -98,36 +102,14 @@ function sortWorkflowsByStatus(workflows: WorkflowGroup[]): WorkflowGroup[] { }); } -const KNOWN_CONCLUSIONS = ["success", "failure", "cancelled"]; -const KNOWN_EVENTS = ["push", "pull_request", "schedule", "workflow_dispatch"]; - -const actionsFilterGroups: FilterChipGroupDef[] = [ - { - label: "Result", - field: "conclusion", - options: [ - { value: "success", label: "Success" }, - { value: "failure", label: "Failure" }, - { value: "cancelled", label: "Cancelled" }, - { value: "running", label: "Running" }, - { value: "other", label: "Other" }, - ], - }, - { - label: "Trigger", - field: "event", - options: [ - { value: "push", label: "Push" }, - { value: "pull_request", label: "PR" }, - { value: "schedule", label: "Schedule" }, - { value: "workflow_dispatch", label: "Manual" }, - ], - }, -]; + +const ACTIONS_FILTER_DEFAULTS = ActionsFiltersSchema.parse({}); export default function ActionsTab(props: ActionsTabProps) { const [expandedWorkflows, setExpandedWorkflows] = createStore>({}); + const tabKey = () => props.customTabId ?? "actions"; + function toggleWorkflow(key: string) { setExpandedWorkflows(key, (v) => !v); } @@ -140,16 +122,25 @@ export default function ActionsTab(props: ActionsTabProps) { viewState.ignoredItems.filter(i => i.type === "workflowRun") ); + // Merge chain: schema defaults → preset → stored runtime overrides + const activeFilters = createMemo(() => + mergeActiveFilters(ActionsFiltersSchema, ACTIONS_FILTER_DEFAULTS, props.customTabId, viewState.tabFilters.actions, { + preset: props.filterPreset, + }) + ); + + const { handleFilterChange, handleResetFilters } = createTabFilterHandlers("actions", () => props.customTabId); + createEffect(() => { const names = activeRepoNames(); if (names.length === 0) return; - pruneExpandedRepos("actions", names); + pruneExpandedRepos(tabKey(), names); }); const { flashingIds: flashingRunIds, peekUpdates } = createFlashDetection({ getItems: () => props.workflowRuns, getHotIds: () => props.hotPollingRunIds, - getExpandedRepos: () => viewState.expandedRepos.actions, + getExpandedRepos: () => viewState.expandedRepos[tabKey()] ?? {}, trackKey: (run) => `${run.status}|${run.conclusion}`, itemLabel: (run) => run.name, itemStatus: (run) => run.conclusion ?? run.status, @@ -166,25 +157,19 @@ export default function ActionsTab(props: ActionsTabProps) { } const filteredRuns = createMemo(() => { - const { org, repo } = viewState.globalFilter; - const ignoredIds = new Set( - ignoredWorkflowRuns() - .map((i) => i.id) - ); - const conclusionFilter = viewState.tabFilters.actions.conclusion; - const eventFilter = viewState.tabFilters.actions.event; + const ignoredIds = new Set(ignoredWorkflowRuns().map((i) => i.id)); + const globalFilter = props.customTabId ? null : viewState.globalFilter; + const conclusionFilter = activeFilters().conclusion; + const eventFilter = activeFilters().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") { if (run.status !== "in_progress") return false; } else if (conclusionFilter === "other") { - if (run.conclusion === null || KNOWN_CONCLUSIONS.includes(run.conclusion)) return false; + if (run.conclusion === null || (KNOWN_CONCLUSIONS as readonly string[]).includes(run.conclusion)) return false; } else { if (run.conclusion !== conclusionFilter) return false; } @@ -192,7 +177,7 @@ export default function ActionsTab(props: ActionsTabProps) { if (eventFilter !== "all") { if (eventFilter === "other") { - if (KNOWN_EVENTS.includes(run.event)) return false; + if ((KNOWN_EVENTS as readonly string[]).includes(run.event)) return false; } else { if (run.event !== eventFilter) return false; } @@ -222,7 +207,9 @@ export default function ActionsTab(props: ActionsTabProps) { () => repoGroups().map(g => g.repoFullName), () => viewState.lockedRepos, () => ignoredWorkflowRuns().length, - () => JSON.stringify(viewState.tabFilters.actions), + () => JSON.stringify(props.customTabId + ? (viewState.customTabFilters[props.customTabId] ?? {}) + : viewState.tabFilters.actions), ); return ( @@ -241,15 +228,15 @@ export default function ActionsTab(props: ActionsTabProps) { setTabFilter("actions", f as ActionsFilterField, v)} - onResetAll={() => resetAllTabFilters("actions")} + values={activeFilters()} + onChange={(f, v) => handleFilterChange(f, v)} + onResetAll={() => handleResetFilters()} />
setAllExpanded("actions", repoGroups().map((g) => g.repoFullName), true)} - onCollapseAll={() => setAllExpanded("actions", repoGroups().map((g) => g.repoFullName), false)} + onExpandAll={() => setAllExpanded(tabKey(), repoGroups().map((g) => g.repoFullName), true)} + onCollapseAll={() => setAllExpanded(tabKey(), repoGroups().map((g) => g.repoFullName), false)} /> {(repoGroup) => { const isEmpty = () => repoGroup.workflows.length === 0; - const isExpanded = () => !isEmpty() && !!viewState.expandedRepos.actions[repoGroup.repoFullName]; + const isExpanded = () => !isEmpty() && !!(viewState.expandedRepos[tabKey()] ?? {})[repoGroup.repoFullName]; const sortedWorkflows = createMemo(() => sortWorkflowsByStatus(repoGroup.workflows) @@ -312,7 +299,7 @@ export default function ActionsTab(props: ActionsTabProps) { repoFullName={repoGroup.repoFullName} isExpanded={isExpanded()} isHighlighted={highlightedReposActions().has(repoGroup.repoFullName)} - onToggle={() => toggleExpandedRepo("actions", repoGroup.repoFullName)} + onToggle={() => toggleExpandedRepo(tabKey(), repoGroup.repoFullName)} trailing={ <> diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 140d562f..035a2309 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, IssueFiltersSchema, PullRequestFiltersSchema, ActionsFiltersSchema } from "../../stores/view"; import type { SortOption } from "../shared/SortDropdown"; import type { Issue, PullRequest, WorkflowRun } from "../../services/api"; import { fetchOrgs } from "../../services/api"; @@ -26,10 +26,32 @@ import { expireToken, user, onAuthCleared, DASHBOARD_STORAGE_KEY } from "../../s import { updateRelaySnapshot } from "../../lib/mcp-relay"; import { pushNotification } from "../../lib/errors"; import { getClient, getGraphqlRateLimit, fetchRateLimitDetails } from "../../services/github"; -import { formatCount } from "../../lib/format"; +import { formatCount, prSizeCategory } 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"; +import { isUserInvolved } from "../../lib/grouping"; +import { KNOWN_CONCLUSIONS, KNOWN_EVENTS } from "../shared/filterTypes"; +import CustomTabModal from "../shared/CustomTabModal"; +import { mergeActiveFilters } from "../../lib/tabFilters"; +import type { CustomTab } from "../../stores/config"; + +// Hoisted to module scope — these are constant values (Zod schema defaults). +const ISSUE_FILTER_DEFAULTS = IssueFiltersSchema.parse({}); +const PR_FILTER_DEFAULTS = PullRequestFiltersSchema.parse({}); +const ACTIONS_FILTER_DEFAULTS = ActionsFiltersSchema.parse({}); + +/** Build a scope matcher for a custom tab's org/repo scope. Shared between customTabData and tabCounts. */ +function buildTabScopeMatcher(tab: CustomTab): (repoFullName: string) => boolean { + 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.toLowerCase())) : null; + return (repoFullName: string) => { + if (repoSet && repoSet.has(repoFullName.toLowerCase())) return true; + if (orgSet && orgSet.has(repoFullName.split("/")[0].toLowerCase())) return true; + return !orgSet && !repoSet; + }; +} const globalSortOptions: SortOption[] = [ { label: "Repo", field: "repo", type: "text" }, @@ -303,17 +325,28 @@ 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); + const editingTab = createMemo(() => { + const id = editingTabId(); + if (!id) return undefined; + return getCustomTab(id); + }); // Redirect away from tracked tab when tracking is disabled at runtime createEffect(() => { @@ -322,6 +355,23 @@ 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"); + } + }); + + // Close modal if the tab being edited is deleted (CR-014) + createEffect(() => { + const id = editingTabId(); + if (id && !config.customTabs.some((t) => t.id === id)) { + setShowCustomTabModal(false); + setEditingTabId(null); + } + }); + // Auto-prune tracked items that are closed/merged (absent from is:open results) createEffect(() => { // IMPORTANT: Access reactive store fields BEFORE early-return guards @@ -448,40 +498,224 @@ export default function DashboardPage() { const refreshTick = createMemo(() => (dashboardData.lastRefreshedAt?.getTime() ?? 0) + clockTick()); + const userLogin = createMemo(() => user()?.login ?? ""); + const allUsers = createMemo(() => { + const login = userLogin().toLowerCase(); + if (!login) return []; + return [ + { login, label: "Me" }, + ...config.trackedUsers.map((u: TrackedUser) => ({ login: u.login, label: u.login })), + ]; + }); + + // Eagerly compute scoped data for exclusive custom tabs (needed by exclusiveOwnership). + // Non-exclusive tabs only compute when they are the active tab. + const customTabData = createMemo(() => { + const currentTabId = activeTab(); + const result: Record = {}; + for (const tab of config.customTabs) { + if (!tab.exclusive && tab.id !== currentTabId) continue; + const matchesScope = buildTabScopeMatcher(tab); + result[tab.id] = { + issues: tab.baseType === "issues" ? dashboardData.issues.filter((i) => matchesScope(i.repoFullName)) : [], + pullRequests: tab.baseType === "pullRequests" ? dashboardData.pullRequests.filter((p) => matchesScope(p.repoFullName)) : [], + workflowRuns: tab.baseType === "actions" ? 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(() => { + const map = exclusiveOwnership().issues; + if (map.size === 0) return dashboardData.issues; + return dashboardData.issues.filter((i) => isItemVisibleOnTab(map, i.id, "issues")); + }); + const visiblePullRequests = createMemo(() => { + const map = exclusiveOwnership().pullRequests; + if (map.size === 0) return dashboardData.pullRequests; + return dashboardData.pullRequests.filter((p) => isItemVisibleOnTab(map, p.id, "pullRequests")); + }); + const visibleWorkflowRuns = createMemo(() => { + const map = exclusiveOwnership().actions; + if (map.size === 0) return dashboardData.workflowRuns; + return dashboardData.workflowRuns.filter((w) => isItemVisibleOnTab(map, w.id, "actions")); + }); + const tabCounts = createMemo(() => { const { org, repo } = viewState.globalFilter; - const ignoredByType = (type: string) => - new Set(viewState.ignoredItems.filter((i) => i.type === type).map((i) => i.id)); + const ignoredIssues = new Set(); + const ignoredPRs = new Set(); + const ignoredRuns = new Set(); + for (const item of viewState.ignoredItems) { + if (item.type === "issue") ignoredIssues.add(item.id); + else if (item.type === "pullRequest") ignoredPRs.add(item.id); + else ignoredRuns.add(item.id); + } + const ownership = exclusiveOwnership(); - const ignoredIssues = ignoredByType("issue"); - const ignoredPRs = ignoredByType("pullRequest"); - const ignoredRuns = ignoredByType("workflowRun"); + const builtinFilter = { org, repo }; + const login = userLogin().toLowerCase(); + const monitoredSet = new Set((config.monitoredRepos ?? []).map((r) => r.fullName)); + const users = allUsers(); + const customCounts: Record = {}; + for (const tab of config.customTabs) { + // customTabData skips non-exclusive inactive tabs (perf optimization), + // so compute scope on demand for tabs absent from the memo. + let data = customTabData()[tab.id]; + if (!data) { + const matchesScope = buildTabScopeMatcher(tab); + data = { + issues: tab.baseType === "issues" ? dashboardData.issues.filter((i) => matchesScope(i.repoFullName)) : [], + pullRequests: tab.baseType === "pullRequests" ? dashboardData.pullRequests.filter((p) => matchesScope(p.repoFullName)) : [], + workflowRuns: tab.baseType === "actions" ? dashboardData.workflowRuns.filter((w) => matchesScope(w.repoFullName)) : [], + }; + } + // Merge filter chain via shared helper (same as tab components) + const preset = tab.filterPreset; + if (tab.baseType === "issues") { + const f = mergeActiveFilters(IssueFiltersSchema, ISSUE_FILTER_DEFAULTS, tab.id, ISSUE_FILTER_DEFAULTS, { + preset, resolveLogin: login, + }); + customCounts[tab.id] = data.issues.filter((i) => { + if (!isItemVisibleOnTab(ownership.issues, i.id, tab.id)) return false; + if (!isIssueVisible(i, { ignoredIds: ignoredIssues, hideDepDashboard: viewState.hideDepDashboard, globalFilter: null })) return false; + if (f.scope === "involves_me" && !isUserInvolved(i, login, monitoredSet)) return false; + if (f.role === "author" && i.userLogin.toLowerCase() !== login) return false; + if (f.role === "assignee" && !i.assigneeLogins?.some((a) => a.toLowerCase() === login)) return false; + if (f.comments === "has" && (i.comments ?? 0) === 0) return false; + if (f.comments === "none" && (i.comments ?? 0) > 0) return false; + if (f.user !== "all") { + const validUser = !users.length || users.some((u) => u.login === f.user); + if (validUser && !monitoredSet.has(i.repoFullName)) { + const surfacedBy = i.surfacedBy ?? [login]; + if (!surfacedBy.includes(f.user)) return false; + } + } + return true; + }).length; + } else if (tab.baseType === "pullRequests") { + const f = mergeActiveFilters(PullRequestFiltersSchema, PR_FILTER_DEFAULTS, tab.id, PR_FILTER_DEFAULTS, { + preset, resolveLogin: login, + }); + customCounts[tab.id] = data.pullRequests.filter((p) => { + if (!isItemVisibleOnTab(ownership.pullRequests, p.id, tab.id)) return false; + if (!isPrVisible(p, { ignoredIds: ignoredPRs, globalFilter: null })) return false; + if (f.scope === "involves_me" && !isUserInvolved(p, login, monitoredSet, p.enriched !== false ? p.reviewerLogins : undefined)) return false; + // Guard role filter on enriched: light-phase PRs have empty reviewerLogins/assigneeLogins + if (p.enriched !== false) { + if (f.role === "author" && p.userLogin.toLowerCase() !== login) return false; + if (f.role === "reviewer" && !p.reviewerLogins?.some((r) => r.toLowerCase() === login)) return false; + if (f.role === "assignee" && !p.assigneeLogins?.some((a) => a.toLowerCase() === login)) return false; + } else { + if (f.role === "author" && p.userLogin.toLowerCase() !== login) return false; + } + if (f.draft === "draft" && !p.draft) return false; + if (f.draft === "ready" && p.draft) return false; + if (f.checkStatus !== "all" && p.enriched !== false) { + if (f.checkStatus === "none") { if (p.checkStatus !== null) return false; } + else if (f.checkStatus === "blocked") { if (p.checkStatus !== "failure" && p.checkStatus !== "conflict") return false; } + else if (p.checkStatus !== f.checkStatus) return false; + } + if (f.reviewDecision !== "all") { + if (f.reviewDecision === "mergeable") { + if (p.reviewDecision !== "APPROVED" && p.reviewDecision !== null) return false; + } else if (p.reviewDecision !== f.reviewDecision) return false; + } + if (f.sizeCategory !== "all" && p.enriched !== false) { + if (prSizeCategory(p.additions, p.deletions) !== f.sizeCategory) return false; + } + if (f.user !== "all") { + const validUser = !users.length || users.some((u) => u.login === f.user); + if (validUser && !monitoredSet.has(p.repoFullName)) { + const surfacedBy = p.surfacedBy ?? [login]; + if (!surfacedBy.includes(f.user)) return false; + } + } + return true; + }).length; + } else { + const f = mergeActiveFilters(ActionsFiltersSchema, ACTIONS_FILTER_DEFAULTS, tab.id, ACTIONS_FILTER_DEFAULTS, { preset }); + customCounts[tab.id] = data.workflowRuns.filter((w) => { + if (!isItemVisibleOnTab(ownership.actions, w.id, tab.id)) return false; + if (!isRunVisible(w, { ignoredIds: ignoredRuns, showPrRuns: viewState.showPrRuns, globalFilter: null })) return false; + if (f.conclusion !== "all") { + if (f.conclusion === "running") { if (w.status !== "in_progress") return false; } + else if (f.conclusion === "other") { if (w.conclusion === null || (KNOWN_CONCLUSIONS as readonly string[]).includes(w.conclusion)) return false; } + else if (w.conclusion !== f.conclusion) return false; + } + if (f.event !== "all") { + if (f.event === "other") { if ((KNOWN_EVENTS as readonly string[]).includes(w.event)) return false; } + else if (w.event !== f.event) return false; + } + return true; + }).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. @@ -497,16 +731,6 @@ export default function DashboardPage() { }); }); - const userLogin = createMemo(() => user()?.login ?? ""); - const allUsers = createMemo(() => { - const login = userLogin().toLowerCase(); - if (!login) return []; - return [ - { login, label: "Me" }, - ...config.trackedUsers.map((u: TrackedUser) => ({ login: u.login, label: u.login })), - ]; - }); - const configRepoNames = createMemo(() => [...new Set([...config.selectedRepos, ...config.upstreamRepos, ...config.monitoredRepos].map(r => r.fullName))] ); @@ -520,9 +744,9 @@ export default function DashboardPage() {
@@ -531,6 +755,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 +776,7 @@ export default function DashboardPage() { + {/* TrackedTab intentionally receives unfiltered dashboardData — it bypasses exclusivity */} 0} configRepoNames={configRepoNames()} @@ -590,8 +819,76 @@ 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()} + customTabId={tab().id} + filterPreset={tab().filterPreset} + /> + + + ); + }} + + + { setShowCustomTabModal(false); setEditingTabId(null); }} + editingTab={editingTab()} + availableOrgs={[...new Set(config.selectedRepos.map((r) => r.owner))]} + availableRepos={config.selectedRepos} + />