mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-06 05:12:03 +08:00
refactor: rebuild desktop app with Tauri
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
@use "../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
section { min-width: 0; }
|
||||
h4 { margin: 12px 0 6px; color: var(--vscode-descriptionForeground); font-size: type.$font-size-xs; font-weight: 500; }
|
||||
}
|
||||
|
||||
.details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
padding: 1px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: var(--vscode-sideBar-border);
|
||||
border-radius: 6px;
|
||||
|
||||
> div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 132px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
background: var(--vscode-editorWidget-background);
|
||||
}
|
||||
|
||||
dt { color: var(--vscode-descriptionForeground); font-size: type.$font-size-xs; }
|
||||
dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
|
||||
code { white-space: pre-wrap; font-family: var(--oa-code-font); font-size: type.$font-size-xs; }
|
||||
}
|
||||
.meta{
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.meta,
|
||||
.empty {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { CallDetail } from "../api";
|
||||
import { JsonEditor } from "./ui/JsonEditor";
|
||||
import { Tabs, type TabItem } from "./ui/Tabs";
|
||||
import styles from "./CallDetails.module.scss";
|
||||
|
||||
const show = (value: string | number | null) => value ?? "-";
|
||||
const timing = (value: number | null) => value == null ? "-" : `${value} ms`;
|
||||
|
||||
export function CallDetails({ detail }: { detail: CallDetail }) {
|
||||
const { call, request, response_chunks: chunks, cursor_trace: cursorTrace } = detail;
|
||||
const responseBody = chunks.map((chunk) => chunk.data).join("");
|
||||
const responseBytes = chunks.reduce((total, chunk) => total + chunk.byte_count, 0);
|
||||
const fields: Array<[string, string | number]> = [
|
||||
["Call ID", call.call_id],
|
||||
[t("调用类型"), call.call_kind === "cursor_official" ? t("Cursor 官方") : "LLM"],
|
||||
[t("路由"), call.route === "cursor_official" ? t("Cursor 官方") : "BYOK"],
|
||||
["Run ID", call.run_id],
|
||||
["Conversation ID", call.conversation_id],
|
||||
[t("上游调用序号"), call.provider_call_index],
|
||||
["Model Hash", show(call.model_hash)],
|
||||
[t("上游类型"), call.provider_type],
|
||||
[t("上游地址"), call.provider_url],
|
||||
[t("最终请求类型"), call.request_type],
|
||||
[t("最终请求地址"), call.request_url],
|
||||
["Model ID", call.model_id],
|
||||
[t("显示名称"), call.display_name],
|
||||
[t("思考强度"), show(call.reasoning_effort)],
|
||||
["Fast", call.fast == null ? "-" : call.fast ? t("是") : t("否")],
|
||||
[t("状态"), call.status],
|
||||
["Finish Reason", show(call.finish_reason)],
|
||||
["HTTP Status", show(call.http_status)],
|
||||
["Created At", `${call.created_at_ms} · ${new Date(call.created_at_ms).toLocaleString()}`],
|
||||
[t("耗时"), timing(call.duration_ms)],
|
||||
["TTFB", timing(call.ttfb_ms)],
|
||||
["TTFT", timing(call.ttft_ms)],
|
||||
["Input Token", show(call.input_tokens)],
|
||||
["Output Token", show(call.output_tokens)],
|
||||
["Total Token", show(call.total_tokens)],
|
||||
["Cache Read Token", show(call.cache_read_tokens)],
|
||||
["Cache Write Token", show(call.cache_write_tokens)],
|
||||
["Reasoning Token", show(call.reasoning_tokens)],
|
||||
[t("消息数"), call.message_count],
|
||||
[t("工具数"), call.tool_count],
|
||||
[t("详细记录"), call.detailed ? t("是") : t("否")],
|
||||
["Error Kind", show(call.error_kind)],
|
||||
["Error Message", show(call.error_message)],
|
||||
];
|
||||
|
||||
const tabs: TabItem[] = [
|
||||
{ value: "call", label: t("调用信息"), content: <section>
|
||||
<dl className={styles.details}>{fields.map(([label, value]) => <div key={label}><dt>{label}</dt><dd><code>{value}</code></dd></div>)}</dl>
|
||||
</section> },
|
||||
{ value: "request", label: t("请求"), content: <section>
|
||||
{request ? <>
|
||||
<div className={styles.meta}>{t("字节数")}:{request.byte_count}</div>
|
||||
<h4>{t("请求头")}</h4><JsonEditor ariaLabel={t("请求头")} value={JSON.stringify(request.headers)} readOnly />
|
||||
<h4>{t("请求体")}</h4><JsonEditor ariaLabel={t("请求体")} value={JSON.stringify(request.body)} readOnly detail />
|
||||
</> : <div className={styles.empty}>{t("未记录请求内容,请开启详细记录后重试。")}</div>}
|
||||
</section> },
|
||||
{ value: "response", label: t("响应流"), content: <section>
|
||||
{chunks.length > 0 ? <>
|
||||
<div className={styles.meta}>{t("分块数")}:{chunks.length} · {t("字节数")}:{responseBytes}</div>
|
||||
<JsonEditor ariaLabel={t("响应流")} value={responseBody} readOnly detail />
|
||||
</> : <div className={styles.empty}>{t("未记录响应内容,请开启详细记录后重试。")}</div>}
|
||||
</section> },
|
||||
];
|
||||
|
||||
if (cursorTrace) tabs.push({ value: "cursor-trace", label: t("Cursor 追踪"), content: <section>
|
||||
<div className={styles.meta}>
|
||||
Request ID:{cursorTrace.trace.request_id} · {t("工件数")}:{cursorTrace.artifacts.length}
|
||||
</div>
|
||||
<JsonEditor
|
||||
ariaLabel={t("Cursor 追踪")}
|
||||
value={JSON.stringify({ trace: cursorTrace.trace, artifacts: cursorTrace.artifacts })}
|
||||
readOnly
|
||||
detail
|
||||
/>
|
||||
</section> });
|
||||
|
||||
return <div className={styles.root}><Tabs items={tabs} /></div>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@use "../styles/typography" as type;
|
||||
|
||||
.status {
|
||||
padding: 3px 7px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: var(--vscode-input-background);
|
||||
border-radius: 99px;
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
.completed { color: #56b870; }
|
||||
.failed { color: var(--vscode-errorForeground, #f48771); }
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { LlmCall } from "../api";
|
||||
import controls from "./ui/Controls.module.scss";
|
||||
import { DataTable, type DataTableColumn } from "./ui/DataTable";
|
||||
import { Icon } from "./ui/Icon";
|
||||
import { TooltipTrigger } from "./ui/TooltipTrigger";
|
||||
import { eyeIcon } from "./ui/icons";
|
||||
import styles from "./CallTable.module.scss";
|
||||
|
||||
const value = (input: string | number | null) => input ?? "-";
|
||||
const milliseconds = (input: number | null) => input == null ? "-" : `${input} ms`;
|
||||
|
||||
export function CallTable({ calls, onDetails }: { calls: LlmCall[]; onDetails: (call: LlmCall) => void }) {
|
||||
const columns: DataTableColumn<LlmCall>[] = [
|
||||
{
|
||||
key: "status",
|
||||
header: t("状态"),
|
||||
render: (call) => (
|
||||
<span
|
||||
className={[styles.status, styles[call.status]]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{call.status}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
key: "display_name",
|
||||
header: t("显示名称"),
|
||||
render: (call) => call.display_name,
|
||||
title: (call) => call.display_name,
|
||||
},
|
||||
{
|
||||
key: "created_at",
|
||||
header: t("时间"),
|
||||
render: (call) => new Date(call.created_at_ms).toLocaleString(),
|
||||
},
|
||||
{
|
||||
key: "model_id",
|
||||
header: t("模型名称"),
|
||||
render: (call) => call.model_id,
|
||||
title: (call) => call.model_id,
|
||||
},
|
||||
{
|
||||
key: "reasoning_effort",
|
||||
header: t("思考强度"),
|
||||
render: (call) => value(call.reasoning_effort),
|
||||
},
|
||||
{
|
||||
key: "fast",
|
||||
header: "Fast",
|
||||
render: (call) => call.fast == null ? "-" : call.fast ? t("是") : t("否"),
|
||||
},
|
||||
|
||||
{
|
||||
key: "call_kind",
|
||||
header: t("调用类型"),
|
||||
render: (call) =>
|
||||
call.call_kind === "cursor_official" ? t("Cursor 官方") : "LLM",
|
||||
},
|
||||
{
|
||||
key: "route",
|
||||
header: t("路由"),
|
||||
render: (call) =>
|
||||
call.route === "cursor_official" ? t("Cursor 官方") : "BYOK",
|
||||
},
|
||||
|
||||
// { key: "model_hash", header: "Model Hash", render: (call) => value(call.model_hash), title: (call) => call.model_hash ?? undefined },
|
||||
// { key: "provider_type", header: t("供应商类型"), render: (call) => call.provider_type },
|
||||
// { key: "provider_url", header: t("供应商地址"), render: (call) => call.provider_url, title: (call) => call.provider_url },
|
||||
// { key: "request_type", header: t("最终请求类型"), render: (call) => call.request_type },
|
||||
// { key: "request_url", header: t("最终请求地址"), render: (call) => call.request_url, title: (call) => call.request_url },
|
||||
{
|
||||
key: "finish_reason",
|
||||
header: "Finish Reason",
|
||||
render: (call) => value(call.finish_reason),
|
||||
},
|
||||
{ key: "http", header: "HTTP", render: (call) => value(call.http_status) },
|
||||
{
|
||||
key: "duration",
|
||||
header: t("耗时"),
|
||||
render: (call) => milliseconds(call.duration_ms),
|
||||
},
|
||||
{
|
||||
key: "ttfb",
|
||||
header: "TTFB",
|
||||
render: (call) => milliseconds(call.ttfb_ms),
|
||||
},
|
||||
{
|
||||
key: "ttft",
|
||||
header: "TTFT",
|
||||
render: (call) => milliseconds(call.ttft_ms),
|
||||
},
|
||||
{
|
||||
key: "input_tokens",
|
||||
header: "Input Token",
|
||||
render: (call) => value(call.input_tokens),
|
||||
},
|
||||
{
|
||||
key: "output_tokens",
|
||||
header: "Output Token",
|
||||
render: (call) => value(call.output_tokens),
|
||||
},
|
||||
{
|
||||
key: "total_tokens",
|
||||
header: "Total Token",
|
||||
render: (call) => value(call.total_tokens),
|
||||
},
|
||||
{
|
||||
key: "cache_read",
|
||||
header: "Cache Read",
|
||||
render: (call) => value(call.cache_read_tokens),
|
||||
},
|
||||
{
|
||||
key: "cache_write",
|
||||
header: "Cache Write",
|
||||
render: (call) => value(call.cache_write_tokens),
|
||||
},
|
||||
{
|
||||
key: "reasoning_tokens",
|
||||
header: "Reasoning Token",
|
||||
render: (call) => value(call.reasoning_tokens),
|
||||
},
|
||||
{
|
||||
key: "message_count",
|
||||
header: t("消息数"),
|
||||
render: (call) => call.message_count,
|
||||
},
|
||||
{
|
||||
key: "tool_count",
|
||||
header: t("工具数"),
|
||||
render: (call) => call.tool_count,
|
||||
},
|
||||
{
|
||||
key: "detailed",
|
||||
header: t("详细记录"),
|
||||
render: (call) => (call.detailed ? t("是") : t("否")),
|
||||
},
|
||||
|
||||
{
|
||||
key: "error_kind",
|
||||
header: "Error Kind",
|
||||
render: (call) => value(call.error_kind),
|
||||
title: (call) => call.error_kind ?? undefined,
|
||||
},
|
||||
{
|
||||
key: "error_message",
|
||||
header: "Error Message",
|
||||
render: (call) => value(call.error_message),
|
||||
title: (call) => call.error_message ?? undefined,
|
||||
},
|
||||
{
|
||||
key: "call_id",
|
||||
header: "Call ID",
|
||||
render: (call) => call.call_id,
|
||||
title: (call) => call.call_id,
|
||||
},
|
||||
{
|
||||
key: "run_id",
|
||||
header: "Run ID",
|
||||
render: (call) => call.run_id,
|
||||
title: (call) => call.run_id,
|
||||
},
|
||||
{
|
||||
key: "conversation_id",
|
||||
header: "Conversation ID",
|
||||
render: (call) => call.conversation_id,
|
||||
title: (call) => call.conversation_id,
|
||||
},
|
||||
{
|
||||
key: "call_index",
|
||||
header: "Call Index",
|
||||
render: (call) => call.provider_call_index,
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: t("操作"),
|
||||
sticky: "right",
|
||||
render: (call) => (
|
||||
<TooltipTrigger label={t("查看详情")}>
|
||||
<button
|
||||
className={controls.iconButton}
|
||||
aria-label={t("查看详情")}
|
||||
onClick={() => onDetails(call)}
|
||||
>
|
||||
<Icon icon={eyeIcon} size="1.1em" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
),
|
||||
},
|
||||
];
|
||||
return <DataTable rows={calls} columns={columns} rowKey={(call) => call.call_id} minWidth={4180} />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.form { grid-template-columns: 1fr; }
|
||||
.fullWidth { grid-column: auto; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ProviderInput, ProviderType } from "../api";
|
||||
import { FormField, TextInput } from "./ui/FormControls";
|
||||
import { JsonEditor } from "./ui/JsonEditor";
|
||||
import { Select } from "./ui/Select";
|
||||
import { claudeIcon, openAiIcon } from "./ui/icons";
|
||||
import styles from "./ProviderEditor.module.scss";
|
||||
|
||||
export function ProviderEditor({ value, headersText, extraText, editing, onChange, onHeadersChange, onExtraChange }: {
|
||||
value: ProviderInput;
|
||||
headersText: string;
|
||||
extraText: string;
|
||||
editing: boolean;
|
||||
onChange: (value: ProviderInput) => void;
|
||||
onHeadersChange: (value: string) => void;
|
||||
onExtraChange: (value: string) => void;
|
||||
}) {
|
||||
const patch = (next: Partial<ProviderInput>) => onChange({ ...value, ...next });
|
||||
return <div className={styles.form}>
|
||||
<FormField className={styles.fullWidth} label={t("名称")}><TextInput placeholder={t("例如:OpenAI")} value={value.name} onChange={(event) => patch({ name: event.target.value })} /></FormField>
|
||||
<FormField label={t("协议")} hint={t("选择上游服务使用的请求协议。")}><Select ariaLabel={t("协议")} value={value.provider_type} options={[
|
||||
{ value: "openai-responses", label: "OpenAI Responses", icon: openAiIcon },
|
||||
{ value: "openai-chat", label: "OpenAI Chat", icon: openAiIcon },
|
||||
{ value: "anthropic", label: "Anthropic", icon: claudeIcon },
|
||||
]} onChange={(provider_type) => patch({ provider_type: provider_type as ProviderType })} /></FormField>
|
||||
<FormField label="Base URL" hint={t("模型服务的 API 根地址;修改后会同步更新该上游模型的路由身份。")}><TextInput placeholder="https://api.example.com/v1" value={value.base_url} onChange={(event) => patch({ base_url: event.target.value })} /></FormField>
|
||||
<FormField className={styles.fullWidth} label="API Key" hint={editing ? t("留空表示保留当前 API Key。") : t("访问模型服务所需的密钥。")}><TextInput type="password" autoComplete="off" placeholder={editing ? t("留空以保留当前密钥") : "sk-xxxxxx"} value={value.api_key ?? ""} onChange={(event) => patch({ api_key: event.target.value })} /></FormField>
|
||||
<FormField className={styles.fullWidth} label={t("自定义 Headers JSON")} hint={t("值必须是字符串;编辑时 null 表示保留对应敏感 Header 的原值。")}><JsonEditor ariaLabel={t("自定义 Headers JSON")} value={headersText} onChange={onHeadersChange} /></FormField>
|
||||
<FormField className={styles.fullWidth} label={t("额外参数 JSON")} hint={t("合并到该上游所有模型的请求体。")}><JsonEditor ariaLabel={t("额外参数 JSON")} value={extraText} onChange={onExtraChange} /></FormField>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
@use "../styles/typography" as type;
|
||||
|
||||
.badge {
|
||||
padding: 3px 7px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: var(--vscode-input-background);
|
||||
border-radius: 99px;
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Provider } from "../api";
|
||||
import controls from "./ui/Controls.module.scss";
|
||||
import { DataTable, type DataTableColumn } from "./ui/DataTable";
|
||||
import { Icon } from "./ui/Icon";
|
||||
import { TooltipTrigger } from "./ui/TooltipTrigger";
|
||||
import { editIcon, trashIcon } from "./ui/icons";
|
||||
import styles from "./ProviderTable.module.scss";
|
||||
|
||||
const json = (value: unknown) => JSON.stringify(value);
|
||||
|
||||
export function ProviderTable({ providers, onEdit, onDelete }: {
|
||||
providers: Provider[];
|
||||
onEdit: (provider: Provider) => void;
|
||||
onDelete: (provider: Provider) => void;
|
||||
}) {
|
||||
const columns: DataTableColumn<Provider>[] = [
|
||||
{ key: "name", header: t("名称"), render: (provider) => provider.name, title: (provider) => provider.name },
|
||||
{ key: "type", header: t("协议"), render: (provider) => provider.provider_type },
|
||||
{ key: "url", header: "Base URL", render: (provider) => provider.base_url, title: (provider) => provider.base_url },
|
||||
{ key: "key", header: "API Key", render: (provider) => <span className={styles.badge}>{provider.has_api_key ? t("已配置") : t("未配置")}</span> },
|
||||
{ key: "headers", header: "Headers JSON", render: (provider) => json(provider.custom_headers), title: (provider) => json(provider.custom_headers) },
|
||||
{ key: "extra", header: t("额外参数 JSON"), render: (provider) => json(provider.extra_params), title: (provider) => json(provider.extra_params) },
|
||||
{ key: "created", header: t("创建时间"), render: (provider) => new Date(provider.created_at_ms).toLocaleString() },
|
||||
{ key: "updated", header: t("更新时间"), render: (provider) => new Date(provider.updated_at_ms).toLocaleString() },
|
||||
{ key: "actions", header: t("操作"), sticky: "right", render: (provider) => <div className={styles.actions}>
|
||||
<TooltipTrigger label={t("编辑上游")}><button className={controls.iconButton} aria-label={t("编辑上游")} onClick={() => onEdit(provider)}><Icon icon={editIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
<TooltipTrigger label={t("删除上游")}><button className={`${controls.iconButton} ${controls.danger}`} aria-label={t("删除上游")} onClick={() => onDelete(provider)}><Icon icon={trashIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
</div> },
|
||||
];
|
||||
return <DataTable rows={providers} columns={columns} rowKey={(provider) => provider.provider_id} minWidth={1400} />;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { RefObject } from "react";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { windowCloseIcon } from "../ui/icons";
|
||||
import type { AdSlot } from "./types";
|
||||
import styles from "./Ads.module.scss";
|
||||
|
||||
type AdMenuProps = {
|
||||
ads: AdSlot[];
|
||||
activeAdId?: string;
|
||||
dismissingAdId?: string;
|
||||
readAdIds: ReadonlySet<string>;
|
||||
triggerRefs: RefObject<Map<string, HTMLButtonElement>>;
|
||||
onOpen: (ad: AdSlot) => void;
|
||||
onDismiss: (ad: AdSlot) => void;
|
||||
};
|
||||
|
||||
export function AdMenu({ ads, activeAdId, dismissingAdId, readAdIds, triggerRefs, onOpen, onDismiss }: AdMenuProps) {
|
||||
return <div className={styles.menuSlots} aria-label={t("推荐内容")}>
|
||||
{ads.map((ad) => <div key={ad.id} className={styles.menuSlotContainer}>
|
||||
<button
|
||||
ref={(node) => {
|
||||
if (node) triggerRefs.current.set(ad.id, node);
|
||||
else triggerRefs.current.delete(ad.id);
|
||||
}}
|
||||
type="button"
|
||||
className={styles.menuSlot}
|
||||
title={`${ad.target.title}\n${ad.target.description}`}
|
||||
aria-label={`${ad.target.title},${ad.target.description}${readAdIds.has(ad.id) ? "" : `,${t("未读")}`}`}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={activeAdId === ad.id}
|
||||
aria-controls={activeAdId === ad.id ? "menu-ad-dialog" : undefined}
|
||||
onClick={() => onOpen(ad)}
|
||||
>
|
||||
{!readAdIds.has(ad.id) && <span className={styles.unreadDot} aria-hidden="true" />}
|
||||
<img className={styles.menuImage} src={ad.target.imageUrl} alt="" />
|
||||
<span className={styles.menuCopy}>
|
||||
<span className={styles.menuTitle}>{ad.target.title}</span>
|
||||
<span className={styles.menuSubtitle}>{ad.target.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuDismiss}
|
||||
aria-label={`${t("不再显示广告")}:${ad.target.title}`}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={dismissingAdId === ad.id}
|
||||
aria-controls={dismissingAdId === ad.id ? "dismiss-ad-dialog" : undefined}
|
||||
onClick={() => onDismiss(ad)}
|
||||
>
|
||||
<Icon icon={windowCloseIcon} size="1em" />
|
||||
</button>
|
||||
</div>)}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.menuSlots {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.menuSlotContainer {
|
||||
position: relative;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.menuSlot {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 32px 0 8px;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.menuDismiss {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: color-mix(in srgb, var(--vscode-descriptionForeground) 42%, transparent);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
color: color-mix(in srgb, var(--vscode-foreground) 72%, transparent);
|
||||
background: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 55%, transparent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.unreadDot {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 25px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: var(--vscode-errorForeground);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 1px var(--vscode-editorWidget-background);
|
||||
}
|
||||
|
||||
.menuImage {
|
||||
flex: 0 0 30px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menuCopy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.menuTitle,
|
||||
.menuSubtitle {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.menuTitle {
|
||||
font-size: type.$font-size-xs;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.menuSubtitle {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-2xs;
|
||||
}
|
||||
|
||||
.floatingAd {
|
||||
position: fixed;
|
||||
z-index: 20000;
|
||||
left: 16px;
|
||||
bottom: 16px;
|
||||
width: 320px;
|
||||
max-width: calc(100vw - 32px);
|
||||
height: min(360px, calc(100vh - 32px));
|
||||
max-height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-editorWidget-background);
|
||||
border-radius: 15px;
|
||||
border: none;
|
||||
box-shadow: 0 24px 20px rgb(0 0 0 / 28%);
|
||||
animation: ad-enter 180ms ease-out;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
flex: 0 0 112px;
|
||||
position: relative;
|
||||
height: 112px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.promotionLabel {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
padding: 3px 7px;
|
||||
color: white;
|
||||
font-size: type.$font-size-xs;
|
||||
line-height: 1;
|
||||
background: rgb(0 0 0 / 48%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: type.$font-size-xl;
|
||||
line-height: 1;
|
||||
background: rgb(0 0 0 / 16%);
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
|
||||
&:hover {
|
||||
background: rgb(0 0 0 / 30%);
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
--scroll-shadow-bottom-size: 16px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.bodyContent {
|
||||
padding: 14px 16px 18px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: type.$font-size-base;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 6px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
}
|
||||
|
||||
.detail {
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: type.$font-size-base;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 4px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 12px 12px;
|
||||
border-top: 1px solid var(--vscode-sideBar-border);
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
color: #202020;
|
||||
background: #f2f2f2;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--vscode-focusBorder);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ad-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.985);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.floatingAd {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { windowCloseIcon } from "../ui/icons";
|
||||
import { VirtualList } from "../virtual/VirtualList";
|
||||
import type { AdAction, AdSlot } from "./types";
|
||||
import styles from "./Ads.module.scss";
|
||||
|
||||
type AdTextSection =
|
||||
| { key: "intro"; kind: "intro" }
|
||||
| { key: string; kind: "detail"; label: string; value: string };
|
||||
|
||||
type FloatingAdProps = {
|
||||
ad: AdSlot;
|
||||
trigger: HTMLButtonElement | null;
|
||||
onClose: () => void;
|
||||
onAction: (action: AdAction) => void;
|
||||
};
|
||||
|
||||
export function FloatingAd({ ad, trigger, onClose, onAction }: FloatingAdProps) {
|
||||
const dialog = useRef<HTMLDivElement>(null);
|
||||
const textSections = useMemo<AdTextSection[]>(() => [
|
||||
{ key: "intro", kind: "intro" },
|
||||
...ad.content.details.map((detail, index) => ({
|
||||
key: `detail-${index}-${detail.label}`,
|
||||
kind: "detail" as const,
|
||||
label: detail.label,
|
||||
value: detail.value,
|
||||
})),
|
||||
], [ad.content.details]);
|
||||
|
||||
useEffect(() => {
|
||||
dialog.current?.focus();
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (!dialog.current?.contains(target) && !trigger?.contains(target)) onClose();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
trigger?.focus();
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onClose, trigger]);
|
||||
|
||||
return createPortal(<div
|
||||
id="menu-ad-dialog"
|
||||
ref={dialog}
|
||||
className={styles.floatingAd}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-label={ad.content.title}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className={styles.hero}>
|
||||
<img src={ad.content.imageUrl} alt="" />
|
||||
<span className={styles.promotionLabel}>{t("推广")}</span>
|
||||
<button type="button" className={styles.close} aria-label={t("关闭广告")} onClick={() => { onClose(); trigger?.focus(); }}>
|
||||
<Icon icon={windowCloseIcon} size="1.1em" />
|
||||
</button>
|
||||
</div>
|
||||
<VirtualList
|
||||
items={textSections}
|
||||
itemKey="key"
|
||||
estimatedItemHeight={58}
|
||||
itemGap={12}
|
||||
className={`${styles.body} scroll-shadow-bottom`}
|
||||
contentClassName={styles.bodyContent}
|
||||
>
|
||||
{(section) => section.kind === "intro"
|
||||
? <div className={styles.intro}>
|
||||
<h2>{ad.content.title}</h2>
|
||||
<p>{ad.content.description}</p>
|
||||
</div>
|
||||
: <div className={styles.detail}>
|
||||
<h3>{section.label}</h3>
|
||||
<p>{section.value}</p>
|
||||
</div>}
|
||||
</VirtualList>
|
||||
<div className={styles.footer}>
|
||||
<button type="button" onClick={() => { onClose(); onAction(ad.content.button.action); }}>{ad.content.button.label}</button>
|
||||
</div>
|
||||
</div>, document.body);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export const AdActionType = {
|
||||
OpenBrowser: "open_browser",
|
||||
} as const;
|
||||
|
||||
export type AdAction = {
|
||||
type: typeof AdActionType.OpenBrowser;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type AdSlot = {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
placement: "menu";
|
||||
target: {
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
};
|
||||
content: {
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
details: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
button: {
|
||||
label: string;
|
||||
action: AdAction;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type AdRuntime = {
|
||||
slots: AdSlot[];
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scroller {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.axis {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
margin-top: 10px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-2xs;
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 28px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltipContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { init, Rect, type ElementEvent } from "zrender";
|
||||
import { Tooltip, type TooltipAnchor } from "../ui/Tooltip";
|
||||
import styles from "./ContributionCalendarChart.module.scss";
|
||||
|
||||
export type ContributionDay = {
|
||||
date: string;
|
||||
tokens: number;
|
||||
};
|
||||
|
||||
type ContributionCalendarChartProps = {
|
||||
data: ContributionDay[];
|
||||
};
|
||||
|
||||
type CalendarCell = ContributionDay & {
|
||||
column: number;
|
||||
row: number;
|
||||
level: number;
|
||||
};
|
||||
|
||||
type CellExtra = CalendarCell & {
|
||||
kind: "calendar-cell";
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type TooltipState = {
|
||||
date: string;
|
||||
tokens: number;
|
||||
anchor: TooltipAnchor;
|
||||
};
|
||||
|
||||
type AxisLabel = {
|
||||
key: string;
|
||||
text: string;
|
||||
left: number;
|
||||
};
|
||||
|
||||
const levelColors = [
|
||||
"rgba(139, 148, 158, 0.20)",
|
||||
"#9be9a8",
|
||||
"#40c463",
|
||||
"#30a14e",
|
||||
"#216e39",
|
||||
];
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
||||
const CALENDAR_CONFIG = {
|
||||
cellAspectRatio: 0.9,
|
||||
cellGap: 3,
|
||||
resizeTransitionMs: 180,
|
||||
rowCount: 7,
|
||||
axisLabelGap: 8,
|
||||
axisLabelWidth: 28,
|
||||
} as const;
|
||||
const tokenFormatter = new Intl.NumberFormat("zh-CN");
|
||||
|
||||
function parseDate(date: string) {
|
||||
return new Date(`${date}T00:00:00Z`);
|
||||
}
|
||||
|
||||
function mondayIndex(date: Date) {
|
||||
return (date.getUTCDay() + 6) % 7;
|
||||
}
|
||||
|
||||
function cellOffset(index: number, cellSize: number) {
|
||||
return index * (cellSize + CALENDAR_CONFIG.cellGap);
|
||||
}
|
||||
|
||||
function isCellExtra(value: unknown): value is CellExtra {
|
||||
return typeof value === "object" && value !== null && (value as CellExtra).kind === "calendar-cell";
|
||||
}
|
||||
|
||||
function buildCalendarLayout(data: ContributionDay[]) {
|
||||
if (data.length === 0) return null;
|
||||
|
||||
const maximum = Math.max(1, ...data.map(({ tokens }) => tokens));
|
||||
const firstDate = parseDate(data[0].date);
|
||||
const calendarStart = new Date(firstDate.getTime() - mondayIndex(firstDate) * DAY_IN_MS);
|
||||
const cells: CalendarCell[] = data.map((day) => {
|
||||
const date = parseDate(day.date);
|
||||
const daysFromStart = Math.round((date.getTime() - calendarStart.getTime()) / DAY_IN_MS);
|
||||
const level = day.tokens === 0 ? 0 : Math.max(1, Math.ceil((day.tokens / maximum) * 4));
|
||||
return { ...day, column: Math.floor(daysFromStart / 7), row: mondayIndex(date), level };
|
||||
});
|
||||
const columnCount = cells.at(-1)!.column + 1;
|
||||
const monthTicks = cells.reduce<Array<{ key: string; text: string; column: number }>>((ticks, cell) => {
|
||||
const date = parseDate(cell.date);
|
||||
const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}`;
|
||||
if (ticks.at(-1)?.key !== key) ticks.push({ key, text: `${date.getUTCMonth() + 1}月`, column: cell.column });
|
||||
return ticks;
|
||||
}, []);
|
||||
return { cells, columnCount, monthTicks };
|
||||
}
|
||||
|
||||
export function ContributionCalendarChart({ data }: ContributionCalendarChartProps) {
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const layoutRef = useRef<ReturnType<typeof buildCalendarLayout>>(null);
|
||||
const scheduleDrawRef = useRef<() => void>(() => undefined);
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
const [axisLabels, setAxisLabels] = useState<AxisLabel[]>([]);
|
||||
const layout = useMemo(() => buildCalendarLayout(data), [data]);
|
||||
layoutRef.current = layout;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const scroller = scrollerRef.current;
|
||||
const node = canvasRef.current;
|
||||
if (!scroller || !node) return;
|
||||
|
||||
const chart = init(node, {
|
||||
renderer: "canvas",
|
||||
width: 1,
|
||||
height: 1,
|
||||
useDirtyRect: true,
|
||||
});
|
||||
|
||||
const handleMouseOver = (event: ElementEvent) => {
|
||||
const extra = event.target?.extra;
|
||||
if (!isCellExtra(extra)) return;
|
||||
const anchor: TooltipAnchor = {
|
||||
contextElement: node,
|
||||
getBoundingClientRect: () => {
|
||||
const bounds = node.getBoundingClientRect();
|
||||
return new DOMRect(bounds.left + extra.x, bounds.top + extra.y, extra.width, extra.height);
|
||||
},
|
||||
};
|
||||
setTooltip({ date: extra.date, tokens: extra.tokens, anchor });
|
||||
};
|
||||
const handleMouseOut = (event: ElementEvent) => {
|
||||
if (isCellExtra(event.target?.extra)) setTooltip(null);
|
||||
};
|
||||
|
||||
chart.on("mouseover", handleMouseOver);
|
||||
chart.on("mouseout", handleMouseOut);
|
||||
|
||||
const cellRects = new Map<string, Rect>();
|
||||
let drawFrame = 0;
|
||||
let lastAvailableWidth = -1;
|
||||
let lastCanvasHeight = -1;
|
||||
let lastLayout: typeof layout = null;
|
||||
const draw = () => {
|
||||
const currentLayout = layoutRef.current;
|
||||
if (!currentLayout) return;
|
||||
const availableWidth = Math.floor(scroller.getBoundingClientRect().width);
|
||||
if (availableWidth <= 0 || (availableWidth === lastAvailableWidth && currentLayout === lastLayout)) return;
|
||||
const gapsWidth = (currentLayout.columnCount - 1) * CALENDAR_CONFIG.cellGap;
|
||||
const cellWidth = Math.max(0, (availableWidth - gapsWidth) / currentLayout.columnCount);
|
||||
const cellHeight = cellWidth / CALENDAR_CONFIG.cellAspectRatio;
|
||||
const width = availableWidth;
|
||||
const height = CALENDAR_CONFIG.rowCount * cellHeight
|
||||
+ (CALENDAR_CONFIG.rowCount - 1) * CALENDAR_CONFIG.cellGap;
|
||||
|
||||
let lastLabelEnd = -Infinity;
|
||||
const nextAxisLabels = currentLayout.monthTicks.flatMap((tick) => {
|
||||
const left = Math.min(
|
||||
cellOffset(tick.column, cellWidth),
|
||||
availableWidth - CALENDAR_CONFIG.axisLabelWidth,
|
||||
);
|
||||
if (left < lastLabelEnd + CALENDAR_CONFIG.axisLabelGap) return [];
|
||||
lastLabelEnd = left + CALENDAR_CONFIG.axisLabelWidth;
|
||||
return [{ ...tick, left }];
|
||||
});
|
||||
setAxisLabels(nextAxisLabels);
|
||||
|
||||
if (availableWidth !== lastAvailableWidth || height !== lastCanvasHeight) {
|
||||
node.style.width = "100%";
|
||||
node.style.height = `${height}px`;
|
||||
chart.resize({ width, height });
|
||||
lastAvailableWidth = availableWidth;
|
||||
lastCanvasHeight = height;
|
||||
}
|
||||
lastLayout = currentLayout;
|
||||
|
||||
const currentDates = new Set(currentLayout.cells.map((cell) => cell.date));
|
||||
for (const [date, rect] of cellRects) {
|
||||
if (currentDates.has(date)) continue;
|
||||
chart.remove(rect);
|
||||
cellRects.delete(date);
|
||||
}
|
||||
|
||||
for (const cell of currentLayout.cells) {
|
||||
const x = cellOffset(cell.column, cellWidth);
|
||||
const y = cellOffset(cell.row, cellHeight);
|
||||
const shape = {
|
||||
x,
|
||||
y,
|
||||
width: cellWidth,
|
||||
height: cellHeight,
|
||||
r: Math.min(3, Math.min(cellWidth, cellHeight) / 4),
|
||||
};
|
||||
const extra = {
|
||||
...cell,
|
||||
kind: "calendar-cell" as const,
|
||||
x,
|
||||
y,
|
||||
width: cellWidth,
|
||||
height: cellHeight,
|
||||
} satisfies CellExtra;
|
||||
const current = cellRects.get(cell.date);
|
||||
|
||||
if (current) {
|
||||
current.extra = extra;
|
||||
current.stopAnimation();
|
||||
current.animateTo(
|
||||
{ shape, style: { fill: levelColors[cell.level] } },
|
||||
{ duration: CALENDAR_CONFIG.resizeTransitionMs, easing: "cubicOut" },
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rect = new Rect({
|
||||
shape,
|
||||
style: {
|
||||
fill: levelColors[cell.level],
|
||||
stroke: "rgba(139, 148, 158, 0.10)",
|
||||
lineWidth: 1,
|
||||
},
|
||||
cursor: "default",
|
||||
extra,
|
||||
});
|
||||
cellRects.set(cell.date, rect);
|
||||
chart.add(rect);
|
||||
}
|
||||
setTooltip(null);
|
||||
};
|
||||
const scheduleDraw = () => {
|
||||
window.cancelAnimationFrame(drawFrame);
|
||||
drawFrame = window.requestAnimationFrame(draw);
|
||||
};
|
||||
scheduleDrawRef.current = scheduleDraw;
|
||||
const observer = new ResizeObserver(scheduleDraw);
|
||||
observer.observe(scroller);
|
||||
scheduleDraw();
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.cancelAnimationFrame(drawFrame);
|
||||
scheduleDrawRef.current = () => undefined;
|
||||
chart.dispose();
|
||||
};
|
||||
}, [layout !== null]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
scheduleDrawRef.current();
|
||||
}, [layout]);
|
||||
|
||||
if (!layout) return null;
|
||||
|
||||
return (
|
||||
<section className={styles.root} aria-label={t("过去一年的 Token 用量")}>
|
||||
<div ref={scrollerRef} className={styles.scroller}>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className={styles.canvas}
|
||||
role="img"
|
||||
aria-label={t("过去一年的 Token 用量日历")}
|
||||
/>
|
||||
<div className={styles.axis} aria-hidden="true">
|
||||
{axisLabels.map((label) => <span key={label.key} style={{ left: label.left }}>{label.text}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip anchor={tooltip?.anchor ?? null}>
|
||||
<div className={styles.tooltipContent}>
|
||||
<strong>{tooltip?.date}</strong>
|
||||
<span>{t("Token 用量:{tokens}", { tokens: tokenFormatter.format(tooltip?.tokens ?? 0) })}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
--daily-token-tooltip-font-size: #{type.$font-size-2xs};
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { EChartsCoreOption } from "echarts/core";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { TokenUsageGranularity } from "../../api";
|
||||
import { formatCompactInteger } from "../../utils/numberFormat";
|
||||
import { EChart } from "./EChart";
|
||||
import styles from "./DailyTokenUsageChart.module.scss";
|
||||
|
||||
export type DailyTokenUsage = {
|
||||
bucketStartMs: number;
|
||||
inputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
outputTokens: number;
|
||||
};
|
||||
|
||||
type TooltipItem = {
|
||||
dataIndex: number;
|
||||
};
|
||||
|
||||
const seriesColors = {
|
||||
input: "#0091ff",
|
||||
cacheRead: "#40c463",
|
||||
cacheWrite: "#3A62BA",
|
||||
output: "#E7B40B",
|
||||
} as const;
|
||||
const levelLineColor = "#E7B40B";
|
||||
const emptyBarColor = "rgba(139, 148, 158, 0.20)";
|
||||
const EMPTY_BAR_RATIO = 1;
|
||||
const DATA_HEIGHT_RATIO = 1;
|
||||
|
||||
const seriesFocus = {
|
||||
emphasis: { focus: "series" },
|
||||
blur: { itemStyle: { opacity: 0.2 } },
|
||||
} as const;
|
||||
|
||||
function colorMark(color: string): string {
|
||||
return `<span style="display:inline-block;width:8px;height:8px;margin-right:6px;border-radius:50%;background-color:${color};vertical-align:middle"></span>`;
|
||||
}
|
||||
|
||||
function formatDay(value: Date) {
|
||||
return `${value.getUTCMonth() + 1}/${value.getUTCDate()}`;
|
||||
}
|
||||
|
||||
function pad(value: number) {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
function formatAxisLabel(bucketStartMs: number, granularity: TokenUsageGranularity) {
|
||||
const value = new Date(bucketStartMs);
|
||||
if (granularity === "minute") return `${pad(value.getHours())}:${pad(value.getMinutes())}`;
|
||||
if (granularity === "hour") return `${pad(value.getHours())}:00`;
|
||||
const day = value.getUTCDay();
|
||||
if (day === 6) return t("周六");
|
||||
if (day === 0) return t("周日");
|
||||
return formatDay(value);
|
||||
}
|
||||
|
||||
function formatTooltipTime(bucketStartMs: number, granularity: TokenUsageGranularity) {
|
||||
const value = new Date(bucketStartMs);
|
||||
if (granularity === "day") return formatDay(value);
|
||||
const time = `${pad(value.getHours())}:${pad(value.getMinutes())}`;
|
||||
return `${value.getMonth() + 1}/${value.getDate()} ${time}`;
|
||||
}
|
||||
|
||||
function totalTokens(day: DailyTokenUsage) {
|
||||
return day.inputTokens + day.cacheReadTokens + day.cacheWriteTokens + day.outputTokens;
|
||||
}
|
||||
|
||||
export function DailyTokenUsageChart({
|
||||
data,
|
||||
granularity,
|
||||
}: {
|
||||
data: DailyTokenUsage[];
|
||||
granularity: TokenUsageGranularity;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const maximumTotal = data.reduce((maximum, day) => Math.max(maximum, totalTokens(day)), 0);
|
||||
const axisMaximum = Math.max(1, maximumTotal / DATA_HEIGHT_RATIO);
|
||||
const emptyBarHeight = axisMaximum * EMPTY_BAR_RATIO;
|
||||
const averageLevel = data.length === 0
|
||||
? 0
|
||||
: data.reduce((sum, day) => sum + totalTokens(day), 0) / data.length;
|
||||
|
||||
const option = useMemo<EChartsCoreOption>(() => ({
|
||||
animationDuration: 450,
|
||||
animationEasing: "cubicOut",
|
||||
grid: { top: 8, right: 0, bottom: 0, left: 0 },
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
confine: true,
|
||||
backgroundColor: "var(--vscode-editorHoverWidget-background)",
|
||||
borderColor: "var(--vscode-editorHoverWidget-border)",
|
||||
textStyle: { color: "var(--vscode-foreground)", fontFamily: "PingFang-Medium" },
|
||||
extraCssText: "border-radius: 8px; box-shadow: 0 12px 32px rgb(0 0 0 / 30%); font-size: var(--daily-token-tooltip-font-size); line-height: 1.5;",
|
||||
axisPointer: {
|
||||
type: "shadow",
|
||||
shadowStyle: { color: "rgba(139, 148, 158, 0.14)" },
|
||||
},
|
||||
formatter: (params: unknown) => {
|
||||
const first = (params as TooltipItem[])[0];
|
||||
const day = data[first.dataIndex];
|
||||
return [
|
||||
formatTooltipTime(day.bucketStartMs, granularity),
|
||||
`${t("总请求")}:${formatCompactInteger(totalTokens(day))}`,
|
||||
`${colorMark(seriesColors.input)}${t("输入(非缓存)")}:${formatCompactInteger(day.inputTokens)}`,
|
||||
`${colorMark(seriesColors.cacheRead)}${t("缓存输入")}:${formatCompactInteger(day.cacheReadTokens)}`,
|
||||
`${colorMark(seriesColors.cacheWrite)}${t("缓存写入")}:${formatCompactInteger(day.cacheWriteTokens)}`,
|
||||
`${colorMark(seriesColors.output)}${t("模型输出")}:${formatCompactInteger(day.outputTokens)}`,
|
||||
].join("<br/>");
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: data.map(({ bucketStartMs }) => bucketStartMs),
|
||||
axisTick: { show: false },
|
||||
axisLine: { lineStyle: { color: "rgba(139, 148, 158, 0.32)" } },
|
||||
axisLabel: {
|
||||
interval: "auto",
|
||||
hideOverlap: true,
|
||||
formatter: (_value: string, index: number) => formatAxisLabel(data[index].bucketStartMs, granularity),
|
||||
color: "#8c8c8c",
|
||||
fontFamily: "HFKos",
|
||||
margin: 14,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
show: false,
|
||||
min: 0,
|
||||
max: axisMaximum,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: t("无用量"),
|
||||
type: "bar",
|
||||
stack: "empty-placeholder",
|
||||
data: data.map((day) => totalTokens(day) === 0 ? emptyBarHeight : 0),
|
||||
barMaxWidth: 18,
|
||||
silent: true,
|
||||
z: 0,
|
||||
itemStyle: { color: emptyBarColor, borderRadius: [2, 2, 0, 0] },
|
||||
emphasis: { disabled: true },
|
||||
},
|
||||
{
|
||||
name: t("输入(非缓存)"),
|
||||
type: "bar",
|
||||
stack: "tokens",
|
||||
data: data.map(({ inputTokens }) => inputTokens),
|
||||
barMaxWidth: 18,
|
||||
itemStyle: { color: seriesColors.input },
|
||||
...seriesFocus,
|
||||
},
|
||||
{
|
||||
name: t("缓存输入"),
|
||||
type: "bar",
|
||||
stack: "tokens",
|
||||
data: data.map(({ cacheReadTokens }) => cacheReadTokens),
|
||||
barMaxWidth: 18,
|
||||
itemStyle: { color: seriesColors.cacheRead },
|
||||
...seriesFocus,
|
||||
},
|
||||
{
|
||||
name: t("缓存写入"),
|
||||
type: "bar",
|
||||
stack: "tokens",
|
||||
data: data.map(({ cacheWriteTokens }) => cacheWriteTokens),
|
||||
barMaxWidth: 18,
|
||||
itemStyle: { color: seriesColors.cacheWrite },
|
||||
...seriesFocus,
|
||||
},
|
||||
{
|
||||
name: t("模型输出"),
|
||||
type: "bar",
|
||||
stack: "tokens",
|
||||
data: data.map(({ outputTokens }) => outputTokens),
|
||||
barMaxWidth: 18,
|
||||
barGap: "-100%",
|
||||
itemStyle: { color: seriesColors.output, borderRadius: [2, 2, 0, 0] },
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: "none",
|
||||
lineStyle: { color: levelLineColor, opacity: hovered ? 1 : 0, type: "dashed", width: 2 },
|
||||
label: {
|
||||
show: hovered,
|
||||
position: "insideStartTop",
|
||||
formatter: t("平均"),
|
||||
color: levelLineColor,
|
||||
distance: 8,
|
||||
},
|
||||
data: [{ yAxis: averageLevel }],
|
||||
},
|
||||
...seriesFocus,
|
||||
},
|
||||
],
|
||||
}), [averageLevel, axisMaximum, data, emptyBarHeight, granularity, hovered]);
|
||||
|
||||
return <EChart
|
||||
option={option}
|
||||
className={styles.root}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
/>;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { BarChart, LineChart } from "echarts/charts";
|
||||
import { GridComponent, LegendComponent, MarkLineComponent, TooltipComponent } from "echarts/components";
|
||||
import { getInstanceByDom, init, use, type EChartsCoreOption } from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { useEffect, useRef, type MouseEventHandler } from "react";
|
||||
import styles from "./EChart.module.scss";
|
||||
|
||||
use([BarChart, LineChart, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent, CanvasRenderer]);
|
||||
|
||||
type EChartProps = {
|
||||
option: EChartsCoreOption;
|
||||
className?: string;
|
||||
onMouseEnter?: MouseEventHandler<HTMLDivElement>;
|
||||
onMouseLeave?: MouseEventHandler<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export function EChart({ option, className = styles.root, onMouseEnter, onMouseLeave }: EChartProps) {
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const node = element.current;
|
||||
if (!node) return;
|
||||
const chart = init(node, undefined, { renderer: "canvas" });
|
||||
const observer = new ResizeObserver(() => chart.resize());
|
||||
observer.observe(node);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
chart.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const node = element.current;
|
||||
if (node) getInstanceByDom(node)?.setOption(option, { notMerge: true });
|
||||
}, [option]);
|
||||
|
||||
return <div ref={element} className={className} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} />;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { EChartsCoreOption } from "echarts/core";
|
||||
import type { LlmCall } from "../../api";
|
||||
import { EChart } from "./EChart";
|
||||
|
||||
export function LatencyChart({ calls }: { calls: LlmCall[] }) {
|
||||
const points = calls.slice(0, 20).reverse();
|
||||
const option: EChartsCoreOption = {
|
||||
animationDuration: 280,
|
||||
color: ["#d79a62", "#ad84cf"],
|
||||
grid: { top: 22, right: 18, bottom: 30, left: 48 },
|
||||
legend: { top: 0, right: 8, textStyle: { color: "#999" } },
|
||||
tooltip: { trigger: "axis" },
|
||||
xAxis: { type: "category", data: points.map((call) => new Date(call.created_at_ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })), axisLabel: { color: "#888" }, axisLine: { lineStyle: { color: "#5555" } } },
|
||||
yAxis: { type: "value", axisLabel: { color: "#888", formatter: "{value} ms" }, splitLine: { lineStyle: { color: "#8882" } } },
|
||||
series: [
|
||||
{ name: "TTFT", type: "line", smooth: true, symbol: "none", data: points.map((call) => call.ttft_ms ?? 0) },
|
||||
{ name: t("总耗时"), type: "line", smooth: true, symbol: "none", data: points.map((call) => call.duration_ms ?? 0) },
|
||||
],
|
||||
};
|
||||
return <EChart option={option} />;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { EChartsCoreOption } from "echarts/core";
|
||||
import type { LlmCall } from "../../api";
|
||||
import { EChart } from "./EChart";
|
||||
|
||||
export function TokenTrendChart({ calls }: { calls: LlmCall[] }) {
|
||||
const points = calls.slice(0, 20).reverse();
|
||||
const option: EChartsCoreOption = {
|
||||
animationDuration: 280,
|
||||
color: ["#6f91f4", "#62c6a3"],
|
||||
grid: { top: 22, right: 18, bottom: 30, left: 48 },
|
||||
legend: { top: 0, right: 8, textStyle: { color: "#999" } },
|
||||
tooltip: { trigger: "axis" },
|
||||
xAxis: { type: "category", data: points.map((call) => new Date(call.created_at_ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })), axisLabel: { color: "#888" }, axisLine: { lineStyle: { color: "#5555" } } },
|
||||
yAxis: { type: "value", axisLabel: { color: "#888" }, splitLine: { lineStyle: { color: "#8882" } } },
|
||||
series: [
|
||||
{ name: t("输入 Token"), type: "bar", stack: "tokens", data: points.map((call) => call.input_tokens ?? 0), barMaxWidth: 22 },
|
||||
{ name: t("输出 Token"), type: "bar", stack: "tokens", data: points.map((call) => call.output_tokens ?? 0), barMaxWidth: 22 },
|
||||
],
|
||||
};
|
||||
return <EChart option={option} />;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { useAppStore } from "../../store/appStore";
|
||||
import controls from "../ui/Controls.module.scss";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
|
||||
const CaReady = createContext(false);
|
||||
const ModelsReady = createContext(false);
|
||||
|
||||
export function CursorCaProvider({ children }: { children: ReactNode }) {
|
||||
const { cursorHarness } = useAppStore();
|
||||
return <CaReady.Provider value={cursorHarness?.ca === "ready"}>{children}</CaReady.Provider>;
|
||||
}
|
||||
|
||||
export function CursorCaGate({ busy, waitingForRefresh, onInitialize, onRefresh, children }: { busy: boolean; waitingForRefresh: boolean; onInitialize: () => void; onRefresh: () => void; children: ReactNode }) {
|
||||
const ready = useContext(CaReady);
|
||||
const { cursorHarness } = useAppStore();
|
||||
if (ready) return children;
|
||||
const unsupported = cursorHarness?.ca === "unsupported";
|
||||
const installedLocally = cursorHarness?.ca === "untrusted";
|
||||
return <div className={styles.gate}>
|
||||
<strong>{unsupported ? t("当前系统暂不支持安装 CA") : installedLocally ? t("需要在系统中信任本地 CA") : t("需要先初始化本地 CA")}</strong>
|
||||
<span>{unsupported ? t("请使用 macOS 或 Windows。") : installedLocally ? t("请在终端中粘贴授权命令并输入密码,完成后点击下方按钮") : t("CA 仅保存在本机,用于安全解析 Cursor 的 HTTPS 请求。")}</span>
|
||||
{!unsupported && <button className={controls.primary} disabled={busy} onClick={waitingForRefresh ? onRefresh : onInitialize}>{busy ? t("刷新中…") : waitingForRefresh ? t("我已初始化,刷新") : installedLocally ? t("打开终端安装 CA") : t("初始化 CA")}</button>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function CursorModelProvider({ children }: { children: ReactNode }) {
|
||||
const { models } = useAppStore();
|
||||
return <ModelsReady.Provider value={models.length > 0}>{children}</ModelsReady.Provider>;
|
||||
}
|
||||
|
||||
export function CursorModelGate({ onAdd, children }: { onAdd: () => void; children: ReactNode }) {
|
||||
const ready = useContext(ModelsReady);
|
||||
if (ready) return children;
|
||||
return <div className={styles.gate}>
|
||||
<strong>{t("还没有可供 Cursor 使用的模型")}</strong>
|
||||
<span>{t("Cursor 接管已生效;添加上游及其模型配置后即可使用 BYOK 模型。")}</span>
|
||||
<button className={controls.primary} onClick={onAdd}>{t("添加模型")}</button>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { ModelInput, Provider, ProviderInput, ProviderType } from "../../api";
|
||||
import { FormField, TextInput } from "../ui/FormControls";
|
||||
import { Checkbox } from "../ui/Checkbox";
|
||||
import { JsonEditor } from "../ui/JsonEditor";
|
||||
import { Combobox, MultiCombobox, Select } from "../ui/Select";
|
||||
import { Switch } from "../ui/Switch";
|
||||
import controls from "../ui/Controls.module.scss";
|
||||
import { TooltipTrigger } from "../ui/TooltipTrigger";
|
||||
import { claudeIcon, openAiIcon } from "../ui/icons";
|
||||
import { defaultCustomHeaders, defaultCustomHeadersText } from "../../utils/providerDefaults";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
|
||||
export type CursorModelDraft = {
|
||||
providerMode: string;
|
||||
provider: ProviderInput;
|
||||
model: ModelInput;
|
||||
modelIds: string[];
|
||||
headersText: string;
|
||||
extraText: string;
|
||||
customRequestUrl: boolean;
|
||||
};
|
||||
|
||||
export const emptyCursorModelDraft = (): CursorModelDraft => ({
|
||||
providerMode: "new",
|
||||
provider: { name: "", provider_type: "openai-responses", base_url: "", api_key: "", custom_headers: { ...defaultCustomHeaders }, extra_params: {} },
|
||||
model: { model_id: "", display_name: "", endpoint_type: "openai-responses", request_url: "", enabled: true, sort_order: 0, context_window_tokens: null, max_output_tokens: null, reasoning_enabled: true, reasoning_effort: null, supports_image_generation: false },
|
||||
modelIds: [],
|
||||
headersText: defaultCustomHeadersText,
|
||||
extraText: "{}",
|
||||
customRequestUrl: false,
|
||||
});
|
||||
|
||||
export function CursorModelEditor({ draft, providers, editing, modelOptions, discovering, onChange, onDiscover }: {
|
||||
draft: CursorModelDraft;
|
||||
providers: Provider[];
|
||||
editing: boolean;
|
||||
modelOptions: string[];
|
||||
discovering: boolean;
|
||||
onChange: (draft: CursorModelDraft) => void;
|
||||
onDiscover: () => void;
|
||||
}) {
|
||||
const setProvider = (patch: Partial<ProviderInput>) => onChange({ ...draft, provider: { ...draft.provider, ...patch } });
|
||||
const setModel = (patch: Partial<ModelInput>) => onChange({ ...draft, model: { ...draft.model, ...patch } });
|
||||
const canDiscover = draft.providerMode !== "new"
|
||||
|| Boolean(draft.provider.base_url.trim() && draft.provider.api_key?.trim());
|
||||
const selectProvider = (providerMode: string) => {
|
||||
const endpointType = providerMode === "new"
|
||||
? draft.provider.provider_type
|
||||
: providers.find((provider) => String(provider.provider_id) === providerMode)?.provider_type;
|
||||
onChange({ ...draft, providerMode, model: endpointType ? { ...draft.model, endpoint_type: endpointType } : draft.model });
|
||||
};
|
||||
const setEndpointType = (endpoint_type: ProviderType) => onChange({
|
||||
...draft,
|
||||
provider: draft.providerMode === "new" ? { ...draft.provider, provider_type: endpoint_type } : draft.provider,
|
||||
model: { ...draft.model, endpoint_type },
|
||||
});
|
||||
const setModelIds = (modelIds: string[]) => onChange({
|
||||
...draft,
|
||||
modelIds,
|
||||
model: {
|
||||
...draft.model,
|
||||
model_id: modelIds[0] ?? "",
|
||||
display_name: modelIds.length === 1 && draft.modelIds.length !== 1 ? modelIds[0] : draft.model.display_name,
|
||||
},
|
||||
});
|
||||
return <div className={styles.editor}>
|
||||
{!editing && <FormField label={t("上游")} hint={t("选择已有上游,或创建一个新的上游。")}><Select ariaLabel={t("选择上游")} value={draft.providerMode} options={[
|
||||
{ value: "new", label: t("新建上游") },
|
||||
...providers.map((provider) => ({ value: String(provider.provider_id), label: provider.name })),
|
||||
]} onChange={selectProvider} /></FormField>}
|
||||
|
||||
<div className={styles.grid}>
|
||||
{!editing && draft.providerMode === "new" && <>
|
||||
<FormField label="Base URL" hint={t("模型服务的 API 根地址,例如 https://api.openai.com/v1。")}><TextInput placeholder="例如:https://api.openai.com/v1" value={draft.provider.base_url} onChange={(event) => setProvider({ base_url: event.target.value })} /></FormField>
|
||||
<FormField label="API Key" hint={t("访问模型服务所需的密钥。")}><TextInput type="password" placeholder="例如:sk-xxxxxx" autoComplete="off" value={draft.provider.api_key ?? ""} onChange={(event) => setProvider({ api_key: event.target.value })} /></FormField>
|
||||
</>}
|
||||
<FormField label={t("端点类型")} hint={t("默认继承上游,可为当前模型单独修改。")}><Select ariaLabel={t("端点类型")} value={draft.model.endpoint_type} options={[
|
||||
{ value: "openai-responses", label: "OpenAI Responses", icon: openAiIcon }, { value: "openai-chat", label: "OpenAI Chat", icon: openAiIcon }, { value: "anthropic", label: "Anthropic", icon: claudeIcon },
|
||||
]} onChange={(endpointType) => setEndpointType(endpointType as ProviderType)} /></FormField>
|
||||
{(editing || draft.modelIds.length <= 1) && <FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给上游的模型名称。")}><TextInput placeholder="例如:GPT-4.1" value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>}
|
||||
<FormField className={styles.fullWidth} label={t("模型名称")} hint={editing ? t("可以直接输入模型标识,也可以从当前上游返回的模型列表中选择。") : t("支持选择或输入多个模型;批量添加时显示名称默认使用对应模型名称。")}>{editing
|
||||
? <Combobox value={draft.model.model_id} options={modelOptions} placeholder="例如:gpt-4.1" append={<button type="button" className={controls.secondary} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} />
|
||||
: <MultiCombobox value={draft.modelIds} options={modelOptions} placeholder="例如:gpt-4.1" append={<button type="button" className={controls.secondary} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</button>} onChange={setModelIds} />
|
||||
}</FormField>
|
||||
<div className={styles.fullWidth}><Checkbox label={t("自定义请求完整地址")} checked={draft.customRequestUrl} onChange={(customRequestUrl) => onChange({ ...draft, customRequestUrl, model: { ...draft.model, request_url: customRequestUrl ? draft.model.request_url : "" } })} /></div>
|
||||
{draft.customRequestUrl && <FormField className={styles.fullWidth} label={t("请求完整地址")} hint={t("支持完整 HTTP(S) 地址或以 / 开头、与上游地址组合的相对路径。")}><TextInput placeholder="例如:https://api.example.com/v1/chat/completions" value={draft.model.request_url} onChange={(event) => setModel({ request_url: event.target.value })} /></FormField>}
|
||||
{!editing && draft.providerMode === "new" && <>
|
||||
<FormField className={styles.fullWidth} label={t("额外参数 JSON")} hint={t("合并到该上游所有模型的请求体。")}><JsonEditor ariaLabel={t("额外参数 JSON")} value={draft.extraText} onChange={(extraText) => onChange({ ...draft, extraText })} /></FormField>
|
||||
<FormField className={styles.fullWidth} label={t("自定义 Headers JSON")} hint={t("附加到该上游所有请求的自定义请求头,值必须是字符串。")}><JsonEditor ariaLabel={t("自定义 Headers JSON")} value={draft.headersText} onChange={(headersText) => onChange({ ...draft, headersText })} /></FormField>
|
||||
</>}
|
||||
<div className={`${styles.switches} ${styles.fullWidth}`}>
|
||||
<label><TooltipTrigger label={t("模型是否允许被 Cursor 选择使用。")}><span>{t("启用模型")}</span></TooltipTrigger><Switch label={t("启用模型")} checked={draft.model.enabled} onChange={(enabled) => setModel({ enabled })} /></label>
|
||||
<label><TooltipTrigger label={t("是否声明模型支持推理能力。")}><span>{t("启用推理")}</span></TooltipTrigger><Switch label={t("启用推理")} checked={draft.model.reasoning_enabled} onChange={(reasoning_enabled) => setModel({ reasoning_enabled })} /></label>
|
||||
<label><TooltipTrigger label={t("是否声明模型支持图片生成。")}><span>{t("图片生成")}</span></TooltipTrigger><Switch label={t("图片生成")} checked={draft.model.supports_image_generation} onChange={(supports_image_generation) => setModel({ supports_image_generation })} /></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.gate {
|
||||
min-height: 250px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
text-align: center;
|
||||
border: 1px dashed var(--vscode-sideBar-border);
|
||||
border-radius: var(--oa-overlay-radius);
|
||||
strong {
|
||||
color: var(--vscode-foreground);
|
||||
font-size: type.$font-size-base;
|
||||
}
|
||||
span {
|
||||
max-width: 440px;
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
button {
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.providerTitle {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.models {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modelRow {
|
||||
min-height: 54px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
// border-top: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
.modelName {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
small {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
.badge {
|
||||
padding: 2px 7px;
|
||||
color: var(--vscode-badge-foreground);
|
||||
background: var(--vscode-badge-background);
|
||||
border-radius: 999px;
|
||||
font-size: type.$font-size-2xs;
|
||||
}
|
||||
.rowActions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.command {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
background: var(--vscode-textCodeBlock-background);
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
user-select: text;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.switches {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
label {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.fullWidth {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: absolute;
|
||||
z-index: 21;
|
||||
top: var(--app-content-top);
|
||||
left: max(var(--app-page-padding), calc((100% - var(--app-page-max-width)) / 2));
|
||||
margin: 0;
|
||||
padding-left: 10px;
|
||||
font-size: type.$font-size-lg;
|
||||
font-weight: bolder;
|
||||
transform: translateY(-150%);
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-drag: none;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.fixedContent {
|
||||
width: calc(100% - var(--app-page-padding) * 2);
|
||||
max-width: var(--app-page-max-width);
|
||||
padding-top: var(--app-content-top);
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.fixedContent,
|
||||
.fixedSection {
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.fixedSection {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { VirtualPageSection } from "./VirtualPage";
|
||||
import { VirtualPage } from "./VirtualPage";
|
||||
import styles from "./PageContent.module.scss";
|
||||
|
||||
export function PageContent({ title, sections, contentClassName, fixed = false }: { title?: ReactNode; sections: VirtualPageSection[]; contentClassName?: string; fixed?: boolean }) {
|
||||
return <div className={styles.root}>
|
||||
{fixed && title != null && <div className={styles.title}>{title}</div>}
|
||||
{fixed
|
||||
? <div className={[styles.fixedContent, contentClassName].filter(Boolean).join(" ")}>{sections.map((section) => <section className={styles.fixedSection} key={section.key}>{section.content}</section>)}</div>
|
||||
: <VirtualPage title={title} sections={sections} contentClassName={contentClassName} />}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header,
|
||||
.footer {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ElementType, ReactNode } from "react";
|
||||
import styles from "./PageLayout.module.scss";
|
||||
|
||||
type PageLayoutProps = {
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
header?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function PageLayout({ as: Component = "div", className, header, footer, children }: PageLayoutProps) {
|
||||
return <Component className={[styles.root, className].filter(Boolean).join(" ")}>
|
||||
{header && <div className={styles.header}>{header}</div>}
|
||||
<div className={styles.body}>{children}</div>
|
||||
{footer && <div className={styles.footer}>{footer}</div>}
|
||||
</Component>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: calc(100% - var(--app-page-padding) * 2);
|
||||
max-width: var(--app-page-max-width);
|
||||
padding-top: var(--app-content-top);
|
||||
padding-bottom: var(--app-page-padding);
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: absolute;
|
||||
z-index: 21;
|
||||
|
||||
top: 0;
|
||||
margin: 0;
|
||||
padding-left: 10px;
|
||||
font-size: type.$font-size-lg;
|
||||
font-weight: bolder;
|
||||
transform: translateY(-150%);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { VirtualList } from "../virtual/VirtualListEngine";
|
||||
import styles from "./VirtualPage.module.scss";
|
||||
|
||||
export type VirtualPageSection = {
|
||||
key: string;
|
||||
estimatedHeight: number;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
export function VirtualPage({ title, sections, className, contentClassName }: { title?: ReactNode; sections: VirtualPageSection[]; className?: string; contentClassName?: string }) {
|
||||
const getKey = useCallback((section: VirtualPageSection) => section.key, []);
|
||||
const estimateSize = useCallback((section: VirtualPageSection) => section.estimatedHeight, []);
|
||||
const renderItem = useCallback((section: VirtualPageSection, { index }: { index: number }) => <section className={styles.section}>
|
||||
{index === 0 && title != null && <div className={styles.title}>{title}</div>}
|
||||
{section.content}
|
||||
</section>, [title]);
|
||||
|
||||
return <VirtualList
|
||||
items={sections}
|
||||
getKey={getKey}
|
||||
estimateSize={estimateSize}
|
||||
renderItem={renderItem}
|
||||
overscan={2}
|
||||
itemGap={16}
|
||||
scrollbarSize={7}
|
||||
scrollbarInsetTop="var(--app-content-top)"
|
||||
className={[styles.root, "scroll-shadow-top", className].filter(Boolean).join(" ")}
|
||||
contentClassName={[styles.content, contentClassName].filter(Boolean).join(" ")}
|
||||
/>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
--cache-hit-track-color: color-mix(in srgb, var(--vscode-foreground) 12%, transparent);
|
||||
--cache-hit-value-color: var(--vscode-gitDecoration-addedResourceForeground, #4ade80);
|
||||
position: relative;
|
||||
width: 132px;
|
||||
height: 82px;
|
||||
align-self: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.label {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 10px;
|
||||
left: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: var(--vscode-foreground);
|
||||
font-family: var(--oa-number-font);
|
||||
font-size: type.$font-size-xl;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ArcElement, Chart as ChartJS, Tooltip, type ChartOptions, type ScriptableContext } from "chart.js";
|
||||
import { useMemo } from "react";
|
||||
import { Doughnut } from "react-chartjs-2";
|
||||
import styles from "./CacheHitRateChart.module.scss";
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip);
|
||||
|
||||
type SegmentRadius = number | {
|
||||
outerStart: number;
|
||||
outerEnd: number;
|
||||
innerStart: number;
|
||||
innerEnd: number;
|
||||
};
|
||||
|
||||
function chartColor(context: ScriptableContext<"doughnut">) {
|
||||
const styles = getComputedStyle(context.chart.canvas);
|
||||
const variable = context.dataIndex === 0 ? "--cache-hit-value-color" : "--cache-hit-track-color";
|
||||
return styles.getPropertyValue(variable).trim();
|
||||
}
|
||||
|
||||
function segmentBorderRadius(percentage: number, dataIndex: number): SegmentRadius {
|
||||
const radius = 5;
|
||||
|
||||
if (percentage <= 0) {
|
||||
return dataIndex === 1
|
||||
? { outerStart: radius, outerEnd: radius, innerStart: radius, innerEnd: radius }
|
||||
: 0;
|
||||
}
|
||||
|
||||
if (percentage >= 100) {
|
||||
return dataIndex === 0
|
||||
? { outerStart: radius, outerEnd: radius, innerStart: radius, innerEnd: radius }
|
||||
: 0;
|
||||
}
|
||||
|
||||
return dataIndex === 0
|
||||
? { outerStart: radius, outerEnd: 0, innerStart: radius, innerEnd: 0 }
|
||||
: { outerStart: 0, outerEnd: radius, innerStart: 0, innerEnd: radius };
|
||||
}
|
||||
|
||||
const options: ChartOptions<"doughnut"> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: "82%",
|
||||
rotation: -90,
|
||||
circumference: 180,
|
||||
animation: { duration: 450 },
|
||||
events: [],
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { enabled: false },
|
||||
},
|
||||
};
|
||||
|
||||
export function CacheHitRateChart({ rate }: { rate: number }) {
|
||||
const finiteRate = Number.isFinite(rate) ? rate : 0;
|
||||
const percentage = Math.max(0, Math.min(100, finiteRate * 100));
|
||||
const label = Number.isFinite(rate) ? `${percentage.toFixed(2)}%` : "--";
|
||||
const data = useMemo(() => ({
|
||||
labels: [t("命中"), t("未命中")],
|
||||
datasets: [{
|
||||
data: [percentage, Math.max(0, 100 - percentage)],
|
||||
backgroundColor: chartColor,
|
||||
borderWidth: 0,
|
||||
hoverBorderWidth: 0,
|
||||
selfJoin: false,
|
||||
borderRadius: (context: ScriptableContext<"doughnut">) => segmentBorderRadius(percentage, context.dataIndex),
|
||||
}],
|
||||
}), [percentage]);
|
||||
|
||||
return <div className={styles.root} role="img" aria-label={t("缓存命中率 {rate}", { rate: label })}>
|
||||
<Doughnut className={styles.canvas} data={data} options={options} />
|
||||
<div className={styles.label}>{label}</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.scroller {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.root {
|
||||
width: 100%;
|
||||
height: 130px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: var(--oa-overlay-radius);
|
||||
font-family: var(--oa-metrics-font);
|
||||
}
|
||||
|
||||
.metric {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
|
||||
& + & {
|
||||
border-left: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.info {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
transition: color 150ms ease;
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
overflow: hidden;
|
||||
color: var(--vscode-foreground);
|
||||
font-family: var(--oa-number-font);
|
||||
font-size: type.$font-size-xxl;
|
||||
line-height: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
margin-top: 12px;
|
||||
overflow: hidden;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tooltipText {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useState } from "react";
|
||||
import { formatCompactInteger, formatInteger } from "../../utils/numberFormat";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { Tooltip, type TooltipAnchor } from "../ui/Tooltip";
|
||||
import { informationOutlineIcon } from "../ui/icons";
|
||||
import { CacheHitRateChart } from "./CacheHitRateChart";
|
||||
import styles from "./HomeMetrics.module.scss";
|
||||
|
||||
export type HomeMetricsData = {
|
||||
llmCalls: number;
|
||||
successfulCalls: number;
|
||||
failedCalls: number;
|
||||
tokenUsage: number;
|
||||
promptTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
};
|
||||
|
||||
const TOKEN_PRICE_PER_MILLION = {
|
||||
input: 5,
|
||||
output: 25,
|
||||
cacheRead: 0.5,
|
||||
cacheWrite: 6.25,
|
||||
} as const;
|
||||
|
||||
function formatMetricValue(value: number) {
|
||||
const full = formatInteger(value);
|
||||
const compact = formatCompactInteger(value);
|
||||
return full === compact ? full : `${full} (${compact})`;
|
||||
}
|
||||
|
||||
function formatRate(value: number | null) {
|
||||
return value === null ? t("暂无数据") : `${(Math.max(0, Math.min(1, value)) * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function calculateRate(numerator: number, denominator: number) {
|
||||
return denominator > 0 ? numerator / denominator : null;
|
||||
}
|
||||
|
||||
function priceTokens(tokens: number, pricePerMillion: number) {
|
||||
return (tokens / 1_000_000) * pricePerMillion;
|
||||
}
|
||||
|
||||
function formatUSD(value: number) {
|
||||
return `$${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function elementAnchor(element: HTMLElement): TooltipAnchor {
|
||||
return {
|
||||
contextElement: element,
|
||||
getBoundingClientRect: () => element.getBoundingClientRect(),
|
||||
};
|
||||
}
|
||||
|
||||
function InfoTooltip({ content }: { content: string }) {
|
||||
const [anchor, setAnchor] = useState<TooltipAnchor | null>(null);
|
||||
|
||||
return <>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.info}
|
||||
aria-label={t("查看说明")}
|
||||
onMouseEnter={(event) => setAnchor(elementAnchor(event.currentTarget))}
|
||||
onMouseLeave={() => setAnchor(null)}
|
||||
onFocus={(event) => setAnchor(elementAnchor(event.currentTarget))}
|
||||
onBlur={() => setAnchor(null)}
|
||||
><Icon icon={informationOutlineIcon} size="1.1em" /></button>
|
||||
<Tooltip anchor={anchor}>
|
||||
<div className={styles.tooltipText}>{content}</div>
|
||||
</Tooltip>
|
||||
</>;
|
||||
}
|
||||
|
||||
export function HomeMetrics({ data }: { data: HomeMetricsData }) {
|
||||
const inputTokens = Math.max(0, data.promptTokens - data.cacheReadTokens - data.cacheWriteTokens);
|
||||
const outputTokens = Math.max(0, data.tokenUsage - data.promptTokens);
|
||||
const defaultCacheHitRate = calculateRate(data.cacheReadTokens, data.cacheReadTokens + inputTokens);
|
||||
const cacheReuseRate = calculateRate(
|
||||
data.cacheReadTokens,
|
||||
data.cacheReadTokens + data.cacheWriteTokens + inputTokens,
|
||||
);
|
||||
const successfulCallRate = calculateRate(data.successfulCalls, data.llmCalls);
|
||||
const costs = {
|
||||
input: priceTokens(inputTokens, TOKEN_PRICE_PER_MILLION.input),
|
||||
output: priceTokens(outputTokens, TOKEN_PRICE_PER_MILLION.output),
|
||||
cacheRead: priceTokens(data.cacheReadTokens, TOKEN_PRICE_PER_MILLION.cacheRead),
|
||||
cacheWrite: priceTokens(data.cacheWriteTokens, TOKEN_PRICE_PER_MILLION.cacheWrite),
|
||||
};
|
||||
const totalCost = costs.input + costs.output + costs.cacheRead + costs.cacheWrite;
|
||||
const cacheCost = costs.cacheRead + costs.cacheWrite;
|
||||
const cacheTooltip = [
|
||||
t("当前:{rate}", { rate: formatRate(defaultCacheHitRate) }),
|
||||
t("公式:缓存读取 /(缓存读取 + 非缓存输入)"),
|
||||
t("默认 {defaultRate} / 计入创建 {reuseRate}", {
|
||||
defaultRate: formatRate(defaultCacheHitRate),
|
||||
reuseRate: formatRate(cacheReuseRate),
|
||||
}),
|
||||
].join("\n");
|
||||
const callsTooltip = [
|
||||
t("按历史 LLM 调用记录汇总,进行中的调用不计入。"),
|
||||
"",
|
||||
t("总调用:{count}", { count: formatMetricValue(data.llmCalls) }),
|
||||
t("成功调用:{count}", { count: formatMetricValue(data.successfulCalls) }),
|
||||
t("异常调用:{count}", { count: formatMetricValue(data.failedCalls) }),
|
||||
t("成功占比:{rate}", { rate: formatRate(successfulCallRate) }),
|
||||
].join("\n");
|
||||
const tokensTooltip = [
|
||||
t("总请求 Token 包含提示词和模型输出。"),
|
||||
"",
|
||||
t("总请求:{tokens}", { tokens: formatMetricValue(data.tokenUsage) }),
|
||||
t("提示词:{tokens}", { tokens: formatMetricValue(data.promptTokens) }),
|
||||
t("输出推算:{tokens}", { tokens: formatMetricValue(outputTokens) }),
|
||||
t("非缓存输入:{tokens}", { tokens: formatMetricValue(inputTokens) }),
|
||||
t("缓存读取:{tokens}", { tokens: formatMetricValue(data.cacheReadTokens) }),
|
||||
t("缓存写入:{tokens}", { tokens: formatMetricValue(data.cacheWriteTokens) }),
|
||||
"",
|
||||
t("缓存读写已计入提示词侧统计。"),
|
||||
].join("\n");
|
||||
const costTooltip = [
|
||||
t("按 Claude Opus 4.7 价格估算。"),
|
||||
t("缓存统计策略:默认口径({rate})", { rate: formatRate(defaultCacheHitRate) }),
|
||||
"",
|
||||
t("普通输入:{tokens} × ${price}/1M = {cost}", {
|
||||
tokens: formatMetricValue(inputTokens),
|
||||
price: TOKEN_PRICE_PER_MILLION.input,
|
||||
cost: formatUSD(costs.input),
|
||||
}),
|
||||
t("模型输出:{tokens} × ${price}/1M = {cost}", {
|
||||
tokens: formatMetricValue(outputTokens),
|
||||
price: TOKEN_PRICE_PER_MILLION.output,
|
||||
cost: formatUSD(costs.output),
|
||||
}),
|
||||
t("缓存读取:{tokens} × ${price}/1M = {cost}", {
|
||||
tokens: formatMetricValue(data.cacheReadTokens),
|
||||
price: TOKEN_PRICE_PER_MILLION.cacheRead,
|
||||
cost: formatUSD(costs.cacheRead),
|
||||
}),
|
||||
t("缓存写入:{tokens} × ${price}/1M = {cost}", {
|
||||
tokens: formatMetricValue(data.cacheWriteTokens),
|
||||
price: TOKEN_PRICE_PER_MILLION.cacheWrite,
|
||||
cost: formatUSD(costs.cacheWrite),
|
||||
}),
|
||||
"",
|
||||
t("合计:{cost}", { cost: formatUSD(totalCost) }),
|
||||
].join("\n");
|
||||
|
||||
return <div className={styles.scroller}>
|
||||
<section className={styles.root} aria-label={t("调用统计")}>
|
||||
<article className={styles.metric}>
|
||||
<div className={styles.label}>{t("缓存命中率")}<InfoTooltip content={cacheTooltip} /></div>
|
||||
<CacheHitRateChart rate={defaultCacheHitRate ?? 0} />
|
||||
</article>
|
||||
<article className={styles.metric}>
|
||||
<div className={styles.label}>{t("LLM 调用")}<InfoTooltip content={callsTooltip} /></div>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.value} title={formatInteger(data.llmCalls)}>{formatCompactInteger(data.llmCalls)}</div>
|
||||
<div className={styles.secondary}>{t("成功 {successful} / 异常 {failed}", {
|
||||
successful: formatCompactInteger(data.successfulCalls),
|
||||
failed: formatCompactInteger(data.failedCalls),
|
||||
})}</div>
|
||||
</div>
|
||||
</article>
|
||||
<article className={styles.metric}>
|
||||
<div className={styles.label}>{t("Token 消耗")}<InfoTooltip content={tokensTooltip} /></div>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.value} title={formatInteger(data.tokenUsage)}>{formatCompactInteger(data.tokenUsage)}</div>
|
||||
<div className={styles.secondary}>{t("提示词 {tokens}", { tokens: formatCompactInteger(data.promptTokens) })}</div>
|
||||
</div>
|
||||
</article>
|
||||
<article className={styles.metric}>
|
||||
<div className={styles.label}>{t("价值估算")}<InfoTooltip content={costTooltip} /></div>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.value} title={formatUSD(totalCost)}>{formatUSD(totalCost)}</div>
|
||||
<div className={styles.secondary}>{t("缓存读写 {cost}", { cost: formatUSD(cacheCost) })}</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root,
|
||||
.presets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filterRow { width: 100%; }
|
||||
|
||||
.root {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.presets {
|
||||
overflow: hidden;
|
||||
// border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: 5px;
|
||||
|
||||
button {
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--vscode-sideBar-border);
|
||||
font-size: type.$font-size-xs;
|
||||
white-space: nowrap;
|
||||
|
||||
&:last-child { border-right: 0; }
|
||||
&:hover { color: var(--vscode-foreground); background: var(--vscode-list-hoverBackground); }
|
||||
&[aria-pressed="true"] { color: var(--vscode-list-activeSelectionForeground); background: var(--vscode-list-activeSelectionBackground); }
|
||||
}
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: fixed;
|
||||
z-index: 14000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-dropdown-background);
|
||||
border: 1px solid var(--vscode-dropdown-border);
|
||||
border-radius: var(--oa-overlay-radius);
|
||||
box-shadow: var(--oa-dropdown-shadow);
|
||||
overflow: visible;
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
input {
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 5px;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
|
||||
&:focus { border-color: var(--vscode-focusBorder); }
|
||||
}
|
||||
}
|
||||
|
||||
.popoverActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { parseTimeInput } from "../../utils/parseTimeInput";
|
||||
import controls from "../ui/Controls.module.scss";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { MultiSelect, type MultiSelectOption } from "../ui/MultiSelect";
|
||||
import { TooltipTrigger } from "../ui/TooltipTrigger";
|
||||
import { refreshIcon } from "../ui/icons";
|
||||
import styles from "./OverviewTimeRangeFilter.module.scss";
|
||||
|
||||
export type OverviewRangePreset = "ten-minutes" | "hour" | "today" | "week" | "month" | "custom";
|
||||
|
||||
const presets: Array<{ value: Exclude<OverviewRangePreset, "custom">; label: string }> = [
|
||||
{ value: "hour", label: "近1小时" },
|
||||
{ value: "today", label: "近1自然日" },
|
||||
{ value: "ten-minutes", label: "近10分钟" },
|
||||
{ value: "week", label: "近一周" },
|
||||
{ value: "month", label: "近一个月" },
|
||||
];
|
||||
|
||||
export function OverviewTimeRangeFilter({ value, customOpen, customStart, customEnd, modelOptions, providerOptions, selectedModels, selectedProviders, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onSelectedProvidersChange, onCustomApply, onRefresh }: {
|
||||
value: OverviewRangePreset;
|
||||
customOpen: boolean;
|
||||
customStart: string;
|
||||
customEnd: string;
|
||||
modelOptions: MultiSelectOption[];
|
||||
providerOptions: MultiSelectOption[];
|
||||
selectedModels: string[];
|
||||
selectedProviders: string[];
|
||||
busy: boolean;
|
||||
onSelect: (value: Exclude<OverviewRangePreset, "custom">) => void;
|
||||
onCustomOpenChange: (open: boolean) => void;
|
||||
onCustomStartChange: (value: string) => void;
|
||||
onCustomEndChange: (value: string) => void;
|
||||
onSelectedModelsChange: (value: string[]) => void;
|
||||
onSelectedProvidersChange: (value: string[]) => void;
|
||||
onCustomApply: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const customButton = useRef<HTMLButtonElement>(null);
|
||||
const popover = useRef<HTMLDivElement>(null);
|
||||
const popoverId = useId();
|
||||
const [position, setPosition] = useState({ left: 0, top: 0, width: 300, maxHeight: 480 });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!customOpen || !customButton.current || !popover.current) return;
|
||||
return autoUpdate(customButton.current, popover.current, () => void computePosition(customButton.current!, popover.current!, {
|
||||
placement: "bottom-end",
|
||||
middleware: [offset(5), flip({ padding: 10 }), shift({ padding: 10 }), size({
|
||||
padding: 10,
|
||||
apply: ({ availableHeight }) => setPosition((current) => ({
|
||||
...current,
|
||||
maxHeight: Math.max(240, availableHeight),
|
||||
})),
|
||||
})],
|
||||
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
|
||||
}, [customOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!customOpen) return;
|
||||
const closeOutside = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (!customButton.current?.contains(target) && !popover.current?.contains(target)) onCustomOpenChange(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", closeOutside);
|
||||
return () => document.removeEventListener("pointerdown", closeOutside);
|
||||
}, [customOpen, onCustomOpenChange]);
|
||||
|
||||
const parsedStart = parseTimeInput(customStart);
|
||||
const parsedEnd = parseTimeInput(customEnd);
|
||||
const customValid = parsedStart !== null && parsedEnd !== null && parsedStart < parsedEnd;
|
||||
|
||||
return <div className={styles.root} aria-label={t("概览时间范围")}>
|
||||
<div className={styles.presets}>
|
||||
{presets.map((preset) => <button
|
||||
key={preset.value}
|
||||
type="button"
|
||||
aria-pressed={value === preset.value}
|
||||
onClick={() => onSelect(preset.value)}
|
||||
>{preset.label}</button>)}
|
||||
<button
|
||||
ref={customButton}
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-controls={customOpen ? popoverId : undefined}
|
||||
aria-expanded={customOpen}
|
||||
aria-pressed={value === "custom"}
|
||||
onClick={() => onCustomOpenChange(!customOpen)}
|
||||
>{t("自定义")}</button>
|
||||
</div>
|
||||
<TooltipTrigger label={t("刷新")}><button className={controls.iconButton} aria-label={t("刷新")} disabled={busy} onClick={onRefresh}>
|
||||
<Icon className={busy ? controls.spin : ""} icon={refreshIcon} size="1.1em" />
|
||||
</button></TooltipTrigger>
|
||||
{customOpen && createPortal(<div
|
||||
id={popoverId}
|
||||
ref={popover}
|
||||
className={styles.popover}
|
||||
role="dialog"
|
||||
aria-label={t("自定义概览筛选")}
|
||||
style={position}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onCustomOpenChange(false);
|
||||
customButton.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label><span>{t("开始时间")}</span><input type="text" placeholder={t("如:2026-08-23 09:00、1小时前")} value={customStart} onChange={(event) => onCustomStartChange(event.target.value)} /></label>
|
||||
<label><span>{t("结束时间")}</span><input type="text" placeholder={t("如:现在、2026-08-23 18:00")} value={customEnd} onChange={(event) => onCustomEndChange(event.target.value)} /></label>
|
||||
<div className={styles.filterRow}><MultiSelect label={t("模型")} value={selectedModels} options={modelOptions} onChange={onSelectedModelsChange} /></div>
|
||||
<div className={styles.filterRow}><MultiSelect label={t("上游")} value={selectedProviders} options={providerOptions} onChange={onSelectedProvidersChange} /></div>
|
||||
<div className={styles.popoverActions}>
|
||||
<button type="button" className={controls.secondary} onClick={() => onCustomOpenChange(false)}>{t("取消")}</button>
|
||||
<button type="button" className={controls.primary} disabled={!customValid} onClick={onCustomApply}>{t("应用")}</button>
|
||||
</div>
|
||||
</div>, document.body)}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
.root {
|
||||
--oa-scrollbar-size: 8px;
|
||||
--oa-scrollbar-min-thumb-size: 20px;
|
||||
--oa-scrollbar-inset-top: 0px;
|
||||
--oa-scrollbar-thumb: var(--vscode-scrollbarSlider-background, rgba(0, 0, 0, 0.1));
|
||||
--oa-scrollbar-thumb-hover: var(--vscode-scrollbarSlider-background, rgba(0, 0, 0, 0.12));
|
||||
--oa-scrollbar-thumb-active: var(--vscode-scrollbarSlider-background, rgba(0, 0, 0, 0.12));
|
||||
--oa-scrollbar-hit-size: max(12px, var(--oa-scrollbar-size));
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
&.always .scrollbar,
|
||||
&.active .scrollbar,
|
||||
&:hover .scrollbar {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
&.active .thumb {
|
||||
background: var(--oa-scrollbar-thumb-active);
|
||||
}
|
||||
}
|
||||
|
||||
.viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scrollbar {
|
||||
position: absolute;
|
||||
top: var(--oa-scrollbar-inset-top);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
width: var(--oa-scrollbar-hit-size);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: var(--oa-scrollbar-size);
|
||||
min-height: var(--oa-scrollbar-min-thumb-size);
|
||||
background: var(--oa-scrollbar-thumb);
|
||||
border-radius: 999px;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: var(--oa-scrollbar-thumb-hover);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from "react"
|
||||
import {
|
||||
useScrollableModel,
|
||||
type ScrollAreaApi,
|
||||
type ScrollAreaState,
|
||||
} from "./useScrollableModel"
|
||||
import styles from "./ScrollArea.module.scss"
|
||||
|
||||
export type { ScrollAreaApi, ScrollAreaState } from "./useScrollableModel"
|
||||
|
||||
export interface ScrollAreaProps {
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
viewportClassName?: string
|
||||
contentClassName?: string
|
||||
|
||||
children: ReactNode | ((state: ScrollAreaState) => ReactNode)
|
||||
|
||||
contentHeight?: number
|
||||
defaultScrollTop?: number
|
||||
scrollTop?: number
|
||||
onScroll?: (state: ScrollAreaState) => void
|
||||
onViewportResize?: (size: { width: number; height: number }) => void
|
||||
|
||||
scrollbarSize?: number
|
||||
minThumbSize?: number
|
||||
scrollbarVisibility?: "auto" | "always" | "hidden"
|
||||
scrollbarInsetTop?: CSSProperties["top"]
|
||||
|
||||
viewportRef?: Ref<HTMLDivElement>
|
||||
contentRef?: Ref<HTMLDivElement>
|
||||
onReady?: (api: ScrollAreaApi) => void
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: Ref<T> | undefined, value: T | null): void {
|
||||
if (!ref) return
|
||||
if (typeof ref === "function") {
|
||||
ref(value)
|
||||
} else {
|
||||
ref.current = value
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function normalizeWheelDelta(event: WheelEvent, viewportHeight: number): number {
|
||||
let deltaY = event.deltaY
|
||||
|
||||
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
|
||||
deltaY *= 16
|
||||
} else if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
|
||||
deltaY *= viewportHeight
|
||||
}
|
||||
|
||||
if (deltaY !== 0 && Math.abs(deltaY) < 1) {
|
||||
if (deltaY < 0) return -1
|
||||
return 1
|
||||
}
|
||||
|
||||
return deltaY
|
||||
}
|
||||
|
||||
export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
|
||||
function ScrollArea(props, forwardedRef) {
|
||||
const {
|
||||
className,
|
||||
style,
|
||||
viewportClassName,
|
||||
contentClassName,
|
||||
children,
|
||||
contentHeight,
|
||||
defaultScrollTop,
|
||||
scrollTop,
|
||||
onScroll,
|
||||
onViewportResize,
|
||||
scrollbarSize = 8,
|
||||
minThumbSize = 20,
|
||||
scrollbarVisibility = "auto",
|
||||
scrollbarInsetTop = 0,
|
||||
viewportRef,
|
||||
contentRef,
|
||||
onReady,
|
||||
} = props
|
||||
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
const localViewportRef = useRef<HTMLDivElement | null>(null)
|
||||
const trackRef = useRef<HTMLDivElement | null>(null)
|
||||
const thumbRef = useRef<HTMLDivElement | null>(null)
|
||||
const dragRef = useRef<{
|
||||
pointerId: number
|
||||
pointerOffsetY: number
|
||||
} | null>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const { state, api, metricsRef, scrollToNow } = useScrollableModel({
|
||||
viewportRef: localViewportRef,
|
||||
trackRef,
|
||||
thumbRef,
|
||||
contentHeight,
|
||||
defaultScrollTop,
|
||||
scrollTop,
|
||||
minThumbSize,
|
||||
onScroll,
|
||||
onViewportResize,
|
||||
})
|
||||
|
||||
useImperativeHandle(forwardedRef, () => hostRef.current as HTMLDivElement)
|
||||
|
||||
useEffect(() => {
|
||||
onReady?.(api)
|
||||
}, [api, onReady])
|
||||
|
||||
const setViewportRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
localViewportRef.current = node
|
||||
assignRef(viewportRef, node)
|
||||
},
|
||||
[viewportRef]
|
||||
)
|
||||
|
||||
const setContentRef = useCallback(
|
||||
(node: HTMLDivElement | null) => assignRef(contentRef, node),
|
||||
[contentRef]
|
||||
)
|
||||
|
||||
const scrollbarHidden =
|
||||
scrollbarVisibility === "hidden" ||
|
||||
!state.isScrollable ||
|
||||
state.viewportHeight <= 0
|
||||
|
||||
const hostClassNames = [styles.root]
|
||||
if (scrollbarVisibility === "always") hostClassNames.push(styles.always)
|
||||
if (dragging || state.isScrolling) hostClassNames.push(styles.active)
|
||||
if (className) hostClassNames.push(className)
|
||||
const hostClassName = hostClassNames.join(" ")
|
||||
|
||||
const hostStyle = useMemo<CSSProperties>(
|
||||
() => ({
|
||||
...style,
|
||||
"--oa-scrollbar-size": `${scrollbarSize}px`,
|
||||
"--oa-scrollbar-min-thumb-size": `${minThumbSize}px`,
|
||||
"--oa-scrollbar-inset-top":
|
||||
typeof scrollbarInsetTop === "number"
|
||||
? `${scrollbarInsetTop}px`
|
||||
: scrollbarInsetTop,
|
||||
} as CSSProperties),
|
||||
[minThumbSize, scrollbarInsetTop, scrollbarSize, style]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const thumb = thumbRef.current
|
||||
const track = trackRef.current
|
||||
if (!thumb || !track) return
|
||||
|
||||
const setScrollTopFromThumb = (clientY: number) => {
|
||||
const currentDrag = dragRef.current
|
||||
if (!currentDrag) return
|
||||
|
||||
const metrics = metricsRef.current
|
||||
const trackRect = track.getBoundingClientRect()
|
||||
const desiredThumbTop = clamp(
|
||||
clientY - trackRect.top - currentDrag.pointerOffsetY,
|
||||
0,
|
||||
metrics.maxThumbTop
|
||||
)
|
||||
let nextScrollTop = 0
|
||||
if (metrics.maxThumbTop > 0) {
|
||||
nextScrollTop = (desiredThumbTop / metrics.maxThumbTop) * metrics.maxScrollTop
|
||||
}
|
||||
|
||||
scrollToNow(nextScrollTop, true, metrics.maxScrollTop)
|
||||
}
|
||||
|
||||
const stopDrag = (event: PointerEvent) => {
|
||||
if (dragRef.current?.pointerId !== event.pointerId) return
|
||||
if (thumb.hasPointerCapture(event.pointerId)) {
|
||||
thumb.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
dragRef.current = null
|
||||
setDragging(false)
|
||||
}
|
||||
|
||||
const handleThumbPointerDown = (event: PointerEvent) => {
|
||||
if (event.pointerType === "mouse" && event.button !== 0) return
|
||||
|
||||
event.preventDefault()
|
||||
thumb.setPointerCapture(event.pointerId)
|
||||
|
||||
const metrics = metricsRef.current
|
||||
const trackRect = track.getBoundingClientRect()
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
pointerOffsetY: clamp(
|
||||
event.clientY - trackRect.top - metrics.thumbTop,
|
||||
0,
|
||||
metrics.thumbHeight
|
||||
),
|
||||
}
|
||||
setDragging(true)
|
||||
}
|
||||
|
||||
const handleThumbPointerMove = (event: PointerEvent) => {
|
||||
if (!dragRef.current || dragRef.current.pointerId !== event.pointerId) return
|
||||
event.preventDefault()
|
||||
setScrollTopFromThumb(event.clientY)
|
||||
}
|
||||
|
||||
const handleTrackPointerDown = (event: PointerEvent) => {
|
||||
if (event.target !== track) return
|
||||
if (event.pointerType === "mouse" && event.button !== 0) return
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const rect = track.getBoundingClientRect()
|
||||
const metrics = metricsRef.current
|
||||
const desiredThumbTop = clamp(
|
||||
event.clientY - rect.top - metrics.thumbHeight / 2,
|
||||
0,
|
||||
metrics.maxThumbTop
|
||||
)
|
||||
let nextScrollTop = 0
|
||||
if (metrics.maxThumbTop > 0) {
|
||||
nextScrollTop = (desiredThumbTop / metrics.maxThumbTop) * metrics.maxScrollTop
|
||||
}
|
||||
scrollToNow(nextScrollTop, true, metrics.maxScrollTop)
|
||||
}
|
||||
|
||||
thumb.addEventListener("pointerdown", handleThumbPointerDown)
|
||||
thumb.addEventListener("pointermove", handleThumbPointerMove)
|
||||
thumb.addEventListener("pointerup", stopDrag)
|
||||
thumb.addEventListener("pointercancel", stopDrag)
|
||||
thumb.addEventListener("lostpointercapture", stopDrag)
|
||||
track.addEventListener("pointerdown", handleTrackPointerDown)
|
||||
|
||||
return () => {
|
||||
thumb.removeEventListener("pointerdown", handleThumbPointerDown)
|
||||
thumb.removeEventListener("pointermove", handleThumbPointerMove)
|
||||
thumb.removeEventListener("pointerup", stopDrag)
|
||||
thumb.removeEventListener("pointercancel", stopDrag)
|
||||
thumb.removeEventListener("lostpointercapture", stopDrag)
|
||||
track.removeEventListener("pointerdown", handleTrackPointerDown)
|
||||
}
|
||||
}, [metricsRef, scrollToNow])
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current
|
||||
const viewport = localViewportRef.current
|
||||
if (!host || !viewport) return
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (event.defaultPrevented || event.ctrlKey) return
|
||||
|
||||
const metrics = metricsRef.current
|
||||
if (!metrics.isScrollable) return
|
||||
|
||||
const deltaY = normalizeWheelDelta(event, metrics.viewportHeight)
|
||||
if (deltaY === 0) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const currentTop = viewport.scrollTop
|
||||
const nextTop = clamp(currentTop + deltaY, 0, metrics.maxScrollTop)
|
||||
if (nextTop === currentTop) return
|
||||
|
||||
scrollToNow(nextTop, true, metrics.maxScrollTop)
|
||||
}
|
||||
|
||||
host.addEventListener("wheel", handleWheel, { passive: false })
|
||||
return () => {
|
||||
host.removeEventListener("wheel", handleWheel)
|
||||
}
|
||||
}, [metricsRef, scrollToNow])
|
||||
|
||||
let renderedChildren: ReactNode = children as ReactNode
|
||||
if (typeof children === "function") renderedChildren = children(state)
|
||||
let scrollbarClassName = styles.scrollbar
|
||||
if (scrollbarHidden) scrollbarClassName = `${styles.scrollbar} ${styles.hidden}`
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
className={hostClassName}
|
||||
style={hostStyle}
|
||||
data-scrolled={state.scrollTop > 0 ? "true" : "false"}
|
||||
>
|
||||
<div
|
||||
ref={setViewportRef}
|
||||
className={[styles.viewport, viewportClassName ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<div ref={setContentRef} className={[styles.content, contentClassName ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{renderedChildren}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={scrollbarClassName}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div ref={trackRef} className={styles.track} />
|
||||
<div
|
||||
ref={thumbRef}
|
||||
className={styles.thumb}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,362 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from "react"
|
||||
|
||||
export interface ScrollAreaApi {
|
||||
scrollTo(top: number): void
|
||||
scrollBy(deltaTop: number): void
|
||||
scrollToTop(): void
|
||||
scrollToBottom(): void
|
||||
getScrollElement(): HTMLDivElement | null
|
||||
}
|
||||
|
||||
export interface ScrollAreaState {
|
||||
scrollTop: number
|
||||
viewportHeight: number
|
||||
scrollHeight: number
|
||||
isScrollable: boolean
|
||||
isScrolling: boolean
|
||||
}
|
||||
|
||||
export interface ScrollMetrics extends ScrollAreaState {
|
||||
maxScrollTop: number
|
||||
trackHeight: number
|
||||
thumbHeight: number
|
||||
thumbTop: number
|
||||
maxThumbTop: number
|
||||
}
|
||||
|
||||
interface UseScrollableModelOptions {
|
||||
viewportRef: RefObject<HTMLDivElement | null>
|
||||
trackRef: RefObject<HTMLDivElement | null>
|
||||
thumbRef: RefObject<HTMLDivElement | null>
|
||||
contentHeight?: number
|
||||
defaultScrollTop?: number
|
||||
scrollTop?: number
|
||||
minThumbSize?: number
|
||||
onScroll?: (state: ScrollAreaState) => void
|
||||
onViewportResize?: (size: { width: number; height: number }) => void
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function readScrollHeight(
|
||||
element: HTMLDivElement | null,
|
||||
contentHeight?: number
|
||||
): number {
|
||||
if (typeof contentHeight === "number") return Math.max(0, contentHeight)
|
||||
if (!element) return contentHeight ?? 0
|
||||
return element.scrollHeight
|
||||
}
|
||||
|
||||
function measureScrollArea(
|
||||
element: HTMLDivElement | null,
|
||||
track: HTMLDivElement | null,
|
||||
contentHeight: number | undefined,
|
||||
minThumbSize: number,
|
||||
isScrolling: boolean
|
||||
): ScrollMetrics {
|
||||
const scrollHeight = readScrollHeight(element, contentHeight)
|
||||
const viewportHeight = element?.clientHeight ?? 0
|
||||
const trackHeight = track?.clientHeight ?? viewportHeight
|
||||
const maxScrollTop = Math.max(0, scrollHeight - viewportHeight)
|
||||
const scrollTop = clamp(element?.scrollTop ?? 0, 0, maxScrollTop)
|
||||
const isScrollable = scrollHeight > viewportHeight + 1
|
||||
|
||||
let rawThumbHeight = 0
|
||||
if (scrollHeight > 0) {
|
||||
rawThumbHeight = (trackHeight * viewportHeight) / scrollHeight
|
||||
}
|
||||
let thumbHeight = 0
|
||||
if (isScrollable && trackHeight > 0) {
|
||||
thumbHeight = Math.min(trackHeight, Math.max(minThumbSize, rawThumbHeight))
|
||||
}
|
||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
||||
let thumbTop = 0
|
||||
if (maxScrollTop > 0) thumbTop = (scrollTop / maxScrollTop) * maxThumbTop
|
||||
|
||||
return {
|
||||
scrollTop,
|
||||
viewportHeight,
|
||||
scrollHeight,
|
||||
isScrollable,
|
||||
isScrolling,
|
||||
maxScrollTop,
|
||||
trackHeight,
|
||||
thumbHeight,
|
||||
thumbTop,
|
||||
maxThumbTop,
|
||||
}
|
||||
}
|
||||
|
||||
function toState(metrics: ScrollMetrics): ScrollAreaState {
|
||||
return {
|
||||
scrollTop: metrics.scrollTop,
|
||||
viewportHeight: metrics.viewportHeight,
|
||||
scrollHeight: metrics.scrollHeight,
|
||||
isScrollable: metrics.isScrollable,
|
||||
isScrolling: metrics.isScrolling,
|
||||
}
|
||||
}
|
||||
|
||||
function shouldCommitState(
|
||||
previous: ScrollAreaState,
|
||||
next: ScrollAreaState
|
||||
): boolean {
|
||||
return (
|
||||
previous.viewportHeight !== next.viewportHeight ||
|
||||
previous.scrollHeight !== next.scrollHeight ||
|
||||
previous.isScrollable !== next.isScrollable ||
|
||||
previous.isScrolling !== next.isScrolling ||
|
||||
(previous.scrollTop > 0) !== (next.scrollTop > 0)
|
||||
)
|
||||
}
|
||||
|
||||
function applyThumbStyle(
|
||||
thumb: HTMLDivElement | null,
|
||||
metrics: ScrollMetrics
|
||||
): void {
|
||||
if (!thumb) return
|
||||
thumb.style.height = `${metrics.thumbHeight}px`
|
||||
thumb.style.transform = `translateY(${metrics.thumbTop}px)`
|
||||
}
|
||||
|
||||
export function useScrollableModel(options: UseScrollableModelOptions) {
|
||||
const {
|
||||
viewportRef,
|
||||
trackRef,
|
||||
thumbRef,
|
||||
contentHeight,
|
||||
defaultScrollTop = 0,
|
||||
scrollTop,
|
||||
minThumbSize = 20,
|
||||
onScroll,
|
||||
onViewportResize,
|
||||
} = options
|
||||
|
||||
const [state, setState] = useState<ScrollAreaState>(() =>
|
||||
toState(measureScrollArea(null, null, contentHeight, minThumbSize, false))
|
||||
)
|
||||
const stateRef = useRef(state)
|
||||
const reactStateRef = useRef(state)
|
||||
const metricsRef = useRef<ScrollMetrics>(
|
||||
measureScrollArea(null, null, contentHeight, minThumbSize, false)
|
||||
)
|
||||
const scrollEndTimerRef = useRef<number | null>(null)
|
||||
const programmaticScrollTopRef = useRef<number | null>(null)
|
||||
const pendingNotificationRef = useRef<ScrollAreaState | null>(null)
|
||||
const notificationScheduledRef = useRef(false)
|
||||
const mountedRef = useRef(true)
|
||||
const defaultScrollTopAppliedRef = useRef(false)
|
||||
const onScrollRef = useRef(onScroll)
|
||||
const onViewportResizeRef = useRef(onViewportResize)
|
||||
|
||||
onScrollRef.current = onScroll
|
||||
onViewportResizeRef.current = onViewportResize
|
||||
|
||||
const scheduleNotification = useCallback((nextState: ScrollAreaState) => {
|
||||
pendingNotificationRef.current = nextState
|
||||
if (notificationScheduledRef.current) return
|
||||
|
||||
notificationScheduledRef.current = true
|
||||
queueMicrotask(() => {
|
||||
notificationScheduledRef.current = false
|
||||
const pendingState = pendingNotificationRef.current
|
||||
pendingNotificationRef.current = null
|
||||
if (!pendingState || !mountedRef.current) return
|
||||
|
||||
if (shouldCommitState(reactStateRef.current, pendingState)) {
|
||||
reactStateRef.current = pendingState
|
||||
setState(pendingState)
|
||||
}
|
||||
onScrollRef.current?.(pendingState)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const snapshot = useCallback(
|
||||
(isScrolling = false): ScrollMetrics => {
|
||||
const metrics = measureScrollArea(
|
||||
viewportRef.current,
|
||||
trackRef.current,
|
||||
contentHeight,
|
||||
minThumbSize,
|
||||
isScrolling
|
||||
)
|
||||
metricsRef.current = metrics
|
||||
applyThumbStyle(thumbRef.current, metrics)
|
||||
|
||||
const nextState = toState(metrics)
|
||||
stateRef.current = nextState
|
||||
scheduleNotification(nextState)
|
||||
|
||||
return metrics
|
||||
},
|
||||
[contentHeight, minThumbSize, scheduleNotification, thumbRef, trackRef, viewportRef]
|
||||
)
|
||||
|
||||
const scheduleScrollEnd = useCallback(() => {
|
||||
if (scrollEndTimerRef.current !== null) {
|
||||
window.clearTimeout(scrollEndTimerRef.current)
|
||||
}
|
||||
|
||||
scrollEndTimerRef.current = window.setTimeout(() => {
|
||||
scrollEndTimerRef.current = null
|
||||
snapshot(false)
|
||||
}, 140)
|
||||
}, [snapshot])
|
||||
|
||||
const scrollToNow = useCallback(
|
||||
(
|
||||
top: number,
|
||||
isScrolling = true,
|
||||
knownMaxScrollTop?: number
|
||||
): ScrollMetrics | null => {
|
||||
const element = viewportRef.current
|
||||
if (!element) return null
|
||||
|
||||
let maxScrollTop = knownMaxScrollTop
|
||||
if (typeof maxScrollTop !== "number") {
|
||||
maxScrollTop = measureScrollArea(
|
||||
element,
|
||||
trackRef.current,
|
||||
contentHeight,
|
||||
minThumbSize,
|
||||
isScrolling
|
||||
).maxScrollTop
|
||||
}
|
||||
const nextTop = clamp(top, 0, maxScrollTop)
|
||||
|
||||
if (element.scrollTop !== nextTop) {
|
||||
programmaticScrollTopRef.current = nextTop
|
||||
element.scrollTop = nextTop
|
||||
}
|
||||
|
||||
const nextMetrics = snapshot(isScrolling)
|
||||
if (isScrolling) scheduleScrollEnd()
|
||||
return nextMetrics
|
||||
},
|
||||
[contentHeight, minThumbSize, scheduleScrollEnd, snapshot, trackRef, viewportRef]
|
||||
)
|
||||
|
||||
const api = useMemo<ScrollAreaApi>(
|
||||
() => ({
|
||||
scrollTo(top: number) {
|
||||
scrollToNow(top)
|
||||
},
|
||||
scrollBy(deltaTop: number) {
|
||||
const element = viewportRef.current
|
||||
if (!element) return
|
||||
scrollToNow(element.scrollTop + deltaTop)
|
||||
},
|
||||
scrollToTop() {
|
||||
scrollToNow(0)
|
||||
},
|
||||
scrollToBottom() {
|
||||
const element = viewportRef.current
|
||||
if (!element) return
|
||||
const metrics = measureScrollArea(
|
||||
element,
|
||||
trackRef.current,
|
||||
contentHeight,
|
||||
minThumbSize,
|
||||
true
|
||||
)
|
||||
scrollToNow(metrics.maxScrollTop)
|
||||
},
|
||||
getScrollElement() {
|
||||
return viewportRef.current
|
||||
},
|
||||
}),
|
||||
[contentHeight, minThumbSize, scrollToNow, trackRef, viewportRef]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const element = viewportRef.current
|
||||
if (!element || defaultScrollTopAppliedRef.current) return
|
||||
|
||||
defaultScrollTopAppliedRef.current = true
|
||||
element.scrollTop = defaultScrollTop
|
||||
snapshot(false)
|
||||
}, [defaultScrollTop, snapshot, viewportRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof scrollTop !== "number") return
|
||||
const element = viewportRef.current
|
||||
if (!element || element.scrollTop === scrollTop) return
|
||||
element.scrollTop = scrollTop
|
||||
snapshot(false)
|
||||
}, [scrollTop, snapshot, viewportRef])
|
||||
|
||||
useEffect(() => {
|
||||
const element = viewportRef.current
|
||||
if (!element) return
|
||||
|
||||
const handleScroll = () => {
|
||||
if (
|
||||
programmaticScrollTopRef.current !== null &&
|
||||
Math.abs(element.scrollTop - programmaticScrollTopRef.current) < 0.5
|
||||
) {
|
||||
programmaticScrollTopRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
snapshot(true)
|
||||
scheduleScrollEnd()
|
||||
}
|
||||
|
||||
element.addEventListener("scroll", handleScroll, { passive: true })
|
||||
return () => {
|
||||
element.removeEventListener("scroll", handleScroll)
|
||||
}
|
||||
}, [scheduleScrollEnd, snapshot, viewportRef])
|
||||
|
||||
useEffect(() => {
|
||||
const element = viewportRef.current
|
||||
const track = trackRef.current
|
||||
if (!element || !track) return
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry) {
|
||||
onViewportResizeRef.current?.({
|
||||
width: entry.contentRect.width,
|
||||
height: entry.contentRect.height,
|
||||
})
|
||||
}
|
||||
snapshot(false)
|
||||
})
|
||||
|
||||
resizeObserver.observe(element)
|
||||
resizeObserver.observe(track)
|
||||
snapshot(false)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [snapshot, trackRef, viewportRef])
|
||||
|
||||
useEffect(() => {
|
||||
snapshot(false)
|
||||
}, [contentHeight, snapshot])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
pendingNotificationRef.current = null
|
||||
notificationScheduledRef.current = false
|
||||
if (scrollEndTimerRef.current !== null) {
|
||||
window.clearTimeout(scrollEndTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { state, api, metricsRef, snapshot, scrollToNow }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 16px;
|
||||
|
||||
& + & {
|
||||
border-top: 1px solid var(--vscode-widget-border);
|
||||
}
|
||||
|
||||
> div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Update } from "@tauri-apps/plugin-updater";
|
||||
import {
|
||||
checkForUpdate,
|
||||
currentAppVersion,
|
||||
hasNativeAppLifecycle,
|
||||
installUpdate,
|
||||
readAutostart,
|
||||
writeAutostart,
|
||||
} from "../../native/appLifecycle";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Switch } from "../ui/Switch";
|
||||
import { TitledCard } from "../ui/TitledCard";
|
||||
import { useMessage } from "../ui/message";
|
||||
import styles from "./AppLifecycleSettingsCard.module.scss";
|
||||
|
||||
export function AppLifecycleSettingsCard() {
|
||||
const message = useMessage();
|
||||
const native = hasNativeAppLifecycle();
|
||||
const updateRef = useRef<Update | null>(null);
|
||||
const [version, setVersion] = useState("…");
|
||||
const [autostart, setAutostart] = useState(false);
|
||||
const [loadingAutostart, setLoadingAutostart] = useState(native);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [availableVersion, setAvailableVersion] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
void currentAppVersion().then((next) => { if (!disposed) setVersion(next); });
|
||||
if (native) {
|
||||
void readAutostart()
|
||||
.then((enabled) => { if (!disposed) setAutostart(enabled); })
|
||||
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)))
|
||||
.finally(() => { if (!disposed) setLoadingAutostart(false); });
|
||||
}
|
||||
return () => {
|
||||
disposed = true;
|
||||
const update = updateRef.current;
|
||||
updateRef.current = null;
|
||||
if (update) void update.close();
|
||||
};
|
||||
}, [message, native]);
|
||||
|
||||
const toggleAutostart = async (enabled: boolean) => {
|
||||
try {
|
||||
setLoadingAutostart(true);
|
||||
await writeAutostart(enabled);
|
||||
setAutostart(await readAutostart());
|
||||
message(enabled ? t("已开启开机启动") : t("已关闭开机启动"));
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setLoadingAutostart(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkUpdate = async () => {
|
||||
try {
|
||||
setChecking(true);
|
||||
const previous = updateRef.current;
|
||||
updateRef.current = null;
|
||||
if (previous) await previous.close();
|
||||
const update = await checkForUpdate();
|
||||
updateRef.current = update;
|
||||
setAvailableVersion(update?.version ?? null);
|
||||
message(update ? t("发现新版本 {version}", { version: update.version }) : t("当前已是最新版本"));
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateNow = async () => {
|
||||
const update = updateRef.current;
|
||||
if (!update) return;
|
||||
try {
|
||||
setInstalling(true);
|
||||
await installUpdate(update);
|
||||
} catch (cause) {
|
||||
setInstalling(false);
|
||||
message(cause instanceof Error ? cause.message : String(cause));
|
||||
}
|
||||
};
|
||||
|
||||
return <TitledCard title={t("应用设置")}>
|
||||
<div className={styles.row}>
|
||||
<div>
|
||||
<strong>{t("开机启动")}</strong>
|
||||
<small>{t("登录系统后自动启动 Cursor BYOK。")}</small>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autostart}
|
||||
disabled={!native || loadingAutostart}
|
||||
label={t("开机启动")}
|
||||
onChange={(enabled) => void toggleAutostart(enabled)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<div>
|
||||
<strong>{t("软件更新")}</strong>
|
||||
<small>{availableVersion
|
||||
? t("版本 {version} 可以安装", { version: availableVersion })
|
||||
: t("当前版本 {version}", { version })}</small>
|
||||
</div>
|
||||
{availableVersion
|
||||
? <Button size="small" variant="primary" disabled={installing} onClick={() => void updateNow()}>
|
||||
{installing ? t("安装中…") : t("下载并安装")}
|
||||
</Button>
|
||||
: <Button size="small" disabled={!native || checking} onClick={() => void checkUpdate()}>
|
||||
{checking ? t("检查中…") : t("检查更新")}
|
||||
</Button>}
|
||||
</div>
|
||||
</TitledCard>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.content,
|
||||
.customFields {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.row {
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.control {
|
||||
width: min(420px, 62%);
|
||||
}
|
||||
|
||||
.value {
|
||||
min-width: 0;
|
||||
max-width: 70%;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.headerAction {
|
||||
padding: 4px;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-size: type.$font-size-xs;
|
||||
|
||||
&:hover { color: var(--vscode-textLink-activeForeground); }
|
||||
&:disabled { opacity: .5; }
|
||||
}
|
||||
|
||||
.actionGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.control { width: 68%; }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { ProxySettings, ProxySettingsInput } from "../../api";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Checkbox } from "../ui/Checkbox";
|
||||
import { TextInput } from "../ui/FormControls";
|
||||
import { Select } from "../ui/Select";
|
||||
import { TitledCard } from "../ui/TitledCard";
|
||||
import styles from "./ProxySettingsCard.module.scss";
|
||||
|
||||
export function ProxySettingsCard({
|
||||
settings,
|
||||
draft,
|
||||
editing,
|
||||
saving,
|
||||
onDraftChange,
|
||||
onEdit,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
settings: ProxySettings | null;
|
||||
draft: ProxySettingsInput;
|
||||
editing: boolean;
|
||||
saving: boolean;
|
||||
onDraftChange: (draft: ProxySettingsInput) => void;
|
||||
onEdit: () => void;
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
const custom = draft.mode === "custom";
|
||||
const modeLabel = (mode: ProxySettingsInput["mode"]) => mode === "system" ? t("使用系统代理") : t("自定义");
|
||||
const action = editing ? (
|
||||
<div className={styles.actionGroup}>
|
||||
<Button size="small" disabled={saving} onClick={onCancel}>{t("取消")}</Button>
|
||||
<Button variant="primary" size="small" disabled={saving} onClick={onSave}>{saving ? t("保存中…") : t("保存")}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className={styles.headerAction} disabled={!settings} onClick={onEdit}>
|
||||
{t("编辑")}
|
||||
</button>
|
||||
);
|
||||
|
||||
return <TitledCard title={t("代理设置")} action={action}>
|
||||
<div className={styles.content}>
|
||||
{editing ? <>
|
||||
<div className={styles.row}>
|
||||
<strong>{t("代理方式")}</strong>
|
||||
<div className={styles.control}><Select ariaLabel={t("代理方式")} value={draft.mode} options={[{ value: "system", label: t("使用系统代理") }, { value: "custom", label: t("自定义") }]} onChange={(mode) => onDraftChange({ ...draft, mode: mode as ProxySettingsInput["mode"] })} /></div>
|
||||
</div>
|
||||
{custom && <div className={styles.customFields}>
|
||||
<div className={styles.row}>
|
||||
<strong>{t("代理地址")}</strong>
|
||||
<div className={styles.control}><TextInput value={draft.address} placeholder="http://127.0.0.1:7890" onChange={(event) => onDraftChange({ ...draft, address: event.target.value })} /></div>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<strong>{t("认证")}</strong>
|
||||
<Checkbox checked={draft.auth_enabled} label={t("代理需要认证")} onChange={(auth_enabled) => onDraftChange({ ...draft, auth_enabled })} />
|
||||
</div>
|
||||
{draft.auth_enabled && <div className={styles.customFields}>
|
||||
<div className={styles.row}>
|
||||
<strong>{t("用户名")}</strong>
|
||||
<div className={styles.control}><TextInput value={draft.username} autoComplete="off" onChange={(event) => onDraftChange({ ...draft, username: event.target.value })} /></div>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<strong>{t("密码")}</strong>
|
||||
<div className={styles.control}><TextInput type="password" value={draft.password ?? ""} autoComplete="new-password" placeholder={settings?.has_password ? t("留空表示保留当前密码") : ""} onChange={(event) => onDraftChange({ ...draft, password: event.target.value })} /></div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>}
|
||||
</> : <>
|
||||
<div className={styles.row}><strong>{t("代理方式")}</strong><span className={styles.value}>{settings ? modeLabel(settings.mode) : t("加载中…")}</span></div>
|
||||
{settings?.mode === "custom" && <div className={styles.customFields}>
|
||||
<div className={styles.row}><strong>{t("代理地址")}</strong><span className={styles.value}>{settings.address}</span></div>
|
||||
<div className={styles.row}><strong>{t("认证")}</strong><span className={styles.value}>{settings.auth_enabled ? t("已启用") : t("未启用")}</span></div>
|
||||
{settings.auth_enabled && <>
|
||||
<div className={styles.row}><strong>{t("用户名")}</strong><span className={styles.value}>{settings.username || "—"}</span></div>
|
||||
<div className={styles.row}><strong>{t("密码")}</strong><span className={styles.value}>{settings.has_password ? t("已设置") : t("未设置")}</span></div>
|
||||
</>}
|
||||
</div>}
|
||||
</>}
|
||||
</div>
|
||||
</TitledCard>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import controls from "./Controls.module.scss";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary";
|
||||
export type ButtonSize = "medium" | "small";
|
||||
|
||||
export function Button({
|
||||
variant = "secondary",
|
||||
size = "medium",
|
||||
className,
|
||||
type = "button",
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
type={type}
|
||||
className={[controls[variant], size === "small" && controls.small, className].filter(Boolean).join(" ")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.root {
|
||||
padding: 0;
|
||||
background: var(--vscode-editorWidget-background);
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: var(--oa-card-radius);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ElementType, ReactNode } from "react";
|
||||
import styles from "./Card.module.scss";
|
||||
|
||||
type CardProps = {
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Card({ as: Component = "div", className, children }: CardProps) {
|
||||
return <Component className={[styles.root, className].filter(Boolean).join(" ")}>{children}</Component>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
width: fit-content;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--vscode-foreground);
|
||||
font-size: type.$font-size-xs;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:has(input:focus-visible) .control {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:has(input:checked) .control {
|
||||
color: var(--vscode-button-foreground);
|
||||
background: var(--vscode-button-background);
|
||||
border-color: var(--vscode-button-background);
|
||||
}
|
||||
|
||||
&:has(input:disabled) {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.control {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--vscode-checkbox-foreground, var(--vscode-button-foreground));
|
||||
background: var(--vscode-checkbox-background, var(--vscode-input-background));
|
||||
border: 1px solid var(--vscode-checkbox-border, var(--vscode-input-border));
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Icon } from "./Icon";
|
||||
import { checkIcon } from "./icons";
|
||||
import styles from "./Checkbox.module.scss";
|
||||
|
||||
export function Checkbox({ checked, disabled, label, onChange }: {
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return <label className={styles.root}>
|
||||
<input type="checkbox" checked={checked} disabled={disabled} onChange={(event) => onChange(event.target.checked)} />
|
||||
<span className={styles.control} aria-hidden="true">{checked && <Icon icon={checkIcon} size="0.875em" />}</span>
|
||||
<span>{label}</span>
|
||||
</label>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
.primary {
|
||||
min-height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 11px;
|
||||
color: var(--vscode-button-foreground);
|
||||
background: var(--vscode-button-background);
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
min-height: 34px;
|
||||
height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 9px;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, var(--vscode-sideBar-border));
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
&:hover { background: var(--vscode-list-hoverBackground); }
|
||||
&:disabled { opacity: .5; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.small {
|
||||
min-height: 24px;
|
||||
height: 24px;
|
||||
padding-inline: 7px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
|
||||
&:hover {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.danger:hover {
|
||||
color: var(--vscode-errorForeground, #f48771);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tableRegion {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
background: var(--vscode-editorWidget-background);
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: var(--oa-card-radius);
|
||||
}
|
||||
|
||||
.viewport {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tableViewport {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tableContent {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.emptyViewport {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
inset: 33px 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-base;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: type.$font-size-base;
|
||||
|
||||
th,
|
||||
td {
|
||||
max-width: 220px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
height: 33px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: var(--vscode-editor-background);
|
||||
font-size: type.$font-size-xs;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
tbody tr:hover td {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
}
|
||||
|
||||
.stickyRight {
|
||||
position: sticky;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
width: 72px;
|
||||
min-width: 72px;
|
||||
background: var(--vscode-editor-background);
|
||||
}
|
||||
|
||||
thead .stickyRight {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
tbody .stickyRight {
|
||||
background: var(--vscode-editorWidget-background);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { ScrollableContent } from "../virtual/ScrollableContent";
|
||||
import { Pagination } from "./Pagination";
|
||||
import styles from "./DataTable.module.scss";
|
||||
|
||||
export type DataTableColumn<T> = {
|
||||
key: string;
|
||||
header: ReactNode;
|
||||
render: (row: T) => ReactNode;
|
||||
title?: (row: T) => string | undefined;
|
||||
className?: string;
|
||||
sticky?: "right";
|
||||
};
|
||||
|
||||
export function DataTable<T>({ rows, columns, rowKey, minWidth, emptyText = t("暂无数据") }: {
|
||||
rows: T[];
|
||||
columns: DataTableColumn<T>[];
|
||||
rowKey: (row: T) => string | number;
|
||||
minWidth?: number | string;
|
||||
emptyText?: ReactNode;
|
||||
}) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const pageCount = Math.max(1, Math.ceil(rows.length / pageSize));
|
||||
useEffect(() => { if (page > pageCount) setPage(pageCount); }, [page, pageCount]);
|
||||
const pageRows = useMemo(() => rows.slice((page - 1) * pageSize, page * pageSize), [page, pageSize, rows]);
|
||||
return <div className={styles.root}>
|
||||
<div className={styles.tableRegion}>
|
||||
<ScrollableContent horizontal className={[styles.viewport, rows.length === 0 ? styles.emptyViewport : ""].filter(Boolean).join(" ")} viewportClassName={styles.tableViewport} contentClassName={styles.tableContent} scrollbarInsetTop="33px">
|
||||
<table className={styles.table} style={{ minWidth }}>
|
||||
<thead><tr>{columns.map((column) => <th key={column.key} className={columnClass(column)}>{column.header}</th>)}</tr></thead>
|
||||
<tbody>{pageRows.map((row) => <tr key={rowKey(row)}>{columns.map((column) => <td key={column.key} className={columnClass(column)} title={column.title?.(row)}>{column.render(row)}</td>)}</tr>)}</tbody>
|
||||
</table>
|
||||
</ScrollableContent>
|
||||
{rows.length === 0 && <div className={styles.emptyState}>{emptyText}</div>}
|
||||
</div>
|
||||
<Pagination page={page} pageCount={pageCount} pageSize={pageSize} total={rows.length} onPageChange={setPage} onPageSizeChange={(nextPageSize) => { setPageSize(nextPageSize); setPage(1); }} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function columnClass<T>(column: DataTableColumn<T>) {
|
||||
return [column.className, column.sticky === "right" ? styles.stickyRight : ""].filter(Boolean).join(" ") || undefined;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 9px;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 5px;
|
||||
outline: 0;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import styles from "./Field.module.scss";
|
||||
|
||||
export function Field({ label, value, onChange, type = "text" }: { label: string; value: string; onChange: (value: string) => void; type?: string }) {
|
||||
return <label className={styles.root}><span>{label}</span><input type={type} value={value} onChange={(event) => onChange(event.target.value)} /></label>;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.field {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
.label {
|
||||
min-height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
cursor: help;
|
||||
&:hover {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-radius: 5px;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
import { Icon } from "./Icon";
|
||||
import { TooltipTrigger } from "./TooltipTrigger";
|
||||
import { informationOutlineIcon } from "./icons";
|
||||
import styles from "./FormControls.module.scss";
|
||||
|
||||
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input {...props} className={[styles.input, props.className].filter(Boolean).join(" ")} />;
|
||||
}
|
||||
|
||||
export function FormField({ label, hint, className, children }: { label: string; hint?: string; className?: string; children: React.ReactNode }) {
|
||||
return <label className={[styles.field, className].filter(Boolean).join(" ")}>
|
||||
<div className={styles.label}>
|
||||
<div>{label}</div>
|
||||
{hint && <TooltipTrigger label={hint}><div className={styles.hint}><Icon icon={informationOutlineIcon} size="1.1em" /></div></TooltipTrigger>}
|
||||
</div>
|
||||
{children}
|
||||
</label>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.icon {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
line-height: 1;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Icon as IconifyIcon, type IconifyIcon as IconData } from "@iconify/react/offline";
|
||||
import styles from "./Icon.module.scss";
|
||||
|
||||
export interface IconProps {
|
||||
icon?: IconData;
|
||||
src?: string;
|
||||
size?: `${number}em`;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Icon({ icon, src, size = "1em", className }: IconProps) {
|
||||
return <div
|
||||
aria-hidden="true"
|
||||
className={[styles.icon, className].filter(Boolean).join(" ")}
|
||||
style={{ height: size, width: size }}
|
||||
>
|
||||
{src ? <img alt="" src={src} /> : icon ? <IconifyIcon height="100%" icon={icon} width="100%" /> : null}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
--json-editor-font-size: #{type.$font-size-2xs};
|
||||
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-radius: 5px;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
}
|
||||
|
||||
.detail {
|
||||
height: 320px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 5px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: var(--vscode-editorWidget-background);
|
||||
border: 1px solid var(--vscode-editorWidget-border, var(--vscode-input-border));
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 6px color-mix(in srgb, #000 22%, transparent);
|
||||
}
|
||||
|
||||
.formatButton {
|
||||
padding: 2px 4px;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
|
||||
&:hover {
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
}
|
||||
|
||||
.host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type * as Monaco from "monaco-editor/editor/editor.api";
|
||||
import EditorWorker from "monaco-editor/editor/editor.worker?worker";
|
||||
import JsonWorker from "monaco-editor/language/json/json.worker?worker";
|
||||
import styles from "./JsonEditor.module.scss";
|
||||
|
||||
type MonacoApi = typeof Monaco;
|
||||
|
||||
let monacoPromise: Promise<MonacoApi> | null = null;
|
||||
|
||||
function loadMonaco() {
|
||||
if (!monacoPromise) {
|
||||
self.MonacoEnvironment = {
|
||||
getWorker: (_moduleId, label) => label === "json" ? new JsonWorker() : new EditorWorker(),
|
||||
};
|
||||
monacoPromise = Promise.all([
|
||||
import("monaco-editor/editor/editor.api"),
|
||||
import("monaco-editor/language/json/monaco.contribution"),
|
||||
]).then(([monaco]) => monaco);
|
||||
}
|
||||
return monacoPromise;
|
||||
}
|
||||
|
||||
function json(value: string) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, 2);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatStructuredText(value: string) {
|
||||
const whole = json(value);
|
||||
if (whole !== null) return whole;
|
||||
|
||||
let changed = false;
|
||||
const formatted = value.split("\n").map((line) => {
|
||||
const match = /^(\s*data:\s*)(.*)$/.exec(line);
|
||||
if (!match || match[2] === "[DONE]") return line;
|
||||
const payload = json(match[2]);
|
||||
if (payload === null) return line;
|
||||
changed = true;
|
||||
return `${match[1]}${payload}`;
|
||||
}).join("\n");
|
||||
return changed ? formatted : value;
|
||||
}
|
||||
|
||||
function hexByte(value: number) {
|
||||
return Math.round(Math.min(255, Math.max(0, value))).toString(16).padStart(2, "0");
|
||||
}
|
||||
|
||||
function monacoColor(value: string, fallback: string) {
|
||||
const color = value.trim() || fallback;
|
||||
const hex = /^#([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.exec(color);
|
||||
if (hex) {
|
||||
const digits = hex[1];
|
||||
return digits.length <= 4
|
||||
? `#${[...digits].map((digit) => digit.repeat(2)).join("")}`
|
||||
: `#${digits}`;
|
||||
}
|
||||
|
||||
const rgb = /^rgba?\((.*)\)$/i.exec(color);
|
||||
if (rgb) {
|
||||
const parts = rgb[1].replace(/\s*[,/]\s*/g, " ").trim().split(/\s+/);
|
||||
if (parts.length === 3 || parts.length === 4) {
|
||||
const channels = parts.slice(0, 3).map((part) => {
|
||||
const number = Number.parseFloat(part);
|
||||
return part.endsWith("%") ? number * 2.55 : number;
|
||||
});
|
||||
const alphaPart = parts[3];
|
||||
const alpha = alphaPart == null
|
||||
? 255
|
||||
: (alphaPart.endsWith("%") ? Number.parseFloat(alphaPart) / 100 : Number.parseFloat(alphaPart)) * 255;
|
||||
if ([...channels, alpha].every(Number.isFinite)) {
|
||||
return `#${channels.map(hexByte).join("")}${alpha === 255 ? "" : hexByte(alpha)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function themeColor(styles: CSSStyleDeclaration, name: string, fallback: string) {
|
||||
return monacoColor(styles.getPropertyValue(name), fallback);
|
||||
}
|
||||
|
||||
function applyEditorTheme(monaco: MonacoApi) {
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
const light = document.documentElement.dataset.theme === "default-light";
|
||||
monaco.editor.defineTheme("cursor-byok", {
|
||||
base: light ? "vs" : "vs-dark",
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
"editor.background": themeColor(rootStyles, "--vscode-input-background", light ? "#ffffff" : "#222222"),
|
||||
"editor.foreground": themeColor(rootStyles, "--vscode-input-foreground", light ? "#202020" : "#ffffffe6"),
|
||||
"editorCursor.foreground": themeColor(rootStyles, "--vscode-editorCursor-foreground", light ? "#202020" : "#ffffff"),
|
||||
"editorLineNumber.foreground": themeColor(rootStyles, "--vscode-editorLineNumber-foreground", light ? "#999999" : "#ffffff40"),
|
||||
"editorLineNumber.activeForeground": themeColor(rootStyles, "--vscode-editorLineNumber-activeForeground", light ? "#202020" : "#ffffffe6"),
|
||||
"editor.selectionBackground": themeColor(rootStyles, "--vscode-editor-selectionBackground", light ? "#0069cc33" : "#49b0ff33"),
|
||||
},
|
||||
});
|
||||
monaco.editor.setTheme("cursor-byok");
|
||||
}
|
||||
|
||||
export function JsonEditor({ value, onChange, readOnly = false, autoFormat = true, detail = false, ariaLabel }: {
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
readOnly?: boolean;
|
||||
autoFormat?: boolean;
|
||||
detail?: boolean;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
const modelRef = useRef<Monaco.editor.ITextModel | null>(null);
|
||||
const onChangeRef = useRef(onChange);
|
||||
const valueRef = useRef(value);
|
||||
const readOnlyRef = useRef(readOnly);
|
||||
const autoFormatRef = useRef(autoFormat);
|
||||
const ariaLabelRef = useRef(ariaLabel);
|
||||
const changingRef = useRef(false);
|
||||
onChangeRef.current = onChange;
|
||||
valueRef.current = value;
|
||||
readOnlyRef.current = readOnly;
|
||||
autoFormatRef.current = autoFormat;
|
||||
ariaLabelRef.current = ariaLabel;
|
||||
|
||||
const format = () => {
|
||||
const model = modelRef.current;
|
||||
if (!model) return;
|
||||
const formatted = formatStructuredText(model.getValue());
|
||||
if (formatted === model.getValue()) return;
|
||||
changingRef.current = true;
|
||||
model.setValue(formatted);
|
||||
changingRef.current = false;
|
||||
onChangeRef.current?.(formatted);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const root = rootRef.current;
|
||||
if (!host || !root) return;
|
||||
let disposed = false;
|
||||
let contentSubscription: Monaco.IDisposable | null = null;
|
||||
let blurSubscription: Monaco.IDisposable | null = null;
|
||||
let themeObserver: MutationObserver | null = null;
|
||||
void loadMonaco().then((monaco) => {
|
||||
if (disposed) return;
|
||||
applyEditorTheme(monaco);
|
||||
const rootStyles = getComputedStyle(root);
|
||||
const fontSize = Number.parseFloat(rootStyles.getPropertyValue("--json-editor-font-size"));
|
||||
const fontFamily = rootStyles.getPropertyValue("--oa-code-font").trim();
|
||||
const initialValue = autoFormatRef.current ? formatStructuredText(valueRef.current) : valueRef.current;
|
||||
const model = monaco.editor.createModel(initialValue, "json");
|
||||
const editor = monaco.editor.create(host, {
|
||||
model,
|
||||
theme: "cursor-byok",
|
||||
ariaLabel: ariaLabelRef.current,
|
||||
readOnly: readOnlyRef.current,
|
||||
domReadOnly: readOnlyRef.current,
|
||||
automaticLayout: true,
|
||||
fontFamily: fontFamily || undefined,
|
||||
fontSize: Number.isFinite(fontSize) ? fontSize : undefined,
|
||||
minimap: { enabled: false },
|
||||
glyphMargin: false,
|
||||
folding: true,
|
||||
lineNumbersMinChars: 3,
|
||||
overviewRulerLanes: 0,
|
||||
overviewRulerBorder: false,
|
||||
renderLineHighlight: "none",
|
||||
scrollBeyondLastLine: false,
|
||||
smoothScrolling: true,
|
||||
wordWrap: "off",
|
||||
padding: { top: 8, bottom: 16 },
|
||||
stickyScroll: { enabled: false },
|
||||
contextmenu: true,
|
||||
formatOnPaste: !readOnlyRef.current,
|
||||
formatOnType: !readOnlyRef.current,
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
});
|
||||
editorRef.current = editor;
|
||||
modelRef.current = model;
|
||||
const remeasure = () => {
|
||||
if (disposed) return;
|
||||
monaco.editor.remeasureFonts();
|
||||
editor.layout();
|
||||
};
|
||||
requestAnimationFrame(remeasure);
|
||||
void document.fonts?.ready.then(remeasure);
|
||||
contentSubscription = model.onDidChangeContent(() => {
|
||||
if (!changingRef.current) onChangeRef.current?.(model.getValue());
|
||||
});
|
||||
blurSubscription = editor.onDidBlurEditorWidget(() => {
|
||||
if (!readOnlyRef.current && autoFormatRef.current) format();
|
||||
});
|
||||
themeObserver = new MutationObserver(() => applyEditorTheme(monaco));
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
themeObserver?.disconnect();
|
||||
blurSubscription?.dispose();
|
||||
contentSubscription?.dispose();
|
||||
editorRef.current?.dispose();
|
||||
modelRef.current?.dispose();
|
||||
editorRef.current = null;
|
||||
modelRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const model = modelRef.current;
|
||||
const next = readOnly && autoFormat ? formatStructuredText(value) : value;
|
||||
if (!model || model.getValue() === next) return;
|
||||
changingRef.current = true;
|
||||
model.setValue(next);
|
||||
changingRef.current = false;
|
||||
}, [autoFormat, readOnly, value]);
|
||||
|
||||
useEffect(() => {
|
||||
editorRef.current?.updateOptions({ readOnly, domReadOnly: readOnly, ariaLabel });
|
||||
}, [ariaLabel, readOnly]);
|
||||
|
||||
return <div ref={rootRef} className={[styles.root, detail && styles.detail].filter(Boolean).join(" ")}>
|
||||
<div className={styles.toolbar}>
|
||||
<button type="button" className={styles.formatButton} onClick={format}>{t("格式化")}</button>
|
||||
</div>
|
||||
<div ref={hostRef} className={styles.host} />
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.region {
|
||||
position: fixed;
|
||||
z-index: 11000;
|
||||
top: calc(48px + env(safe-area-inset-top));
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0 16px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: min(520px, calc(100vw - 32px));
|
||||
padding: 10px 16px;
|
||||
color: var(--vscode-notifications-foreground);
|
||||
background: var(--vscode-notifications-background);
|
||||
border: 1px solid var(--vscode-notifications-border);
|
||||
border-radius: var(--oa-overlay-radius);
|
||||
box-shadow: 0 10px 30px rgb(0 0 0 / 38%);
|
||||
font-size: type.$font-size-base;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
pointer-events: auto;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
animation: enter 200ms ease;
|
||||
transition: opacity 200ms ease, transform 200ms ease;
|
||||
}
|
||||
|
||||
.leaving {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
@keyframes enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.message {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { getMessageSnapshot, subscribeToMessages } from "./message";
|
||||
import styles from "./MessageProvider.module.scss";
|
||||
|
||||
export function MessageProvider() {
|
||||
const { current } = useSyncExternalStore(subscribeToMessages, getMessageSnapshot);
|
||||
|
||||
return createPortal(
|
||||
<div className={styles.region} aria-live="polite" aria-atomic="true">
|
||||
{current && (
|
||||
<div key={current.id} className={`${styles.message} ${current.leaving ? styles.leaving : ""}`} role="status">
|
||||
{current.content}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.mask {
|
||||
position: fixed;
|
||||
z-index: 13000;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--window-header-height) 24px 24px;
|
||||
background: rgb(0 0 0 / 52%);
|
||||
}
|
||||
|
||||
.dragLayer {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: var(--window-header-height);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(620px, calc(100vw - 48px));
|
||||
max-height: calc(100vh - var(--window-header-height) - 24px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-editorWidget-background);
|
||||
border: 1px solid var(--vscode-editorWidget-border);
|
||||
border-radius: var(--oa-overlay-radius);
|
||||
box-shadow: 0 20px 50px rgb(0 0 0 / 45%);
|
||||
|
||||
header {
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid var(--vscode-editorWidget-border);
|
||||
font-size: type.$font-size-base;
|
||||
font-weight: 630;
|
||||
}
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border-top: 1px solid var(--vscode-editorWidget-border);
|
||||
}
|
||||
}
|
||||
|
||||
.wide {
|
||||
width: min(1080px, calc(100vw - 48px));
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
max-height: calc(100vh - var(--window-header-height) - 112px);
|
||||
}
|
||||
|
||||
.bodyViewport {
|
||||
height: auto;
|
||||
max-height: calc(100vh - var(--window-header-height) - 112px);
|
||||
}
|
||||
|
||||
.bodyContent {
|
||||
padding: 18px;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ScrollableContent } from "../virtual/ScrollableContent";
|
||||
import controls from "./Controls.module.scss";
|
||||
import styles from "./Modal.module.scss";
|
||||
|
||||
export function Modal({ id, open, title, children, busy, wide, onClose, onSubmit, closeLabel = t("取消"), submitLabel = t("保存") }: { id?: string; open: boolean; title: string; children: ReactNode; busy?: boolean; wide?: boolean; onClose: () => void; onSubmit?: () => void; closeLabel?: string; submitLabel?: string }) {
|
||||
const dialog = useRef<HTMLDivElement>(null);
|
||||
const closeRef = useRef(onClose);
|
||||
const busyRef = useRef(Boolean(busy));
|
||||
closeRef.current = onClose;
|
||||
busyRef.current = Boolean(busy);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape" && !busyRef.current) closeRef.current(); };
|
||||
document.addEventListener("keydown", onKey);
|
||||
requestAnimationFrame(() => dialog.current?.querySelector<HTMLElement>("input,button")?.focus());
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
if (previous && document.contains(previous)) previous.focus();
|
||||
};
|
||||
}, [open]);
|
||||
if (!open) return null;
|
||||
return createPortal(<div className={styles.mask}>
|
||||
<div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" />
|
||||
<div id={id} ref={dialog} className={[styles.dialog, wide && styles.wide].filter(Boolean).join(" ")} role="dialog" aria-modal="true" aria-label={title}>
|
||||
<header>{title}</header>
|
||||
<ScrollableContent alwaysShowVertical className={styles.body} viewportClassName={styles.bodyViewport} contentClassName={styles.bodyContent}>{children}</ScrollableContent>
|
||||
<footer>
|
||||
<button type="button" className={controls.primary} disabled={busy} onClick={onClose}>{closeLabel}</button>
|
||||
{onSubmit && <button type="button" className={controls.primary} disabled={busy} onClick={onSubmit}>{busy ? t("处理中…") : submitLabel}</button>}
|
||||
</footer>
|
||||
</div>
|
||||
</div>, document.body);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.target {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-radius: 5px;
|
||||
|
||||
&:focus-within,
|
||||
&[data-open] { border-color: var(--vscode-focusBorder); }
|
||||
}
|
||||
|
||||
.trigger {
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-size: type.$font-size-xs;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.clear {
|
||||
width: 30px;
|
||||
height: 100%;
|
||||
flex: 0 0 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
|
||||
&:hover { color: var(--vscode-foreground); background: var(--vscode-list-hoverBackground); }
|
||||
}
|
||||
|
||||
.summary {
|
||||
overflow: hidden;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 14001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 4px;
|
||||
background: var(--vscode-dropdown-background);
|
||||
border: 1px solid var(--vscode-dropdown-border);
|
||||
border-radius: var(--oa-overlay-radius);
|
||||
box-shadow: var(--oa-dropdown-shadow);
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 5px 8px;
|
||||
color: var(--vscode-dropdown-foreground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
|
||||
&:hover {
|
||||
color: var(--vscode-list-hoverForeground, var(--vscode-dropdown-foreground));
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
&[aria-selected="true"] {
|
||||
color: var(--vscode-list-activeSelectionForeground);
|
||||
background: var(--vscode-list-activeSelectionBackground);
|
||||
}
|
||||
|
||||
&[aria-selected="true"]:hover {
|
||||
background: var(--vscode-list-inactiveSelectionBackground, var(--vscode-list-activeSelectionBackground));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listFrame,
|
||||
.list {
|
||||
min-height: 32px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.listFrame {
|
||||
flex: 0 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.option {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
> span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.check { width: 1.1em; flex: 0 0 1.1em; }
|
||||
.empty { padding: 8px; color: var(--vscode-descriptionForeground); font-size: type.$font-size-xs; }
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--vscode-dropdown-border);
|
||||
|
||||
button {
|
||||
width: auto;
|
||||
min-height: 28px;
|
||||
justify-content: center;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Icon, type IconProps } from "./Icon";
|
||||
import { checkIcon, chevronDownIcon, windowCloseIcon } from "./icons";
|
||||
import { VirtualList } from "../virtual/VirtualList";
|
||||
import styles from "./MultiSelect.module.scss";
|
||||
|
||||
export type MultiSelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: IconProps["icon"];
|
||||
};
|
||||
|
||||
export function MultiSelect({ label, value, options, onChange }: {
|
||||
label: string;
|
||||
value: string[];
|
||||
options: MultiSelectOption[];
|
||||
onChange: (value: string[]) => void;
|
||||
}) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const firstOption = useRef<HTMLButtonElement>(null);
|
||||
const menuId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0, width: 220, maxHeight: 280 });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !root.current || !menu.current) return;
|
||||
return autoUpdate(root.current, menu.current, () => void computePosition(root.current!, menu.current!, {
|
||||
placement: "bottom-start",
|
||||
middleware: [offset(5), flip({ padding: 10 }), shift({ padding: 10 }), size({
|
||||
padding: 10,
|
||||
apply: ({ rects, availableHeight }) => setPosition((current) => ({
|
||||
...current,
|
||||
width: rects.reference.width,
|
||||
maxHeight: Math.max(120, availableHeight),
|
||||
})),
|
||||
})],
|
||||
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
firstOption.current?.focus();
|
||||
const closeOutside = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (!root.current?.contains(target) && !menu.current?.contains(target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", closeOutside);
|
||||
return () => document.removeEventListener("pointerdown", closeOutside);
|
||||
}, [open]);
|
||||
|
||||
const selected = new Set(value);
|
||||
const summary = value.length === 0 ? t("全部") : t("已选 {count} 项", { count: value.length });
|
||||
const toggle = (option: string) => onChange(selected.has(option)
|
||||
? value.filter((item) => item !== option)
|
||||
: [...value, option]);
|
||||
|
||||
const close = () => { setOpen(false); trigger.current?.focus(); };
|
||||
const openMenu = () => {
|
||||
const width = root.current?.getBoundingClientRect().width;
|
||||
if (width) setPosition((current) => ({ ...current, width }));
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
return <>
|
||||
<div ref={root} className={styles.target} data-open={open || undefined}>
|
||||
<button
|
||||
ref={trigger}
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
aria-label={t("筛选项:{label},{summary}", { label, summary })}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={open ? menuId : undefined}
|
||||
aria-expanded={open}
|
||||
onClick={() => { if (open) close(); else openMenu(); }}
|
||||
onKeyDown={(event) => { if (event.key === "ArrowDown") { event.preventDefault(); openMenu(); } }}
|
||||
>
|
||||
<span>{label}</span><span className={styles.summary} aria-hidden="true">{summary}</span><Icon icon={chevronDownIcon} size="1.1em" />
|
||||
</button>
|
||||
{value.length > 0 && <button type="button" className={styles.clear} aria-label={t("清除{label}筛选", { label })} onClick={() => onChange([])}>
|
||||
<Icon icon={windowCloseIcon} size="1.1em" />
|
||||
</button>}
|
||||
</div>
|
||||
{open && createPortal(<div
|
||||
id={menuId}
|
||||
ref={menu}
|
||||
className={styles.menu}
|
||||
aria-label={label}
|
||||
style={position}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); close(); } }}
|
||||
>
|
||||
<div
|
||||
className={styles.listFrame}
|
||||
role="listbox"
|
||||
aria-label={label}
|
||||
aria-multiselectable="true"
|
||||
style={{ height: Math.min(options.length * 32, Math.max(32, position.maxHeight - 45)) }}
|
||||
><VirtualList
|
||||
items={options}
|
||||
itemKey="value"
|
||||
className={styles.list}
|
||||
style={{ height: "100%" }}
|
||||
estimatedItemHeight={32}
|
||||
overscan={4}
|
||||
empty={<div className={styles.empty}>{t("暂无选项")}</div>}
|
||||
>{(option, index) => <button
|
||||
ref={index === 0 ? firstOption : undefined}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-label={option.label}
|
||||
aria-selected={selected.has(option.value)}
|
||||
onClick={() => toggle(option.value)}
|
||||
>
|
||||
<span className={styles.option}>{option.icon && <Icon icon={option.icon} />}<span>{option.label}</span></span>
|
||||
<span className={styles.check} aria-hidden="true">{selected.has(option.value) && <Icon icon={checkIcon} size="1.1em" />}</span>
|
||||
</button>}</VirtualList></div>
|
||||
{options.length > 0 && <div className={styles.footer}>
|
||||
<button type="button" onClick={() => onChange(options.map((option) => option.value))}>{t("全选")}</button>
|
||||
<button type="button" onClick={() => onChange([])}>{t("全不选")}</button>
|
||||
</div>}
|
||||
</div>, document.body)}
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
flex: 0 0 auto;
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 0px 0 6px 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
> button {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
.pageSize {
|
||||
width: 92px;
|
||||
margin-right: 4px;
|
||||
|
||||
> button {
|
||||
height: 26px;
|
||||
padding: 0 7px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import controls from "./Controls.module.scss";
|
||||
import { Icon } from "./Icon";
|
||||
import { chevronDoubleLeftIcon, chevronDoubleRightIcon, chevronLeftIcon, chevronRightIcon } from "./icons";
|
||||
import { Select } from "./Select";
|
||||
import { TooltipTrigger } from "./TooltipTrigger";
|
||||
import styles from "./Pagination.module.scss";
|
||||
|
||||
export function Pagination({ page, pageCount, pageSize, total, pageSizes = [20, 50, 100], onPageChange, onPageSizeChange }: {
|
||||
page: number;
|
||||
pageCount: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
pageSizes?: number[];
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (pageSize: number) => void;
|
||||
}) {
|
||||
const disabledPrevious = page <= 1;
|
||||
const disabledNext = page >= pageCount;
|
||||
return <div className={styles.root}>
|
||||
<span>{t("共 {count} 条", { count: total })}</span>
|
||||
<div className={styles.controls}>
|
||||
<div className={styles.pageSize}><Select ariaLabel={t("每页条数")} value={String(pageSize)} options={pageSizes.map((size) => ({ value: String(size), label: t("{count} 条/页", { count: size }) }))} onChange={(value) => onPageSizeChange(Number(value))} /></div>
|
||||
<span>{t("第 {page} / {count} 页", { page, count: pageCount })}</span>
|
||||
<TooltipTrigger label={t("第一页")}><button className={controls.iconButton} aria-label={t("第一页")} disabled={disabledPrevious} onClick={() => onPageChange(1)}><Icon icon={chevronDoubleLeftIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
<TooltipTrigger label={t("上一页")}><button className={controls.iconButton} aria-label={t("上一页")} disabled={disabledPrevious} onClick={() => onPageChange(page - 1)}><Icon icon={chevronLeftIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
<TooltipTrigger label={t("下一页")}><button className={controls.iconButton} aria-label={t("下一页")} disabled={disabledNext} onClick={() => onPageChange(page + 1)}><Icon icon={chevronRightIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
<TooltipTrigger label={t("最后一页")}><button className={controls.iconButton} aria-label={t("最后一页")} disabled={disabledNext} onClick={() => onPageChange(pageCount)}><Icon icon={chevronDoubleRightIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.trigger,
|
||||
.combo input {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-radius: 5px;
|
||||
outline: none;
|
||||
font-size: type.$font-size-xs;
|
||||
&:focus {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.optionContent {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
|
||||
> :last-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.dropdownIcon {
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
.dropdownIconOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.comboRow {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.combo {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-radius: 6px;
|
||||
background: var(--vscode-input-background);
|
||||
&:focus-within {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
}
|
||||
|
||||
.multiCombo {
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-radius: 6px;
|
||||
|
||||
&:focus-within,
|
||||
&[data-open] {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
}
|
||||
|
||||
.multiValues {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 6px;
|
||||
|
||||
> input {
|
||||
min-width: 120px;
|
||||
min-height: 24px;
|
||||
flex: 1;
|
||||
padding: 0 3px;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.multiCount {
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 3px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.combo input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.comboToggle {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 34px;
|
||||
align-self: center;
|
||||
padding: 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
appearance: none;
|
||||
font-size: type.$font-size-base;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:hover {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 14000;
|
||||
overflow: hidden;
|
||||
padding: 4px;
|
||||
background: var(--vscode-dropdown-background);
|
||||
border: 1px solid var(--vscode-dropdown-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--oa-dropdown-shadow);
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
display: block;
|
||||
padding: 5px 8px;
|
||||
color: var(--vscode-dropdown-foreground);
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
&:hover,
|
||||
&[data-active],
|
||||
&[aria-selected="true"] {
|
||||
background: var(--vscode-list-activeSelectionBackground);
|
||||
color: var(--vscode-list-activeSelectionForeground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu .multiOption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
> span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
|
||||
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { VirtualList } from "../virtual/VirtualList";
|
||||
import type { VirtualListApi } from "../virtual/virtualTypes";
|
||||
import { Icon, type IconProps } from "./Icon";
|
||||
import { checkIcon, chevronDownIcon } from "./icons";
|
||||
import styles from "./Select.module.scss";
|
||||
|
||||
export type SelectOption = { value: string; label: string; icon?: IconProps["icon"] };
|
||||
|
||||
export function Select({ value, options, disabled, ariaLabel, onChange }: { value: string; options: SelectOption[]; disabled?: boolean; ariaLabel: string; onChange: (value: string) => void }) {
|
||||
const button = useRef<HTMLButtonElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const listApi = useRef<VirtualListApi | null>(null);
|
||||
const menuId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0, width: 0, maxHeight: 280 });
|
||||
const selected = options.find((option) => option.value === value);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !button.current || !menu.current) return;
|
||||
return autoUpdate(button.current, menu.current, () => void computePosition(button.current!, menu.current!, {
|
||||
placement: "bottom-start",
|
||||
middleware: [offset(5), flip({ padding: 10 }), shift({ padding: 10 }), size({ padding: 10, apply: ({ rects, availableHeight }) => setPosition((current) => ({ ...current, width: rects.reference.width, maxHeight: Math.max(120, availableHeight) })) })],
|
||||
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (open && active >= 0) listApi.current?.scrollToIndex(active);
|
||||
}, [active, open]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const outside = (event: PointerEvent) => { if (!button.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) setOpen(false); };
|
||||
document.addEventListener("pointerdown", outside);
|
||||
return () => document.removeEventListener("pointerdown", outside);
|
||||
}, [open]);
|
||||
|
||||
const move = (step: number) => {
|
||||
if (!options.length) return;
|
||||
setOpen(true);
|
||||
setActive((index) => (index + step + options.length) % options.length);
|
||||
};
|
||||
const choose = (option: SelectOption) => { onChange(option.value); setOpen(false); button.current?.focus(); };
|
||||
return <>
|
||||
<button ref={button} type="button" className={styles.trigger} aria-label={ariaLabel} aria-haspopup="listbox" aria-controls={open ? menuId : undefined} aria-expanded={open} disabled={disabled} onClick={() => { const nextOpen = !open; setOpen(nextOpen); if (nextOpen) setActive(Math.max(0, options.findIndex((option) => option.value === value))); }} onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown") { event.preventDefault(); move(1); }
|
||||
if (event.key === "ArrowUp") { event.preventDefault(); move(-1); }
|
||||
if (event.key === "Enter" && open) { event.preventDefault(); choose(options[active]); }
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
}}><span className={styles.optionContent}>{selected?.icon && <Icon icon={selected.icon} />}<span>{selected?.label ?? value}</span></span><Icon icon={chevronDownIcon} size="1.1em" className={[styles.dropdownIcon, open && styles.dropdownIconOpen].filter(Boolean).join(" ")} /></button>
|
||||
{open && createPortal(<div id={menuId} ref={menu} className={styles.menu} role="listbox" style={{ left: position.left, top: position.top, width: position.width }}>
|
||||
<VirtualList items={options} itemKey="value" estimatedItemHeight={30} onReady={(api) => { listApi.current = api; api.scrollToIndex(active); }} style={{ height: Math.min(options.length * 30, Math.max(30, position.maxHeight - 8)) }}>
|
||||
{(option, index) => <button type="button" role="option" aria-selected={option.value === value} data-active={index === active || undefined} onMouseEnter={() => setActive(index)} onClick={() => choose(option)}><span className={styles.optionContent}>{option.icon && <Icon icon={option.icon} />}<span>{option.label}</span></span></button>}
|
||||
</VirtualList>
|
||||
</div>, document.body)}
|
||||
</>;
|
||||
}
|
||||
|
||||
export function Combobox({ value, options = [], placeholder, disabled, append, onChange }: { value: string; options?: string[]; placeholder?: string; disabled?: boolean; append?: ReactNode; onChange: (value: string) => void }) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const listApi = useRef<VirtualListApi | null>(null);
|
||||
const menuId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(-1);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0, width: 0, maxHeight: 280 });
|
||||
const filtered = useMemo(() => {
|
||||
const query = value.trim().toLocaleLowerCase();
|
||||
return options.filter((option) => !query || option.toLocaleLowerCase().includes(query));
|
||||
}, [options, value]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !root.current || !menu.current) return;
|
||||
return autoUpdate(root.current, menu.current, () => void computePosition(root.current!, menu.current!, {
|
||||
placement: "bottom-start",
|
||||
middleware: [offset(6), flip({ padding: 12 }), shift({ padding: 12 }), size({ padding: 12, apply: ({ rects, availableHeight }) => setPosition((current) => ({ ...current, width: rects.reference.width, maxHeight: Math.max(160, availableHeight) })) })],
|
||||
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
|
||||
}, [open, filtered.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const outside = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) setOpen(false); };
|
||||
document.addEventListener("pointerdown", outside);
|
||||
return () => document.removeEventListener("pointerdown", outside);
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (open && active >= 0) listApi.current?.scrollToIndex(active);
|
||||
}, [active, open]);
|
||||
|
||||
const openMenu = () => {
|
||||
if (disabled || options.length === 0) return;
|
||||
setOpen(true);
|
||||
const selected = filtered.indexOf(value);
|
||||
setActive(selected >= 0 ? selected : 0);
|
||||
};
|
||||
const choose = (option: string) => { onChange(option); setOpen(false); input.current?.focus(); };
|
||||
const move = (step: number) => {
|
||||
if (!open) { openMenu(); return; }
|
||||
if (!filtered.length) return;
|
||||
setActive((index) => (index < 0 ? 0 : (index + step + filtered.length) % filtered.length));
|
||||
};
|
||||
return <div className={styles.comboRow}><div ref={root} className={styles.combo}>
|
||||
<input ref={input} value={value} placeholder={placeholder} disabled={disabled} role="combobox" aria-haspopup="listbox" aria-controls={open ? menuId : undefined} aria-expanded={open} aria-autocomplete="list" onFocus={() => { if (options.length) openMenu(); }} onBlur={() => window.setTimeout(() => setOpen(false), 100)} onChange={(event) => { onChange(event.target.value); setActive(0); if (options.length) setOpen(true); }} onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown") { event.preventDefault(); move(1); }
|
||||
if (event.key === "ArrowUp") { event.preventDefault(); move(-1); }
|
||||
if (event.key === "Enter" && open && filtered[active]) { event.preventDefault(); choose(filtered[active]); }
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
}} />
|
||||
<button type="button" className={styles.comboToggle} disabled={disabled} aria-label={t("打开模型列表")} aria-expanded={open} tabIndex={-1} onMouseDown={(event) => event.preventDefault()} onClick={() => { if (open) setOpen(false); else { openMenu(); input.current?.focus(); } }}><Icon icon={chevronDownIcon} size="1.1em" className={[styles.dropdownIcon, open && styles.dropdownIconOpen].filter(Boolean).join(" ")} /></button>
|
||||
{open && createPortal(<div id={menuId} ref={menu} className={styles.menu} role="listbox" style={{ left: position.left, top: position.top, width: position.width }}>
|
||||
<VirtualList items={filtered} itemKey={(option) => option} estimatedItemHeight={30} onReady={(api) => { listApi.current = api; if (active >= 0) api.scrollToIndex(active); }} style={{ height: Math.min(filtered.length * 30, Math.max(30, position.maxHeight - 8)) }}>
|
||||
{(option, index) => <button type="button" role="option" aria-selected={option === value} data-active={index === active || undefined} onMouseEnter={() => setActive(index)} onMouseDown={(event) => event.preventDefault()} onClick={() => choose(option)}>{option}</button>}
|
||||
</VirtualList>
|
||||
</div>, document.body)}
|
||||
</div>{append}</div>;
|
||||
}
|
||||
|
||||
export function MultiCombobox({ value, options = [], placeholder, disabled, append, onChange }: {
|
||||
value: string[];
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
append?: ReactNode;
|
||||
onChange: (value: string[]) => void;
|
||||
}) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const listApi = useRef<VirtualListApi | null>(null);
|
||||
const menuId = useId();
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
const [position, setPosition] = useState({ left: 0, top: 0, width: 0, maxHeight: 280 });
|
||||
const selected = useMemo(() => new Set(value), [value]);
|
||||
const filtered = useMemo(() => {
|
||||
const normalized = query.trim().toLocaleLowerCase();
|
||||
return options.filter((option) => !normalized || option.toLocaleLowerCase().includes(normalized));
|
||||
}, [options, query]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !root.current || !menu.current) return;
|
||||
return autoUpdate(root.current, menu.current, () => void computePosition(root.current!, menu.current!, {
|
||||
placement: "bottom-start",
|
||||
middleware: [offset(6), flip({ padding: 12 }), shift({ padding: 12 }), size({ padding: 12, apply: ({ rects, availableHeight }) => setPosition((current) => ({ ...current, width: rects.reference.width, maxHeight: Math.max(160, availableHeight) })) })],
|
||||
}).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y }))));
|
||||
}, [filtered.length, open]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const outside = (event: PointerEvent) => {
|
||||
if (!root.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", outside);
|
||||
return () => document.removeEventListener("pointerdown", outside);
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (open && active >= 0) listApi.current?.scrollToIndex(active);
|
||||
}, [active, open]);
|
||||
|
||||
const add = (modelId: string) => {
|
||||
const normalized = modelId.trim();
|
||||
if (normalized && !selected.has(normalized)) onChange([...value, normalized]);
|
||||
setQuery("");
|
||||
};
|
||||
const toggle = (modelId: string) => {
|
||||
onChange(selected.has(modelId) ? value.filter((item) => item !== modelId) : [...value, modelId]);
|
||||
setQuery("");
|
||||
input.current?.focus();
|
||||
};
|
||||
const move = (step: number) => {
|
||||
if (!filtered.length) return;
|
||||
setOpen(true);
|
||||
setActive((index) => (index + step + filtered.length) % filtered.length);
|
||||
};
|
||||
|
||||
return <div className={styles.comboRow}><div ref={root} className={styles.multiCombo} data-open={open || undefined}>
|
||||
<div className={styles.multiValues}>
|
||||
{value.length > 0 && <span className={styles.multiCount}>{t("已选择 {count} 个", { count: value.length })}</span>}
|
||||
<input ref={input} value={query} placeholder={value.length ? t("继续选择或输入") : placeholder} disabled={disabled} role="combobox" aria-haspopup="listbox" aria-controls={open ? menuId : undefined} aria-expanded={open} aria-autocomplete="list" onFocus={() => { if (options.length) setOpen(true); }} onChange={(event) => { setQuery(event.target.value); setActive(0); if (options.length) setOpen(true); }} onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown") { event.preventDefault(); move(1); }
|
||||
if (event.key === "ArrowUp") { event.preventDefault(); move(-1); }
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const normalized = query.trim();
|
||||
const exact = filtered.find((option) => option.toLocaleLowerCase() === normalized.toLocaleLowerCase());
|
||||
if (normalized && !exact) add(normalized);
|
||||
else if (open && filtered[active]) toggle(filtered[active]);
|
||||
}
|
||||
if (event.key === "Backspace" && !query && value.length) onChange(value.slice(0, -1));
|
||||
if (event.key === "Escape") { setOpen(false); input.current?.focus(); }
|
||||
}} />
|
||||
</div>
|
||||
<button type="button" className={styles.comboToggle} disabled={disabled || !options.length} aria-label={t("打开模型列表")} aria-expanded={open} tabIndex={-1} onMouseDown={(event) => event.preventDefault()} onClick={() => { setOpen(!open); input.current?.focus(); }}><Icon icon={chevronDownIcon} size="1.1em" className={[styles.dropdownIcon, open && styles.dropdownIconOpen].filter(Boolean).join(" ")} /></button>
|
||||
{open && createPortal(<div id={menuId} ref={menu} className={styles.menu} role="listbox" aria-multiselectable="true" style={{ left: position.left, top: position.top, width: position.width }} onPointerDown={(event) => event.stopPropagation()} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); setOpen(false); input.current?.focus(); } }}>
|
||||
{filtered.length ? <VirtualList items={filtered} itemKey={(option) => option} estimatedItemHeight={30} onReady={(api) => { listApi.current = api; api.scrollToIndex(active); }} style={{ height: Math.min(filtered.length * 30, Math.max(30, position.maxHeight - 8)) }}>
|
||||
{(option, index) => <button type="button" className={styles.multiOption} role="option" aria-selected={selected.has(option)} data-active={index === active || undefined} onMouseEnter={() => setActive(index)} onMouseDown={(event) => event.preventDefault()} onClick={() => toggle(option)}><span>{option}</span>{selected.has(option) && <Icon icon={checkIcon} size="1.1em" />}</button>}
|
||||
</VirtualList> : <div className={styles.empty}>{t("输入模型名称后按 Enter 添加")}</div>}
|
||||
</div>, document.body)}
|
||||
</div>{append}</div>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
.root {
|
||||
position: relative;
|
||||
width: 38px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--vscode-foreground) 22%, transparent);
|
||||
transition: background 160ms ease;
|
||||
|
||||
&[data-checked] { background: var(--vscode-button-background); }
|
||||
&:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: 2px; }
|
||||
&:disabled { opacity: 0.5; }
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--vscode-button-foreground);
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 28%);
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
&[data-checked] span { transform: translateX(16px); }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import styles from "./Switch.module.scss";
|
||||
|
||||
export function Switch({ checked, disabled, label, onChange }: { checked: boolean; disabled?: boolean; label: string; onChange: (checked: boolean) => void }) {
|
||||
return <button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
className={styles.root}
|
||||
data-checked={checked || undefined}
|
||||
onClick={() => onChange(!checked)}
|
||||
><span /></button>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
|
||||
.tab {
|
||||
position: relative;
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-base;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
&[aria-selected="true"] {
|
||||
color: var(--vscode-foreground);
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: -1px;
|
||||
left: 8px;
|
||||
height: 2px;
|
||||
content: "";
|
||||
background: var(--vscode-focusBorder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
padding-top: 16px;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useId, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import styles from "./Tabs.module.scss";
|
||||
|
||||
export type TabItem = {
|
||||
value: string;
|
||||
label: string;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
export function Tabs({ items, defaultValue }: { items: TabItem[]; defaultValue?: string }) {
|
||||
const initialValue = defaultValue && items.some((item) => item.value === defaultValue)
|
||||
? defaultValue
|
||||
: items[0]?.value;
|
||||
const [activeValue, setActiveValue] = useState(initialValue);
|
||||
const tabsId = useId();
|
||||
const activeItem = items.find((item) => item.value === activeValue) ?? items[0];
|
||||
|
||||
if (!activeItem) return null;
|
||||
|
||||
const selectAdjacent = (event: KeyboardEvent<HTMLButtonElement>, offset: number) => {
|
||||
const currentIndex = items.findIndex((item) => item.value === activeItem.value);
|
||||
const nextIndex = (currentIndex + offset + items.length) % items.length;
|
||||
setActiveValue(items[nextIndex].value);
|
||||
event.currentTarget.parentElement
|
||||
?.querySelectorAll<HTMLButtonElement>('[role="tab"]')[nextIndex]
|
||||
?.focus();
|
||||
};
|
||||
|
||||
return <div className={styles.root}>
|
||||
<div className={styles.list} role="tablist">
|
||||
{items.map((item) => {
|
||||
const selected = item.value === activeItem.value;
|
||||
return <button
|
||||
key={item.value}
|
||||
id={`${tabsId}-tab-${item.value}`}
|
||||
className={styles.tab}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
aria-controls={`${tabsId}-panel-${item.value}`}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
onClick={() => setActiveValue(item.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowLeft") selectAdjacent(event, -1);
|
||||
if (event.key === "ArrowRight") selectAdjacent(event, 1);
|
||||
}}
|
||||
>{item.label}</button>;
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
id={`${tabsId}-panel-${activeItem.value}`}
|
||||
className={styles.panel}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`${tabsId}-tab-${activeItem.value}`}
|
||||
>{activeItem.content}</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
min-height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 10px;
|
||||
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: type.$font-size-base;
|
||||
font-weight: 630;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 14px;
|
||||
padding: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
background: var(--vscode-input-background);
|
||||
border-radius: 6px;
|
||||
font-family: var(--oa-code-font);
|
||||
font-size: type.$font-size-xs;
|
||||
line-height: 1.55;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Card } from "./Card";
|
||||
import styles from "./TitledCard.module.scss";
|
||||
|
||||
export function TitledCard({ title, action, children }: { title: ReactNode; action?: ReactNode; children: ReactNode }) {
|
||||
return <Card as="section" className={styles.root}><header className={styles.header}><div className={styles.title}>{title}</div>{action}</header>{children}</Card>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.root {
|
||||
position: fixed;
|
||||
z-index: 15000;
|
||||
max-width: 420px;
|
||||
padding: 8px 10px;
|
||||
color: var(--vscode-editorHoverWidget-foreground, var(--vscode-foreground));
|
||||
background: var(--vscode-editorHoverWidget-background);
|
||||
border: 1px solid var(--vscode-editorHoverWidget-border);
|
||||
border-radius: var(--oa-overlay-radius);
|
||||
box-shadow: var(--oa-tooltip-shadow);
|
||||
font-size: type.$font-size-xs;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { autoUpdate, computePosition, flip, offset, shift, type VirtualElement } from "@floating-ui/dom";
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import styles from "./Tooltip.module.scss";
|
||||
|
||||
export type TooltipAnchor = VirtualElement;
|
||||
|
||||
export function Tooltip({ anchor, children }: { anchor: TooltipAnchor | null; children: ReactNode }) {
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState<{ left: number; top: number } | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const tooltip = tooltipRef.current;
|
||||
if (!anchor || !tooltip) {
|
||||
setPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatePosition = () => {
|
||||
void computePosition(anchor, tooltip, {
|
||||
placement: "top",
|
||||
middleware: [offset(8), flip({ padding: 12 }), shift({ padding: 12 })],
|
||||
}).then(({ x, y }) => setPosition({ left: x, top: y }));
|
||||
};
|
||||
|
||||
return autoUpdate(anchor, tooltip, updatePosition);
|
||||
}, [anchor]);
|
||||
|
||||
if (!anchor) return null;
|
||||
|
||||
// Tooltip overlays always live under body, outside the trigger's layout and stacking context.
|
||||
return createPortal(
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className={styles.root}
|
||||
role="tooltip"
|
||||
style={{ left: position?.left ?? 0, top: position?.top ?? 0, visibility: position ? "visible" : "hidden" }}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { cloneElement, useState, type FocusEventHandler, type MouseEventHandler, type ReactElement } from "react";
|
||||
import { Tooltip, type TooltipAnchor } from "./Tooltip";
|
||||
|
||||
function anchorFor(element: HTMLElement): TooltipAnchor {
|
||||
return { contextElement: element, getBoundingClientRect: () => element.getBoundingClientRect() };
|
||||
}
|
||||
|
||||
type TriggerProps = {
|
||||
onMouseEnter?: MouseEventHandler<HTMLElement>;
|
||||
onMouseLeave?: MouseEventHandler<HTMLElement>;
|
||||
onFocus?: FocusEventHandler<HTMLElement>;
|
||||
onBlur?: FocusEventHandler<HTMLElement>;
|
||||
};
|
||||
|
||||
export function TooltipTrigger({ label, children }: { label: string; children: ReactElement<TriggerProps> }) {
|
||||
const [anchor, setAnchor] = useState<TooltipAnchor | null>(null);
|
||||
const trigger = cloneElement(children, {
|
||||
onMouseEnter: (event) => {
|
||||
children.props.onMouseEnter?.(event);
|
||||
setAnchor(anchorFor(event.currentTarget));
|
||||
},
|
||||
onMouseLeave: (event) => {
|
||||
children.props.onMouseLeave?.(event);
|
||||
setAnchor(null);
|
||||
},
|
||||
onFocus: (event) => {
|
||||
children.props.onFocus?.(event);
|
||||
setAnchor(anchorFor(event.currentTarget));
|
||||
},
|
||||
onBlur: (event) => {
|
||||
children.props.onBlur?.(event);
|
||||
setAnchor(null);
|
||||
},
|
||||
});
|
||||
|
||||
return <>
|
||||
{trigger}
|
||||
<Tooltip anchor={anchor}>{label}</Tooltip>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { IconifyIcon } from "@iconify/react";
|
||||
|
||||
// Selected from the complete @iconify/json catalog. Keeping only the selected
|
||||
// SVG data here prevents entire icon collections from entering the app bundle.
|
||||
const icon = (body: string, width = 24, height = 24): IconifyIcon => ({ body, width, height });
|
||||
|
||||
export const flatColorAreaChartIcon = icon('<path fill="#3F51B5" d="M42 37H6V25l10-15l14 7L42 6z"/><path fill="#00BCD4" d="M42 42H6V32l10-8l14 2l12-9z"/>', 48, 48);
|
||||
export const flatColorSalesPerformanceIcon = icon('<g fill="#FFA000"><path d="M32 8h12v32c0 1.1-2.7 2-6 2s-6-.9-6-2zM4 14h12v26c0 1.1-2.7 2-6 2s-6-.9-6-2zM18 23h12v17c0 1.1-2.7 2-6 2s-6-.9-6-2z"/></g><g fill="#FFC107"><ellipse cx="38" cy="8" rx="6" ry="2"/><ellipse cx="10" cy="14" rx="6" ry="2"/><ellipse cx="24" cy="23" rx="6" ry="2"/></g><g fill="none" stroke="#FFC107" stroke-width="2"><path d="M32 11c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2M4 17c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2M18 26c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2"/></g>', 48, 48);
|
||||
export const flatColorLinkIcon = icon('<path fill="#1976D2" d="M38 13h-3c-5.5 0-10 4.5-10 10s4.5 10 10 10h3c5.5 0 10-4.5 10-10s-4.5-10-10-10m0 16h-3c-3.3 0-6-2.7-6-6s2.7-6 6-6h3c3.3 0 6 2.7 6 6s-2.7 6-6 6M13 13h-3C4.5 13 0 17.5 0 23s4.5 10 10 10h3c5.5 0 10-4.5 10-10s-4.5-10-10-10m0 16h-3c-3.3 0-6-2.7-6-6s2.7-6 6-6h3c3.3 0 6 2.7 6 6s-2.7 6-6 6"/><path fill="#42A5F5" d="M33 21H15c-1.1 0-2 .9-2 2s.9 2 2 2h18c1.1 0 2-.9 2-2s-.9-2-2-2"/>', 48, 48);
|
||||
export const flatColorOrganizationIcon = icon('<path fill="#90CAF9" d="M42 42H6V10c0-2.2 1.8-4 4-4h28c2.2 0 4 1.8 4 4z"/><path fill="#64B5F6" d="M6 42h36v2H6z"/><path fill="#1565C0" d="M31 27h6v5h-6zm-10 0h6v5h-6zm-10 0h6v5h-6zm20 8h6v5h-6zm-20 0h6v5h-6zm20-16h6v5h-6zm-10 0h6v5h-6zm-10 0h6v5h-6zm20-8h6v5h-6zm-10 0h6v5h-6zm-10 0h6v5h-6zm10 24h6v9h-6z"/>', 48, 48);
|
||||
export const flatColorBarChartIcon = icon('<path fill="#00BCD4" d="M19 22h10v20H19zM6 12h10v30H6zm26-6h10v36H32z"/>', 48, 48);
|
||||
export const flatColorComboChartIcon = icon('<path fill="#00BCD4" d="M37 18h6v24h-6zm-8 8h6v16h-6zm-8-4h6v20h-6zm-8 10h6v10h-6zm-8-4h6v14H5z"/><g fill="#3F51B5"><circle cx="8" cy="16" r="3"/><circle cx="16" cy="18" r="3"/><circle cx="24" cy="11" r="3"/><circle cx="32" cy="13" r="3"/><circle cx="40" cy="9" r="3"/><path d="m39.1 7.2l-7.3 3.7l-8.3-2.1l-8 7l-7-1.7l-1 3.8l9 2.3l8-7l7.7 1.9l8.7-4.3z"/></g>', 48, 48);
|
||||
export const flatColorDataConfigurationIcon = icon('<path fill="#D1C4E9" d="M38 7H10c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2V9c0-1.1-.9-2-2-2m0 12H10c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2v-6c0-1.1-.9-2-2-2m0 12H10c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2v-6c0-1.1-.9-2-2-2"/><path fill="#607D8B" d="M45.2 38.1c.1-.4.1-.8.1-1.1s0-.8-.1-1.1l2.3-1.7c.2-.2.3-.5.2-.7l-2.3-3.9c-.1-.2-.4-.3-.7-.2l-2.6 1.2c-.6-.5-1.3-.9-2-1.2l-.3-2.9c0-.3-.3-.5-.5-.5h-4.5c-.3 0-.5.2-.5.5l-.3 2.9c-.7.3-1.4.7-2 1.2l-2.6-1.2c-.3-.1-.6 0-.7.2l-2.3 3.9c-.1.2-.1.6.2.7l2.3 1.7c-.1.4-.1.8-.1 1.1s0 .8.1 1.1l-2.3 1.7c-.2.2-.3.5-.2.7l2.3 3.9c.1.2.4.3.7.2l2.6-1.2c.6.5 1.3.9 2 1.2l.3 2.9c0 .3.3.5.5.5h4.5c.3 0 .5-.2.5-.5l.3-2.9c.7-.3 1.4-.7 2-1.2l2.6 1.2c.3.1.6 0 .7-.2l2.3-3.9c.1-.2.1-.6-.2-.7zM37 42.2c-2.9 0-5.2-2.3-5.2-5.2s2.3-5.2 5.2-5.2s5.2 2.3 5.2 5.2s-2.3 5.2-5.2 5.2"/><path fill="#455A64" d="M37 31c-3.3 0-6 2.7-6 6s2.7 6 6 6s6-2.7 6-6s-2.7-6-6-6m0 9c-1.7 0-3-1.3-3-3s1.3-3 3-3s3 1.3 3 3s-1.3 3-3 3"/>', 48, 48);
|
||||
export const flatColorSettingsIcon = icon('<path fill="#607D8B" d="M39.6 27.2c.1-.7.2-1.4.2-2.2s-.1-1.5-.2-2.2l4.5-3.2c.4-.3.6-.9.3-1.4L40 10.8c-.3-.5-.8-.7-1.3-.4l-5 2.3c-1.2-.9-2.4-1.6-3.8-2.2L29.4 5c-.1-.5-.5-.9-1-.9h-8.6c-.5 0-1 .4-1 .9l-.5 5.5c-1.4.6-2.7 1.3-3.8 2.2l-5-2.3c-.5-.2-1.1 0-1.3.4l-4.3 7.4c-.3.5-.1 1.1.3 1.4l4.5 3.2c-.1.7-.2 1.4-.2 2.2s.1 1.5.2 2.2L4 30.4c-.4.3-.6.9-.3 1.4L8 39.2c.3.5.8.7 1.3.4l5-2.3c1.2.9 2.4 1.6 3.8 2.2l.5 5.5c.1.5.5.9 1 .9h8.6c.5 0 1-.4 1-.9l.5-5.5c1.4-.6 2.7-1.3 3.8-2.2l5 2.3c.5.2 1.1 0 1.3-.4l4.3-7.4c.3-.5.1-1.1-.3-1.4zM24 35c-5.5 0-10-4.5-10-10s4.5-10 10-10s10 4.5 10 10s-4.5 10-10 10"/><path fill="#455A64" d="M24 13c-6.6 0-12 5.4-12 12s5.4 12 12 12s12-5.4 12-12s-5.4-12-12-12m0 17c-2.8 0-5-2.2-5-5s2.2-5 5-5s5 2.2 5 5s-2.2 5-5 5"/>', 48, 48);
|
||||
|
||||
export const claudeIcon = icon('<path fill="#d97757" d="m50.228 170.321l50.357-28.257l.843-2.463l-.843-1.361h-2.462l-8.426-.518l-28.775-.778l-24.952-1.037l-24.175-1.296l-6.092-1.297L0 125.796l.583-3.759l5.12-3.434l7.324.648l16.202 1.101l24.304 1.685l17.629 1.037l26.118 2.722h4.148l.583-1.685l-1.426-1.037l-1.101-1.037l-25.147-17.045l-27.22-18.017l-14.258-10.37l-7.713-5.25l-3.888-4.925l-1.685-10.758l7-7.713l9.397.649l2.398.648l9.527 7.323l20.35 15.75L94.817 91.9l3.889 3.24l1.555-1.102l.195-.777l-1.75-2.917l-14.453-26.118l-15.425-26.572l-6.87-11.018l-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0l10.63 1.426l4.472 3.888l6.61 15.101l10.694 23.786l16.591 32.34l4.861 9.592l2.592 8.879l.973 2.722h1.685v-1.556l1.36-18.211l2.528-22.36l2.463-28.776l.843-8.1l4.018-9.722l7.971-5.25l6.222 2.981l5.12 7.324l-.713 4.73l-3.046 19.768l-5.962 30.98l-3.889 20.739h2.268l2.593-2.593l10.499-13.934l17.628-22.036l7.778-8.749l9.073-9.657l5.833-4.601h11.018l8.1 12.055l-3.628 12.443l-11.342 14.388l-9.398 12.184l-13.48 18.147l-8.426 14.518l.778 1.166l2.01-.194l30.46-6.481l16.462-2.982l19.637-3.37l8.88 4.148l.971 4.213l-3.5 8.62l-20.998 5.184l-24.628 4.926l-36.682 8.685l-.454.324l.519.648l16.526 1.555l7.065.389h17.304l32.21 2.398l8.426 5.574l5.055 6.805l-.843 5.184l-12.962 6.611l-17.498-4.148l-40.83-9.721l-14-3.5h-1.944v1.167l11.666 11.406l21.387 19.314l26.767 24.887l1.36 6.157l-3.434 4.86l-3.63-.518l-23.526-17.693l-9.073-7.972l-20.545-17.304h-1.36v1.814l4.73 6.935l25.017 37.59l1.296 11.536l-1.814 3.76l-6.481 2.268l-7.13-1.297l-14.647-20.544l-15.1-23.138l-12.185-20.739l-1.49.843l-7.194 77.448l-3.37 3.953l-7.778 2.981l-6.48-4.925l-3.436-7.972l3.435-15.749l4.148-20.544l3.37-16.333l3.046-20.285l1.815-6.74l-.13-.454l-1.49.194l-15.295 20.999l-23.267 31.433l-18.406 19.702l-4.407 1.75l-7.648-3.954l.713-7.064l4.277-6.286l25.47-32.405l15.36-20.092l9.917-11.6l-.065-1.686h-.583L44.07 198.125l-12.055 1.555l-5.185-4.86l.648-7.972l2.463-2.593l20.35-13.999z"/>', 256, 257);
|
||||
export const openAiIcon = icon('<path fill="currentColor" d="M20.57 10.18c.45-1.36.3-2.85-.43-4.09a5.03 5.03 0 0 0-5.42-2.42a5.05 5.05 0 0 0-7.14-.38c-.66.59-1.15 1.35-1.43 2.19A4.98 4.98 0 0 0 2.82 7.9c-1.1 1.9-.85 4.29.62 5.91c-.45 1.36-.3 2.85.43 4.09a5.045 5.045 0 0 0 5.43 2.42A5.03 5.03 0 0 0 13.06 22c2.19 0 4.14-1.41 4.81-3.5a4.98 4.98 0 0 0 3.33-2.42a5.04 5.04 0 0 0-.62-5.89Zm-7.52 10.51c-.88 0-1.72-.31-2.4-.87l.12-.07l3.98-2.3c.2-.12.33-.33.33-.57v-5.61l1.68.97s.03.02.03.04v4.65c0 2.07-1.68 3.74-3.75 3.75ZM5 17.25c-.44-.76-.6-1.65-.45-2.51l.12.07l3.99 2.3c.2.12.45.12.65 0l4.87-2.81v1.94s-.01.04-.03.05l-4.03 2.33A3.756 3.756 0 0 1 5 17.25M3.95 8.58a3.7 3.7 0 0 1 1.97-1.64v4.73c0 .23.12.45.32.56l4.85 2.8l-1.68.97h-.06l-4.03-2.32a3.754 3.754 0 0 1-1.37-5.12zm13.83 3.21l-4.86-2.82L14.6 8h.06l4.03 2.33a3.743 3.743 0 0 1 1.37 5.12a3.8 3.8 0 0 1-1.94 1.64v-4.73a.67.67 0 0 0-.34-.56Zm1.68-2.52l-.12-.07l-3.98-2.32a.63.63 0 0 0-.65 0L9.84 9.69V7.75s0-.04.02-.05l4.03-2.32a3.75 3.75 0 0 1 5.12 1.38c.44.76.59 1.64.45 2.51v.02ZM8.93 12.72l-1.68-.97s-.03-.03-.03-.05V7.06c0-2.07 1.68-3.75 3.75-3.74c.87 0 1.72.31 2.39.87l-.12.07l-3.98 2.3c-.2.12-.33.33-.33.57v5.6Zm.91-1.97l2.17-1.25l2.17 1.25v2.5l-2.16 1.25l-2.17-1.25v-2.5Z"/>');
|
||||
|
||||
// Menu icons intentionally use filled glyphs from different collections so they
|
||||
// can be compared in context before settling on one visual family.
|
||||
export const homeIcon = icon('<g fill="currentColor"><path d="M13 15.4C13 13.3258 13 12.2887 13.659 11.6444C14.318 11 15.3787 11 17.5 11C19.6213 11 20.682 11 21.341 11.6444C22 12.2887 22 13.3258 22 15.4V17.6C22 19.6742 22 20.7113 21.341 21.3556C20.682 22 19.6213 22 17.5 22C15.3787 22 14.318 22 13.659 21.3556C13 20.7113 13 19.6742 13 17.6V15.4Z"/><path d="M2 8.6C2 10.6742 2 11.7113 2.65901 12.3556C3.31802 13 4.37868 13 6.5 13C8.62132 13 9.68198 13 10.341 12.3556C11 11.7113 11 10.6742 11 8.6V6.4C11 4.32582 11 3.28873 10.341 2.64437C9.68198 2 8.62132 2 6.5 2C4.37868 2 3.31802 2 2.65901 2.64437C2 3.28873 2 4.32582 2 6.4V8.6Z"/><path d="M13 5.5C13 4.4128 13 3.8692 13.1713 3.44041C13.3996 2.86867 13.8376 2.41443 14.389 2.17761C14.8024 2 15.3266 2 16.375 2H18.625C19.6734 2 20.1976 2 20.611 2.17761C21.1624 2.41443 21.6004 2.86867 21.8287 3.44041C22 3.8692 22 4.4128 22 5.5C22 6.5872 22 7.1308 21.8287 7.55959C21.6004 8.13133 21.1624 8.58557 20.611 8.82239C20.1976 9 19.6734 9 18.625 9H16.375C15.3266 9 14.8024 9 14.389 8.82239C13.8376 8.58557 13.3996 8.13133 13.1713 7.55959C13 7.1308 13 6.5872 13 5.5Z"/><path d="M2 18.5C2 19.5872 2 20.1308 2.17127 20.5596C2.39963 21.1313 2.83765 21.5856 3.38896 21.8224C3.80245 22 4.32663 22 5.375 22H7.625C8.67337 22 9.19755 22 9.61104 21.8224C10.1624 21.5856 10.6004 21.1313 10.8287 20.5596C11 20.1308 11 19.5872 11 18.5C11 17.4128 11 16.8692 10.8287 16.4404C10.6004 15.8687 10.1624 15.4144 9.61104 15.1776C9.19755 15 8.67337 15 7.625 15H5.375C4.32663 15 3.80245 15 3.38896 15.1776C2.83765 15.4144 2.39963 15.8687 2.17127 16.4404C2 16.8692 2 17.4128 2 18.5Z"/></g>');
|
||||
export const activityIcon = icon('<path fill="currentColor" fill-rule="evenodd" d="M5.07868 5.06891C8.87402 1.27893 15.0437 1.31923 18.8622 5.13778C22.6824 8.95797 22.7211 15.1313 18.9262 18.9262C15.1312 22.7211 8.95793 22.6824 5.13774 18.8622C2.87389 16.5984 1.93904 13.5099 2.34047 10.5812C2.39672 10.1708 2.775 9.88377 3.18537 9.94002C3.59575 9.99627 3.88282 10.3745 3.82658 10.7849C3.4866 13.2652 4.27782 15.881 6.1984 17.8016C9.44288 21.0461 14.6664 21.0646 17.8655 17.8655C21.0646 14.6664 21.046 9.44292 17.8015 6.19844C14.5587 2.95561 9.33889 2.93539 6.13935 6.12957L6.88705 6.13333C7.30126 6.13541 7.63535 6.47288 7.63327 6.88709C7.63119 7.3013 7.29372 7.63539 6.87951 7.63331L4.33396 7.62052C3.92269 7.61845 3.58981 7.28556 3.58774 6.8743L3.57495 4.32874C3.57286 3.91454 3.90696 3.57707 4.32117 3.57498C4.73538 3.5729 5.07285 3.907 5.07493 4.32121L5.07868 5.06891ZM11.9999 7.24992C12.4141 7.24992 12.7499 7.58571 12.7499 7.99992V11.6893L15.0302 13.9696C15.3231 14.2625 15.3231 14.7374 15.0302 15.0302C14.7373 15.3231 14.2624 15.3231 13.9696 15.0302L11.2499 12.3106V7.99992C11.2499 7.58571 11.5857 7.24992 11.9999 7.24992Z" clip-rule="evenodd"/>'); // solar:history-bold
|
||||
export const providerIcon = icon('<g fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"><path d="M2 7C2 5.11438 2 4.17157 2.58579 3.58579C3.17157 3 4.11438 3 6 3H18C19.8856 3 20.8284 3 21.4142 3.58579C22 4.17157 22 5.11438 22 7C22 8.88562 22 9.82843 21.4142 10.4142C20.8284 11 19.8856 11 18 11H6C4.11438 11 3.17157 11 2.58579 10.4142C2 9.82843 2 8.88562 2 7ZM6 6.25C5.58579 6.25 5.25 6.58579 5.25 7C5.25 7.41421 5.58579 7.75 6 7.75H8C8.41421 7.75 8.75 7.41421 8.75 7C8.75 6.58579 8.41421 6.25 8 6.25H6ZM10.25 7C10.25 6.58579 10.5858 6.25 11 6.25H18C18.4142 6.25 18.75 6.58579 18.75 7C18.75 7.41421 18.4142 7.75 18 7.75H11C10.5858 7.75 10.25 7.41421 10.25 7Z"/><path d="M2 17C2 15.1144 2 14.1716 2.58579 13.5858C3.17157 13 4.11438 13 6 13H18C19.8856 13 20.8284 13 21.4142 13.5858C22 14.1716 22 15.1144 22 17C22 18.8856 22 19.8284 21.4142 20.4142C20.8284 21 19.8856 21 18 21H6C4.11438 21 3.17157 21 2.58579 20.4142C2 19.8284 2 18.8856 2 17ZM6 16.25C5.58579 16.25 5.25 16.5858 5.25 17C5.25 17.4142 5.58579 17.75 6 17.75H8C8.41421 17.75 8.75 17.41421 8.75 17C8.75 16.5858 8.41421 16.25 8 16.25H6ZM10.25 17C10.25 16.5858 10.5858 16.25 11 16.25H18C18.4142 16.25 18.75 16.5858 18.75 17C18.75 17.4142 18.4142 17.75 18 17.75H11C10.5858 17.75 10.25 17.41421 10.25 17Z"/></g>'); // solar:server-bold
|
||||
export const settingsIcon = icon('<path fill="currentColor" fill-rule="evenodd" d="M14.2788 2.15224C13.9085 2 13.439 2 12.5 2C11.561 2 11.0915 2 10.7212 2.15224C10.2274 2.35523 9.83509 2.74458 9.63056 3.23463C9.53719 3.45834 9.50065 3.7185 9.48635 4.09799C9.46534 4.65568 9.17716 5.17189 8.69017 5.45093C8.20318 5.72996 7.60864 5.71954 7.11149 5.45876C6.77318 5.2813 6.52789 5.18262 6.28599 5.15102C5.75609 5.08178 5.22018 5.22429 4.79616 5.5472C4.47814 5.78938 4.24339 6.1929 3.7739 6.99993C3.30441 7.80697 3.06967 8.21048 3.01735 8.60491C2.94758 9.1308 3.09118 9.66266 3.41655 10.0835C3.56506 10.2756 3.77377 10.437 4.0977 10.639C4.57391 10.936 4.88032 11.4419 4.88029 12C4.88026 12.5581 4.57386 13.0639 4.0977 13.3608C3.77372 13.5629 3.56497 13.7244 3.41645 13.9165C3.09108 14.3373 2.94749 14.8691 3.01725 15.395C3.06957 15.7894 3.30432 16.193 3.7738 17C4.24329 17.807 4.47804 18.2106 4.79606 18.4527C5.22008 18.7756 5.75599 18.9181 6.28589 18.8489C6.52778 18.8173 6.77305 18.7186 7.11133 18.5412C7.60852 18.2804 8.2031 18.27 8.69012 18.549C9.17714 18.8281 9.46533 19.3443 9.48635 19.9021C9.50065 20.2815 9.53719 20.5417 9.63056 20.7654C9.83509 21.2554 10.2274 21.6448 10.7212 21.8478C11.0915 22 11.561 22 12.5 22C13.439 22 13.9085 22 14.2788 21.8478C14.7726 21.6448 15.1649 21.2554 15.3694 20.7654C15.4628 20.5417 15.4994 20.2815 15.5137 19.902C15.5347 19.3443 15.8228 18.8281 16.3098 18.549C16.7968 18.2699 17.3914 18.2804 17.8886 18.5412C18.2269 18.7186 18.4721 18.8172 18.714 18.8488C19.2439 18.9181 19.7798 18.7756 20.2038 18.4527C20.5219 18.2105 20.7566 17.807 21.2261 16.9999C21.6956 16.1929 21.9303 15.7894 21.9827 15.395C22.0524 14.8691 21.9088 14.3372 21.5835 13.9164C21.4349 13.7243 21.2262 13.5628 20.9022 13.3608C20.4261 13.0639 20.1197 12.558 20.1197 11.9999C20.1197 11.4418 20.4261 10.9361 20.9022 10.6392C21.2263 10.4371 21.435 10.2757 21.5836 10.0835C21.9089 9.66273 22.0525 9.13087 21.9828 8.60497C21.9304 8.21055 21.6957 7.80703 21.2262 7C20.7567 6.19297 20.522 5.78945 20.2039 5.54727C19.7799 5.22436 19.244 5.08185 18.7141 5.15109C18.4722 5.18269 18.2269 5.28136 17.8887 5.4588C17.3915 5.71959 16.7969 5.73002 16.3099 5.45096C15.8229 5.17191 15.5347 4.65566 15.5136 4.09794C15.4993 3.71848 15.4628 3.45833 15.3694 3.23463C15.1649 2.74458 14.7726 2.35523 14.2788 2.15224ZM12.5 15C14.1695 15 15.5228 13.6569 15.5228 12C15.5228 10.3431 14.1695 9 12.5 9C10.8305 9 9.47716 10.3431 9.47716 12C9.47716 13.6569 10.8305 15 12.5 15Z" clip-rule="evenodd"/>'); // solar:settings-bold
|
||||
// Operation icons use the mdi: family. Keep actions visually outlined where the
|
||||
// collection provides an outline variant.
|
||||
export const addIcon = icon('<path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z"/>'); // mdi:plus
|
||||
export const editIcon = icon('<path fill="currentColor" d="m14.06 9l.94.94L5.92 19H5v-.92zm3.6-6c-.25 0-.51.1-.7.29l-1.83 1.83l3.75 3.75l1.83-1.83c.39-.39.39-1.04 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29m-3.6 3.19L3 17.25V21h3.75L17.81 9.94z"/>'); // mdi:pencil-outline
|
||||
export const eyeIcon = icon('<path fill="currentColor" d="M12 9a3 3 0 0 1 3 3a3 3 0 0 1-3 3a3 3 0 0 1-3-3a3 3 0 0 1 3-3m0-4.5c5 0 9.27 3.11 11 7.5c-1.73 4.39-6 7.5-11 7.5S2.73 16.39 1 12c1.73-4.39 6-7.5 11-7.5M3.18 12a9.821 9.821 0 0 0 17.64 0a9.821 9.821 0 0 0-17.64 0"/>'); // mdi:eye-outline
|
||||
export const informationOutlineIcon = icon('<path fill="currentColor" d="M11 9h2V7h-2m1 13c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8m0-18A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2m-1 15h2v-6h-2z"/>'); // mdi:information-outline
|
||||
export const cilBadgeIcon = icon('<path fill="currentColor" d="m328.375 384l3.698 74.999l-75.862-52.719l-76.287 52.769L183.625 384h-32.039l-5.522 112h36.692l73.413-50.78L329.242 496h36.694l-5.522-112zm87.034-229.086l-2.194-48.054L372.7 80.933l-25.932-40.519l-48.055-2.2L256 16.093l-42.713 22.126l-48.055 2.2L139.3 80.933L98.785 106.86l-2.194 48.054l-22.127 42.714l22.127 42.715l2.2 48.053l40.509 25.927l25.928 40.52l48.055 2.195L256 379.164l42.713-22.126l48.055-2.195l25.928-40.52l40.518-25.923l2.195-48.053l22.127-42.715Zm-31.646 76.949L382 270.377l-32.475 20.78l-20.78 32.475l-38.515 1.76L256 343.125l-34.234-17.733l-38.515-1.76l-20.78-32.475L130 270.377l-1.759-38.514l-17.741-34.235l17.737-34.228L130 124.88l32.471-20.78l20.78-32.474l38.515-1.76L256 52.132l34.234 17.733l38.515 1.76l20.78 32.474L382 124.88l1.759 38.515l17.741 34.233Z"/>', 512, 512); // cil:badge
|
||||
export const refreshIcon = icon('<path fill="currentColor" d="M17.65 6.35A7.96 7.96 0 0 0 12 4a8 8 0 0 0-8 8a8 8 0 0 0 8 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18a6 6 0 0 1-6-6a6 6 0 0 1 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4z"/>'); // mdi:refresh
|
||||
export const trashIcon = icon('<path fill="currentColor" d="M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6zM8 9h8v10H8zm7.5-5l-1-1h-5l-1 1H5v2h14V4z"/>'); // mdi:delete-outline
|
||||
export const checkIcon = icon('<path fill="currentColor" d="M21 7L9 19l-5.5-5.5l1.41-1.41L9 16.17L19.59 5.59z"/>');
|
||||
export const chevronDownIcon = icon('<path fill="currentColor" d="M7.41 8.58L12 13.17l4.59-4.59L18 10l-6 6l-6-6z"/>');
|
||||
export const chevronLeftIcon = icon('<path fill="currentColor" d="M15.41 16.58L10.83 12l4.58-4.59L14 6l-6 6l6 6z"/>');
|
||||
export const chevronRightIcon = icon('<path fill="currentColor" d="M8.59 16.58L13.17 12L8.59 7.41L10 6l6 6l-6 6z"/>');
|
||||
export const chevronDoubleLeftIcon = icon('<path fill="currentColor" d="M18.41 7.41L17 6l-6 6l6 6l1.41-1.41L13.83 12zm-6 0L11 6l-6 6l6 6l1.41-1.41L7.83 12z"/>');
|
||||
export const chevronDoubleRightIcon = icon('<path fill="currentColor" d="M5.59 7.41L7 6l6 6l-6 6l-1.41-1.41L10.17 12zm6 0L13 6l6 6l-6 6l-1.41-1.41L16.17 12z"/>');
|
||||
export const windowCloseIcon = icon('<path fill="currentColor" d="M13.46 12L19 17.54V19h-1.46L12 13.46L6.46 19H5v-1.46L10.54 12L5 6.46V5h1.46L12 10.54L17.54 5H19v1.46z"/>');
|
||||
export const windowMaximizeIcon = icon('<path fill="currentColor" d="M4 4h16v16H4zm2 4v10h12V8z"/>');
|
||||
export const windowMinimizeIcon = icon('<path fill="currentColor" d="M20 14H4v-4h16"/>');
|
||||
export const windowRestoreIcon = icon('<path fill="currentColor" d="M4 8h4V4h12v12h-4v4H4zm12 0v6h2V6h-8v2zM6 12v6h8v-6z"/>');
|
||||
@@ -0,0 +1,99 @@
|
||||
const MIN_VISIBLE_MS = 300;
|
||||
const EXIT_MS = 200;
|
||||
|
||||
export type MessageOptions = {
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
export type MessageItem = {
|
||||
id: string;
|
||||
content: string;
|
||||
leaving: boolean;
|
||||
};
|
||||
|
||||
type MessageSnapshot = {
|
||||
current: MessageItem | null;
|
||||
};
|
||||
|
||||
type MessageApi = ((content: unknown, options?: MessageOptions) => string | null) & {
|
||||
remove: (id: string) => void;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
let seed = 0;
|
||||
let shownAt = 0;
|
||||
let dismissTimer: number | null = null;
|
||||
let exitTimer: number | null = null;
|
||||
let snapshot: MessageSnapshot = { current: null };
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function update(current: MessageItem | null) {
|
||||
snapshot = { current };
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function clearTimer(timer: number | null) {
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
clearTimer(dismissTimer);
|
||||
clearTimer(exitTimer);
|
||||
dismissTimer = null;
|
||||
exitTimer = null;
|
||||
}
|
||||
|
||||
function beginExit(id: string, force = false) {
|
||||
const current = snapshot.current;
|
||||
if (!current || current.id !== id || current.leaving) return;
|
||||
|
||||
const elapsed = Date.now() - shownAt;
|
||||
if (!force && elapsed < MIN_VISIBLE_MS) {
|
||||
clearTimer(dismissTimer);
|
||||
dismissTimer = window.setTimeout(() => beginExit(id, true), MIN_VISIBLE_MS - elapsed);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimers();
|
||||
update({ ...current, leaving: true });
|
||||
exitTimer = window.setTimeout(() => {
|
||||
if (snapshot.current?.id === id) update(null);
|
||||
exitTimer = null;
|
||||
}, EXIT_MS);
|
||||
}
|
||||
|
||||
function showMessage(content: unknown, options: MessageOptions = {}) {
|
||||
const normalizedContent = String(content ?? "").trim();
|
||||
if (!normalizedContent) return null;
|
||||
|
||||
clearTimers();
|
||||
const duration = Number.isFinite(options.duration) ? Math.max(0, options.duration!) : 2400;
|
||||
const id = `message-${Date.now()}-${seed += 1}`;
|
||||
shownAt = Date.now();
|
||||
update({ id, content: normalizedContent, leaving: false });
|
||||
|
||||
if (duration > 0) {
|
||||
dismissTimer = window.setTimeout(() => beginExit(id), Math.max(duration, MIN_VISIBLE_MS));
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export const message = showMessage as MessageApi;
|
||||
message.remove = (id) => beginExit(id);
|
||||
message.clear = () => {
|
||||
const id = snapshot.current?.id;
|
||||
if (id) beginExit(id, true);
|
||||
};
|
||||
|
||||
export function subscribeToMessages(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function getMessageSnapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function useMessage() {
|
||||
return message;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.root { position: relative; min-width: 0; min-height: 0; overflow: hidden; }
|
||||
.scrollArea { width: 100%; height: 100%; }
|
||||
.horizontal .scrollArea { padding-bottom: 7px; }
|
||||
.horizontalViewport { overflow-x: auto; }
|
||||
.horizontalTrack { position: absolute; z-index: 20; right: 0; bottom: 0; left: 0; height: 7px; opacity: 0; pointer-events: none; touch-action: none; user-select: none; transition: opacity 120ms ease; }
|
||||
.horizontalTrack.visible { opacity: 1; pointer-events: auto; }
|
||||
.horizontalThumb { height: 7px; min-width: 20px; background: var(--vscode-scrollbarSlider-background, rgb(0 0 0 / 10%)); border-radius: 999px; cursor: default; }
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { ScrollArea } from "../scrollable/ScrollArea";
|
||||
import styles from "./ScrollableContent.module.scss";
|
||||
|
||||
type ScrollableContentProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
viewportClassName?: string;
|
||||
contentClassName?: string;
|
||||
scrollbarInsetTop?: string;
|
||||
horizontal?: boolean;
|
||||
alwaysShowVertical?: boolean;
|
||||
};
|
||||
|
||||
export function ScrollableContent({ children, className, viewportClassName, contentClassName, scrollbarInsetTop, horizontal = false, alwaysShowVertical = false }: ScrollableContentProps) {
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
const content = useRef<HTMLDivElement>(null);
|
||||
const track = useRef<HTMLDivElement>(null);
|
||||
const horizontalDragOffset = useRef<number | null>(null);
|
||||
const [metrics, setMetrics] = useState({ visible: false, left: 0, width: 0 });
|
||||
useEffect(() => {
|
||||
if (!horizontal || !viewport.current || !content.current || !track.current) return;
|
||||
const viewportNode = viewport.current;
|
||||
const contentNode = content.current;
|
||||
const update = () => {
|
||||
const maximum = Math.max(0, viewportNode.scrollWidth - viewportNode.clientWidth);
|
||||
const trackWidth = track.current?.clientWidth ?? 0;
|
||||
const width = maximum > 0 ? Math.max(20, trackWidth * viewportNode.clientWidth / viewportNode.scrollWidth) : 0;
|
||||
setMetrics({ visible: maximum > 0, left: maximum > 0 ? viewportNode.scrollLeft / maximum * Math.max(0, trackWidth - width) : 0, width });
|
||||
};
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(viewportNode);
|
||||
observer.observe(contentNode);
|
||||
viewportNode.addEventListener("scroll", update, { passive: true });
|
||||
update();
|
||||
return () => { observer.disconnect(); viewportNode.removeEventListener("scroll", update); };
|
||||
}, [horizontal]);
|
||||
const moveHorizontal = (clientX: number, thumbOffset: number) => {
|
||||
const viewportNode = viewport.current;
|
||||
const trackNode = track.current;
|
||||
if (!viewportNode || !trackNode) return;
|
||||
const rect = trackNode.getBoundingClientRect();
|
||||
const maximum = viewportNode.scrollWidth - viewportNode.clientWidth;
|
||||
const maximumThumb = rect.width - metrics.width;
|
||||
if (maximum <= 0 || maximumThumb <= 0) return;
|
||||
viewportNode.scrollLeft = Math.max(0, Math.min(maximumThumb, clientX - rect.left - thumbOffset)) / maximumThumb * maximum;
|
||||
};
|
||||
return <div className={[styles.root, horizontal && styles.horizontal, className].filter(Boolean).join(" ")}>
|
||||
<ScrollArea className={styles.scrollArea} viewportClassName={[horizontal && styles.horizontalViewport, viewportClassName].filter(Boolean).join(" ")} contentClassName={contentClassName} viewportRef={viewport} contentRef={content} scrollbarSize={7} scrollbarInsetTop={scrollbarInsetTop} scrollbarVisibility={alwaysShowVertical ? "always" : "auto"}>{children}</ScrollArea>
|
||||
{horizontal && <div
|
||||
ref={track}
|
||||
className={[styles.horizontalTrack, metrics.visible && styles.visible].filter(Boolean).join(" ")}
|
||||
onPointerDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
event.preventDefault();
|
||||
moveHorizontal(event.clientX, metrics.width / 2);
|
||||
}}
|
||||
><div
|
||||
className={styles.horizontalThumb}
|
||||
style={{ width: metrics.width, transform: `translateX(${metrics.left}px)` }}
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
horizontalDragOffset.current = event.clientX - event.currentTarget.getBoundingClientRect().left;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (horizontalDragOffset.current === null || !event.currentTarget.hasPointerCapture(event.pointerId)) return;
|
||||
event.preventDefault();
|
||||
moveHorizontal(event.clientX, horizontalDragOffset.current);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
horizontalDragOffset.current = null;
|
||||
}}
|
||||
onPointerCancel={() => { horizontalDragOffset.current = null; }}
|
||||
onLostPointerCapture={() => { horizontalDragOffset.current = null; }}
|
||||
/></div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback, type CSSProperties, type ReactNode } from "react";
|
||||
import { VirtualList as VirtualListEngine } from "./VirtualListEngine";
|
||||
import type { VirtualListApi } from "./virtualTypes";
|
||||
|
||||
type ItemKey<TItem> = keyof TItem | ((item: TItem, index: number) => string | number);
|
||||
|
||||
export type VirtualListProps<TItem> = {
|
||||
items: TItem[];
|
||||
itemKey: ItemKey<TItem>;
|
||||
children: (item: TItem, index: number) => ReactNode;
|
||||
empty?: ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
style?: CSSProperties;
|
||||
overscan?: number;
|
||||
itemGap?: number;
|
||||
estimatedItemHeight?: number;
|
||||
scrollbarInsetTop?: CSSProperties["top"];
|
||||
onReady?: (api: VirtualListApi) => void;
|
||||
};
|
||||
|
||||
export function VirtualList<TItem>({
|
||||
items,
|
||||
itemKey,
|
||||
children,
|
||||
empty = null,
|
||||
className,
|
||||
contentClassName,
|
||||
style,
|
||||
overscan = 4,
|
||||
itemGap = 0,
|
||||
estimatedItemHeight = 54,
|
||||
scrollbarInsetTop,
|
||||
onReady,
|
||||
}: VirtualListProps<TItem>) {
|
||||
const getKey = useCallback((item: TItem, index: number) => {
|
||||
const key = typeof itemKey === "function" ? itemKey(item, index) : item[itemKey];
|
||||
return String(key);
|
||||
}, [itemKey]);
|
||||
|
||||
const estimateSize = useCallback(() => estimatedItemHeight, [estimatedItemHeight]);
|
||||
|
||||
if (!items.length) {
|
||||
return <div className={className} style={style}>{empty}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<VirtualListEngine
|
||||
items={items}
|
||||
getKey={getKey}
|
||||
estimateSize={estimateSize}
|
||||
renderItem={(item, context) => children(item, context.index)}
|
||||
overscan={overscan}
|
||||
itemGap={itemGap}
|
||||
scrollbarSize={7}
|
||||
scrollbarInsetTop={scrollbarInsetTop}
|
||||
onReady={onReady}
|
||||
className={className}
|
||||
contentClassName={contentClassName}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import {
|
||||
ScrollArea,
|
||||
type ScrollAreaApi,
|
||||
type ScrollAreaState,
|
||||
} from "../scrollable/ScrollArea"
|
||||
import { RangeMap } from "./rangeMap"
|
||||
import type {
|
||||
VirtualListApi,
|
||||
VirtualListProps,
|
||||
VirtualListRenderContext,
|
||||
} from "./virtualTypes"
|
||||
|
||||
interface RenderRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
interface ViewportAnchor {
|
||||
index: number
|
||||
topDelta: number
|
||||
}
|
||||
|
||||
interface ContentInsets {
|
||||
top: number
|
||||
bottom: number
|
||||
}
|
||||
|
||||
interface VirtualRowProps {
|
||||
index: number
|
||||
top: number
|
||||
measure: (index: number, node: HTMLElement | null) => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function getVisibleRange(
|
||||
rangeMap: RangeMap,
|
||||
scrollTop: number,
|
||||
viewportHeight: number
|
||||
): RenderRange {
|
||||
if (rangeMap.count === 0 || viewportHeight <= 0) return { start: 0, end: 0 }
|
||||
|
||||
return {
|
||||
start: rangeMap.indexAt(scrollTop),
|
||||
end: rangeMap.indexAfter(scrollTop + viewportHeight - 1),
|
||||
}
|
||||
}
|
||||
|
||||
function getRenderRange(
|
||||
rangeMap: RangeMap,
|
||||
scrollTop: number,
|
||||
viewportHeight: number,
|
||||
overscan: number
|
||||
): RenderRange {
|
||||
const visibleRange = getVisibleRange(rangeMap, scrollTop, viewportHeight)
|
||||
|
||||
return {
|
||||
start: Math.max(0, visibleRange.start - overscan),
|
||||
end: Math.min(rangeMap.count, visibleRange.end + overscan),
|
||||
}
|
||||
}
|
||||
|
||||
function getViewportAnchor(
|
||||
rangeMap: RangeMap,
|
||||
scrollTop: number,
|
||||
viewportHeight: number
|
||||
): ViewportAnchor | null {
|
||||
const visibleRange = getVisibleRange(rangeMap, scrollTop, viewportHeight)
|
||||
if (visibleRange.end <= visibleRange.start) return null
|
||||
|
||||
if (scrollTop === rangeMap.positionAt(visibleRange.start)) {
|
||||
return {
|
||||
index: visibleRange.start,
|
||||
topDelta: 0,
|
||||
}
|
||||
}
|
||||
|
||||
if (visibleRange.end - visibleRange.start > 1) {
|
||||
const index = visibleRange.start + 1
|
||||
return {
|
||||
index,
|
||||
topDelta: rangeMap.positionAt(index) - scrollTop,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isSameRange(left: RenderRange, right: RenderRange): boolean {
|
||||
return left.start === right.start && left.end === right.end
|
||||
}
|
||||
|
||||
function VirtualRow(props: VirtualRowProps) {
|
||||
const { index, top, measure, children } = props
|
||||
const rowRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = rowRef.current
|
||||
if (!node) return
|
||||
|
||||
measure(index, node)
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
measure(index, node)
|
||||
})
|
||||
resizeObserver.observe(node)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
measure(index, null)
|
||||
}
|
||||
}, [index, measure])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rowRef}
|
||||
data-index={index}
|
||||
style={{
|
||||
left: 0,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
transform: `translateY(${top}px)`,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function VirtualList<TItem>(props: VirtualListProps<TItem>) {
|
||||
const {
|
||||
items,
|
||||
getKey,
|
||||
estimateSize,
|
||||
renderItem,
|
||||
overscan = 0,
|
||||
itemGap = 0,
|
||||
deferInitialRender = false,
|
||||
scrollbarSize,
|
||||
minThumbSize,
|
||||
scrollbarVisibility,
|
||||
scrollbarInsetTop,
|
||||
className,
|
||||
contentClassName,
|
||||
style,
|
||||
onRangeChange,
|
||||
onReady,
|
||||
} = props
|
||||
|
||||
const itemSnapshot = useMemo(
|
||||
() =>
|
||||
items.map((item, index) => {
|
||||
const key = getKey(item, index)
|
||||
const estimatedSize = estimateSize(item, index) + (index < items.length - 1 ? itemGap : 0)
|
||||
return { key, estimatedSize }
|
||||
}),
|
||||
[estimateSize, getKey, itemGap, items]
|
||||
)
|
||||
const rangeMapRef = useRef(
|
||||
new RangeMap(itemSnapshot.map((item) => item.estimatedSize))
|
||||
)
|
||||
const itemSignature = itemSnapshot
|
||||
.map((item) => `${item.key}:${item.estimatedSize}`)
|
||||
.join("\0")
|
||||
const itemSignatureRef = useRef(itemSignature)
|
||||
const shouldResetScrollRef = useRef(false)
|
||||
const scrollApiRef = useRef<ScrollAreaApi | null>(null)
|
||||
const scrollStateRef = useRef<ScrollAreaState | null>(null)
|
||||
const spacerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [contentInsets, setContentInsets] = useState<ContentInsets>({
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
})
|
||||
const renderRangeRef = useRef<RenderRange>({ start: 0, end: 0 })
|
||||
const [initialRenderReady, setInitialRenderReady] = useState(
|
||||
() => !deferInitialRender
|
||||
)
|
||||
const [scrollState, setScrollState] = useState<ScrollAreaState>({
|
||||
scrollTop: 0,
|
||||
viewportHeight: 0,
|
||||
scrollHeight: rangeMapRef.current.totalSize,
|
||||
isScrollable: false,
|
||||
isScrolling: false,
|
||||
})
|
||||
const [, forceUpdate] = useState(0)
|
||||
|
||||
const setContentRef = useCallback((node: HTMLDivElement | null) => {
|
||||
const styles = node ? getComputedStyle(node) : null
|
||||
const nextInsets = {
|
||||
top: styles ? Number.parseFloat(styles.paddingTop) || 0 : 0,
|
||||
bottom: styles ? Number.parseFloat(styles.paddingBottom) || 0 : 0,
|
||||
}
|
||||
setContentInsets((currentInsets) =>
|
||||
currentInsets.top === nextInsets.top && currentInsets.bottom === nextInsets.bottom
|
||||
? currentInsets
|
||||
: nextInsets
|
||||
)
|
||||
}, [])
|
||||
|
||||
const contentInsetTop = contentInsets.top
|
||||
|
||||
if (!scrollStateRef.current) {
|
||||
scrollStateRef.current = scrollState
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!deferInitialRender) {
|
||||
setInitialRenderReady(true)
|
||||
return
|
||||
}
|
||||
|
||||
let firstFrame = 0
|
||||
let secondFrame = 0
|
||||
firstFrame = requestAnimationFrame(() => {
|
||||
secondFrame = requestAnimationFrame(() => {
|
||||
setInitialRenderReady(true)
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(firstFrame)
|
||||
cancelAnimationFrame(secondFrame)
|
||||
}
|
||||
}, [deferInitialRender])
|
||||
|
||||
if (itemSignatureRef.current !== itemSignature) {
|
||||
const estimatedSizes = itemSnapshot.map((item) => item.estimatedSize)
|
||||
rangeMapRef.current.reset(estimatedSizes)
|
||||
itemSignatureRef.current = itemSignature
|
||||
shouldResetScrollRef.current = true
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldResetScrollRef.current) return
|
||||
shouldResetScrollRef.current = false
|
||||
scrollApiRef.current?.scrollToTop()
|
||||
forceUpdate((current) => current + 1)
|
||||
}, [itemSignature])
|
||||
|
||||
const rangeMap = rangeMapRef.current
|
||||
const initialRenderCount = Math.max(1, overscan * 2 + 1)
|
||||
const canRenderItems =
|
||||
initialRenderReady &&
|
||||
(!deferInitialRender || scrollState.viewportHeight > 0)
|
||||
let renderRange: RenderRange
|
||||
if (!canRenderItems) {
|
||||
renderRange = { start: 0, end: 0 }
|
||||
} else if (scrollState.viewportHeight > 0) {
|
||||
renderRange = getRenderRange(
|
||||
rangeMap,
|
||||
Math.max(0, scrollState.scrollTop - contentInsetTop),
|
||||
scrollState.viewportHeight,
|
||||
overscan
|
||||
)
|
||||
} else {
|
||||
renderRange = {
|
||||
start: 0,
|
||||
end: Math.min(rangeMap.count, initialRenderCount),
|
||||
}
|
||||
}
|
||||
renderRangeRef.current = renderRange
|
||||
|
||||
useEffect(() => {
|
||||
if (renderRange.end > renderRange.start) {
|
||||
onRangeChange?.({ start: renderRange.start, end: renderRange.end - 1 })
|
||||
}
|
||||
}, [onRangeChange, renderRange.end, renderRange.start])
|
||||
|
||||
const measure = useCallback((index: number, node: HTMLElement | null) => {
|
||||
if (!node) return
|
||||
|
||||
const rangeMap = rangeMapRef.current
|
||||
const currentScrollState = scrollStateRef.current
|
||||
let anchor: ViewportAnchor | null = null
|
||||
if (currentScrollState) {
|
||||
anchor = getViewportAnchor(
|
||||
rangeMap,
|
||||
Math.max(0, currentScrollState.scrollTop - contentInsetTop),
|
||||
currentScrollState.viewportHeight
|
||||
)
|
||||
}
|
||||
|
||||
const measuredGap = index < items.length - 1 ? itemGap : 0
|
||||
const sizeDelta = rangeMap.updateSize(
|
||||
index,
|
||||
node.getBoundingClientRect().height + measuredGap
|
||||
)
|
||||
if (sizeDelta === 0) return
|
||||
|
||||
if (spacerRef.current) {
|
||||
spacerRef.current.style.height = `${rangeMap.totalSize}px`
|
||||
}
|
||||
|
||||
if (anchor && index < anchor.index) {
|
||||
scrollApiRef.current?.scrollTo(
|
||||
Math.max(0, contentInsetTop + rangeMap.positionAt(anchor.index) - anchor.topDelta)
|
||||
)
|
||||
}
|
||||
|
||||
forceUpdate((current) => current + 1)
|
||||
}, [contentInsetTop, itemGap, items.length])
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(nextState: ScrollAreaState) => {
|
||||
const previousState = scrollStateRef.current
|
||||
scrollStateRef.current = nextState
|
||||
|
||||
const nextRange = getRenderRange(
|
||||
rangeMapRef.current,
|
||||
Math.max(0, nextState.scrollTop - contentInsetTop),
|
||||
nextState.viewportHeight,
|
||||
overscan
|
||||
)
|
||||
const dimensionsChanged =
|
||||
!previousState ||
|
||||
previousState.viewportHeight !== nextState.viewportHeight ||
|
||||
previousState.scrollHeight !== nextState.scrollHeight
|
||||
const scrollingChanged =
|
||||
!previousState || previousState.isScrolling !== nextState.isScrolling
|
||||
const rangeChanged = !isSameRange(nextRange, renderRangeRef.current)
|
||||
|
||||
if (
|
||||
dimensionsChanged ||
|
||||
scrollingChanged ||
|
||||
rangeChanged
|
||||
) {
|
||||
setScrollState(nextState)
|
||||
}
|
||||
},
|
||||
[contentInsetTop, overscan]
|
||||
)
|
||||
|
||||
const api = useMemo<VirtualListApi>(
|
||||
() => ({
|
||||
scrollToIndex(index, align = "auto") {
|
||||
const rangeMap = rangeMapRef.current
|
||||
const itemTop = contentInsetTop + rangeMap.positionAt(index)
|
||||
const itemHeight = rangeMap.sizeAt(index)
|
||||
const currentScrollState = scrollStateRef.current
|
||||
const viewportHeight = currentScrollState?.viewportHeight ?? 0
|
||||
const currentTop = currentScrollState?.scrollTop ?? 0
|
||||
const currentBottom = currentTop + viewportHeight
|
||||
|
||||
let nextTop = itemTop
|
||||
if (align === "center") {
|
||||
nextTop = itemTop - (viewportHeight - itemHeight) / 2
|
||||
} else if (align === "end") {
|
||||
nextTop = itemTop - viewportHeight + itemHeight
|
||||
} else if (align === "auto") {
|
||||
if (itemTop >= currentTop && itemTop + itemHeight <= currentBottom) {
|
||||
return
|
||||
}
|
||||
if (itemTop < currentTop) nextTop = itemTop
|
||||
else nextTop = itemTop - viewportHeight + itemHeight
|
||||
}
|
||||
|
||||
scrollApiRef.current?.scrollTo(nextTop)
|
||||
},
|
||||
scrollToOffset(offset) {
|
||||
scrollApiRef.current?.scrollTo(offset)
|
||||
},
|
||||
scrollToTop() {
|
||||
scrollApiRef.current?.scrollToTop()
|
||||
},
|
||||
}),
|
||||
[contentInsetTop]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
onReady?.(api)
|
||||
}, [api, onReady])
|
||||
|
||||
const visibleItems = []
|
||||
for (let index = renderRange.start; index < renderRange.end; index += 1) {
|
||||
visibleItems.push(index)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
className={className}
|
||||
contentClassName={contentClassName}
|
||||
contentRef={setContentRef}
|
||||
style={style}
|
||||
contentHeight={contentInsetTop + rangeMap.totalSize + contentInsets.bottom}
|
||||
onReady={(scrollApi) => {
|
||||
scrollApiRef.current = scrollApi
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
scrollbarSize={scrollbarSize}
|
||||
minThumbSize={minThumbSize}
|
||||
scrollbarVisibility={scrollbarVisibility}
|
||||
scrollbarInsetTop={scrollbarInsetTop}
|
||||
>
|
||||
<div
|
||||
ref={spacerRef}
|
||||
style={{
|
||||
height: `${rangeMap.totalSize}px`,
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{visibleItems.map((index) => {
|
||||
const item = items[index]
|
||||
const context: VirtualListRenderContext = {
|
||||
index,
|
||||
isScrolling: scrollState.isScrolling,
|
||||
measureElement: (node) => measure(index, node),
|
||||
}
|
||||
|
||||
return (
|
||||
<VirtualRow
|
||||
key={getKey(item, index)}
|
||||
index={index}
|
||||
top={rangeMap.positionAt(index)}
|
||||
measure={measure}
|
||||
>
|
||||
{renderItem(item, context)}
|
||||
</VirtualRow>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
export type {
|
||||
VirtualListApi,
|
||||
VirtualListProps,
|
||||
VirtualListRenderContext,
|
||||
} from "./virtualTypes"
|
||||
@@ -0,0 +1,88 @@
|
||||
function clampIndex(index: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(index, min), max)
|
||||
}
|
||||
|
||||
export class RangeMap {
|
||||
private sizes: number[]
|
||||
private prefixSums: number[]
|
||||
|
||||
constructor(sizes: number[]) {
|
||||
this.sizes = sizes.map((size) => Math.max(1, size))
|
||||
this.prefixSums = []
|
||||
this.rebuildPrefixSums()
|
||||
}
|
||||
|
||||
get count(): number {
|
||||
return this.sizes.length
|
||||
}
|
||||
|
||||
get totalSize(): number {
|
||||
return this.prefixSums[this.prefixSums.length - 1] ?? 0
|
||||
}
|
||||
|
||||
reset(sizes: number[]): void {
|
||||
this.sizes = sizes.map((size) => Math.max(1, size))
|
||||
this.rebuildPrefixSums()
|
||||
}
|
||||
|
||||
updateSize(index: number, size: number): number {
|
||||
if (index < 0 || index >= this.sizes.length) return 0
|
||||
|
||||
const nextSize = Math.max(1, Math.ceil(size))
|
||||
const previousSize = this.sizes[index]
|
||||
if (previousSize === nextSize) return 0
|
||||
|
||||
this.sizes[index] = nextSize
|
||||
this.rebuildPrefixSums(index)
|
||||
return nextSize - previousSize
|
||||
}
|
||||
|
||||
sizeAt(index: number): number {
|
||||
return this.sizes[clampIndex(index, 0, this.sizes.length - 1)] ?? 0
|
||||
}
|
||||
|
||||
positionAt(index: number): number {
|
||||
if (index <= 0) return 0
|
||||
if (index >= this.sizes.length) return this.totalSize
|
||||
return this.prefixSums[index - 1] ?? 0
|
||||
}
|
||||
|
||||
indexAt(offset: number): number {
|
||||
if (this.sizes.length === 0) return 0
|
||||
if (offset <= 0) return 0
|
||||
if (offset >= this.totalSize) return this.sizes.length - 1
|
||||
|
||||
let low = 0
|
||||
let high = this.prefixSums.length - 1
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high) / 2)
|
||||
if (this.prefixSums[mid] > offset) high = mid
|
||||
else low = mid + 1
|
||||
}
|
||||
|
||||
return low
|
||||
}
|
||||
|
||||
indexAfter(offset: number): number {
|
||||
if (this.sizes.length === 0) return 0
|
||||
if (offset < 0) return 0
|
||||
if (offset >= this.totalSize) return this.sizes.length
|
||||
|
||||
return Math.min(this.indexAt(offset) + 1, this.sizes.length)
|
||||
}
|
||||
|
||||
private rebuildPrefixSums(startIndex = 0): void {
|
||||
if (startIndex <= 0 || this.prefixSums.length !== this.sizes.length) {
|
||||
this.prefixSums = new Array(this.sizes.length)
|
||||
startIndex = 0
|
||||
}
|
||||
|
||||
let runningTotal = 0
|
||||
if (startIndex > 0) runningTotal = this.prefixSums[startIndex - 1] ?? 0
|
||||
|
||||
for (let index = startIndex; index < this.sizes.length; index += 1) {
|
||||
runningTotal += this.sizes[index]
|
||||
this.prefixSums[index] = runningTotal
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { CSSProperties, ReactNode } from "react"
|
||||
|
||||
export interface VirtualListApi {
|
||||
scrollToIndex(
|
||||
index: number,
|
||||
align?: "start" | "center" | "end" | "auto"
|
||||
): void
|
||||
scrollToOffset(offset: number): void
|
||||
scrollToTop(): void
|
||||
}
|
||||
|
||||
export interface VirtualListRenderContext {
|
||||
index: number
|
||||
measureElement: (node: HTMLElement | null) => void
|
||||
isScrolling: boolean
|
||||
}
|
||||
|
||||
export interface VirtualListProps<TItem> {
|
||||
items: TItem[]
|
||||
getKey: (item: TItem, index: number) => string
|
||||
estimateSize: (item: TItem, index: number) => number
|
||||
renderItem: (item: TItem, context: VirtualListRenderContext) => ReactNode
|
||||
|
||||
overscan?: number
|
||||
itemGap?: number
|
||||
deferInitialRender?: boolean
|
||||
scrollbarSize?: number
|
||||
minThumbSize?: number
|
||||
scrollbarVisibility?: "auto" | "always" | "hidden"
|
||||
scrollbarInsetTop?: CSSProperties["top"]
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
style?: CSSProperties
|
||||
onRangeChange?: (range: { start: number; end: number }) => void
|
||||
onReady?: (api: VirtualListApi) => void
|
||||
}
|
||||
|
||||
export interface VirtualTreeNode {
|
||||
id: string
|
||||
parentId?: string
|
||||
depth: number
|
||||
collapsible?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface VirtualTreeRenderContext {
|
||||
index: number
|
||||
depth: number
|
||||
expanded: boolean
|
||||
selected: boolean
|
||||
toggleExpanded(): void
|
||||
measureElement: (node: HTMLElement | null) => void
|
||||
}
|
||||
|
||||
export interface VirtualTreeProps<TNode extends VirtualTreeNode> {
|
||||
nodes: TNode[]
|
||||
getKey?: (node: TNode) => string
|
||||
estimateSize: (node: TNode, index: number) => number
|
||||
renderNode: (node: TNode, context: VirtualTreeRenderContext) => ReactNode
|
||||
|
||||
expandedIds?: Set<string>
|
||||
defaultExpandedIds?: Set<string>
|
||||
onExpandedChange?: (expandedIds: Set<string>) => void
|
||||
|
||||
selectedId?: string
|
||||
onSelect?: (node: TNode) => void
|
||||
|
||||
overscan?: number
|
||||
scrollbarSize?: number
|
||||
minThumbSize?: number
|
||||
scrollbarVisibility?: "auto" | "always" | "hidden"
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
export interface VirtualComboboxOption {
|
||||
key: string
|
||||
label: string
|
||||
description?: string
|
||||
searchText?: string
|
||||
}
|
||||
|
||||
export interface VirtualComboboxProps {
|
||||
options: VirtualComboboxOption[]
|
||||
value: string
|
||||
placeholder: string
|
||||
emptyText: string
|
||||
ariaLabel?: string
|
||||
onChange: (key: string) => void
|
||||
className?: string
|
||||
estimateSize?: number
|
||||
maxPopupHeight?: number
|
||||
scrollbarSize?: number
|
||||
minThumbSize?: number
|
||||
scrollbarVisibility?: "auto" | "always" | "hidden"
|
||||
}
|
||||
Reference in New Issue
Block a user