"use client";
import Link from "@/components/routing/app-link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { IconBrandGitlab, IconMenu2 } from "@tabler/icons-react";
import { Alert, AlertDescription } from "@kandev/ui/alert";
import { Button } from "@kandev/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
import { PageTopbar } from "@/components/page-topbar";
import { fetchGitLabStatus } from "@/lib/api/domains/gitlab-api";
import type { GitLabStatus, Issue, MR } from "@/lib/types/gitlab";
import { MRList } from "@/components/gitlab/my-gitlab/mr-list";
import { IssueList } from "@/components/gitlab/my-gitlab/issue-list";
import {
PresetsSidebar,
type SidebarSelection,
} from "@/components/gitlab/my-gitlab/presets-sidebar";
import { PresetsScopeBar } from "@/components/gitlab/my-gitlab/presets-scope-bar";
import { MR_PRESETS, ISSUE_PRESETS } from "@/components/gitlab/my-gitlab/presets";
import { useGitLabSearch } from "@/components/gitlab/my-gitlab/use-gitlab-search";
import { useSavedPresets, type SavedPreset } from "@/components/gitlab/my-gitlab/use-saved-presets";
import {
useKnownProjects,
resetKnownProjectsStore,
} from "@/components/gitlab/my-gitlab/use-known-projects";
import { useCommittedQuery } from "@/components/gitlab/my-gitlab/use-committed-query";
import { ListToolbar } from "@/components/gitlab/my-gitlab/list-toolbar";
import { ResultsPagination } from "@/components/gitlab/my-gitlab/results-pagination";
import { SavePresetDialog } from "@/components/gitlab/my-gitlab/save-preset-dialog";
import { useMRKeyToTasks } from "@/hooks/domains/gitlab/use-mr-key-to-tasks";
import { useGitLabActionPresets } from "@/hooks/domains/gitlab/use-gitlab-action-presets";
import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots";
import type { Repository, Workflow, WorkflowStep } from "@/lib/types/http";
import {
QuickTaskLauncher,
type GitLabLaunchPayload,
type GitLabTaskPreset,
} from "@/components/gitlab/my-gitlab/quick-task-launcher";
import { toGitLabTaskPreset } from "@/components/gitlab/my-gitlab/task-presets";
import { useAppStore } from "@/components/state-provider";
type GitLabPageClientProps = {
workspaceId?: string;
workflows?: Workflow[];
steps?: WorkflowStep[];
repositories?: Repository[];
};
function PageHeader({
host,
onOpenMobileSidebar,
}: {
host: string;
onOpenMobileSidebar?: () => void;
}) {
return (
}
actions={
onOpenMobileSidebar && (
)
}
/>
);
}
function NotConnectedNotice({ reconnect }: { reconnect?: boolean }) {
return (
{reconnect
? "GitLab credentials are configured, but authentication failed. Reconnect in "
: "GitLab is not connected. Configure GitLab authentication in "}
Settings → GitLab
{" "}
to see your merge requests and issues.
);
}
function resolveTitle(selection: SidebarSelection, saved: SavedPreset[]): string {
if (selection.source === "saved") {
return saved.find((p) => p.id === selection.id)?.label ?? "Saved query";
}
const presets = selection.kind === "mr" ? MR_PRESETS : ISSUE_PRESETS;
return (
presets.find((p) => p.value === selection.id)?.label ??
(selection.kind === "mr" ? "Merge requests" : "Issues")
);
}
function ResultsList({
selection,
items,
loading,
error,
mrPresets,
issuePresets,
onStartTask,
mrKeyToTasks,
workspaceId,
host,
}: {
selection: SidebarSelection;
items: Array;
loading: boolean;
error: string | null;
mrPresets: GitLabTaskPreset[];
issuePresets: GitLabTaskPreset[];
onStartTask: (payload: GitLabLaunchPayload) => void;
mrKeyToTasks: ReturnType;
workspaceId?: string;
host: string;
}) {
if (selection.kind === "mr") {
return (
);
}
return (
);
}
type GitLabPageState = ReturnType;
function useProjectOptions(
selection: SidebarSelection,
committedQuery: string,
items: Array,
projectFilter: string,
): string[] {
const pageProjects = useMemo(
() => items.filter((it) => !!it.project_path).map((it) => it.project_path),
[items],
);
const resetKey = `${selection.kind}:${selection.source}:${selection.id}:${committedQuery.trim()}`;
const knownProjects = useKnownProjects(resetKey, pageProjects);
return useMemo(() => {
const set = new Set(knownProjects);
if (projectFilter) set.add(projectFilter);
return Array.from(set).sort();
}, [knownProjects, projectFilter]);
}
function useGitLabPageState(searchEnabled: boolean, workspaceId?: string) {
const [selection, setSelection] = useState(() => ({
kind: "mr",
source: "preset",
id: MR_PRESETS[0]?.value ?? "",
}));
const {
draft: customQuery,
committed: committedQuery,
setDraft: setCustomQuery,
setImmediate: setQueryImmediate,
commit: commitCustomQuery,
} = useCommittedQuery("");
const [projectFilter, setProjectFilter] = useState("");
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const {
presets: savedPresets,
save: saveSavedPreset,
remove: removeSavedPreset,
} = useSavedPresets();
const presets = selection.kind === "mr" ? MR_PRESETS : ISSUE_PRESETS;
const search = useGitLabSearch({
workspaceId: workspaceId ?? "",
kind: selection.kind,
presets,
preset: selection.source === "preset" ? selection.id : "",
customQuery: committedQuery,
projectFilter,
enabled: searchEnabled && Boolean(workspaceId),
});
const projectOptions = useProjectOptions(
selection,
committedQuery,
search.rawItems,
projectFilter,
);
const title = useMemo(() => resolveTitle(selection, savedPresets), [selection, savedPresets]);
const onSelect = useCallback(
(s: SidebarSelection) => {
setSelection(s);
if (s.source === "saved") {
const found = savedPresets.find((p) => p.id === s.id);
setQueryImmediate(found?.customQuery ?? "");
setProjectFilter(found?.projectFilter ?? "");
return;
}
setQueryImmediate("");
setProjectFilter("");
},
[savedPresets, setQueryImmediate],
);
// Use committedQuery (not the unflushed draft) so the saved preset always
// matches what is currently displayed in the list.
const canSaveCurrent = committedQuery.trim().length > 0 || projectFilter.length > 0;
const suggestedLabel =
committedQuery.trim() || (projectFilter ? `In ${projectFilter}` : "Saved query");
const onOpenSaveDialog = () => {
if (canSaveCurrent) setSaveDialogOpen(true);
};
const onConfirmSave = (label: string) => {
const created = saveSavedPreset({
kind: selection.kind,
label,
customQuery: committedQuery,
projectFilter,
});
setSelection({ kind: selection.kind, source: "saved", id: created.id });
};
const onDeleteSaved = (id: string) => {
removeSavedPreset(id);
if (selection.source === "saved" && selection.id === id) {
const fallbackPresets = selection.kind === "mr" ? MR_PRESETS : ISSUE_PRESETS;
setSelection({ kind: selection.kind, source: "preset", id: fallbackPresets[0]?.value ?? "" });
setQueryImmediate("");
setProjectFilter("");
}
};
return {
selection,
customQuery,
committedQuery,
setCustomQuery,
commitCustomQuery,
projectFilter,
setProjectFilter,
savedPresets,
search,
projectOptions,
title,
onSelect,
canSaveCurrent,
suggestedLabel,
saveDialogOpen,
setSaveDialogOpen,
onOpenSaveDialog,
onConfirmSave,
onDeleteSaved,
};
}
function AuthenticatedLayout({
workspaceId,
state,
mrPresets,
issuePresets,
onStartTask,
host,
}: {
workspaceId?: string;
state: GitLabPageState;
mrPresets: GitLabTaskPreset[];
issuePresets: GitLabTaskPreset[];
onStartTask: (payload: GitLabLaunchPayload) => void;
host: string;
}) {
const { selection, search, projectOptions, title } = state;
const mrKeyToTasks = useMRKeyToTasks(workspaceId ?? null);
useAllWorkflowSnapshots(workspaceId ?? null);
// When the user narrows by project, the toolbar badge shows how many of the
// current page actually match (so it never reads "47" while three rows
// render). Pagination still uses search.total so the user can navigate to
// later pages that may contain more matches.
const displayedCount = state.projectFilter ? search.items.length : search.total;
return (
);
}
function useGitLabStatusFetch(workspaceId: string | undefined, enabled: boolean) {
const [result, setResult] = useState<{ workspaceId?: string; status: GitLabStatus | null }>({
workspaceId,
status: null,
});
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!enabled || !workspaceId) {
setLoading(true);
return;
}
let cancelled = false;
setLoading(true);
fetchGitLabStatus({ cache: "no-store", workspaceId })
.then((s) => {
if (!cancelled) setResult({ workspaceId, status: s });
})
.catch(() => {
if (!cancelled) setResult({ workspaceId, status: null });
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [enabled, workspaceId]);
const current = enabled && result.workspaceId === workspaceId;
return { status: current ? result.status : null, loading: !current || loading };
}
function GitLabPageBody({
statusLoading,
connected,
reconnect,
workspaceId,
state,
mrPresets,
issuePresets,
onStartTask,
host,
}: {
statusLoading: boolean;
connected: boolean;
reconnect: boolean;
workspaceId?: string;
state: GitLabPageState;
mrPresets: GitLabTaskPreset[];
issuePresets: GitLabTaskPreset[];
onStartTask: (payload: GitLabLaunchPayload) => void;
host: string;
}) {
if (statusLoading) {
return Checking GitLab status…
;
}
if (!connected) {
return (
);
}
return (
);
}
function GitLabPageOverlays({
state,
mobileSidebarOpen,
setMobileSidebarOpen,
workspaceId,
workflows,
steps,
repositories,
configuredHost,
launchPayload,
setLaunchPayload,
}: {
state: GitLabPageState;
mobileSidebarOpen: boolean;
setMobileSidebarOpen: (open: boolean) => void;
workspaceId?: string;
workflows: Workflow[];
steps: WorkflowStep[];
repositories: Repository[];
configuredHost: string;
launchPayload: GitLabLaunchPayload | null;
setLaunchPayload: (payload: GitLabLaunchPayload | null) => void;
}) {
const onMobileSidebarSelect = (selection: Parameters[0]) => {
state.onSelect(selection);
setMobileSidebarOpen(false);
};
const onMobileSaveCurrent = () => {
setMobileSidebarOpen(false);
state.onOpenSaveDialog();
};
return (
<>
Filters
setLaunchPayload(null)}
/>
>
);
}
function useGitLabWorkspaceScope(serverWorkspaceId?: string) {
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
const workspaceId = activeWorkspaceId ?? serverWorkspaceId;
const switching = Boolean(serverWorkspaceId && workspaceId && serverWorkspaceId !== workspaceId);
const { status, loading } = useGitLabStatusFetch(workspaceId, !switching);
useEffect(() => {
if (switching) window.location.reload();
}, [switching]);
return {
workspaceId,
switching,
status,
statusLoading: switching || loading,
connected: Boolean(status?.authenticated),
reconnect: Boolean(status?.token_configured && !status.authenticated),
};
}
function useGitLabTaskPresets(workspaceId: string | undefined, switching: boolean) {
const { presets } = useGitLabActionPresets(switching ? null : workspaceId);
const mrPresets = useMemo(() => (presets?.mr ?? []).map(toGitLabTaskPreset), [presets?.mr]);
const issuePresets = useMemo(
() => (presets?.issue ?? []).map(toGitLabTaskPreset),
[presets?.issue],
);
return { mrPresets, issuePresets };
}
export function GitLabPageClient({
workspaceId,
workflows = [],
steps = [],
repositories = [],
}: GitLabPageClientProps = {}) {
const scope = useGitLabWorkspaceScope(workspaceId);
const { status, statusLoading, connected, reconnect } = scope;
const host = status?.host ?? "https://gitlab.com";
const state = useGitLabPageState(!statusLoading && connected, scope.workspaceId);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [launchPayload, setLaunchPayload] = useState(null);
const { mrPresets, issuePresets } = useGitLabTaskPresets(scope.workspaceId, scope.switching);
useEffect(() => resetKnownProjectsStore, []);
const onOpenMobileSidebar = useCallback(() => setMobileSidebarOpen(true), []);
return (
);
}