From 1307efafd31c64d1eb55b923ae7bce5032dbe645 Mon Sep 17 00:00:00 2001 From: Roshvan Chalkey <35650088+Roshvan@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:24:33 -0500 Subject: [PATCH 1/4] feat(desktop): separate tasks and pull requests in sidebar --- .../DashboardSidebarHeader.tsx | 73 +++- .../OpenClosedFilter/OpenClosedFilter.tsx | 43 +++ .../components/OpenClosedFilter/index.ts | 1 + .../ProjectFilter/ProjectFilter.tsx | 21 +- .../components/ProjectFilter/index.ts | 0 .../WorkItemDetailHeader.tsx | 70 ++++ .../components/WorkItemDetailHeader/index.ts | 1 + .../WorkItemDetailState.tsx | 39 ++ .../components/WorkItemDetailState/index.ts | 1 + .../WorkItemsSearch/WorkItemsSearch.tsx | 52 +++ .../components/WorkItemsSearch/index.ts | 1 + .../_dashboard/hooks/useProjectHost/index.ts | 1 + .../useProjectHost/useProjectHost.test.ts | 20 + .../hooks/useProjectHost/useProjectHost.ts | 37 ++ .../pull-requests/$prNumber/page.tsx | 229 +++++++++++ .../PullRequestsView/PullRequestsView.tsx | 172 +++++++++ .../PullRequestsContent.tsx | 135 ++++--- .../components/PullRequestsContent/index.ts | 0 .../PullRequestsTopBar/PullRequestsTopBar.tsx | 53 +++ .../components/PullRequestsTopBar/index.ts | 1 + .../components/PullRequestsView/index.ts | 1 + .../_dashboard/pull-requests/layout.tsx | 24 ++ .../_dashboard/pull-requests/page.tsx | 21 + .../stores/pullRequestsFilterStore/index.ts | 4 + .../pullRequestsFilterStore.test.ts | 28 ++ .../pullRequestsFilterStore.ts | 48 +++ .../EditableTitle/EditableTitle.tsx | 21 +- .../_dashboard/tasks/$taskId/page.tsx | 6 +- .../components/TasksView/TasksView.test.ts | 48 +++ .../tasks/components/TasksView/TasksView.tsx | 137 ++++--- .../GitHubIssuesContent.tsx | 133 ++++--- .../components/TasksTopBar/TasksTopBar.tsx | 358 +++++++++--------- .../TasksView/components/TasksTopBar/index.ts | 6 +- .../tasks/issue/$issueNumber/page.tsx | 243 ++++++------ .../_dashboard/tasks/layout.tsx | 4 + .../_authenticated/_dashboard/tasks/page.tsx | 18 +- .../_dashboard/tasks/pr/$prNumber/page.tsx | 228 +---------- .../tasks/stores/tasks-filter-state.test.ts | 81 ++++ .../tasks/stores/tasks-filter-state.ts | 28 +- .../utils/parsePositiveIntegerParam/index.ts | 1 + .../parsePositiveIntegerParam.test.ts | 17 + .../parsePositiveIntegerParam.ts | 5 + .../shouldKeepWorkItemsPlaceholder/index.ts | 1 + .../shouldKeepWorkItemsPlaceholder.test.ts | 34 ++ .../shouldKeepWorkItemsPlaceholder.ts | 9 + .../WorkspaceSidebarHeader.tsx | 75 +++- 46 files changed, 1831 insertions(+), 698 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/OpenClosedFilter/OpenClosedFilter.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/OpenClosedFilter/index.ts rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/{tasks/components/TasksView/components/TasksTopBar => }/components/ProjectFilter/ProjectFilter.tsx (83%) rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/{tasks/components/TasksView/components/TasksTopBar => }/components/ProjectFilter/index.ts (100%) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemDetailHeader/WorkItemDetailHeader.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemDetailHeader/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemDetailState/WorkItemDetailState.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemDetailState/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemsSearch/WorkItemsSearch.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemsSearch/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useProjectHost/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useProjectHost/useProjectHost.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useProjectHost/useProjectHost.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/$prNumber/page.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/PullRequestsView.tsx rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/{tasks/components/TasksView => pull-requests/components/PullRequestsView}/components/PullRequestsContent/PullRequestsContent.tsx (72%) rename apps/desktop/src/renderer/routes/_authenticated/_dashboard/{tasks/components/TasksView => pull-requests/components/PullRequestsView}/components/PullRequestsContent/index.ts (100%) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/components/PullRequestsTopBar/PullRequestsTopBar.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/components/PullRequestsTopBar/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/layout.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/page.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/stores/pullRequestsFilterStore/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/stores/pullRequestsFilterStore/pullRequestsFilterStore.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/stores/pullRequestsFilterStore/pullRequestsFilterStore.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam/parsePositiveIntegerParam.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam/parsePositiveIntegerParam.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder/shouldKeepWorkItemsPlaceholder.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder/shouldKeepWorkItemsPlaceholder.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx index 9052ba8310f..90137605270 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx @@ -9,6 +9,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useMatchRoute, useNavigate } from "@tanstack/react-router"; import { useRef } from "react"; +import { GoGitPullRequest } from "react-icons/go"; import { HiOutlineClipboardDocumentList } from "react-icons/hi2"; import { LuClock, LuLayers, LuPlus, LuSearch } from "react-icons/lu"; import { @@ -28,6 +29,10 @@ import { useFolderFirstImport } from "renderer/routes/_authenticated/_dashboard/ import { NavigationControls } from "renderer/routes/_authenticated/_dashboard/components/NavigationControls"; import { SidebarToggle } from "renderer/routes/_authenticated/_dashboard/components/SidebarToggle"; import { useFailedAutomations } from "renderer/routes/_authenticated/_dashboard/hooks/useFailedAutomations"; +import { + pullRequestsSearchFromFilters, + usePullRequestsFilterStore, +} from "renderer/routes/_authenticated/_dashboard/pull-requests/stores/pullRequestsFilterStore"; import { tasksSearchFromFilters, useTasksFilterStore, @@ -100,6 +105,10 @@ export function DashboardSidebarHeader({ fuzzy: true, }); const isTasksOpen = !!matchRoute({ to: "/tasks", fuzzy: true }); + const isPullRequestsOpen = !!matchRoute({ + to: "/pull-requests", + fuzzy: true, + }); const isAutomationsOpen = !!matchRoute({ to: "/automations", fuzzy: true }); const { myFailedCount } = useFailedAutomations(); @@ -110,7 +119,13 @@ export function DashboardSidebarHeader({ typeTab: lastTypeTab, projectFilter: lastProjectFilter, linearProjectFilter: lastLinearProjectFilter, + includeClosedIssues: lastIncludeClosedIssues, } = useTasksFilterStore(); + const { + search: lastPullRequestsSearch, + projectFilter: lastPullRequestsProjectFilter, + includeClosed: lastPullRequestsIncludeClosed, + } = usePullRequestsFilterStore(); const handleWorkspacesClick = () => { navigate({ to: "/v2-workspaces" }); @@ -131,6 +146,20 @@ export function DashboardSidebarHeader({ typeTab: lastTypeTab, projectFilter: lastProjectFilter, linearProjectFilter: lastLinearProjectFilter, + includeClosedIssues: lastIncludeClosedIssues, + }), + }); + }); + }; + + const handlePullRequestsClick = () => { + gateFeature(GATED_FEATURES.TASKS, () => { + navigate({ + to: "/pull-requests", + search: pullRequestsSearchFromFilters({ + search: lastPullRequestsSearch, + projectFilter: lastPullRequestsProjectFilter, + includeClosed: lastPullRequestsIncludeClosed, }), }); }); @@ -246,6 +275,8 @@ export function DashboardSidebarHeader({ - Tasks & PRs + Tasks + + + + + + + Pull requests @@ -410,6 +461,8 @@ export function DashboardSidebarHeader({ + + ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/OpenClosedFilter/OpenClosedFilter.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/OpenClosedFilter/OpenClosedFilter.tsx new file mode 100644 index 00000000000..50089862513 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/OpenClosedFilter/OpenClosedFilter.tsx @@ -0,0 +1,43 @@ +import { ToggleGroup, ToggleGroupItem } from "@superset/ui/toggle-group"; + +interface OpenClosedFilterProps { + includeClosed: boolean; + onChange: (includeClosed: boolean) => void; +} + +export function OpenClosedFilter({ + includeClosed, + onChange, +}: OpenClosedFilterProps) { + return ( +
+ State + { + if (value) onChange(value === "all"); + }} + variant="outline" + size="sm" + aria-label="Filter by state" + className="h-8 rounded-md border-0 bg-muted/50 p-0.5" + > + + Open + + + All + + +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/OpenClosedFilter/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/OpenClosedFilter/index.ts new file mode 100644 index 00000000000..06a402b47de --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/OpenClosedFilter/index.ts @@ -0,0 +1 @@ +export { OpenClosedFilter } from "./OpenClosedFilter"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/ProjectFilter/ProjectFilter.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/ProjectFilter/ProjectFilter.tsx similarity index 83% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/ProjectFilter/ProjectFilter.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/ProjectFilter/ProjectFilter.tsx index 508b163db9b..f6b94da6826 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/ProjectFilter/ProjectFilter.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/ProjectFilter/ProjectFilter.tsx @@ -24,7 +24,7 @@ export function ProjectFilter({ value, onChange }: ProjectFilterProps) { const [search, setSearch] = useState(""); // Projects are fully local — identity comes from the host fan-out. - const { projects: hostProjects } = useHostProjects(); + const { projects: hostProjects, isReady } = useHostProjects(); const projects = useMemo( () => hostProjects.map((project) => ({ @@ -51,6 +51,7 @@ export function ProjectFilter({ value, onChange }: ProjectFilterProps) { setOpen(false); setSearch(""); }; + const fallbackLabel = isReady ? "Select project" : "Loading projects"; return ( {selected ? ( @@ -77,8 +78,8 @@ export function ProjectFilter({ value, onChange }: ProjectFilterProps) { ) : ( )} - - {selected ? selected.name : "Project"} + + {selected ? selected.name : fallbackLabel} @@ -91,8 +92,14 @@ export function ProjectFilter({ value, onChange }: ProjectFilterProps) { onValueChange={setSearch} /> - {filtered.length === 0 && search && ( - No projects found. + {filtered.length === 0 && ( + + {isReady + ? search + ? "No projects found." + : "No projects available." + : "Loading projects…"} + )} {filtered.length > 0 && ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/ProjectFilter/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/ProjectFilter/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/ProjectFilter/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/ProjectFilter/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemDetailHeader/WorkItemDetailHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemDetailHeader/WorkItemDetailHeader.tsx new file mode 100644 index 00000000000..f526b242fe7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemDetailHeader/WorkItemDetailHeader.tsx @@ -0,0 +1,70 @@ +import { Button } from "@superset/ui/button"; +import type { ReactNode } from "react"; +import { HiArrowLeft } from "react-icons/hi2"; +import { LuExternalLink, LuPlus } from "react-icons/lu"; + +interface WorkItemDetailHeaderProps { + itemNumber: number | null; + icon: ReactNode; + backLabel: string; + externalLabel: string; + url: string | null; + onBack: () => void; + onAddToWorkspace: (() => void) | null; +} + +export function WorkItemDetailHeader({ + itemNumber, + icon, + backLabel, + externalLabel, + url, + onBack, + onAddToWorkspace, +}: WorkItemDetailHeaderProps) { + return ( +
+ + {icon} + + {itemNumber === null ? "#—" : `#${itemNumber}`} + +
+ {url && ( + + )} + {onAddToWorkspace && ( + + )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemDetailHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemDetailHeader/index.ts new file mode 100644 index 00000000000..4738f7e3920 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemDetailHeader/index.ts @@ -0,0 +1 @@ +export { WorkItemDetailHeader } from "./WorkItemDetailHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemDetailState/WorkItemDetailState.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemDetailState/WorkItemDetailState.tsx new file mode 100644 index 00000000000..0d1c0dc9a48 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemDetailState/WorkItemDetailState.tsx @@ -0,0 +1,39 @@ +import { Button } from "@superset/ui/button"; +import { cn } from "@superset/ui/utils"; +import { LuRefreshCw } from "react-icons/lu"; + +interface WorkItemDetailStateProps { + message: string; + isLoading?: boolean; + isError?: boolean; + onRetry?: () => void; +} + +export function WorkItemDetailState({ + message, + isLoading = false, + isError = false, + onRetry, +}: WorkItemDetailStateProps) { + return ( +
+ {isLoading && ( + + )} + {message} + {onRetry && ( + + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemDetailState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemDetailState/index.ts new file mode 100644 index 00000000000..990309543eb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemDetailState/index.ts @@ -0,0 +1 @@ +export { WorkItemDetailState } from "./WorkItemDetailState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemsSearch/WorkItemsSearch.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemsSearch/WorkItemsSearch.tsx new file mode 100644 index 00000000000..df53f55254e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemsSearch/WorkItemsSearch.tsx @@ -0,0 +1,52 @@ +import { Input } from "@superset/ui/input"; +import { useRef } from "react"; +import { HiOutlineMagnifyingGlass } from "react-icons/hi2"; +import { useHotkey } from "renderer/hotkeys"; + +interface WorkItemsSearchProps { + value: string; + onChange: (value: string) => void; + placeholder: string; + label: string; +} + +export function WorkItemsSearch({ + value, + onChange, + placeholder, + label, +}: WorkItemsSearchProps) { + const inputRef = useRef(null); + + useHotkey( + "FOCUS_TASK_SEARCH", + () => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, + { preventDefault: true }, + ); + + return ( +
+ + onChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + onChange(""); + inputRef.current?.blur(); + } + }} + className="h-8 border-0 bg-muted/50 pl-9 pr-3 text-sm focus-visible:ring-1" + /> +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemsSearch/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemsSearch/index.ts new file mode 100644 index 00000000000..a4979ee7196 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemsSearch/index.ts @@ -0,0 +1 @@ +export { WorkItemsSearch } from "./WorkItemsSearch"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useProjectHost/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useProjectHost/index.ts new file mode 100644 index 00000000000..0b853e23c42 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useProjectHost/index.ts @@ -0,0 +1 @@ +export { selectServingHostId, useProjectHost } from "./useProjectHost"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useProjectHost/useProjectHost.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useProjectHost/useProjectHost.test.ts new file mode 100644 index 00000000000..3b57d1ca99b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useProjectHost/useProjectHost.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; +import { selectServingHostId } from "./useProjectHost"; + +describe("selectServingHostId", () => { + test("prefers the local host when it serves the project", () => { + expect( + selectServingHostId(["remote-machine", "local-machine"], "local-machine"), + ).toBe("local-machine"); + }); + + test("falls back to the first serving host", () => { + expect(selectServingHostId(["remote-a", "remote-b"], "local-machine")).toBe( + "remote-a", + ); + }); + + test("does not silently route an unavailable project to the local host", () => { + expect(selectServingHostId([], "local-machine")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useProjectHost/useProjectHost.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useProjectHost/useProjectHost.ts new file mode 100644 index 00000000000..ffa9c73938b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/useProjectHost/useProjectHost.ts @@ -0,0 +1,37 @@ +import { useMemo } from "react"; +import { useHostProjects } from "renderer/hooks/host-projects/useHostProjects"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; + +export function selectServingHostId( + hostIds: string[], + localMachineId: string | null, +): string | null { + if (localMachineId && hostIds.includes(localMachineId)) { + return localMachineId; + } + return hostIds[0] ?? null; +} + +export function useProjectHost(projectId: string | null) { + const { projects, isReady } = useHostProjects(); + const { machineId } = useLocalHostService(); + const project = useMemo( + () => + projectId + ? (projects.find((candidate) => candidate.projectKey === projectId) ?? + null) + : null, + [projectId, projects], + ); + const hostId = useMemo( + () => selectServingHostId(project?.hostIds ?? [], machineId), + [machineId, project?.hostIds], + ); + + return { + hostId, + isReady, + project, + projects, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/$prNumber/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/$prNumber/page.tsx new file mode 100644 index 00000000000..70c1943fef4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/$prNumber/page.tsx @@ -0,0 +1,229 @@ +import { ScrollArea } from "@superset/ui/scroll-area"; +import { useQuery } from "@tanstack/react-query"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { MarkdownRenderer } from "renderer/components/MarkdownRenderer"; +import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { WorkItemDetailHeader } from "renderer/routes/_authenticated/_dashboard/components/WorkItemDetailHeader"; +import { WorkItemDetailState } from "renderer/routes/_authenticated/_dashboard/components/WorkItemDetailState"; +import { useProjectHost } from "renderer/routes/_authenticated/_dashboard/hooks/useProjectHost"; +import { parsePositiveIntegerParam } from "renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam"; +import { + normalizePRState, + PRIcon, +} from "renderer/screens/main/components/PRIcon"; +import { + type LinkedPR, + useNewWorkspaceDraftStore, +} from "renderer/stores/new-workspace-draft"; +import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; +import { Route as PullRequestsLayoutRoute } from "../layout"; +import { pullRequestsSearchFromFilters } from "../stores/pullRequestsFilterStore"; + +export const Route = createFileRoute( + "/_authenticated/_dashboard/pull-requests/$prNumber/", +)({ + component: PullRequestDetailPage, +}); + +function PullRequestDetailPage() { + const { prNumber: prNumberRaw } = Route.useParams(); + const prNumber = parsePositiveIntegerParam(prNumberRaw); + const search = PullRequestsLayoutRoute.useSearch(); + const navigate = useNavigate(); + const projectId = search.project ?? null; + const { + hostId, + isReady: areProjectsReady, + project, + } = useProjectHost(projectId); + const hostUrl = useHostUrl(hostId ?? undefined); + const updateDraft = useNewWorkspaceDraftStore((state) => state.updateDraft); + const resetDraft = useNewWorkspaceDraftStore((state) => state.resetDraft); + const openModal = useOpenNewWorkspaceModal(); + + const backSearch = useMemo( + () => + pullRequestsSearchFromFilters({ + search: search.search ?? "", + projectFilter: projectId, + includeClosed: search.state === "all", + }), + [projectId, search.search, search.state], + ); + + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ["pull-request-detail", projectId, hostUrl, prNumber], + queryFn: async () => { + if (!hostUrl || !projectId || prNumber === null) return null; + const client = getHostServiceClientByUrl(hostUrl); + return client.pullRequests.getContent.query({ + projectId, + prNumber, + }); + }, + enabled: !!hostUrl && !!project && !!projectId && prNumber !== null, + retry: false, + staleTime: 30_000, + gcTime: 10 * 60_000, + }); + + const handleBack = () => { + navigate({ to: "/pull-requests", search: backSearch }); + }; + + const handleAddToWorkspace = () => { + if (!projectId || !hostId || !data) return; + const linkedPR: LinkedPR = { + prNumber: data.number, + title: data.title, + url: data.url, + state: normalizePRState(data.state, data.isDraft), + }; + resetDraft(); + updateDraft({ + selectedProjectId: projectId, + hostId, + linkedPR, + }); + openModal(projectId); + }; + + const defaultState = normalizePRState("open", false); + const state = data + ? normalizePRState(data.state, data.isDraft) + : defaultState; + const header = ( + } + backLabel="Back to pull requests" + externalLabel="Open pull request in GitHub" + url={data?.url ?? null} + onBack={handleBack} + onAddToWorkspace={data ? handleAddToWorkspace : null} + /> + ); + + if (prNumber === null) { + return ( +
+ {header} + +
+ ); + } + + if (!projectId) { + return ( +
+ {header} + +
+ ); + } + + if (!project) { + return ( +
+ {header} + +
+ ); + } + + if (!hostId || !hostUrl) { + return ( +
+ {header} + +
+ ); + } + + if (isLoading) { + return ( +
+ {header} + +
+ ); + } + + if (error instanceof Error || !data) { + return ( +
+ {header} + void refetch()} + /> +
+ ); + } + + const stateLabel = data.isDraft ? "Draft" : data.state; + const branchSummary = data.branch + ? `${data.headRepositoryOwner && data.isCrossRepository ? `${data.headRepositoryOwner}:${data.branch}` : data.branch} → ${data.baseBranch}` + : null; + + return ( +
+ {header} + +
+
+ +

+ {data.title} +

+
+ +
+ {stateLabel} + {data.author && ( + <> + · + by {data.author} + + )} + {branchSummary && ( + <> + · + + {branchSummary} + + + )} +
+ + {data.body.trim() ? ( + + ) : ( +

+ No description provided. +

+ )} +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/PullRequestsView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/PullRequestsView.tsx new file mode 100644 index 00000000000..756ed9c1009 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/PullRequestsView.tsx @@ -0,0 +1,172 @@ +import { useNavigate } from "@tanstack/react-router"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useProjectHost } from "renderer/routes/_authenticated/_dashboard/hooks/useProjectHost"; +import { + pullRequestsSearchFromFilters, + usePullRequestsFilterStore, +} from "../../stores/pullRequestsFilterStore"; +import { PullRequestsContent } from "./components/PullRequestsContent"; +import { PullRequestsTopBar } from "./components/PullRequestsTopBar"; + +interface PullRequestsViewProps { + initialSearch?: string; + initialProject?: string; + initialState?: "open" | "all"; +} + +export function PullRequestsView({ + initialSearch, + initialProject, + initialState, +}: PullRequestsViewProps) { + const navigate = useNavigate(); + const { + search: storedSearch, + projectFilter: storedProjectFilter, + includeClosed: storedIncludeClosed, + setSearch: storeSetSearch, + setProjectFilter: storeSetProjectFilter, + setIncludeClosed: storeSetIncludeClosed, + } = usePullRequestsFilterStore(); + const [searchQuery, setSearchQuery] = useState(initialSearch ?? storedSearch); + const projectFilter = initialProject ?? storedProjectFilter; + const includeClosed = + initialState === undefined ? storedIncludeClosed : initialState === "all"; + const debounceRef = useRef>(null); + const { + hostId: projectHostId, + isReady: areProjectsReady, + project: selectedProject, + projects: hostProjects, + } = useProjectHost(projectFilter); + + useEffect(() => { + setSearchQuery(initialSearch ?? ""); + }, [initialSearch]); + + useEffect(() => { + storeSetSearch(searchQuery); + }, [searchQuery, storeSetSearch]); + + const buildSearch = useCallback( + (overrides: { + search?: string; + project?: string | null; + includeClosed?: boolean; + }) => + pullRequestsSearchFromFilters({ + search: overrides.search ?? searchQuery, + projectFilter: + overrides.project !== undefined ? overrides.project : projectFilter, + includeClosed: overrides.includeClosed ?? includeClosed, + }), + [includeClosed, projectFilter, searchQuery], + ); + + const syncSearchToUrl = useCallback( + (query: string) => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + navigate({ + to: "/pull-requests", + search: buildSearch({ search: query }), + replace: true, + }); + }, 300); + }, + [buildSearch, navigate], + ); + + useEffect( + () => () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }, + [], + ); + + useEffect(() => { + storeSetProjectFilter(projectFilter); + }, [projectFilter, storeSetProjectFilter]); + + useEffect(() => { + storeSetIncludeClosed(includeClosed); + }, [includeClosed, storeSetIncludeClosed]); + + const projects = useMemo( + () => + hostProjects.map((project) => ({ + id: project.projectKey, + name: project.name, + })), + [hostProjects], + ); + + useEffect(() => { + if ( + projectFilter && + projects.some((project) => project.id === projectFilter) + ) + return; + if (!areProjectsReady) return; + const firstProject = projects[0]; + if (!firstProject) return; + navigate({ + to: "/pull-requests", + search: buildSearch({ project: firstProject.id }), + replace: true, + }); + }, [areProjectsReady, buildSearch, navigate, projectFilter, projects]); + + const handleSearchChange = useCallback( + (query: string) => { + setSearchQuery(query); + storeSetSearch(query); + syncSearchToUrl(query); + }, + [storeSetSearch, syncSearchToUrl], + ); + + const handleProjectFilterChange = (project: string) => { + storeSetProjectFilter(project); + navigate({ + to: "/pull-requests", + search: buildSearch({ project }), + replace: true, + }); + }; + + const handleIncludeClosedChange = (nextIncludeClosed: boolean) => { + storeSetIncludeClosed(nextIncludeClosed); + navigate({ + to: "/pull-requests", + search: buildSearch({ includeClosed: nextIncludeClosed }), + replace: true, + }); + }; + + return ( +
+ +
+ 0} + searchQuery={searchQuery} + includeClosed={includeClosed} + /> +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/PullRequestsContent/PullRequestsContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/components/PullRequestsContent/PullRequestsContent.tsx similarity index 72% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/PullRequestsContent/PullRequestsContent.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/components/PullRequestsContent/PullRequestsContent.tsx index 2b60a6b3c42..fdff9f73284 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/PullRequestsContent/PullRequestsContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/components/PullRequestsContent/PullRequestsContent.tsx @@ -1,14 +1,14 @@ import { Button } from "@superset/ui/button"; -import { Checkbox } from "@superset/ui/checkbox"; -import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { GoGitPullRequest } from "react-icons/go"; import { HiOutlineArrowTopRightOnSquare } from "react-icons/hi2"; import { LuMinus, LuPlus, LuRefreshCw } from "react-icons/lu"; import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { shouldKeepWorkItemsPlaceholder } from "renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder"; import { normalizePRState, PRIcon, @@ -21,7 +21,11 @@ import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; interface PullRequestsContentProps { projectFilter: string | null; + hostId: string | null; + areProjectsReady: boolean; + hasProjects: boolean; searchQuery: string; + includeClosed: boolean; onCollapse?: () => void; } @@ -29,13 +33,15 @@ const PAGE_SIZE = 30; export function PullRequestsContent({ projectFilter, + hostId, + areProjectsReady, + hasProjects, searchQuery, + includeClosed, onCollapse, }: PullRequestsContentProps) { - const [showClosed, setShowClosed] = useState(false); - const showClosedId = useId(); const debouncedQuery = useDebouncedValue(searchQuery, 300); - const hostUrl = useHostUrl(null); + const hostUrl = useHostUrl(hostId ?? undefined); const navigate = useNavigate(); const updateDraft = useNewWorkspaceDraftStore((s) => s.updateDraft); const resetDraft = useNewWorkspaceDraftStore((s) => s.resetDraft); @@ -51,12 +57,12 @@ export function PullRequestsContent({ refetch, } = useInfiniteQuery({ queryKey: [ - "tasks", + "pullRequests", "searchPullRequests", projectFilter, hostUrl, debouncedQuery.trim(), - showClosed, + includeClosed, ], queryFn: async ({ pageParam }) => { if (!hostUrl || !projectFilter) { @@ -72,7 +78,7 @@ export function PullRequestsContent({ projectId: projectFilter, query: debouncedQuery.trim() || undefined, limit: PAGE_SIZE, - includeClosed: showClosed, + includeClosed, page: pageParam, }); }, @@ -81,7 +87,15 @@ export function PullRequestsContent({ lastPage.hasNextPage ? lastPage.page + 1 : undefined, staleTime: 30_000, gcTime: 10 * 60_000, - placeholderData: keepPreviousData, + placeholderData: (previousData, previousQuery) => { + return shouldKeepWorkItemsPlaceholder( + previousQuery?.queryKey, + projectFilter, + hostUrl, + ) + ? previousData + : undefined; + }, enabled: !!projectFilter && !!hostUrl, retry: false, }); @@ -121,7 +135,11 @@ export function PullRequestsContent({ state: normalizePRState(pr.state, pr.isDraft), }; resetDraft(); - updateDraft({ selectedProjectId: projectFilter, linkedPR }); + updateDraft({ + selectedProjectId: projectFilter, + hostId, + linkedPR, + }); openModal(projectFilter); }; @@ -132,9 +150,13 @@ export function PullRequestsContent({ const handleOpenPreview = (prNumber: number) => { if (!projectFilter) return; navigate({ - to: "/tasks/pr/$prNumber", + to: "/pull-requests/$prNumber", params: { prNumber: String(prNumber) }, - search: { project: projectFilter }, + search: { + search: searchQuery || undefined, + project: projectFilter, + state: includeClosed ? "all" : undefined, + }, }); }; @@ -143,8 +165,25 @@ export function PullRequestsContent({
- - Select a project to see pull requests. + + {areProjectsReady + ? hasProjects + ? "Select a project to see pull requests." + : "Add a project to see pull requests." + : "Loading projects…"} + +
+
+ ); + } + + if (!hostId || !hostUrl) { + return ( +
+
+ + + The device that hosts this project is unavailable.
@@ -159,24 +198,31 @@ export function PullRequestsContent({ : `${pullRequests.length} of ${totalCount}`; return ( -
+
- - Pull requests - - - {countLabel} + + {countLabel}{" "} + {totalCount === 1 ? "pull request" : "pull requests"} {onCollapse && ( @@ -184,6 +230,7 @@ export function PullRequestsContent({ variant="ghost" size="icon-xs" title="Minimize" + aria-label="Minimize pull requests" onClick={onCollapse} > @@ -191,45 +238,27 @@ export function PullRequestsContent({ )}
-
- setShowClosed(checked === true)} - /> - - {isFetching && !isInitialLoad && ( - Loading… - )} -
-
- {error instanceof Error && ( -
- {error.message} + {error instanceof Error ? ( +
+ {error.message} +
- )} - - {repoMismatch && ( + ) : repoMismatch ? (
PR URL must match {repoMismatch}.
- )} - - {isInitialLoad ? ( + ) : isInitialLoad ? (
- + Loading pull requests…
- ) : totalCount === 0 && !isFetching && !error ? ( + ) : totalCount === 0 && !isFetching ? (
- {showClosed + {includeClosed ? "No pull requests found." : "No open pull requests."} @@ -242,7 +271,7 @@ export function PullRequestsContent({ // biome-ignore lint/a11y/useSemanticElements: row contains nested action buttons, so the outer element is a div with role/tabIndex
handleOpenPreview(pr.prNumber)} onKeyDown={(e) => { if (e.target !== e.currentTarget) return; @@ -271,6 +300,7 @@ export function PullRequestsContent({ variant="ghost" size="icon-xs" title="Open in browser" + aria-label={`Open pull request #${pr.prNumber} in browser`} onClick={(e) => { e.stopPropagation(); handleOpenUrl(pr.url); @@ -282,6 +312,7 @@ export function PullRequestsContent({ variant="outline" size="sm" title="Add to workspace" + aria-label={`Add pull request #${pr.prNumber} to workspace`} className="h-7 gap-1.5 px-2 text-xs" onClick={(e) => { e.stopPropagation(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/PullRequestsContent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/components/PullRequestsContent/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/PullRequestsContent/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/components/PullRequestsContent/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/components/PullRequestsTopBar/PullRequestsTopBar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/components/PullRequestsTopBar/PullRequestsTopBar.tsx new file mode 100644 index 00000000000..bc6fb525996 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/components/PullRequestsTopBar/PullRequestsTopBar.tsx @@ -0,0 +1,53 @@ +import { OpenClosedFilter } from "renderer/routes/_authenticated/_dashboard/components/OpenClosedFilter"; +import { ProjectFilter } from "renderer/routes/_authenticated/_dashboard/components/ProjectFilter"; +import { WorkItemsSearch } from "renderer/routes/_authenticated/_dashboard/components/WorkItemsSearch"; + +interface PullRequestsTopBarProps { + searchQuery: string; + onSearchChange: (query: string) => void; + projectFilter: string | null; + onProjectFilterChange: (projectId: string) => void; + includeClosed: boolean; + onIncludeClosedChange: (includeClosed: boolean) => void; +} + +export function PullRequestsTopBar({ + searchQuery, + onSearchChange, + projectFilter, + onProjectFilterChange, + includeClosed, + onIncludeClosedChange, +}: PullRequestsTopBarProps) { + return ( +
+
+
+
+ Repository + +
+
+ +
+
+ +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/components/PullRequestsTopBar/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/components/PullRequestsTopBar/index.ts new file mode 100644 index 00000000000..80ef7de6e2c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/components/PullRequestsTopBar/index.ts @@ -0,0 +1 @@ +export { PullRequestsTopBar } from "./PullRequestsTopBar"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/index.ts new file mode 100644 index 00000000000..d1d7405dc23 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/index.ts @@ -0,0 +1 @@ +export { PullRequestsView } from "./PullRequestsView"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/layout.tsx new file mode 100644 index 00000000000..ba47f28ff27 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/layout.tsx @@ -0,0 +1,24 @@ +import { createFileRoute, Outlet } from "@tanstack/react-router"; + +export type PullRequestsSearch = { + search?: string; + project?: string; + state?: "open" | "all"; +}; + +export const Route = createFileRoute( + "/_authenticated/_dashboard/pull-requests", +)({ + component: PullRequestsLayout, + validateSearch: (search: Record): PullRequestsSearch => ({ + search: typeof search.search === "string" ? search.search : undefined, + project: typeof search.project === "string" ? search.project : undefined, + state: ["open", "all"].includes(search.state as string) + ? (search.state as PullRequestsSearch["state"]) + : undefined, + }), +}); + +function PullRequestsLayout() { + return ; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/page.tsx new file mode 100644 index 00000000000..020bc0844a2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/page.tsx @@ -0,0 +1,21 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { PullRequestsView } from "./components/PullRequestsView"; +import { Route as PullRequestsLayoutRoute } from "./layout"; + +export const Route = createFileRoute( + "/_authenticated/_dashboard/pull-requests/", +)({ + component: PullRequestsPage, +}); + +function PullRequestsPage() { + const { search, project, state } = PullRequestsLayoutRoute.useSearch(); + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/stores/pullRequestsFilterStore/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/stores/pullRequestsFilterStore/index.ts new file mode 100644 index 00000000000..b98d658d647 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/stores/pullRequestsFilterStore/index.ts @@ -0,0 +1,4 @@ +export { + pullRequestsSearchFromFilters, + usePullRequestsFilterStore, +} from "./pullRequestsFilterStore"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/stores/pullRequestsFilterStore/pullRequestsFilterStore.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/stores/pullRequestsFilterStore/pullRequestsFilterStore.test.ts new file mode 100644 index 00000000000..d099df6c358 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/stores/pullRequestsFilterStore/pullRequestsFilterStore.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { pullRequestsSearchFromFilters } from "./pullRequestsFilterStore"; + +describe("pullRequestsSearchFromFilters", () => { + test("omits default filters", () => { + expect( + pullRequestsSearchFromFilters({ + search: "", + projectFilter: null, + includeClosed: false, + }), + ).toEqual({}); + }); + + test("serializes independent pull request filters", () => { + expect( + pullRequestsSearchFromFilters({ + search: "remote host", + projectFilter: "project-1", + includeClosed: true, + }), + ).toEqual({ + search: "remote host", + project: "project-1", + state: "all", + }); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/stores/pullRequestsFilterStore/pullRequestsFilterStore.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/stores/pullRequestsFilterStore/pullRequestsFilterStore.ts new file mode 100644 index 00000000000..d391a942223 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/stores/pullRequestsFilterStore/pullRequestsFilterStore.ts @@ -0,0 +1,48 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface PullRequestsFilterState { + search: string; + projectFilter: string | null; + includeClosed: boolean; + setSearch: (search: string) => void; + setProjectFilter: (projectFilter: string | null) => void; + setIncludeClosed: (includeClosed: boolean) => void; +} + +export const usePullRequestsFilterStore = create()( + persist( + (set) => ({ + search: "", + projectFilter: null, + includeClosed: false, + setSearch: (search) => set({ search }), + setProjectFilter: (projectFilter) => set({ projectFilter }), + setIncludeClosed: (includeClosed) => set({ includeClosed }), + }), + { + name: "pull-requests-filter-state", + version: 1, + partialize: (state) => ({ + projectFilter: state.projectFilter, + includeClosed: state.includeClosed, + }), + }, + ), +); + +interface PullRequestsFilters { + search: string; + projectFilter: string | null; + includeClosed: boolean; +} + +export function pullRequestsSearchFromFilters( + filters: PullRequestsFilters, +): Record { + const search: Record = {}; + if (filters.search) search.search = filters.search; + if (filters.projectFilter) search.project = filters.projectFilter; + if (filters.includeClosed) search.state = "all"; + return search; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/EditableTitle/EditableTitle.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/EditableTitle/EditableTitle.tsx index f5b1ee8152a..1868e8e1718 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/EditableTitle/EditableTitle.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/EditableTitle/EditableTitle.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; interface EditableTitleProps { value: string; @@ -7,13 +7,20 @@ interface EditableTitleProps { export function EditableTitle({ value, onSave }: EditableTitleProps) { const [localValue, setLocalValue] = useState(value); - const inputRef = useRef(null); + const inputRef = useRef(null); // Sync with external value changes useEffect(() => { setLocalValue(value); }, [value]); + useLayoutEffect(() => { + const input = inputRef.current; + if (!input) return; + input.style.height = "auto"; + input.style.height = `${input.scrollHeight}px`; + }, []); + const handleBlur = () => { const trimmed = localValue.trim(); if (trimmed && trimmed !== value) { @@ -23,7 +30,8 @@ export function EditableTitle({ value, onSave }: EditableTitleProps) { } }; - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.nativeEvent.isComposing) return; if (e.key === "Enter") { e.preventDefault(); inputRef.current?.blur(); @@ -35,15 +43,16 @@ export function EditableTitle({ value, onSave }: EditableTitleProps) { }; return ( - setLocalValue(e.target.value)} onBlur={handleBlur} onKeyDown={handleKeyDown} - className="w-full text-2xl font-semibold mb-6 p-0 bg-transparent border-none outline-none focus:outline-none placeholder:text-muted-foreground" + className="mb-6 block w-full resize-none overflow-hidden border-none bg-transparent p-0 text-2xl font-semibold leading-tight outline-none placeholder:text-muted-foreground focus:outline-none" placeholder="Task title..." + aria-label="Task title" /> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx index 96351532bde..ecbdfbf74aa 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx @@ -43,6 +43,7 @@ function TaskDetailPage() { type, project, linearProject, + state, } = TasksLayoutRoute.useSearch(); const navigate = useNavigate(); const collections = useCollections(); @@ -57,11 +58,12 @@ function TaskDetailPage() { tab: tab ?? "all", assignee: assignee ?? null, search: searchQuery ?? "", - typeTab: type ?? "tasks", + typeTab: type === "issues" ? "issues" : "tasks", projectFilter: project ?? null, linearProjectFilter: linearProject ?? null, + includeClosedIssues: state === "all", }); - }, [tab, assignee, searchQuery, type, project, linearProject]); + }, [tab, assignee, searchQuery, type, project, linearProject, state]); useEscapeToNavigate("/tasks", { search: backSearch }); // Support both UUID and slug lookups diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.test.ts index 9d9963aed16..1b4fe1901cd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.test.ts @@ -61,3 +61,51 @@ describe("Run in Workspace selection wiring (#2641)", () => { expect(source).toContain("RunInWorkspacePopover"); }); }); + +describe("Tasks and pull requests navigation", () => { + test("keeps GitHub issues as a contextual Tasks source", () => { + const source = readComponent("components/TasksTopBar/TasksTopBar.tsx"); + + expect(source).toContain('label: "Linear"'); + expect(source).toContain('label: "GitHub issues"'); + expect(source).not.toContain('label: "PRs"'); + }); + + test("keeps pull request UI out of TasksView", () => { + const source = readComponent("TasksView.tsx"); + + expect(source).not.toContain("WorkItemsTabs"); + expect(source).not.toContain("PullRequestsTopBar"); + expect(source).not.toContain("PullRequestsContent"); + }); + + test("gives pull requests an independent route and view", () => { + const pageSource = readComponent("../../../pull-requests/page.tsx"); + const viewSource = readComponent( + "../../../pull-requests/components/PullRequestsView/PullRequestsView.tsx", + ); + + expect(pageSource).toContain(" { + const sidebarSources = [ + readComponent( + "../../../components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx", + ), + readComponent( + "../../../../../../screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx", + ), + ]; + + for (const source of sidebarSources) { + expect(source).toContain('to: "/tasks"'); + expect(source).toContain('to: "/pull-requests"'); + expect(source).toContain('aria-label="Tasks"'); + expect(source).toContain('aria-label="Pull requests"'); + expect(source).not.toContain("Tasks & PRs"); + } + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.tsx index d310d04bc87..c1805bfceb6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.tsx @@ -8,7 +8,7 @@ import { useRef, useState, } from "react"; -import { useHostProjects } from "renderer/hooks/host-projects/useHostProjects"; +import { useProjectHost } from "renderer/routes/_authenticated/_dashboard/hooks/useProjectHost"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { tasksSearchFromFilters, @@ -20,18 +20,22 @@ import { type SelectedIssue, } from "./components/GitHubIssuesContent"; import { LinearCTA } from "./components/LinearCTA"; -import { PullRequestsContent } from "./components/PullRequestsContent"; import { TableContent } from "./components/TableContent"; -import { type TabValue, TasksTopBar } from "./components/TasksTopBar"; +import { + type TabValue, + type TaskSource, + TasksTopBar, +} from "./components/TasksTopBar"; import type { TaskWithStatus } from "./hooks/useTasksData"; interface TasksViewProps { initialTab?: "all" | "active" | "backlog"; initialAssignee?: string; initialSearch?: string; - initialType?: "tasks" | "prs" | "issues"; + initialType?: "tasks" | "issues"; initialProject?: string; initialLinearProject?: string; + initialState?: "open" | "all"; } export function TasksView({ @@ -41,6 +45,7 @@ export function TasksView({ initialType, initialProject, initialLinearProject, + initialState, }: TasksViewProps) { const navigate = useNavigate(); const collections = useCollections(); @@ -59,20 +64,31 @@ export function TasksView({ setTypeTab: storeSetTypeTab, setProjectFilter: storeSetProjectFilter, setLinearProjectFilter: storeSetLinearProjectFilter, + includeClosedIssues: storedIncludeClosedIssues, + setIncludeClosedIssues: storeSetIncludeClosedIssues, viewMode, setViewMode, } = useTasksFilterStore(); + const includeClosedIssues = + initialState === undefined + ? storedIncludeClosedIssues + : initialState === "all"; const debounceRef = useRef>(null); + useEffect(() => { + setSearchQuery(initialSearch ?? ""); + }, [initialSearch]); + const buildSearch = useCallback( (overrides: { tab?: TabValue; assignee?: string | null; search?: string; - type?: "tasks" | "prs" | "issues"; + type?: "tasks" | "issues"; project?: string | null; linearProject?: string | null; + includeClosedIssues?: boolean; }) => tasksSearchFromFilters({ tab: overrides.tab ?? currentTab, @@ -88,6 +104,8 @@ export function TasksView({ overrides.linearProject !== undefined ? overrides.linearProject : linearProjectFilter, + includeClosedIssues: + overrides.includeClosedIssues ?? includeClosedIssues, }), [ currentTab, @@ -96,6 +114,7 @@ export function TasksView({ typeTab, projectFilter, linearProjectFilter, + includeClosedIssues, ], ); @@ -152,6 +171,10 @@ export function TasksView({ storeSetLinearProjectFilter(linearProjectFilter); }, [linearProjectFilter, storeSetLinearProjectFilter]); + useEffect(() => { + storeSetIncludeClosedIssues(includeClosedIssues); + }, [includeClosedIssues, storeSetIncludeClosedIssues]); + const { data: integrations } = useLiveQuery( (q) => q @@ -163,8 +186,12 @@ export function TasksView({ ); // Projects are fully local — identity comes from the host fan-out. - const { projects: hostProjects, isReady: hostProjectsReady } = - useHostProjects(); + const { + hostId: projectHostId, + isReady: areProjectsReady, + project: selectedProject, + projects: hostProjects, + } = useProjectHost(projectFilter); const v2Projects = useMemo( () => hostProjects.map((project) => ({ @@ -175,10 +202,10 @@ export function TasksView({ ); useEffect(() => { + if (projectFilter && v2Projects.some((p) => p.id === projectFilter)) return; // A partial fan-out must not rewrite the user's filter: the selected // project may live on a host that hasn't answered yet. - if (!hostProjectsReady) return; - if (projectFilter && v2Projects.some((p) => p.id === projectFilter)) return; + if (!areProjectsReady) return; const firstProject = v2Projects[0]; if (!firstProject) return; navigate({ @@ -186,7 +213,7 @@ export function TasksView({ search: buildSearch({ project: firstProject.id }), replace: true, }); - }, [hostProjectsReady, projectFilter, v2Projects, navigate, buildSearch]); + }, [areProjectsReady, projectFilter, v2Projects, navigate, buildSearch]); const isLinearConnected = integrations?.some((i) => i.provider === "linear") ?? false; @@ -203,8 +230,25 @@ export function TasksView({ }); }; - const handleTypeTabChange = (type: "tasks" | "prs" | "issues") => { - navigate({ to: "/tasks", search: buildSearch({ type }), replace: true }); + const navigateToType = (type: TaskSource, resetSearch: boolean) => { + const nextSearch = resetSearch ? "" : searchQuery; + if (resetSearch) { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + setSearchQuery(""); + storeSetSearch(""); + } + navigate({ + to: "/tasks", + search: buildSearch({ type, search: nextSearch }), + replace: true, + }); + }; + + const handleTaskSourceChange = (source: TaskSource) => { + navigateToType(source, true); }; const handleProjectFilterChange = (project: string) => { @@ -219,6 +263,15 @@ export function TasksView({ }); }; + const handleIncludeClosedIssuesChange = (nextIncludeClosed: boolean) => { + storeSetIncludeClosedIssues(nextIncludeClosed); + navigate({ + to: "/tasks", + search: buildSearch({ includeClosedIssues: nextIncludeClosed }), + replace: true, + }); + }; + const [selectedTasks, setSelectedTasks] = useState([]); const clearSelectionRef = useRef<(() => void) | null>(null); @@ -261,33 +314,33 @@ export function TasksView({ integrations !== undefined && !isLinearConnected && typeTab === "tasks"; const showTasks = typeTab === "tasks"; - const showPRs = typeTab === "prs"; const showIssues = typeTab === "issues"; + const taskSource: TaskSource = showIssues ? "issues" : "tasks"; return (
- {!showLinearCTA && ( - - )} + {showLinearCTA ? ( @@ -312,16 +365,14 @@ export function TasksView({ onSelectionChange={handleSelectionChange} /> ))} - {showPRs && ( - - )} {showIssues && ( 0} + searchQuery={searchQuery} + includeClosed={includeClosedIssues} onSelectionChange={handleIssueSelectionChange} /> )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/GitHubIssuesContent/GitHubIssuesContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/GitHubIssuesContent/GitHubIssuesContent.tsx index a990e000b76..eaee3aa831b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/GitHubIssuesContent/GitHubIssuesContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/GitHubIssuesContent/GitHubIssuesContent.tsx @@ -1,21 +1,15 @@ import { Button } from "@superset/ui/button"; import { Checkbox } from "@superset/ui/checkbox"; -import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { - useCallback, - useEffect, - useId, - useMemo, - useRef, - useState, -} from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { GoIssueClosed, GoIssueOpened } from "react-icons/go"; import { HiOutlineArrowTopRightOnSquare } from "react-icons/hi2"; import { LuMinus, LuPlus, LuRefreshCw } from "react-icons/lu"; import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { shouldKeepWorkItemsPlaceholder } from "renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder"; import { type LinkedIssue, useNewWorkspaceDraftStore, @@ -31,7 +25,11 @@ export interface SelectedIssue { interface GitHubIssuesContentProps { projectFilter: string | null; + hostId: string | null; + areProjectsReady: boolean; + hasProjects: boolean; searchQuery: string; + includeClosed: boolean; onCollapse?: () => void; onSelectionChange?: ( issues: SelectedIssue[], @@ -43,17 +41,19 @@ const PAGE_SIZE = 30; export function GitHubIssuesContent({ projectFilter, + hostId, + areProjectsReady, + hasProjects, searchQuery, + includeClosed, onCollapse, onSelectionChange, }: GitHubIssuesContentProps) { - const [showClosed, setShowClosed] = useState(false); - const showClosedId = useId(); const [selectedIssues, setSelectedIssues] = useState< Map >(new Map()); const debouncedQuery = useDebouncedValue(searchQuery, 300); - const hostUrl = useHostUrl(null); + const hostUrl = useHostUrl(hostId ?? undefined); const navigate = useNavigate(); const updateDraft = useNewWorkspaceDraftStore((s) => s.updateDraft); const resetDraft = useNewWorkspaceDraftStore((s) => s.resetDraft); @@ -74,7 +74,7 @@ export function GitHubIssuesContent({ projectFilter, hostUrl, debouncedQuery.trim(), - showClosed, + includeClosed, ], queryFn: async ({ pageParam }) => { if (!hostUrl || !projectFilter) { @@ -90,7 +90,7 @@ export function GitHubIssuesContent({ projectId: projectFilter, query: debouncedQuery.trim() || undefined, limit: PAGE_SIZE, - includeClosed: showClosed, + includeClosed, page: pageParam, }); }, @@ -99,7 +99,15 @@ export function GitHubIssuesContent({ lastPage.hasNextPage ? lastPage.page + 1 : undefined, staleTime: 30_000, gcTime: 10 * 60_000, - placeholderData: keepPreviousData, + placeholderData: (previousData, previousQuery) => { + return shouldKeepWorkItemsPlaceholder( + previousQuery?.queryKey, + projectFilter, + hostUrl, + ) + ? previousData + : undefined; + }, enabled: !!projectFilter && !!hostUrl, retry: false, }); @@ -172,6 +180,7 @@ export function GitHubIssuesContent({ resetDraft(); updateDraft({ selectedProjectId: projectFilter, + hostId, linkedIssues: [linkedIssue], }); openModal(projectFilter); @@ -186,7 +195,12 @@ export function GitHubIssuesContent({ navigate({ to: "/tasks/issue/$issueNumber", params: { issueNumber: String(issueNumber) }, - search: { project: projectFilter }, + search: { + search: searchQuery || undefined, + type: "issues", + project: projectFilter, + state: includeClosed ? "all" : undefined, + }, }); }; @@ -195,7 +209,26 @@ export function GitHubIssuesContent({
- Select a project to see issues. + + {areProjectsReady + ? hasProjects + ? "Select a project to see GitHub issues." + : "Add a project to see GitHub issues." + : "Loading projects…"} + +
+
+ ); + } + + if (!hostId || !hostUrl) { + return ( +
+
+ + + The device that hosts this project is unavailable. +
); @@ -209,24 +242,31 @@ export function GitHubIssuesContent({ : `${issues.length} of ${totalCount}`; return ( -
+
- - GitHub issues - - - {countLabel} + + {countLabel}{" "} + {totalCount === 1 ? "GitHub issue" : "GitHub issues"} {onCollapse && ( @@ -234,6 +274,7 @@ export function GitHubIssuesContent({ variant="ghost" size="icon-xs" title="Minimize" + aria-label="Minimize GitHub issues" onClick={onCollapse} > @@ -241,45 +282,27 @@ export function GitHubIssuesContent({ )}
-
- setShowClosed(checked === true)} - /> - - {isFetching && !isInitialLoad && ( - Loading… - )} -
-
- {error instanceof Error && ( -
- {error.message} + {error instanceof Error ? ( +
+ {error.message} +
- )} - - {repoMismatch && ( + ) : repoMismatch ? (
Issue URL must match {repoMismatch}.
- )} - - {isInitialLoad ? ( + ) : isInitialLoad ? (
- + Loading issues…
- ) : totalCount === 0 && !isFetching && !error ? ( + ) : totalCount === 0 && !isFetching ? (
- {showClosed ? "No issues found." : "No open issues."} + {includeClosed ? "No issues found." : "No open issues."}
) : ( @@ -292,7 +315,7 @@ export function GitHubIssuesContent({ // biome-ignore lint/a11y/useSemanticElements: row contains nested action buttons, so the outer element is a div with role/tabIndex
handleOpenPreview(issue.issueNumber)} onKeyDown={(e) => { if (e.target !== e.currentTarget) return; @@ -344,6 +367,7 @@ export function GitHubIssuesContent({ variant="ghost" size="icon-xs" title="Open in browser" + aria-label={`Open issue #${issue.issueNumber} in browser`} onClick={(e) => { e.stopPropagation(); handleOpenUrl(issue.url); @@ -355,6 +379,7 @@ export function GitHubIssuesContent({ variant="outline" size="sm" title="Add to workspace" + aria-label={`Add issue #${issue.issueNumber} to workspace`} className="h-7 gap-1.5 px-2 text-xs" onClick={(e) => { e.stopPropagation(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx index 6332f3e4214..8ed199052fa 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx @@ -1,32 +1,32 @@ import { Button } from "@superset/ui/button"; -import { Input } from "@superset/ui/input"; import { Tabs, TabsList, TabsTrigger } from "@superset/ui/tabs"; import { cn } from "@superset/ui/utils"; -import { useRef, useState } from "react"; -import { GoGitPullRequest, GoIssueOpened } from "react-icons/go"; +import { useState } from "react"; +import { GoIssueOpened } from "react-icons/go"; import { - HiOutlineMagnifyingGlass, HiOutlinePencilSquare, HiOutlineQueueList, HiOutlineViewColumns, HiXMark, } from "react-icons/hi2"; +import { SiLinear } from "react-icons/si"; import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; -import { useHotkey } from "renderer/hotkeys"; -import type { TypeTab, ViewMode } from "../../../../stores/tasks-filter-state"; +import { OpenClosedFilter } from "renderer/routes/_authenticated/_dashboard/components/OpenClosedFilter"; +import { ProjectFilter } from "renderer/routes/_authenticated/_dashboard/components/ProjectFilter"; +import { WorkItemsSearch } from "renderer/routes/_authenticated/_dashboard/components/WorkItemsSearch"; +import type { ViewMode } from "../../../../stores/tasks-filter-state"; import type { TaskWithStatus } from "../../hooks/useTasksData"; import type { SelectedIssue } from "../GitHubIssuesContent"; -import { ActiveIcon } from "../shared/icons/ActiveIcon"; import { AssigneeFilter } from "./components/AssigneeFilter"; import { CreateTaskDialog } from "./components/CreateTaskDialog"; import { LinearProjectFilter } from "./components/LinearProjectFilter"; -import { ProjectFilter } from "./components/ProjectFilter"; import { RunInWorkspacePopover } from "./components/RunInWorkspacePopover"; import { RunInWorkspacePopoverV2 } from "./components/RunInWorkspacePopoverV2"; import { RunIssuesInWorkspacePopover } from "./components/RunIssuesInWorkspacePopover"; import { StatusFilter } from "./components/StatusFilter"; export type TabValue = "all" | "active" | "backlog"; +export type TaskSource = "tasks" | "issues"; interface TasksTopBarProps { currentTab: TabValue; @@ -41,18 +41,19 @@ interface TasksTopBarProps { onClearIssueSelection?: () => void; viewMode: ViewMode; onViewModeChange: (mode: ViewMode) => void; - typeTab: TypeTab; - onTypeTabChange: (typeTab: TypeTab) => void; + taskSource: TaskSource; + onTaskSourceChange: (taskSource: TaskSource) => void; projectFilter: string | null; onProjectFilterChange: (projectId: string) => void; linearProjectFilter: string | null; onLinearProjectFilterChange: (projectId: string | null) => void; + includeClosedIssues: boolean; + onIncludeClosedIssuesChange: (includeClosed: boolean) => void; } -const TYPE_TABS = [ - { value: "tasks" as const, label: "Tasks", Icon: ActiveIcon }, - { value: "prs" as const, label: "PRs", Icon: GoGitPullRequest }, - { value: "issues" as const, label: "Issues", Icon: GoIssueOpened }, +const TASK_SOURCES = [ + { value: "tasks" as const, label: "Linear", Icon: SiLinear }, + { value: "issues" as const, label: "GitHub issues", Icon: GoIssueOpened }, ] as const; export function TasksTopBar({ @@ -68,197 +69,192 @@ export function TasksTopBar({ onClearIssueSelection, viewMode, onViewModeChange, - typeTab, - onTypeTabChange, + taskSource, + onTaskSourceChange, projectFilter, onProjectFilterChange, linearProjectFilter, onLinearProjectFilterChange, + includeClosedIssues, + onIncludeClosedIssuesChange, }: TasksTopBarProps) { - const showTaskOnlyControls = typeTab === "tasks"; - const showIssues = typeTab === "issues"; + const showTaskOnlyControls = taskSource === "tasks"; + const showIssues = taskSource === "issues"; const taskSelectedCount = selectedTasks.length; const issueSelectedCount = selectedIssues.length; const selectedCount = showIssues ? issueSelectedCount : taskSelectedCount; - const searchInputRef = useRef(null); const [isCreateTaskOpen, setIsCreateTaskOpen] = useState(false); const isV2CloudEnabled = useIsV2CloudEnabled(); - useHotkey( - "FOCUS_TASK_SEARCH", - () => { - searchInputRef.current?.focus(); - searchInputRef.current?.select(); - }, - { preventDefault: true }, - ); - const hasSelection = selectedCount > 0; return ( <> -
- {/* Left side: tabs/filters or selection actions */} -
- {hasSelection ? ( - <> - - - {selectedCount} selected - -
- {showIssues ? ( - {})} - /> - ) : isV2CloudEnabled ? ( - {})} - /> - ) : ( - {})} - /> - )} - - ) : ( - <> - {showTaskOnlyControls ? ( - - ) : ( - - )} - -
- - onTypeTabChange(value as TypeTab)} - > - - {TYPE_TABS.map((tab) => { - const Icon = tab.Icon; - return ( - - - - {tab.label} - - - ); - })} - - - - {showTaskOnlyControls && ( - <> -
- - - -
- - +
+
+ {hasSelection ? ( + <> + + + {selectedCount} selected + +
+ {showIssues ? ( + {})} /> - - )} - - )} -
+ ) : isV2CloudEnabled ? ( + {})} + /> + ) : ( + {})} + /> + )} + + ) : ( + <> + + onTaskSourceChange(value as TaskSource) + } + className="flex-row gap-0" + > + + {TASK_SOURCES.map((source) => { + const Icon = source.Icon; + return ( + + + {source.label} + + ); + })} + + - {/* Window-drag leaf standing in for the hidden TopBar. */} -
+
- {/* Right side: create + view toggle + search */} -
- {showTaskOnlyControls && ( - <> - + {showTaskOnlyControls ? ( + <> + +
+ +
+ + + ) : ( + <> +
+ + Repository + + +
+
+ + + )} + + )} +
-
- - + +
- - -
- - )} + + + + + )} -
- - onSearchChange(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape") { - onSearchChange(""); - searchInputRef.current?.blur(); - } - }} - className="h-8 pl-9 pr-3 text-sm bg-muted/50 border-0 focus-visible:ring-1" + label={showIssues ? "Search GitHub issues" : "Search tasks"} />
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/index.ts index 0e91843fc6c..27a0e8c7300 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/index.ts @@ -1 +1,5 @@ -export { type TabValue, TasksTopBar } from "./TasksTopBar"; +export { + type TabValue, + type TaskSource, + TasksTopBar, +} from "./TasksTopBar"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/issue/$issueNumber/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/issue/$issueNumber/page.tsx index 6942098de4d..0b6777935af 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/issue/$issueNumber/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/issue/$issueNumber/page.tsx @@ -1,20 +1,22 @@ -import { Button } from "@superset/ui/button"; import { ScrollArea } from "@superset/ui/scroll-area"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useMemo } from "react"; import { GoIssueClosed, GoIssueOpened } from "react-icons/go"; -import { HiArrowLeft } from "react-icons/hi2"; -import { LuExternalLink, LuPlus } from "react-icons/lu"; import { MarkdownRenderer } from "renderer/components/MarkdownRenderer"; import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { WorkItemDetailHeader } from "renderer/routes/_authenticated/_dashboard/components/WorkItemDetailHeader"; +import { WorkItemDetailState } from "renderer/routes/_authenticated/_dashboard/components/WorkItemDetailState"; +import { useProjectHost } from "renderer/routes/_authenticated/_dashboard/hooks/useProjectHost"; +import { parsePositiveIntegerParam } from "renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam"; import { type LinkedIssue, useNewWorkspaceDraftStore, } from "renderer/stores/new-workspace-draft"; import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; import { Route as TasksLayoutRoute } from "../../layout"; +import { tasksSearchFromFilters } from "../../stores/tasks-filter-state"; export const Route = createFileRoute( "/_authenticated/_dashboard/tasks/issue/$issueNumber/", @@ -24,36 +26,52 @@ export const Route = createFileRoute( function IssueDetailPage() { const { issueNumber: issueNumberRaw } = Route.useParams(); - const issueNumber = Number.parseInt(issueNumberRaw, 10); + const issueNumber = parsePositiveIntegerParam(issueNumberRaw); const search = TasksLayoutRoute.useSearch(); const navigate = useNavigate(); - const hostUrl = useHostUrl(null); const projectId = search.project ?? null; - const updateDraft = useNewWorkspaceDraftStore((s) => s.updateDraft); - const resetDraft = useNewWorkspaceDraftStore((s) => s.resetDraft); + const { + hostId, + isReady: areProjectsReady, + project, + } = useProjectHost(projectId); + const hostUrl = useHostUrl(hostId ?? undefined); + const updateDraft = useNewWorkspaceDraftStore((state) => state.updateDraft); + const resetDraft = useNewWorkspaceDraftStore((state) => state.resetDraft); const openModal = useOpenNewWorkspaceModal(); - const backSearch = useMemo(() => { - const s: Record = {}; - if (search.tab) s.tab = search.tab; - if (search.assignee) s.assignee = search.assignee; - if (search.search) s.search = search.search; - if (search.type) s.type = search.type; - if (search.project) s.project = search.project; - return s; - }, [search]); - - const { data, isLoading, error } = useQuery({ + const backSearch = useMemo( + () => + tasksSearchFromFilters({ + tab: search.tab ?? "all", + assignee: search.assignee ?? null, + search: search.search ?? "", + typeTab: "issues", + projectFilter: projectId, + linearProjectFilter: search.linearProject ?? null, + includeClosedIssues: search.state === "all", + }), + [ + projectId, + search.assignee, + search.linearProject, + search.search, + search.state, + search.tab, + ], + ); + + const { data, isLoading, error, refetch } = useQuery({ queryKey: ["issue-detail", projectId, hostUrl, issueNumber], queryFn: async () => { - if (!hostUrl || !projectId) return null; + if (!hostUrl || !projectId || issueNumber === null) return null; const client = getHostServiceClientByUrl(hostUrl); return client.issues.getContent.query({ projectId, issueNumber, }); }, - enabled: !!hostUrl && !!projectId && Number.isFinite(issueNumber), + enabled: !!hostUrl && !!project && !!projectId && issueNumber !== null, retry: false, staleTime: 30_000, gcTime: 10 * 60_000, @@ -64,7 +82,7 @@ function IssueDetailPage() { }; const handleAddToWorkspace = () => { - if (!projectId || !data) return; + if (!projectId || !hostId || !data) return; const linkedIssue: LinkedIssue = { slug: `gh-${data.number}`, title: data.title, @@ -76,78 +94,114 @@ function IssueDetailPage() { resetDraft(); updateDraft({ selectedProjectId: projectId, + hostId, linkedIssues: [linkedIssue], }); openModal(projectId); }; + const isClosed = data?.state.toLowerCase() === "closed"; + const StateIcon = isClosed ? GoIssueClosed : GoIssueOpened; + const stateIconClass = isClosed ? "text-violet-500" : "text-emerald-500"; + const header = ( + } + backLabel="Back to GitHub issues" + externalLabel="Open issue in GitHub" + url={data?.url ?? null} + onBack={handleBack} + onAddToWorkspace={data ? handleAddToWorkspace : null} + /> + ); + + if (issueNumber === null) { + return ( +
+ {header} + +
+ ); + } + if (!projectId) { return ( -
- No project specified. +
+ {header} + +
+ ); + } + + if (!project) { + return ( +
+ {header} + +
+ ); + } + + if (!hostId || !hostUrl) { + return ( +
+ {header} +
); } if (isLoading) { return ( -
- Loading issue… +
+ {header} +
); } if (error instanceof Error || !data) { return ( -
-
+ {header} + void refetch()} /> -
- {error instanceof Error ? error.message : "Issue not found."} -
); } - const isClosed = data.state.toLowerCase() === "closed"; - const StateIcon = isClosed ? GoIssueClosed : GoIssueOpened; - return ( -
-
- - -
-
- -

+
+ {header} + +
+
+ +

{data.title}

-
+
{data.state} {data.author && ( <> - · - by {data.author} + · + by {data.author} )}
@@ -155,7 +209,7 @@ function IssueDetailPage() { {data.body.trim() ? ( ) : ( -

+

No description provided.

)} @@ -164,68 +218,3 @@ function IssueDetailPage() {
); } - -interface HeaderProps { - issueNumber: number; - url: string | null; - isClosed: boolean; - onBack: () => void; - onAddToWorkspace: (() => void) | null; -} - -function Header({ - issueNumber, - url, - isClosed, - onBack, - onAddToWorkspace, -}: HeaderProps) { - const StateIcon = isClosed ? GoIssueClosed : GoIssueOpened; - return ( -
- - - - #{issueNumber} - - {/* Window-drag leaf standing in for the hidden TopBar. */} -
-
- {url && ( - - - - )} - {onAddToWorkspace && ( - - )} -
-
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/layout.tsx index 00a4378e30b..e425cb6f63b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/layout.tsx @@ -7,6 +7,7 @@ export type TasksSearch = { type?: "tasks" | "prs" | "issues"; project?: string; linearProject?: string; + state?: "open" | "all"; }; export const Route = createFileRoute("/_authenticated/_dashboard/tasks")({ @@ -25,6 +26,9 @@ export const Route = createFileRoute("/_authenticated/_dashboard/tasks")({ typeof search.linearProject === "string" ? search.linearProject : undefined, + state: ["open", "all"].includes(search.state as string) + ? (search.state as TasksSearch["state"]) + : undefined, }), }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx index 610499afa83..dcef64ea43f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx @@ -1,4 +1,4 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, Navigate } from "@tanstack/react-router"; import { TasksView } from "./components/TasksView"; import { Route as TasksLayoutRoute } from "./layout"; @@ -7,8 +7,21 @@ export const Route = createFileRoute("/_authenticated/_dashboard/tasks/")({ }); function TasksPage() { - const { tab, assignee, search, type, project, linearProject } = + const { tab, assignee, search, type, project, linearProject, state } = TasksLayoutRoute.useSearch(); + if (type === "prs") { + return ( + + ); + } return ( ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/pr/$prNumber/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/pr/$prNumber/page.tsx index a83d2f7b1ca..7d05551a76d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/pr/$prNumber/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/pr/$prNumber/page.tsx @@ -1,228 +1,22 @@ -import { Button } from "@superset/ui/button"; -import { ScrollArea } from "@superset/ui/scroll-area"; -import { useQuery } from "@tanstack/react-query"; -import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useMemo } from "react"; -import { HiArrowLeft } from "react-icons/hi2"; -import { LuExternalLink, LuPlus } from "react-icons/lu"; -import { MarkdownRenderer } from "renderer/components/MarkdownRenderer"; -import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; -import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; -import { - normalizePRState, - PRIcon, - type PRState, -} from "renderer/screens/main/components/PRIcon"; -import { - type LinkedPR, - useNewWorkspaceDraftStore, -} from "renderer/stores/new-workspace-draft"; -import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; +import { createFileRoute, Navigate } from "@tanstack/react-router"; import { Route as TasksLayoutRoute } from "../../layout"; export const Route = createFileRoute( "/_authenticated/_dashboard/tasks/pr/$prNumber/", )({ - component: PullRequestDetailPage, + component: LegacyPullRequestRedirect, }); -function PullRequestDetailPage() { - const { prNumber: prNumberRaw } = Route.useParams(); - const prNumber = Number.parseInt(prNumberRaw, 10); - const search = TasksLayoutRoute.useSearch(); - const navigate = useNavigate(); - const hostUrl = useHostUrl(null); - const projectId = search.project ?? null; - const updateDraft = useNewWorkspaceDraftStore((s) => s.updateDraft); - const resetDraft = useNewWorkspaceDraftStore((s) => s.resetDraft); - const openModal = useOpenNewWorkspaceModal(); +function LegacyPullRequestRedirect() { + const { prNumber } = Route.useParams(); + const { search, project, state } = TasksLayoutRoute.useSearch(); - const backSearch = useMemo(() => { - const s: Record = {}; - if (search.tab) s.tab = search.tab; - if (search.assignee) s.assignee = search.assignee; - if (search.search) s.search = search.search; - if (search.type) s.type = search.type; - if (search.project) s.project = search.project; - return s; - }, [search]); - - const { data, isLoading, error } = useQuery({ - queryKey: ["pull-request-detail", projectId, hostUrl, prNumber], - queryFn: async () => { - if (!hostUrl || !projectId) return null; - const client = getHostServiceClientByUrl(hostUrl); - return client.pullRequests.getContent.query({ - projectId, - prNumber, - }); - }, - enabled: !!hostUrl && !!projectId && Number.isFinite(prNumber), - retry: false, - staleTime: 30_000, - gcTime: 10 * 60_000, - }); - - const handleBack = () => { - navigate({ to: "/tasks", search: backSearch }); - }; - - const handleAddToWorkspace = () => { - if (!projectId || !data) return; - const linkedPR: LinkedPR = { - prNumber: data.number, - title: data.title, - url: data.url, - state: normalizePRState(data.state, data.isDraft), - }; - resetDraft(); - updateDraft({ selectedProjectId: projectId, linkedPR }); - openModal(projectId); - }; - - if (!projectId) { - return ( -
- No project specified. -
- ); - } - - if (isLoading) { - return ( -
- Loading pull request… -
- ); - } - - if (error instanceof Error || !data) { - return ( -
-
-
- {error instanceof Error ? error.message : "Pull request not found."} -
-
- ); - } - - const state = normalizePRState(data.state, data.isDraft); - const stateLabel = data.isDraft ? "Draft" : data.state; - const branchSummary = data.branch - ? `${data.headRepositoryOwner && data.isCrossRepository ? `${data.headRepositoryOwner}:${data.branch}` : data.branch} → ${data.baseBranch}` - : null; - - return ( -
-
- - -
-
- -

- {data.title} -

-
- -
- {stateLabel} - {data.author && ( - <> - · - by {data.author} - - )} - {branchSummary && ( - <> - · - {branchSummary} - - )} -
- - {data.body.trim() ? ( - - ) : ( -

- No description provided. -

- )} -
-
-
- ); -} - -interface HeaderProps { - prNumber: number; - url: string | null; - state: PRState; - onBack: () => void; - onAddToWorkspace: (() => void) | null; -} - -function Header({ - prNumber, - url, - state, - onBack, - onAddToWorkspace, -}: HeaderProps) { return ( -
- - - - #{prNumber} - - {/* Window-drag leaf standing in for the hidden TopBar. */} -
-
- {url && ( - - - - )} - {onAddToWorkspace && ( - - )} -
-
+ ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state.test.ts new file mode 100644 index 00000000000..d9e06ddbe7c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test"; +import { + migrateTasksFilterState, + tasksSearchFromFilters, +} from "./tasks-filter-state"; + +describe("tasksSearchFromFilters", () => { + test("keeps default task filters out of the URL", () => { + expect( + tasksSearchFromFilters({ + tab: "all", + assignee: null, + search: "", + typeTab: "tasks", + projectFilter: null, + linearProjectFilter: null, + includeClosedIssues: false, + }), + ).toEqual({}); + }); + + test("preserves GitHub Issue context across detail navigation", () => { + expect( + tasksSearchFromFilters({ + tab: "active", + assignee: "me", + search: "remote host", + typeTab: "issues", + projectFilter: "project-1", + linearProjectFilter: null, + includeClosedIssues: true, + }), + ).toEqual({ + tab: "active", + assignee: "me", + search: "remote host", + type: "issues", + project: "project-1", + state: "all", + }); + }); + + test("does not leak the issue state filter into Linear tasks", () => { + expect( + tasksSearchFromFilters({ + tab: "all", + assignee: null, + search: "", + typeTab: "tasks", + projectFilter: "project-1", + linearProjectFilter: "linear-project-1", + includeClosedIssues: true, + }), + ).toEqual({ + project: "project-1", + linearProject: "linear-project-1", + }); + }); +}); + +describe("migrateTasksFilterState", () => { + test("moves legacy PR tabs back to Tasks and defaults issue state safely", () => { + expect( + migrateTasksFilterState({ + typeTab: "prs", + projectFilter: "project-1", + }), + ).toMatchObject({ + typeTab: "tasks", + projectFilter: "project-1", + includeClosedIssues: false, + }); + }); + + test("survives corrupt persisted values", () => { + expect(migrateTasksFilterState(null)).toMatchObject({ + typeTab: "tasks", + includeClosedIssues: false, + }); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state.ts index 727f7eb2bca..3e87d838bdd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state.ts @@ -2,7 +2,7 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; export type ViewMode = "table" | "board"; -export type TypeTab = "tasks" | "prs" | "issues"; +export type TypeTab = "tasks" | "issues"; export type FilterTab = "all" | "active" | "backlog"; interface TasksFilterState { @@ -13,6 +13,7 @@ interface TasksFilterState { typeTab: TypeTab; projectFilter: string | null; linearProjectFilter: string | null; + includeClosedIssues: boolean; setTab: (tab: FilterTab) => void; setAssignee: (assignee: string | null) => void; setSearch: (search: string) => void; @@ -20,6 +21,21 @@ interface TasksFilterState { setTypeTab: (typeTab: TypeTab) => void; setProjectFilter: (projectFilter: string | null) => void; setLinearProjectFilter: (linearProjectFilter: string | null) => void; + setIncludeClosedIssues: (includeClosedIssues: boolean) => void; +} + +export function migrateTasksFilterState( + persistedState: unknown, +): TasksFilterState { + const state = + persistedState && typeof persistedState === "object" + ? (persistedState as Record) + : {}; + return { + ...state, + typeTab: state.typeTab === "issues" ? "issues" : "tasks", + includeClosedIssues: state.includeClosedIssues === true, + } as unknown as TasksFilterState; } export const useTasksFilterStore = create()( @@ -32,6 +48,7 @@ export const useTasksFilterStore = create()( typeTab: "tasks", projectFilter: null, linearProjectFilter: null, + includeClosedIssues: false, setTab: (tab) => set({ tab }), setAssignee: (assignee) => set({ assignee }), setSearch: (search) => set({ search }), @@ -40,16 +57,20 @@ export const useTasksFilterStore = create()( setProjectFilter: (projectFilter) => set({ projectFilter }), setLinearProjectFilter: (linearProjectFilter) => set({ linearProjectFilter }), + setIncludeClosedIssues: (includeClosedIssues) => + set({ includeClosedIssues }), }), { name: "tasks-filter-state", - version: 1, + version: 3, + migrate: migrateTasksFilterState, partialize: (state) => ({ projectFilter: state.projectFilter, linearProjectFilter: state.linearProjectFilter, tab: state.tab, typeTab: state.typeTab, viewMode: state.viewMode, + includeClosedIssues: state.includeClosedIssues, }), }, ), @@ -62,6 +83,7 @@ export interface TasksFilters { typeTab: TypeTab; projectFilter: string | null; linearProjectFilter: string | null; + includeClosedIssues: boolean; } export function tasksSearchFromFilters( @@ -75,5 +97,7 @@ export function tasksSearchFromFilters( if (filters.projectFilter) out.project = filters.projectFilter; if (filters.linearProjectFilter) out.linearProject = filters.linearProjectFilter; + if (filters.typeTab === "issues" && filters.includeClosedIssues) + out.state = "all"; return out; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam/index.ts new file mode 100644 index 00000000000..eef8ecb7a96 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam/index.ts @@ -0,0 +1 @@ +export { parsePositiveIntegerParam } from "./parsePositiveIntegerParam"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam/parsePositiveIntegerParam.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam/parsePositiveIntegerParam.test.ts new file mode 100644 index 00000000000..cd6d8ff0755 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam/parsePositiveIntegerParam.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test"; +import { parsePositiveIntegerParam } from "./parsePositiveIntegerParam"; + +describe("parsePositiveIntegerParam", () => { + test("accepts positive safe integers", () => { + expect(parsePositiveIntegerParam("5975")).toBe(5975); + }); + + test("rejects partial, non-positive, and unsafe values", () => { + expect(parsePositiveIntegerParam("12abc")).toBeNull(); + expect(parsePositiveIntegerParam("0")).toBeNull(); + expect(parsePositiveIntegerParam("-1")).toBeNull(); + expect( + parsePositiveIntegerParam(String(Number.MAX_SAFE_INTEGER + 1)), + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam/parsePositiveIntegerParam.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam/parsePositiveIntegerParam.ts new file mode 100644 index 00000000000..429e2c2bf3d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/parsePositiveIntegerParam/parsePositiveIntegerParam.ts @@ -0,0 +1,5 @@ +export function parsePositiveIntegerParam(value: string): number | null { + if (!/^[1-9]\d*$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder/index.ts new file mode 100644 index 00000000000..a18ac111f21 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder/index.ts @@ -0,0 +1 @@ +export { shouldKeepWorkItemsPlaceholder } from "./shouldKeepWorkItemsPlaceholder"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder/shouldKeepWorkItemsPlaceholder.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder/shouldKeepWorkItemsPlaceholder.test.ts new file mode 100644 index 00000000000..01119bc0ccb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder/shouldKeepWorkItemsPlaceholder.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { shouldKeepWorkItemsPlaceholder } from "./shouldKeepWorkItemsPlaceholder"; + +describe("shouldKeepWorkItemsPlaceholder", () => { + test("keeps rows while filtering within one project and host", () => { + expect( + shouldKeepWorkItemsPlaceholder( + ["pullRequests", "searchPullRequests", "project-1", "host-a"], + "project-1", + "host-a", + ), + ).toBe(true); + }); + + test("drops stale rows when the project changes", () => { + expect( + shouldKeepWorkItemsPlaceholder( + ["pullRequests", "searchPullRequests", "project-1", "host-a"], + "project-2", + "host-a", + ), + ).toBe(false); + }); + + test("drops stale rows when routing moves to another host", () => { + expect( + shouldKeepWorkItemsPlaceholder( + ["pullRequests", "searchPullRequests", "project-1", "host-a"], + "project-1", + "host-b", + ), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder/shouldKeepWorkItemsPlaceholder.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder/shouldKeepWorkItemsPlaceholder.ts new file mode 100644 index 00000000000..78a2b489cf0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/shouldKeepWorkItemsPlaceholder/shouldKeepWorkItemsPlaceholder.ts @@ -0,0 +1,9 @@ +export function shouldKeepWorkItemsPlaceholder( + previousQueryKey: readonly unknown[] | undefined, + projectId: string | null, + hostUrl: string | null, +): boolean { + return ( + previousQueryKey?.[2] === projectId && previousQueryKey?.[3] === hostUrl + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx index df8b478b95c..3cad40f8546 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx @@ -1,9 +1,14 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useMatchRoute, useNavigate } from "@tanstack/react-router"; +import { GoGitPullRequest } from "react-icons/go"; import { HiOutlineClipboardDocumentList } from "react-icons/hi2"; import { LuLayers } from "react-icons/lu"; import { GATED_FEATURES, usePaywall } from "renderer/components/Paywall"; +import { + pullRequestsSearchFromFilters, + usePullRequestsFilterStore, +} from "renderer/routes/_authenticated/_dashboard/pull-requests/stores/pullRequestsFilterStore"; import { tasksSearchFromFilters, useTasksFilterStore, @@ -24,6 +29,10 @@ export function WorkspaceSidebarHeader({ const isWorkspacesListOpen = !!matchRoute({ to: "/workspaces" }); const isTasksOpen = !!matchRoute({ to: "/tasks", fuzzy: true }); + const isPullRequestsOpen = !!matchRoute({ + to: "/pull-requests", + fuzzy: true, + }); const handleWorkspacesClick = () => { if (isWorkspacesListOpen) { @@ -41,7 +50,13 @@ export function WorkspaceSidebarHeader({ typeTab: lastTypeTab, projectFilter: lastProjectFilter, linearProjectFilter: lastLinearProjectFilter, + includeClosedIssues: lastIncludeClosedIssues, } = useTasksFilterStore(); + const { + search: lastPullRequestsSearch, + projectFilter: lastPullRequestsProjectFilter, + includeClosed: lastPullRequestsIncludeClosed, + } = usePullRequestsFilterStore(); const handleTasksClick = () => { gateFeature(GATED_FEATURES.TASKS, () => { @@ -54,6 +69,20 @@ export function WorkspaceSidebarHeader({ typeTab: lastTypeTab, projectFilter: lastProjectFilter, linearProjectFilter: lastLinearProjectFilter, + includeClosedIssues: lastIncludeClosedIssues, + }), + }); + }); + }; + + const handlePullRequestsClick = () => { + gateFeature(GATED_FEATURES.TASKS, () => { + navigate({ + to: "/pull-requests", + search: pullRequestsSearchFromFilters({ + search: lastPullRequestsSearch, + projectFilter: lastPullRequestsProjectFilter, + includeClosed: lastPullRequestsIncludeClosed, }), }); }); @@ -85,6 +114,8 @@ export function WorkspaceSidebarHeader({ - Tasks & PRs + Tasks + + + + + + + Pull requests @@ -127,6 +178,8 @@ export function WorkspaceSidebarHeader({
+ Tasks + + + From dacae659d1fac99e04fd280b82b203a0f022bd4f Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 3 Aug 2026 11:39:11 -0700 Subject: [PATCH 2/4] fix(desktop): re-measure task title height on text change, drop dead stored-search seed Claude-Session: https://claude.ai/code/session_01WPvjzEZjYWFoedQUiCKBPb --- .../components/PullRequestsView/PullRequestsView.tsx | 3 +-- .../tasks/$taskId/components/EditableTitle/EditableTitle.tsx | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/PullRequestsView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/PullRequestsView.tsx index 756ed9c1009..1257de22848 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/PullRequestsView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pull-requests/components/PullRequestsView/PullRequestsView.tsx @@ -21,14 +21,13 @@ export function PullRequestsView({ }: PullRequestsViewProps) { const navigate = useNavigate(); const { - search: storedSearch, projectFilter: storedProjectFilter, includeClosed: storedIncludeClosed, setSearch: storeSetSearch, setProjectFilter: storeSetProjectFilter, setIncludeClosed: storeSetIncludeClosed, } = usePullRequestsFilterStore(); - const [searchQuery, setSearchQuery] = useState(initialSearch ?? storedSearch); + const [searchQuery, setSearchQuery] = useState(initialSearch ?? ""); const projectFilter = initialProject ?? storedProjectFilter; const includeClosed = initialState === undefined ? storedIncludeClosed : initialState === "all"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/EditableTitle/EditableTitle.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/EditableTitle/EditableTitle.tsx index 1868e8e1718..3b4aaf0b56b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/EditableTitle/EditableTitle.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/EditableTitle/EditableTitle.tsx @@ -14,12 +14,13 @@ export function EditableTitle({ value, onSave }: EditableTitleProps) { setLocalValue(value); }, [value]); + // biome-ignore lint/correctness/useExhaustiveDependencies: re-measure height whenever the rendered text changes useLayoutEffect(() => { const input = inputRef.current; if (!input) return; input.style.height = "auto"; input.style.height = `${input.scrollHeight}px`; - }, []); + }, [localValue]); const handleBlur = () => { const trimmed = localValue.trim(); From c352f78424ccbb531c57ae7322836580b9e1d123 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 3 Aug 2026 16:36:48 -0700 Subject: [PATCH 3/4] fix(desktop): hide the empty TopBar and keep window drag on pull-requests views Extends the hidden-TopBar treatment from #6023 to the new /pull-requests route: the layout hides the strip there, and the pull-requests toolbar and shared work-item detail header carry the window-drag filler leaves. Claude-Session: https://claude.ai/code/session_01WPvjzEZjYWFoedQUiCKBPb --- .../components/WorkItemDetailHeader/WorkItemDetailHeader.tsx | 2 ++ .../src/renderer/routes/_authenticated/_dashboard/layout.tsx | 1 + .../components/PullRequestsTopBar/PullRequestsTopBar.tsx | 2 ++ 3 files changed, 5 insertions(+) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemDetailHeader/WorkItemDetailHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemDetailHeader/WorkItemDetailHeader.tsx index f526b242fe7..23c1967d5ca 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemDetailHeader/WorkItemDetailHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/WorkItemDetailHeader/WorkItemDetailHeader.tsx @@ -37,6 +37,8 @@ export function WorkItemDetailHeader({ {itemNumber === null ? "#—" : `#${itemNumber}`} + {/* Window-drag leaf standing in for the hidden TopBar. */} +
{url && (
+ {/* Window-drag leaf standing in for the hidden TopBar. */} +
Date: Tue, 4 Aug 2026 09:56:23 -0700 Subject: [PATCH 4/4] fix(desktop): register pull-requests-filter-state in the persisted-key registry Claude-Session: https://claude.ai/code/session_01WPvjzEZjYWFoedQUiCKBPb --- .../lib/persisted-keys/persisted-key-registry.test-data.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/desktop/src/renderer/lib/persisted-keys/persisted-key-registry.test-data.ts b/apps/desktop/src/renderer/lib/persisted-keys/persisted-key-registry.test-data.ts index 1422ef028cf..38ce55359df 100644 --- a/apps/desktop/src/renderer/lib/persisted-keys/persisted-key-registry.test-data.ts +++ b/apps/desktop/src/renderer/lib/persisted-keys/persisted-key-registry.test-data.ts @@ -120,6 +120,10 @@ export const PERSISTED_KEY_REGISTRY: ReadonlyArray< "src/renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state.ts", ["tasks-filter-state"], ], + [ + "src/renderer/routes/_authenticated/_dashboard/pull-requests/stores/pullRequestsFilterStore/pullRequestsFilterStore.ts", + ["pull-requests-filter-state"], + ], [ "src/renderer/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx", ["active_organization_id"],