Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

192 lines
7.2 KiB
TypeScript

"use client";
import { Check, ChevronDown, ChevronRight, FolderOpen } from "lucide-react";
import { CalendarDays } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { formatRelativeTime } from "@/lib/relative-time";
import type { SelectGroup, SelectionUnit } from "@/lib/chat-import";
/**
* Unit-level scope picker shared by the import wizard and "Edit scope". The
* selection unit is the *group* (a project for Claude Code, a day for Codex):
* checking a unit brings in all of its conversations and locks the agent to it,
* so a later sync pulls new conversations inside chosen units and nothing else.
* Sessions are shown expanded as a read-only preview — they aren't individually
* selectable, which is what keeps "what I picked" and "what syncs" the same.
*/
export interface ScopePickerProps {
groups: SelectGroup[];
unit: SelectionUnit;
/** Selected group keys (cwds or `YYYY-MM-DD`). */
selected: Set<string>;
onToggle: (key: string) => void;
onSelectAll: () => void;
onClearAll: () => void;
lang: string;
}
function formatDay(key: string, lang: string): string {
const d = new Date(`${key}T00:00:00`);
if (Number.isNaN(d.getTime())) return key;
return d.toLocaleDateString(lang, {
year: "numeric",
month: "short",
day: "numeric",
weekday: "short",
});
}
export default function ScopePicker({
groups,
unit,
selected,
onToggle,
onSelectAll,
onClearAll,
lang,
}: ScopePickerProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const totalSessions = groups.reduce((n, g) => n + g.sessions.length, 0);
const selectedSessions = groups.reduce(
(n, g) => (selected.has(g.key) ? n + g.sessions.length : n),
0,
);
const GroupIcon = unit === "dates" ? CalendarDays : FolderOpen;
const toggleExpand = (key: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
return (
<div className="space-y-2.5">
<div className="flex items-center justify-between gap-3">
<span className="text-[11.5px] text-[var(--muted-foreground)]">
{unit === "dates"
? t("{{days}} days · {{sessions}} conversations", {
days: groups.length,
sessions: totalSessions,
})
: t("{{projects}} projects · {{sessions}} conversations", {
projects: groups.length,
sessions: totalSessions,
})}
{selectedSessions > 0 ? (
<span className="ml-1.5 font-medium text-[var(--foreground)]">
· {t("{{count}} selected", { count: selectedSessions })}
</span>
) : null}
</span>
<div className="flex items-center gap-1 text-[11.5px] font-medium">
<button
type="button"
onClick={onSelectAll}
className="rounded-md px-2 py-1 text-[var(--muted-foreground)] transition-colors hover:bg-[var(--muted)]/50 hover:text-[var(--foreground)]"
>
{t("Select all")}
</button>
<button
type="button"
onClick={onClearAll}
className="rounded-md px-2 py-1 text-[var(--muted-foreground)] transition-colors hover:bg-[var(--muted)]/50 hover:text-[var(--foreground)]"
>
{t("Clear")}
</button>
</div>
</div>
<div className="max-h-[44vh] space-y-1.5 overflow-y-auto pr-1">
{groups.map((group) => {
const checked = selected.has(group.key);
const isOpen = expanded.has(group.key);
return (
<div
key={group.key}
className={`overflow-hidden rounded-xl border transition-colors ${
checked
? "border-[var(--primary)]/40 bg-[var(--primary)]/[0.04]"
: "border-[var(--border)]/60 bg-[var(--card)]/40"
}`}
>
<div className="flex items-center gap-2.5 px-2.5 py-2">
<button
type="button"
onClick={() => onToggle(group.key)}
aria-label={t("Select")}
aria-pressed={checked}
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
checked
? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]"
: "border-[var(--border)] bg-transparent hover:border-[var(--primary)]/60"
}`}
>
{checked ? <Check size={11} strokeWidth={3} /> : null}
</button>
<button
type="button"
onClick={() => toggleExpand(group.key)}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
>
{isOpen ? (
<ChevronDown
size={14}
className="shrink-0 text-[var(--muted-foreground)]"
/>
) : (
<ChevronRight
size={14}
className="shrink-0 text-[var(--muted-foreground)]"
/>
)}
<GroupIcon
size={14}
className="shrink-0 text-[var(--muted-foreground)]"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-[12.5px] font-medium text-[var(--foreground)]">
{unit === "dates"
? formatDay(group.label, lang)
: group.label}
</span>
{group.sublabel ? (
<span className="block truncate text-[10.5px] text-[var(--muted-foreground)]/80">
{group.sublabel}
</span>
) : null}
</span>
</button>
<span className="shrink-0 text-[10.5px] text-[var(--muted-foreground)]">
{t("{{count}} chats", { count: group.sessions.length })}
</span>
</div>
{isOpen && (
<ul className="divide-y divide-[var(--border)]/40 border-t border-[var(--border)]/40">
{group.sessions.map((session) => (
<li
key={session.externalId}
className="flex items-center gap-2.5 px-3 py-2 pl-9"
>
<span className="min-w-0 flex-1 truncate text-[12px] text-[var(--muted-foreground)]">
{session.provisionalTitle || t("Untitled conversation")}
</span>
<span className="shrink-0 text-[10.5px] text-[var(--muted-foreground)]/70">
{formatRelativeTime(session.lastModified / 1000, lang)}
</span>
</li>
))}
</ul>
)}
</div>
);
})}
</div>
</div>
);
}