-
}
- actions={
-
- }
+
+
-
- {!statusLoading && !connected ? (
+ {statusLoading && (
+
Checking GitLab status…
+ )}
+ {!statusLoading && !connected && (
+
- ) : (
-
- )}
-
-
- );
-}
-
-function BrowseTabs({
- search,
- setSearch,
- mrFilter,
- setMrFilter,
- issueFilter,
- setIssueFilter,
- customQuery,
-}: {
- search: string;
- setSearch: (v: string) => void;
- mrFilter: string;
- setMrFilter: (v: string) => void;
- issueFilter: string;
- setIssueFilter: (v: string) => void;
- customQuery: string;
-}) {
- return (
- <>
-
setSearch(e.target.value)}
- className="font-mono text-sm"
- />
-
-
-
-
-
- Merge requests
-
-
-
- Issues
-
-
-
-
-
-
-
-
-
-
-
- >
- );
-}
-
-function FilterButtons({
- value,
- onChange,
- options,
-}: {
- value: string;
- onChange: (v: string) => void;
- options: readonly { value: string; label: string }[];
-}) {
- return (
-
- {options.map((f) => (
-
+ )}
+ {!statusLoading && connected &&
}
+
+
- {f.label}
-
- ))}
+
+ Filters
+
+
+
+
+
);
}
diff --git a/apps/web/components/gitlab/my-gitlab/issue-list.tsx b/apps/web/components/gitlab/my-gitlab/issue-list.tsx
new file mode 100644
index 0000000000..7a1f72c8ac
--- /dev/null
+++ b/apps/web/components/gitlab/my-gitlab/issue-list.tsx
@@ -0,0 +1,103 @@
+"use client";
+
+import Link from "next/link";
+import { IconCircle, IconCircleCheck, IconExternalLink } from "@tabler/icons-react";
+import { Badge } from "@kandev/ui/badge";
+import { Spinner } from "@kandev/ui/spinner";
+import { cn, formatRelativeTime } from "@/lib/utils";
+import type { Issue } from "@/lib/types/gitlab";
+
+type IssueListProps = {
+ items: Issue[];
+ loading: boolean;
+ error: string | null;
+};
+
+function IssueLabels({ labels }: { labels: string[] }) {
+ if (!labels?.length) return null;
+ return (
+ <>
+ {labels.slice(0, 4).map((l) => (
+
+ {l}
+
+ ))}
+ {labels.length > 4 && (
+
+{labels.length - 4}
+ )}
+ >
+ );
+}
+
+function IssueRow({ issue }: { issue: Issue }) {
+ const isOpen = issue.state !== "closed";
+ const StateIcon = isOpen ? IconCircle : IconCircleCheck;
+ const stateClass = isOpen
+ ? "text-emerald-600 dark:text-emerald-400"
+ : "text-purple-600 dark:text-purple-400";
+ return (
+
+
+
+
+
{issue.title}
+
+
+
+
+ {issue.project_path}#{issue.iid}
+
+ ·
+
+ by {issue.author_username} · opened {formatRelativeTime(issue.created_at)}
+
+
+
+
+
+ );
+}
+
+function IssueListBody({ loading, error, items }: IssueListProps) {
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+ if (error) {
+ return
{error}
;
+ }
+ if (items.length === 0) {
+ return (
+
+ No issues match this filter.
+
+ );
+ }
+ return (
+
+ {items.map((issue) => (
+
+ ))}
+
+ );
+}
+
+export function IssueList(props: IssueListProps) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/components/gitlab/my-gitlab/list-toolbar.tsx b/apps/web/components/gitlab/my-gitlab/list-toolbar.tsx
new file mode 100644
index 0000000000..5856a17462
--- /dev/null
+++ b/apps/web/components/gitlab/my-gitlab/list-toolbar.tsx
@@ -0,0 +1,150 @@
+"use client";
+
+import { IconRefresh } from "@tabler/icons-react";
+import { Button } from "@kandev/ui/button";
+import { Input } from "@kandev/ui/input";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
+import { cn, formatRelativeTime } from "@/lib/utils";
+
+const ALL_PROJECTS = "__all__";
+
+type ListToolbarProps = {
+ title: string;
+ count: number;
+ loading: boolean;
+ lastFetchedAt: Date | null;
+ customQuery: string;
+ committedQuery: string;
+ onCustomQueryChange: (value: string) => void;
+ onCommitCustomQuery: () => void;
+ projectFilter: string;
+ onProjectFilterChange: (value: string) => void;
+ projectOptions: string[];
+ onRefresh: () => void;
+};
+
+function RefreshControls({
+ loading,
+ lastFetchedAt,
+ onRefresh,
+ showUpdatedPrefix,
+}: {
+ loading: boolean;
+ lastFetchedAt: Date | null;
+ onRefresh: () => void;
+ showUpdatedPrefix: boolean;
+}) {
+ return (
+ <>
+ {lastFetchedAt && !loading && (
+
+ {showUpdatedPrefix ? "Updated " : ""}
+ {formatRelativeTime(lastFetchedAt.toISOString())}
+
+ )}
+
+ >
+ );
+}
+
+export function ListToolbar({
+ title,
+ count,
+ loading,
+ lastFetchedAt,
+ customQuery,
+ committedQuery,
+ onCustomQueryChange,
+ onCommitCustomQuery,
+ projectFilter,
+ onProjectFilterChange,
+ projectOptions,
+ onRefresh,
+}: ListToolbarProps) {
+ const selectValue = projectFilter || ALL_PROJECTS;
+ const dirty = customQuery !== committedQuery;
+ return (
+
+
+
+
+ {title}
+
+
+ {loading ? "…" : count}
+
+
+
+
+
+
+
+
+ onCustomQueryChange(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ onCommitCustomQuery();
+ }
+ }}
+ onBlur={() => {
+ if (dirty) onCommitCustomQuery();
+ }}
+ placeholder='Custom query — press Enter. e.g. "labels=bug&state=opened"'
+ className="h-8 pr-20"
+ data-testid="gitlab-list-toolbar-custom-query"
+ />
+ {dirty && (
+
+ Press Enter
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/components/gitlab/my-gitlab/mr-list.tsx b/apps/web/components/gitlab/my-gitlab/mr-list.tsx
new file mode 100644
index 0000000000..b0d0520a02
--- /dev/null
+++ b/apps/web/components/gitlab/my-gitlab/mr-list.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import Link from "next/link";
+import {
+ IconGitPullRequest,
+ IconGitPullRequestClosed,
+ IconGitMerge,
+ IconExternalLink,
+} from "@tabler/icons-react";
+import type { Icon } from "@tabler/icons-react";
+import { Spinner } from "@kandev/ui/spinner";
+import { cn, formatRelativeTime } from "@/lib/utils";
+import type { MR } from "@/lib/types/gitlab";
+
+type MRListProps = {
+ items: MR[];
+ loading: boolean;
+ error: string | null;
+};
+
+function mrStateIcon(mr: MR): { Icon: Icon; className: string } {
+ const state = mr.state === "opened" ? "open" : mr.state;
+ if (state === "merged")
+ return { Icon: IconGitMerge, className: "text-purple-600 dark:text-purple-400" };
+ if (state === "closed")
+ return { Icon: IconGitPullRequestClosed, className: "text-red-600 dark:text-red-400" };
+ if (mr.draft) return { Icon: IconGitPullRequest, className: "text-muted-foreground" };
+ return { Icon: IconGitPullRequest, className: "text-emerald-600 dark:text-emerald-400" };
+}
+
+function MRRow({ mr }: { mr: MR }) {
+ const { Icon: StateIcon, className: stateIconClass } = mrStateIcon(mr);
+ return (
+
+
+
+
+
{mr.title}
+
+
+
+
+ {mr.project_path}!{mr.iid}
+
+ ·
+
+ {mr.head_branch} → {mr.base_branch}
+
+ ·
+
+ by {mr.author_username} · opened {formatRelativeTime(mr.created_at)}
+
+
+
+
+ );
+}
+
+function MRListBody({ loading, error, items }: MRListProps) {
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+ if (error) {
+ return
{error}
;
+ }
+ if (items.length === 0) {
+ return (
+
+ No merge requests match this filter.
+
+ );
+ }
+ return (
+
+ {items.map((mr) => (
+
+ ))}
+
+ );
+}
+
+export function MRList(props: MRListProps) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/components/gitlab/my-gitlab/presets-sidebar.tsx b/apps/web/components/gitlab/my-gitlab/presets-sidebar.tsx
new file mode 100644
index 0000000000..e4da8bb325
--- /dev/null
+++ b/apps/web/components/gitlab/my-gitlab/presets-sidebar.tsx
@@ -0,0 +1,255 @@
+"use client";
+
+import { IconX, IconDeviceFloppy, IconBookmark } from "@tabler/icons-react";
+import type { Icon } from "@tabler/icons-react";
+import { cn } from "@/lib/utils";
+import { MR_PRESETS, ISSUE_PRESETS, type PresetOption, type PresetGroup } from "./presets";
+import type { SavedPreset } from "./use-saved-presets";
+
+export type SidebarSelection = {
+ kind: "mr" | "issue";
+ source: "preset" | "saved";
+ id: string;
+};
+
+type PresetsSidebarProps = {
+ selected: SidebarSelection;
+ onSelect: (s: SidebarSelection) => void;
+ savedPresets: SavedPreset[];
+ onDeleteSaved: (id: string) => void;
+ canSaveCurrent: boolean;
+ onSaveCurrent: () => void;
+ mrPresets?: PresetOption[];
+ issuePresets?: PresetOption[];
+};
+
+function KindToggle({
+ kind,
+ onChange,
+}: {
+ kind: "mr" | "issue";
+ onChange: (k: "mr" | "issue") => void;
+}) {
+ return (
+
+ {(["mr", "issue"] as const).map((value) => (
+
+ ))}
+
+ );
+}
+
+function SectionHeader({ title }: { title: string }) {
+ return (
+
+ {title}
+
+ );
+}
+
+function PresetItem({
+ label,
+ Icon: ItemIcon,
+ active,
+ onClick,
+ trailing,
+}: {
+ label: string;
+ Icon: Icon;
+ active: boolean;
+ onClick: () => void;
+ trailing?: React.ReactNode;
+}) {
+ const onKeyDown = (e: React.KeyboardEvent
) => {
+ // Ignore keys originating from descendants (e.g. the nested delete button),
+ // otherwise Enter/Space on delete would also fire row selection.
+ if (e.currentTarget !== e.target) return;
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onClick();
+ }
+ };
+ return (
+
+
+ {label}
+ {trailing}
+
+ );
+}
+
+function PresetGroupList({
+ presets,
+ group,
+ selected,
+ onSelect,
+ kind,
+}: {
+ presets: PresetOption[];
+ group: PresetGroup;
+ selected: SidebarSelection;
+ onSelect: (s: SidebarSelection) => void;
+ kind: "mr" | "issue";
+}) {
+ const items = presets.filter((p) => p.group === group);
+ if (items.length === 0) return null;
+ return (
+ <>
+
+ {items.map((p) => (
+ onSelect({ kind, source: "preset", id: p.value })}
+ />
+ ))}
+ >
+ );
+}
+
+function SavedSection({
+ saved,
+ selected,
+ onSelect,
+ onDelete,
+ kind,
+ canSaveCurrent,
+ onSaveCurrent,
+}: {
+ saved: SavedPreset[];
+ selected: SidebarSelection;
+ onSelect: (s: SidebarSelection) => void;
+ onDelete: (id: string) => void;
+ kind: "mr" | "issue";
+ canSaveCurrent: boolean;
+ onSaveCurrent: () => void;
+}) {
+ return (
+ <>
+
+ {saved.length === 0 && (
+
+ No saved queries yet.
+
+ )}
+ {saved.map((s) => (
+ onSelect({ kind, source: "saved", id: s.id })}
+ trailing={
+
+ }
+ />
+ ))}
+
+ >
+ );
+}
+
+export function PresetsSidebar({
+ selected,
+ onSelect,
+ savedPresets,
+ onDeleteSaved,
+ canSaveCurrent,
+ onSaveCurrent,
+ mrPresets = MR_PRESETS,
+ issuePresets = ISSUE_PRESETS,
+}: PresetsSidebarProps) {
+ const presets = selected.kind === "mr" ? mrPresets : issuePresets;
+ const saved = savedPresets.filter((p) => p.kind === selected.kind);
+ const onKindChange = (kind: "mr" | "issue") => {
+ const fallback = (kind === "mr" ? mrPresets : issuePresets)[0]?.value ?? "";
+ onSelect({ kind, source: "preset", id: fallback });
+ };
+ return (
+
+ );
+}
diff --git a/apps/web/components/gitlab/my-gitlab/presets.ts b/apps/web/components/gitlab/my-gitlab/presets.ts
new file mode 100644
index 0000000000..54437fdd6c
--- /dev/null
+++ b/apps/web/components/gitlab/my-gitlab/presets.ts
@@ -0,0 +1,58 @@
+import { IconInbox, IconUserPlus, IconGitMerge, IconPlus } from "@tabler/icons-react";
+import type { Icon } from "@tabler/icons-react";
+
+export type PresetGroup = "inbox" | "created";
+
+export type PresetOption = {
+ value: string;
+ label: string;
+ // Backend filter token consumed by gitlab.translateUserSearchFilter.
+ // Accepted: "assigned_to_me", "created_by_me", "review_requested" (MRs only).
+ // Everything else is treated as a raw `key=value&...` filter and is parsed
+ // by appendFilter — keep this to the curated tokens; the custom-query input
+ // is the escape hatch for everything else.
+ filter: string;
+ group: PresetGroup;
+ icon: Icon;
+};
+
+export const MR_PRESETS: PresetOption[] = [
+ {
+ value: "review_requested",
+ label: "Review requested",
+ filter: "review_requested",
+ group: "inbox",
+ icon: IconInbox,
+ },
+ {
+ value: "assigned",
+ label: "Assigned",
+ filter: "assigned_to_me",
+ group: "inbox",
+ icon: IconUserPlus,
+ },
+ {
+ value: "authored",
+ label: "Authored",
+ filter: "created_by_me",
+ group: "created",
+ icon: IconGitMerge,
+ },
+];
+
+export const ISSUE_PRESETS: PresetOption[] = [
+ {
+ value: "assigned",
+ label: "Assigned",
+ filter: "assigned_to_me",
+ group: "inbox",
+ icon: IconInbox,
+ },
+ {
+ value: "created",
+ label: "Created",
+ filter: "created_by_me",
+ group: "created",
+ icon: IconPlus,
+ },
+];
diff --git a/apps/web/components/gitlab/my-gitlab/results-pagination.tsx b/apps/web/components/gitlab/my-gitlab/results-pagination.tsx
new file mode 100644
index 0000000000..a5a614f89f
--- /dev/null
+++ b/apps/web/components/gitlab/my-gitlab/results-pagination.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { Fragment } from "react";
+import {
+ Pagination,
+ PaginationContent,
+ PaginationEllipsis,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+} from "@kandev/ui/pagination";
+
+type ResultsPaginationProps = {
+ page: number;
+ pageSize: number;
+ total: number;
+ onPageChange: (page: number) => void;
+};
+
+function pageWindow(page: number, totalPages: number): number[] {
+ const pages = new Set([1, totalPages, page - 1, page, page + 1]);
+ return Array.from(pages)
+ .filter((p) => p >= 1 && p <= totalPages)
+ .sort((a, b) => a - b);
+}
+
+export function ResultsPagination({ page, pageSize, total, onPageChange }: ResultsPaginationProps) {
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
+ if (totalPages <= 1) return null;
+
+ // Clamp the incoming page so a stale out-of-range value (e.g. user on page 5,
+ // refresh returns fewer results → totalPages = 2) doesn't render "101–30 of
+ // 30" and doesn't break the prev/next button enabled state.
+ const safePage = Math.min(Math.max(1, page), totalPages);
+ const windowPages = pageWindow(safePage, totalPages);
+ const start = (safePage - 1) * pageSize + 1;
+ const end = Math.min(safePage * pageSize, total);
+
+ return (
+
+
+ {start}–{end} of {total}
+
+
+
+
+ {
+ e.preventDefault();
+ if (safePage > 1) onPageChange(safePage - 1);
+ }}
+ aria-disabled={safePage <= 1}
+ className={safePage <= 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
+ />
+
+ {windowPages.map((p, i) => {
+ const prev = windowPages[i - 1];
+ const needsGap = prev !== undefined && p - prev > 1;
+ return (
+
+ {needsGap && (
+
+
+
+ )}
+
+ {
+ e.preventDefault();
+ onPageChange(p);
+ }}
+ className="cursor-pointer"
+ >
+ {p}
+
+
+
+ );
+ })}
+
+ {
+ e.preventDefault();
+ if (safePage < totalPages) onPageChange(safePage + 1);
+ }}
+ aria-disabled={safePage >= totalPages}
+ className={
+ safePage >= totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"
+ }
+ />
+
+
+
+
+ );
+}
diff --git a/apps/web/components/gitlab/my-gitlab/save-preset-dialog.tsx b/apps/web/components/gitlab/my-gitlab/save-preset-dialog.tsx
new file mode 100644
index 0000000000..e26080ba48
--- /dev/null
+++ b/apps/web/components/gitlab/my-gitlab/save-preset-dialog.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+import { useCallback, useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@kandev/ui/dialog";
+import { Input } from "@kandev/ui/input";
+import { Button } from "@kandev/ui/button";
+import { Label } from "@kandev/ui/label";
+
+type SavePresetDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ kind: "mr" | "issue";
+ customQuery: string;
+ projectFilter: string;
+ suggestedLabel: string;
+ onSave: (label: string) => void;
+};
+
+function SavePresetForm({
+ kind,
+ customQuery,
+ projectFilter,
+ suggestedLabel,
+ onSave,
+ onClose,
+}: {
+ kind: "mr" | "issue";
+ customQuery: string;
+ projectFilter: string;
+ suggestedLabel: string;
+ onSave: (label: string) => void;
+ onClose: () => void;
+}) {
+ const [value, setValue] = useState(suggestedLabel);
+ const trimmed = value.trim();
+ const canSubmit = trimmed.length > 0;
+
+ const handleSubmit = useCallback(() => {
+ if (!trimmed) return;
+ onSave(trimmed);
+ onClose();
+ }, [trimmed, onSave, onClose]);
+
+ return (
+ <>
+
+ Save GitLab query
+
+ Save this {kind === "mr" ? "merge request" : "issue"} query to the sidebar for quick
+ access later.
+
+
+
+
+
+ setValue(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleSubmit();
+ }}
+ onFocus={(e) => e.target.select()}
+ placeholder="e.g. Needs my review"
+ />
+
+
+ {customQuery && (
+
+ Query
+
+ {customQuery}
+
+
+ )}
+ {projectFilter && (
+
+ Project
+
+ {projectFilter}
+
+
+ )}
+
+
+
+
+
+
+ >
+ );
+}
+
+export function SavePresetDialog({
+ open,
+ onOpenChange,
+ kind,
+ customQuery,
+ projectFilter,
+ suggestedLabel,
+ onSave,
+}: SavePresetDialogProps) {
+ const handleClose = useCallback(() => onOpenChange(false), [onOpenChange]);
+ return (
+
+ );
+}
diff --git a/apps/web/components/gitlab/my-gitlab/use-committed-query.test.ts b/apps/web/components/gitlab/my-gitlab/use-committed-query.test.ts
new file mode 100644
index 0000000000..c6029871ee
--- /dev/null
+++ b/apps/web/components/gitlab/my-gitlab/use-committed-query.test.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import { useCommittedQuery } from "./use-committed-query";
+
+const BUG = "labels=bug";
+
+describe("useCommittedQuery (gitlab)", () => {
+ it("initializes draft and committed to the initial value", () => {
+ const { result } = renderHook(() => useCommittedQuery(BUG));
+ expect(result.current.draft).toBe(BUG);
+ expect(result.current.committed).toBe(BUG);
+ });
+
+ it("setDraft updates draft without touching committed", () => {
+ const { result } = renderHook(() => useCommittedQuery(""));
+ act(() => result.current.setDraft(BUG));
+ expect(result.current.draft).toBe(BUG);
+ expect(result.current.committed).toBe("");
+ });
+
+ it("commit syncs committed to the current draft", () => {
+ const { result } = renderHook(() => useCommittedQuery(""));
+ act(() => result.current.setDraft("scope=all"));
+ act(() => result.current.commit());
+ expect(result.current.committed).toBe("scope=all");
+ });
+
+ it("setImmediate updates draft and committed together", () => {
+ const { result } = renderHook(() => useCommittedQuery(""));
+ act(() => result.current.setImmediate("now"));
+ expect(result.current.draft).toBe("now");
+ expect(result.current.committed).toBe("now");
+ });
+});
diff --git a/apps/web/components/gitlab/my-gitlab/use-committed-query.ts b/apps/web/components/gitlab/my-gitlab/use-committed-query.ts
new file mode 100644
index 0000000000..5310d19a4d
--- /dev/null
+++ b/apps/web/components/gitlab/my-gitlab/use-committed-query.ts
@@ -0,0 +1,20 @@
+"use client";
+
+import { useCallback, useState } from "react";
+
+// Mirror of github's useCommittedQuery: the draft is what the input renders,
+// the committed value is what drives the fetch. Typing only updates the draft;
+// Enter / blur commits.
+export function useCommittedQuery(initial: string) {
+ const [draft, setDraft] = useState(initial);
+ const [committed, setCommitted] = useState(initial);
+
+ const setImmediate = useCallback((value: string) => {
+ setDraft(value);
+ setCommitted(value);
+ }, []);
+
+ const commit = useCallback(() => setCommitted(draft), [draft]);
+
+ return { draft, committed, setDraft, setImmediate, commit };
+}
diff --git a/apps/web/components/gitlab/my-gitlab/use-gitlab-search.test.ts b/apps/web/components/gitlab/my-gitlab/use-gitlab-search.test.ts
new file mode 100644
index 0000000000..c0d80e88f5
--- /dev/null
+++ b/apps/web/components/gitlab/my-gitlab/use-gitlab-search.test.ts
@@ -0,0 +1,245 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { cleanup, renderHook, waitFor, act } from "@testing-library/react";
+import { IconInbox } from "@tabler/icons-react";
+import type { Issue, MR, MRSearchPage, IssueSearchPage } from "@/lib/types/gitlab";
+import type { PresetOption } from "./presets";
+
+const searchUserMRsMock = vi.fn<[unknown], Promise>();
+const searchUserIssuesMock = vi.fn<[unknown], Promise>();
+
+vi.mock("@/lib/api/domains/gitlab-api", () => ({
+ searchUserMRs: (args: unknown) => searchUserMRsMock(args),
+ searchUserIssues: (args: unknown) => searchUserIssuesMock(args),
+}));
+
+import { pickFilter, useGitLabSearch } from "./use-gitlab-search";
+
+afterEach(() => cleanup());
+
+const PRESET_REVIEW = "review_requested";
+const PRESET_ASSIGNED = "assigned";
+const CUSTOM_QUERY = "labels=bug";
+
+const PRESETS: PresetOption[] = [
+ {
+ value: PRESET_REVIEW,
+ label: "Review requested",
+ filter: PRESET_REVIEW,
+ group: "inbox",
+ icon: IconInbox,
+ },
+ {
+ value: PRESET_ASSIGNED,
+ label: "Assigned",
+ filter: "assigned_to_me",
+ group: "inbox",
+ icon: IconInbox,
+ },
+];
+
+const EMPTY_PAGE: MRSearchPage = { mrs: [], total_count: 0, page: 1, per_page: 25 };
+
+describe("pickFilter", () => {
+ it("uses preset filter when custom query is empty", () => {
+ expect(pickFilter(PRESETS, PRESET_REVIEW, "")).toEqual({
+ filter: PRESET_REVIEW,
+ customQuery: "",
+ });
+ });
+
+ it("routes custom query and drops preset filter", () => {
+ expect(pickFilter(PRESETS, PRESET_REVIEW, CUSTOM_QUERY)).toEqual({
+ filter: "",
+ customQuery: CUSTOM_QUERY,
+ });
+ });
+
+ it("trims whitespace-only custom query and falls back to preset", () => {
+ expect(pickFilter(PRESETS, PRESET_ASSIGNED, " ")).toEqual({
+ filter: "assigned_to_me",
+ customQuery: "",
+ });
+ });
+
+ it("falls back to empty filter when preset is unknown", () => {
+ expect(pickFilter(PRESETS, "missing", "")).toEqual({ filter: "", customQuery: "" });
+ });
+});
+
+function fakeMR(overrides: Partial = {}): MR {
+ return {
+ id: 1,
+ iid: 1,
+ project_id: 1,
+ title: "",
+ url: "",
+ web_url: "",
+ state: "opened",
+ head_branch: "feat",
+ head_sha: "",
+ base_branch: "main",
+ author_username: "alice",
+ project_namespace: "acme",
+ project_path: "acme/api",
+ body: "",
+ draft: false,
+ merge_status: "",
+ has_conflicts: false,
+ additions: 0,
+ deletions: 0,
+ reviewers: [],
+ assignees: [],
+ created_at: "",
+ updated_at: "",
+ ...overrides,
+ };
+}
+
+function fakeIssue(overrides: Partial = {}): Issue {
+ return {
+ id: 1,
+ iid: 1,
+ project_id: 1,
+ title: "",
+ body: "",
+ url: "",
+ web_url: "",
+ state: "opened",
+ author_username: "alice",
+ project_namespace: "acme",
+ project_path: "acme/api",
+ labels: [],
+ assignees: [],
+ created_at: "",
+ updated_at: "",
+ ...overrides,
+ };
+}
+
+function resetMocks() {
+ searchUserMRsMock.mockReset();
+ searchUserIssuesMock.mockReset();
+}
+
+describe("useGitLabSearch — fetch wiring", () => {
+ beforeEach(resetMocks);
+
+ it("forwards preset filter to MR API", async () => {
+ searchUserMRsMock.mockResolvedValue(EMPTY_PAGE);
+ renderHook(() => useGitLabSearch("mr", PRESETS, PRESET_REVIEW, ""));
+ await waitFor(() => expect(searchUserMRsMock).toHaveBeenCalled());
+ const args = searchUserMRsMock.mock.calls[0][0] as Record;
+ expect(args.filter).toBe(PRESET_REVIEW);
+ expect(args.customQuery).toBe("");
+ expect(args.page).toBe(1);
+ });
+
+ it("forwards custom query and drops preset filter", async () => {
+ searchUserMRsMock.mockResolvedValue(EMPTY_PAGE);
+ renderHook(() => useGitLabSearch("mr", PRESETS, PRESET_REVIEW, CUSTOM_QUERY));
+ await waitFor(() => expect(searchUserMRsMock).toHaveBeenCalled());
+ const args = searchUserMRsMock.mock.calls[0][0] as Record;
+ expect(args.filter).toBe("");
+ expect(args.customQuery).toBe(CUSTOM_QUERY);
+ });
+
+ it("dispatches to issues endpoint when kind is issue", async () => {
+ const issue = fakeIssue();
+ searchUserIssuesMock.mockResolvedValue({
+ issues: [issue],
+ total_count: 1,
+ page: 1,
+ per_page: 25,
+ });
+ const { result } = renderHook(() => useGitLabSearch("issue", PRESETS, PRESET_ASSIGNED, ""));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.items).toEqual([issue]);
+ expect(searchUserMRsMock).not.toHaveBeenCalled();
+ });
+});
+
+describe("useGitLabSearch — state", () => {
+ beforeEach(resetMocks);
+
+ it("populates items and total on success", async () => {
+ const mr = fakeMR();
+ searchUserMRsMock.mockResolvedValue({ mrs: [mr], total_count: 1, page: 1, per_page: 25 });
+ const { result } = renderHook(() => useGitLabSearch("mr", PRESETS, PRESET_ASSIGNED, ""));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.items).toEqual([mr]);
+ expect(result.current.total).toBe(1);
+ expect(result.current.error).toBeNull();
+ });
+
+ it("surfaces error message without items", async () => {
+ searchUserMRsMock.mockRejectedValue(new Error("boom"));
+ const { result } = renderHook(() => useGitLabSearch("mr", PRESETS, PRESET_ASSIGNED, ""));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.error).toBe("boom");
+ expect(result.current.items).toEqual([]);
+ });
+
+ it("filters items by project_path client-side, total stays at server total", async () => {
+ const a = fakeMR({ project_path: "acme/api" });
+ const b = fakeMR({ project_path: "acme/web" });
+ searchUserMRsMock.mockResolvedValue({ mrs: [a, b], total_count: 99, page: 1, per_page: 25 });
+ const { result } = renderHook(() =>
+ useGitLabSearch("mr", PRESETS, PRESET_ASSIGNED, "", "acme/web"),
+ );
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.items.map((m) => m.project_path)).toEqual(["acme/web"]);
+ expect(result.current.rawItems.length).toBe(2);
+ // The hook returns the raw server total so pagination can still navigate to
+ // later pages that may contain more matches. Consumers compute a filtered
+ // display count from items.length when they want one.
+ expect(result.current.total).toBe(99);
+ });
+});
+
+describe("useGitLabSearch — pagination & sequencing", () => {
+ beforeEach(resetMocks);
+
+ it("resets page to 1 when preset changes", async () => {
+ searchUserMRsMock.mockResolvedValue(EMPTY_PAGE);
+ const { result, rerender } = renderHook(
+ ({ p }: { p: string }) => useGitLabSearch("mr", PRESETS, p, ""),
+ { initialProps: { p: PRESET_ASSIGNED } },
+ );
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ act(() => result.current.setPage(3));
+ await waitFor(() => expect(result.current.page).toBe(3));
+ rerender({ p: PRESET_REVIEW });
+ await waitFor(() => expect(result.current.page).toBe(1));
+ });
+
+ it("drops stale responses when filter changes mid-flight", async () => {
+ let resolveFirst: (v: MRSearchPage) => void = () => {};
+ searchUserMRsMock.mockReturnValueOnce(
+ new Promise((res) => {
+ resolveFirst = res;
+ }),
+ );
+ const second = fakeMR({ title: "second" });
+ searchUserMRsMock.mockResolvedValueOnce({
+ mrs: [second],
+ total_count: 1,
+ page: 1,
+ per_page: 25,
+ });
+
+ const { result, rerender } = renderHook(
+ ({ p }: { p: string }) => useGitLabSearch("mr", PRESETS, p, ""),
+ { initialProps: { p: PRESET_ASSIGNED } },
+ );
+ rerender({ p: PRESET_REVIEW });
+ await waitFor(() => expect(result.current.items).toEqual([second]));
+ resolveFirst({
+ mrs: [fakeMR({ title: "first" }), fakeMR()],
+ total_count: 2,
+ page: 1,
+ per_page: 25,
+ });
+ await new Promise((r) => setTimeout(r, 10));
+ expect(result.current.items).toEqual([second]);
+ });
+});
diff --git a/apps/web/components/gitlab/my-gitlab/use-gitlab-search.ts b/apps/web/components/gitlab/my-gitlab/use-gitlab-search.ts
new file mode 100644
index 0000000000..ff190a6977
--- /dev/null
+++ b/apps/web/components/gitlab/my-gitlab/use-gitlab-search.ts
@@ -0,0 +1,140 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { searchUserIssues, searchUserMRs } from "@/lib/api/domains/gitlab-api";
+import type { Issue, MR } from "@/lib/types/gitlab";
+import type { PresetOption } from "./presets";
+
+type SearchKind = "mr" | "issue";
+type Item = MR | Issue;
+
+export const SEARCH_PAGE_SIZE = 25;
+
+type SearchState = {
+ items: Item[];
+ loading: boolean;
+ error: string | null;
+ lastFetchedAt: Date | null;
+ total: number;
+};
+
+type FetchArgs = {
+ filter: string;
+ customQuery: string;
+ page: number;
+};
+
+export function pickFilter(
+ presets: PresetOption[],
+ preset: string,
+ customQuery: string,
+): { filter: string; customQuery: string } {
+ const trimmed = customQuery.trim();
+ if (trimmed) {
+ return { filter: "", customQuery: trimmed };
+ }
+ const found = presets.find((p) => p.value === preset);
+ return { filter: found?.filter ?? "", customQuery: "" };
+}
+
+// Apply project filter client-side. GitLab's global /merge_requests and
+// /issues endpoints have no path_with_namespace qualifier — filtering server-
+// side would require a project_id lookup. The dropdown is populated from the
+// page's own results (useKnownProjects), so what's offered is always what's
+// already shown — making this a UX narrowing more than a server query.
+function filterByProject(items: Item[], project: string): Item[] {
+ if (!project) return items;
+ return items.filter((it) => it.project_path === project);
+}
+
+export function useGitLabSearch(
+ kind: SearchKind,
+ presets: PresetOption[],
+ preset: string,
+ customQuery: string,
+ projectFilter: string = "",
+) {
+ const [state, setState] = useState({
+ items: [],
+ loading: false,
+ error: null,
+ lastFetchedAt: null,
+ total: 0,
+ });
+ const [page, setPage] = useState(1);
+ const requestSeq = useRef(0);
+
+ useEffect(() => {
+ setPage(1);
+ }, [preset, customQuery, kind]);
+
+ const fetchData = useCallback(
+ async ({ filter: ef, customQuery: ec, page: epage }: FetchArgs) => {
+ const seq = ++requestSeq.current;
+ setState((s) => ({ ...s, loading: true, error: null }));
+ try {
+ const params = { filter: ef, customQuery: ec, page: epage, perPage: SEARCH_PAGE_SIZE };
+ const response =
+ kind === "mr" ? await searchUserMRs(params) : await searchUserIssues(params);
+ if (seq !== requestSeq.current) return;
+ const items: Item[] =
+ kind === "mr"
+ ? ((response as { mrs: MR[] | null } | null)?.mrs ?? [])
+ : ((response as { issues: Issue[] | null } | null)?.issues ?? []);
+ setState({
+ items,
+ loading: false,
+ error: null,
+ lastFetchedAt: new Date(),
+ total: response?.total_count ?? items.length,
+ });
+ } catch (err) {
+ if (seq !== requestSeq.current) return;
+ setState((s) => ({
+ items: [],
+ loading: false,
+ error: err instanceof Error ? err.message : "Failed to search GitLab",
+ lastFetchedAt: s.lastFetchedAt,
+ total: 0,
+ }));
+ }
+ },
+ [kind],
+ );
+
+ const resolved = useMemo(
+ () => pickFilter(presets, preset, customQuery),
+ [presets, preset, customQuery],
+ );
+
+ useEffect(() => {
+ void fetchData({ filter: resolved.filter, customQuery: resolved.customQuery, page });
+ }, [fetchData, resolved.filter, resolved.customQuery, page]);
+
+ const refresh = useCallback(
+ () => fetchData({ filter: resolved.filter, customQuery: resolved.customQuery, page }),
+ [fetchData, resolved.filter, resolved.customQuery, page],
+ );
+
+ const filtered = useMemo(
+ () => filterByProject(state.items, projectFilter),
+ [state.items, projectFilter],
+ );
+
+ // `total` is the server-side count (used by pagination so the user can still
+ // navigate to later pages that may contain more matches when projectFilter
+ // is active). The consumer decides what to *display* as a count — see the
+ // page client, which shows filtered.length next to the title when narrowing.
+ return {
+ items: filtered,
+ rawItems: state.items,
+ loading: state.loading,
+ error: state.error,
+ lastFetchedAt: state.lastFetchedAt,
+ total: state.total,
+ page,
+ setPage,
+ pageSize: SEARCH_PAGE_SIZE,
+ refresh,
+ };
+}
diff --git a/apps/web/components/gitlab/my-gitlab/use-known-projects.test.ts b/apps/web/components/gitlab/my-gitlab/use-known-projects.test.ts
new file mode 100644
index 0000000000..2a7fb73269
--- /dev/null
+++ b/apps/web/components/gitlab/my-gitlab/use-known-projects.test.ts
@@ -0,0 +1,43 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import { __getSnapshotForTests, recordForKey, resetKnownProjectsStore } from "./use-known-projects";
+
+describe("recordForKey (gitlab)", () => {
+ beforeEach(() => {
+ resetKnownProjectsStore();
+ });
+
+ it("accumulates unique projects under the same key", () => {
+ recordForKey("k1", ["acme/api", "acme/web"]);
+ recordForKey("k1", ["acme/infra"]);
+ expect(__getSnapshotForTests()).toEqual(["acme/api", "acme/infra", "acme/web"]);
+ });
+
+ it("returns sorted output", () => {
+ recordForKey("k2", ["z/z", "a/a", "m/m"]);
+ expect(__getSnapshotForTests()).toEqual(["a/a", "m/m", "z/z"]);
+ });
+
+ it("deduplicates across successive calls", () => {
+ recordForKey("k3", ["acme/api"]);
+ recordForKey("k3", ["acme/api", "acme/web"]);
+ expect(__getSnapshotForTests()).toEqual(["acme/api", "acme/web"]);
+ });
+
+ it("clears accumulator when key changes", () => {
+ recordForKey("k4a", ["acme/api", "acme/web"]);
+ recordForKey("k4b", ["other/repo"]);
+ expect(__getSnapshotForTests()).toEqual(["other/repo"]);
+ });
+
+ it("ignores empty strings", () => {
+ recordForKey("k5", ["", "acme/api", ""]);
+ expect(__getSnapshotForTests()).toEqual(["acme/api"]);
+ });
+
+ it("resetKnownProjectsStore wipes the snapshot", () => {
+ recordForKey("k6", ["acme/api"]);
+ expect(__getSnapshotForTests()).toEqual(["acme/api"]);
+ resetKnownProjectsStore();
+ expect(__getSnapshotForTests()).toEqual([]);
+ });
+});
diff --git a/apps/web/components/gitlab/my-gitlab/use-known-projects.ts b/apps/web/components/gitlab/my-gitlab/use-known-projects.ts
new file mode 100644
index 0000000000..9db71d5239
--- /dev/null
+++ b/apps/web/components/gitlab/my-gitlab/use-known-projects.ts
@@ -0,0 +1,68 @@
+"use client";
+
+import { useEffect, useSyncExternalStore } from "react";
+
+// Module-level accumulator: project_paths seen in the current query context.
+// Scoped by a reset key (kind + selection + custom query). Within a context
+// the set only grows so narrowing the project filter doesn't hide the rest of
+// the options the user already saw.
+let currentKey: string | null = null;
+const seen = new Set();
+let snapshot: string[] = [];
+const emptySnapshot: string[] = [];
+const listeners = new Set<() => void>();
+
+function subscribe(cb: () => void) {
+ listeners.add(cb);
+ return () => {
+ listeners.delete(cb);
+ };
+}
+
+function getSnapshot(): string[] {
+ return snapshot;
+}
+
+function getServerSnapshot(): string[] {
+ return emptySnapshot;
+}
+
+export function recordForKey(key: string, projects: readonly string[]) {
+ let changed = false;
+ if (currentKey !== key) {
+ currentKey = key;
+ if (seen.size > 0) {
+ seen.clear();
+ snapshot = [];
+ changed = true;
+ }
+ }
+ for (const p of projects) {
+ if (p && !seen.has(p)) {
+ seen.add(p);
+ changed = true;
+ }
+ }
+ if (!changed) return;
+ snapshot = Array.from(seen).sort();
+ for (const l of listeners) l();
+}
+
+export function resetKnownProjectsStore() {
+ currentKey = null;
+ if (seen.size === 0 && snapshot.length === 0) return;
+ seen.clear();
+ snapshot = [];
+ for (const l of listeners) l();
+}
+
+export function useKnownProjects(resetKey: string, fromItems: readonly string[]): string[] {
+ useEffect(() => {
+ recordForKey(resetKey, fromItems);
+ }, [resetKey, fromItems]);
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+}
+
+export function __getSnapshotForTests(): string[] {
+ return snapshot;
+}
diff --git a/apps/web/components/gitlab/my-gitlab/use-saved-presets.test.ts b/apps/web/components/gitlab/my-gitlab/use-saved-presets.test.ts
new file mode 100644
index 0000000000..aa9b1b3a65
--- /dev/null
+++ b/apps/web/components/gitlab/my-gitlab/use-saved-presets.test.ts
@@ -0,0 +1,172 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import {
+ __resetSnapshotForTests,
+ readStorage,
+ useSavedPresets,
+ type SavedPreset,
+} from "./use-saved-presets";
+
+const STORAGE_KEY = "kandev:gitlab-presets:v1";
+
+function makeLocalStorageMock() {
+ const store = new Map();
+ return {
+ getItem: (key: string) => store.get(key) ?? null,
+ setItem: (key: string, value: string) => store.set(key, value),
+ removeItem: (key: string) => store.delete(key),
+ clear: () => store.clear(),
+ get length() {
+ return store.size;
+ },
+ key: (index: number) => Array.from(store.keys())[index] ?? null,
+ };
+}
+
+const localStorageMock = makeLocalStorageMock();
+vi.stubGlobal("localStorage", localStorageMock);
+
+function set(raw: string | null) {
+ if (raw === null) localStorageMock.removeItem(STORAGE_KEY);
+ else localStorageMock.setItem(STORAGE_KEY, raw);
+}
+
+const valid: SavedPreset = {
+ id: "g_1",
+ kind: "mr",
+ label: "My MRs",
+ customQuery: "scope=created_by_me",
+ projectFilter: "",
+ createdAt: "2026-01-01T00:00:00Z",
+};
+
+describe("readStorage (gitlab)", () => {
+ beforeEach(() => {
+ localStorageMock.clear();
+ });
+
+ it("returns empty array when no value is stored", () => {
+ expect(readStorage()).toEqual([]);
+ });
+
+ it("returns empty array for malformed JSON", () => {
+ set("not-json{");
+ expect(readStorage()).toEqual([]);
+ });
+
+ it("returns empty array when parsed value is not an array", () => {
+ set(JSON.stringify({ id: "g_1" }));
+ expect(readStorage()).toEqual([]);
+ });
+
+ it("keeps valid entries", () => {
+ set(JSON.stringify([valid]));
+ expect(readStorage()).toEqual([valid]);
+ });
+
+ it("drops entries missing an id", () => {
+ const missingId = { ...valid } as Partial;
+ delete missingId.id;
+ set(JSON.stringify([missingId, valid]));
+ expect(readStorage()).toEqual([valid]);
+ });
+
+ it("drops entries with invalid kind", () => {
+ set(JSON.stringify([{ ...valid, kind: "pr" }, valid]));
+ expect(readStorage()).toEqual([valid]);
+ });
+
+ it("drops non-object entries", () => {
+ set(JSON.stringify(["string", 42, null, valid]));
+ expect(readStorage()).toEqual([valid]);
+ });
+
+ it("drops entries with non-string label", () => {
+ set(JSON.stringify([{ ...valid, label: 123 }, valid]));
+ expect(readStorage()).toEqual([valid]);
+ });
+
+ it("accepts issue kind", () => {
+ const issue: SavedPreset = { ...valid, kind: "issue" };
+ set(JSON.stringify([issue]));
+ expect(readStorage()).toEqual([issue]);
+ });
+
+ it("drops entries missing customQuery", () => {
+ const broken = { ...valid } as Partial;
+ delete broken.customQuery;
+ set(JSON.stringify([broken, valid]));
+ expect(readStorage()).toEqual([valid]);
+ });
+
+ it("drops entries missing projectFilter", () => {
+ const broken = { ...valid } as Partial;
+ delete broken.projectFilter;
+ set(JSON.stringify([broken, valid]));
+ expect(readStorage()).toEqual([valid]);
+ });
+
+ it("drops entries missing createdAt", () => {
+ const broken = { ...valid } as Partial;
+ delete broken.createdAt;
+ set(JSON.stringify([broken, valid]));
+ expect(readStorage()).toEqual([valid]);
+ });
+});
+
+describe("useSavedPresets (gitlab)", () => {
+ beforeEach(() => {
+ localStorageMock.clear();
+ __resetSnapshotForTests();
+ });
+
+ it("save appends a preset and assigns id + createdAt", () => {
+ const { result } = renderHook(() => useSavedPresets());
+ act(() => {
+ result.current.save({
+ kind: "mr",
+ label: "My MRs",
+ customQuery: "scope=created_by_me",
+ projectFilter: "",
+ });
+ });
+ expect(result.current.presets).toHaveLength(1);
+ const [p] = result.current.presets;
+ expect(p.label).toBe("My MRs");
+ expect(p.kind).toBe("mr");
+ expect(p.id).toMatch(/^g_/);
+ expect(p.createdAt).toBeTruthy();
+ });
+
+ it("remove deletes the preset", () => {
+ const { result } = renderHook(() => useSavedPresets());
+ let created!: SavedPreset;
+ act(() => {
+ created = result.current.save({
+ kind: "issue",
+ label: "Triage",
+ customQuery: "labels=bug",
+ projectFilter: "",
+ });
+ });
+ expect(result.current.presets).toHaveLength(1);
+ act(() => result.current.remove(created.id));
+ expect(result.current.presets).toHaveLength(0);
+ });
+
+ it("two hook instances stay in sync via the module-level store", () => {
+ const a = renderHook(() => useSavedPresets());
+ const b = renderHook(() => useSavedPresets());
+ act(() => {
+ a.result.current.save({
+ kind: "mr",
+ label: "Shared",
+ customQuery: "q",
+ projectFilter: "",
+ });
+ });
+ expect(a.result.current.presets).toHaveLength(1);
+ expect(b.result.current.presets).toHaveLength(1);
+ expect(b.result.current.presets[0].label).toBe("Shared");
+ });
+});
diff --git a/apps/web/components/gitlab/my-gitlab/use-saved-presets.ts b/apps/web/components/gitlab/my-gitlab/use-saved-presets.ts
new file mode 100644
index 0000000000..3cf6948132
--- /dev/null
+++ b/apps/web/components/gitlab/my-gitlab/use-saved-presets.ts
@@ -0,0 +1,104 @@
+"use client";
+
+import { useCallback, useSyncExternalStore } from "react";
+
+const STORAGE_KEY = "kandev:gitlab-presets:v1";
+
+export type SavedPreset = {
+ id: string;
+ kind: "mr" | "issue";
+ label: string;
+ customQuery: string;
+ projectFilter: string;
+ createdAt: string;
+};
+
+export function readStorage(): SavedPreset[] {
+ if (typeof window === "undefined") return [];
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw) as unknown;
+ if (!Array.isArray(parsed)) return [];
+ return parsed.filter(
+ (p): p is SavedPreset =>
+ typeof p === "object" &&
+ p !== null &&
+ typeof (p as SavedPreset).id === "string" &&
+ ((p as SavedPreset).kind === "mr" || (p as SavedPreset).kind === "issue") &&
+ typeof (p as SavedPreset).label === "string" &&
+ typeof (p as SavedPreset).customQuery === "string" &&
+ typeof (p as SavedPreset).projectFilter === "string" &&
+ typeof (p as SavedPreset).createdAt === "string",
+ );
+ } catch {
+ return [];
+ }
+}
+
+function writeStorage(presets: SavedPreset[]) {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
+ } catch {
+ /* ignore quota / access errors */
+ }
+}
+
+const listeners = new Set<() => void>();
+let snapshot: SavedPreset[] | null = null;
+const emptySnapshot: SavedPreset[] = [];
+
+function getSnapshot(): SavedPreset[] {
+ if (snapshot === null) snapshot = readStorage();
+ return snapshot;
+}
+
+function getServerSnapshot(): SavedPreset[] {
+ return emptySnapshot;
+}
+
+function subscribe(cb: () => void) {
+ listeners.add(cb);
+ return () => {
+ listeners.delete(cb);
+ };
+}
+
+function publish(next: SavedPreset[]) {
+ snapshot = next;
+ writeStorage(next);
+ for (const l of listeners) l();
+}
+
+// Test-only: drop the module-level snapshot so the next read goes through
+// readStorage again. Used by the hook tests so each `it` starts from a known
+// empty state independent of test execution order.
+export function __resetSnapshotForTests() {
+ snapshot = null;
+ for (const l of listeners) l();
+}
+
+export function useSavedPresets() {
+ const presets = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+
+ // Always merge against the latest localStorage read instead of the in-memory
+ // snapshot. With two tabs open, snapshot in tab A is stale the moment tab B
+ // writes — appending to it would silently drop B's preset. readStorage is
+ // cheap (small JSON, single key) so this is fine on the save/remove paths.
+ const save = useCallback((input: Omit) => {
+ const preset: SavedPreset = {
+ ...input,
+ id: `g_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
+ createdAt: new Date().toISOString(),
+ };
+ publish([...readStorage(), preset]);
+ return preset;
+ }, []);
+
+ const remove = useCallback((id: string) => {
+ publish(readStorage().filter((p) => p.id !== id));
+ }, []);
+
+ return { presets, save, remove };
+}
diff --git a/apps/web/hooks/domains/gitlab/use-user-search.test.ts b/apps/web/hooks/domains/gitlab/use-user-search.test.ts
deleted file mode 100644
index 8fc6fd94b9..0000000000
--- a/apps/web/hooks/domains/gitlab/use-user-search.test.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { cleanup, renderHook, waitFor } from "@testing-library/react";
-import type { Issue, MR, MRSearchPage, IssueSearchPage } from "@/lib/types/gitlab";
-
-const searchUserMRsMock = vi.fn<[unknown], Promise>();
-const searchUserIssuesMock = vi.fn<[unknown], Promise>();
-
-vi.mock("@/lib/api/domains/gitlab-api", () => ({
- searchUserMRs: (args: unknown) => searchUserMRsMock(args),
- searchUserIssues: (args: unknown) => searchUserIssuesMock(args),
-}));
-
-import { useGitLabUserIssues, useGitLabUserMRs } from "./use-user-search";
-
-afterEach(() => cleanup());
-
-function fakeMR(): MR {
- return {
- id: 1,
- iid: 1,
- project_id: 1,
- title: "",
- url: "",
- web_url: "",
- state: "opened",
- head_branch: "feat",
- head_sha: "",
- base_branch: "main",
- author_username: "alice",
- project_namespace: "acme",
- project_path: "acme/api",
- body: "",
- draft: false,
- merge_status: "",
- has_conflicts: false,
- additions: 0,
- deletions: 0,
- reviewers: [],
- assignees: [],
- created_at: "",
- updated_at: "",
- };
-}
-
-function fakeIssue(): Issue {
- return {
- id: 1,
- iid: 1,
- project_id: 1,
- title: "",
- body: "",
- url: "",
- web_url: "",
- state: "opened",
- author_username: "alice",
- project_namespace: "acme",
- project_path: "acme/api",
- labels: [],
- assignees: [],
- created_at: "",
- updated_at: "",
- };
-}
-
-describe("useGitLabUserMRs", () => {
- beforeEach(() => {
- searchUserMRsMock.mockReset();
- });
-
- it("forwards filter, query, and perPage to the API", async () => {
- searchUserMRsMock.mockResolvedValueOnce({ mrs: [], total_count: 0, page: 1, per_page: 25 });
- renderHook(() => useGitLabUserMRs("authored", "labels=bug", 25));
- await waitFor(() => expect(searchUserMRsMock).toHaveBeenCalledTimes(1));
- expect(searchUserMRsMock).toHaveBeenCalledWith({
- filter: "authored",
- customQuery: "labels=bug",
- perPage: 25,
- });
- });
-
- it("populates items on success and clears loading", async () => {
- const mr = fakeMR();
- searchUserMRsMock.mockResolvedValueOnce({ mrs: [mr], total_count: 1, page: 1, per_page: 50 });
- const { result } = renderHook(() => useGitLabUserMRs("a", ""));
- await waitFor(() => expect(result.current.loading).toBe(false));
- expect(result.current.items).toEqual([mr]);
- expect(result.current.error).toBeNull();
- });
-
- it("surfaces an error message and empty items on rejection", async () => {
- searchUserMRsMock.mockRejectedValueOnce(new Error("boom"));
- const { result } = renderHook(() => useGitLabUserMRs("a", ""));
- await waitFor(() => expect(result.current.loading).toBe(false));
- expect(result.current.error).toBe("boom");
- expect(result.current.items).toEqual([]);
- });
-
- it("ignores stale responses when inputs change mid-flight", async () => {
- let resolveFirst: (v: MRSearchPage) => void = () => {};
- searchUserMRsMock.mockReturnValueOnce(
- new Promise((res) => {
- resolveFirst = res;
- }),
- );
- const second = fakeMR();
- searchUserMRsMock.mockResolvedValueOnce({
- mrs: [second],
- total_count: 1,
- page: 1,
- per_page: 50,
- });
-
- const { result, rerender } = renderHook(
- ({ filter }: { filter: string }) => useGitLabUserMRs(filter, ""),
- { initialProps: { filter: "a" } },
- );
- rerender({ filter: "b" });
- await waitFor(() => expect(result.current.items).toEqual([second]));
-
- // The first request resolves *after* the swap — it must NOT clobber
- // the newer items list.
- resolveFirst({
- mrs: [fakeMR(), fakeMR()],
- total_count: 2,
- page: 1,
- per_page: 50,
- });
- await new Promise((r) => setTimeout(r, 10));
- expect(result.current.items).toEqual([second]);
- });
-});
-
-describe("useGitLabUserIssues", () => {
- beforeEach(() => {
- searchUserIssuesMock.mockReset();
- });
-
- it("forwards args and returns items", async () => {
- const issue = fakeIssue();
- searchUserIssuesMock.mockResolvedValueOnce({
- issues: [issue],
- total_count: 1,
- page: 1,
- per_page: 50,
- });
- const { result } = renderHook(() => useGitLabUserIssues("assigned_to_me", ""));
- await waitFor(() => expect(result.current.loading).toBe(false));
- expect(result.current.items).toEqual([issue]);
- });
-
- it("handles rejection without populating items", async () => {
- searchUserIssuesMock.mockRejectedValueOnce(new Error("net"));
- const { result } = renderHook(() => useGitLabUserIssues("a", ""));
- await waitFor(() => expect(result.current.loading).toBe(false));
- expect(result.current.error).toBe("net");
- expect(result.current.items).toEqual([]);
- });
-});
diff --git a/apps/web/hooks/domains/gitlab/use-user-search.ts b/apps/web/hooks/domains/gitlab/use-user-search.ts
deleted file mode 100644
index 6ab6e96dbc..0000000000
--- a/apps/web/hooks/domains/gitlab/use-user-search.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-"use client";
-
-import { useEffect, useRef, useState } from "react";
-import { searchUserIssues, searchUserMRs } from "@/lib/api/domains/gitlab-api";
-import type { Issue, MR } from "@/lib/types/gitlab";
-
-type SearchState = {
- items: T[];
- loading: boolean;
- error: string | null;
-};
-
-function initial(): SearchState {
- return { items: [], loading: true, error: null };
-}
-
-async function fetchMRs(
- filter: string,
- query: string,
- perPage: number,
- onResult: (s: SearchState) => void,
-) {
- onResult({ items: [], loading: true, error: null });
- try {
- const res = await searchUserMRs({ filter, customQuery: query, perPage });
- onResult({ items: res?.mrs ?? [], loading: false, error: null });
- } catch (err) {
- onResult({
- items: [],
- loading: false,
- error: err instanceof Error ? err.message : "Failed to load MRs",
- });
- }
-}
-
-async function fetchIssues(
- filter: string,
- query: string,
- perPage: number,
- onResult: (s: SearchState) => void,
-) {
- onResult({ items: [], loading: true, error: null });
- try {
- const res = await searchUserIssues({ filter, customQuery: query, perPage });
- onResult({ items: res?.issues ?? [], loading: false, error: null });
- } catch (err) {
- onResult({
- items: [],
- loading: false,
- error: err instanceof Error ? err.message : "Failed to load issues",
- });
- }
-}
-
-/** Fetch the current user's MRs from GitLab. Re-runs whenever filter or query change. */
-export function useGitLabUserMRs(filter: string, query: string, perPage = 50): SearchState {
- const [state, setState] = useState>(initial);
- const requestRef = useRef(0);
-
- useEffect(() => {
- const requestId = ++requestRef.current;
- void fetchMRs(filter, query, perPage, (next) => {
- if (requestRef.current !== requestId) return;
- setState(next);
- });
- }, [filter, query, perPage]);
-
- return state;
-}
-
-/** Fetch the current user's issues from GitLab. */
-export function useGitLabUserIssues(
- filter: string,
- query: string,
- perPage = 50,
-): SearchState {
- const [state, setState] = useState>(initial);
- const requestRef = useRef(0);
-
- useEffect(() => {
- const requestId = ++requestRef.current;
- void fetchIssues(filter, query, perPage, (next) => {
- if (requestRef.current !== requestId) return;
- setState(next);
- });
- }, [filter, query, perPage]);
-
- return state;
-}