refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.catalogHeading h3,
|
||||
.status,
|
||||
.failure p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status,
|
||||
.failure {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.failure {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.failure button {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.catalog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.search > svg {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
padding: 0 34px 0 36px;
|
||||
outline: none;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search input::placeholder {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.search input:focus-visible {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent);
|
||||
}
|
||||
|
||||
.catalogHeading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.catalogHeading h3 {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.catalogHeading span {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.card[data-open='true'] {
|
||||
border-color: var(--dsw-alias-border-l1);
|
||||
box-shadow: var(--dsw-shadow-lv1);
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
border: 0;
|
||||
padding: 12px 14px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cardContent:hover,
|
||||
.card[data-open='true'] > .cardContent {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.cardContent:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cardTrailing {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex: none;
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.statusDot[data-phase='active'] {
|
||||
background: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.statusDot[data-phase='failed'] {
|
||||
background: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.statusDot[data-phase='loading'] {
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.configTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 20px;
|
||||
border-radius: 5px;
|
||||
padding: 1px 6px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.configTag[data-enabled='true'] {
|
||||
background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.card[data-open='true'] .chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.cardDetails {
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
padding: 10px 14px 12px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
}
|
||||
|
||||
.entryValue {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: grid;
|
||||
grid-template-columns: 76px minmax(0, 1fr);
|
||||
gap: 6px 10px;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.details div {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.details dt {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
.details dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.chevron {
|
||||
transition: transform 140ms var(--ds-ease-in-out);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.cards {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
|
||||
import type { PluginInventorySnapshot } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import {
|
||||
IconChevronDownOutline14,
|
||||
IconSearchOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PluginInventoryLocaleKey } from './locales.ts'
|
||||
import css from './PluginInventorySettingsTab.module.css'
|
||||
|
||||
/** Registration-side Remote face used by the section. */
|
||||
export interface PluginInventorySettingsTabInjected {
|
||||
/** Read a current Host inventory snapshot. */
|
||||
list: () => Promise<PluginInventorySnapshot>
|
||||
}
|
||||
|
||||
type PluginInventoryEntry = PluginInventorySnapshot['entries'][number]
|
||||
type PluginFiberPhase = PluginInventoryEntry['fiberPhase']
|
||||
|
||||
/** Full component props assembled by the Settings slot renderer. */
|
||||
export type PluginInventorySettingsTabProps =
|
||||
PropsRuntime<'settings.plugins.tab'>
|
||||
& PropsLocale<'settings.pluginInventory'>
|
||||
& InjectFace<PluginInventorySettingsTabInjected>
|
||||
|
||||
type ViewState =
|
||||
| { readonly status: 'loading' }
|
||||
| { readonly status: 'error' }
|
||||
| { readonly status: 'ready'; readonly snapshot: PluginInventorySnapshot }
|
||||
|
||||
const PHASE_KEYS = {
|
||||
pending: 'pending',
|
||||
loading: 'loadingPhase',
|
||||
active: 'active',
|
||||
failed: 'failed',
|
||||
unloading: 'unloading',
|
||||
} satisfies Record<Exclude<PluginFiberPhase, null>, PluginInventoryLocaleKey>
|
||||
|
||||
/** Localized accessible label for one root Fiber phase. */
|
||||
function phaseLabel(
|
||||
phase: PluginFiberPhase,
|
||||
t: PluginInventorySettingsTabProps['t'],
|
||||
): string {
|
||||
return phase === null ? t('unobserved') : t(PHASE_KEYS[phase])
|
||||
}
|
||||
|
||||
/** Compact a module specifier without guessing whether its Loader id was generated. */
|
||||
function moduleShortName(moduleName: string): string {
|
||||
const unscoped = moduleName.startsWith('@') ? moduleName.slice(moduleName.indexOf('/') + 1) : moduleName
|
||||
return unscoped
|
||||
.replace(/^cordis:/, '')
|
||||
.replace(/^cordis-plugin-/, '')
|
||||
.replace(/^dsh-(?:host-|client-)?/, '')
|
||||
}
|
||||
|
||||
/** Whether an inventory row matches the local catalog query. */
|
||||
function matches(entry: PluginInventoryEntry, normalizedQuery: string): boolean {
|
||||
if (normalizedQuery.length === 0) return true
|
||||
return [entry.moduleName, entry.entryId]
|
||||
.some(value => value.toLocaleLowerCase().includes(normalizedQuery))
|
||||
}
|
||||
|
||||
/** Render the read-only current Loader inventory. */
|
||||
export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsTabProps): ReactNode {
|
||||
const catalogId = useId()
|
||||
const [request, setRequest] = useState(0)
|
||||
const [query, setQuery] = useState('')
|
||||
const [expanded, setExpanded] = useState<PluginInventoryEntry['entryId'] | null>(null)
|
||||
const [state, setState] = useState<ViewState>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let current = true
|
||||
void Promise.resolve().then(() => list()).then(
|
||||
(snapshot) => { if (current) setState({ status: 'ready', snapshot }) },
|
||||
() => { if (current) setState({ status: 'error' }) },
|
||||
)
|
||||
return () => { current = false }
|
||||
}, [list, request])
|
||||
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase()
|
||||
const filteredEntries = useMemo(
|
||||
() => state.status === 'ready'
|
||||
? state.snapshot.entries.filter(entry => matches(entry, normalizedQuery))
|
||||
: [],
|
||||
[normalizedQuery, state],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded !== null && !filteredEntries.some(entry => entry.entryId === expanded)) {
|
||||
setExpanded(null)
|
||||
}
|
||||
}, [expanded, filteredEntries])
|
||||
|
||||
const retry = (): void => {
|
||||
setState({ status: 'loading' })
|
||||
setRequest(value => value + 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.section} aria-busy={state.status === 'loading'}>
|
||||
{state.status === 'loading' ? <p className={css.status}>{t('loading')}</p> : null}
|
||||
{state.status === 'error' ? (
|
||||
<div className={css.failure}>
|
||||
<p role="alert">{t('error')}</p>
|
||||
<button type="button" onClick={retry}>{t('retry')}</button>
|
||||
</div>
|
||||
) : null}
|
||||
{state.status === 'ready' ? (
|
||||
<div className={css.catalog}>
|
||||
<label className={css.search}>
|
||||
<IconSearchOutline16 aria-hidden="true" />
|
||||
<span className={css.visuallyHidden}>{t('search')}</span>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder={t('search')}
|
||||
aria-label={t('search')}
|
||||
onChange={(event) => { setQuery(event.currentTarget.value) }}
|
||||
/>
|
||||
</label>
|
||||
<div className={css.catalogHeading}>
|
||||
<h3>{t('catalog')}</h3>
|
||||
<span data-plugin-count={filteredEntries.length}>{filteredEntries.length}</span>
|
||||
</div>
|
||||
{state.snapshot.entries.length === 0 ? <p className={css.status}>{t('empty')}</p> : null}
|
||||
{state.snapshot.entries.length > 0 && filteredEntries.length === 0
|
||||
? <p className={css.status}>{t('emptySearch')}</p>
|
||||
: null}
|
||||
{filteredEntries.length > 0 ? (
|
||||
<ul className={css.cards}>
|
||||
{filteredEntries.map((entry) => {
|
||||
const status = phaseLabel(entry.fiberPhase, t)
|
||||
const title = moduleShortName(entry.moduleName)
|
||||
const configuration = t(entry.enabled ? 'enabledTag' : 'disabledTag')
|
||||
const open = expanded === entry.entryId
|
||||
const detailId = `${catalogId}-details-${encodeURIComponent(entry.entryId)}`
|
||||
return (
|
||||
<li
|
||||
className={css.card}
|
||||
key={entry.entryId}
|
||||
data-plugin-entry={entry.entryId}
|
||||
data-open={open ? 'true' : undefined}
|
||||
>
|
||||
<button
|
||||
className={css.cardContent}
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={detailId}
|
||||
aria-label={entry.enabled ? `${title}, ${status}, ${configuration}` : `${title}, ${configuration}`}
|
||||
onClick={() => {
|
||||
setExpanded(current => current === entry.entryId ? null : entry.entryId)
|
||||
}}
|
||||
>
|
||||
<strong className={css.cardTitle} title={entry.moduleName}>{title}</strong>
|
||||
<span className={css.cardTrailing}>
|
||||
{entry.enabled ? (
|
||||
<span
|
||||
className={css.statusDot}
|
||||
data-phase={entry.fiberPhase ?? 'unobserved'}
|
||||
role="img"
|
||||
aria-label={status}
|
||||
title={status}
|
||||
/>
|
||||
) : null}
|
||||
<span className={css.configTag} data-enabled={entry.enabled ? 'true' : 'false'}>
|
||||
{configuration}
|
||||
</span>
|
||||
<IconChevronDownOutline14 className={css.chevron} size={12} aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className={css.cardDetails} id={detailId}>
|
||||
<code className={css.entryValue} data-loader-entry>{entry.entryId}</code>
|
||||
<dl className={css.details}>
|
||||
<div>
|
||||
<dt>{t('configuration')}</dt>
|
||||
<dd>{configuration}</dd>
|
||||
</div>
|
||||
{entry.enabled ? (
|
||||
<div>
|
||||
<dt>{t('cordis')}</dt>
|
||||
<dd>{status}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Read-only Host plugin inventory registered into Web Settings. */
|
||||
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { PluginInventorySettingsTab, type PluginInventorySettingsTabInjected } from './PluginInventorySettingsTab.tsx'
|
||||
import { en, zh, type PluginInventoryLocaleKey } from './locales.ts'
|
||||
|
||||
export type { PluginInventorySettingsTabInjected, PluginInventorySettingsTabProps } from './PluginInventorySettingsTab.tsx'
|
||||
export type { PluginInventoryLocaleKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Read-only Host plugin inventory copy. */
|
||||
'settings.pluginInventory': PluginInventoryLocaleKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
export const NS = 'settings.pluginInventory'
|
||||
|
||||
/** Services required by the Settings registration and generated Remote face. */
|
||||
export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory']
|
||||
|
||||
/** Contribute the lazy inventory tab to the Plugins settings section. */
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-plugin-inventory: dictionaries')
|
||||
|
||||
const t = ctx.locale.bind(NS)
|
||||
const list: PluginInventorySettingsTabInjected['list'] = async () => {
|
||||
const result = await ctx.remote.pluginInventory.list()
|
||||
if (!result.ok) {
|
||||
throw new Error(`pluginInventory.list failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
return result.value
|
||||
}
|
||||
const injected = (): PluginInventorySettingsTabInjected => ({ list })
|
||||
|
||||
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
||||
name: 'settings.plugins.tab',
|
||||
id: 'all',
|
||||
order: 10,
|
||||
label: () => t('tab'),
|
||||
locale: NS,
|
||||
inject: injected,
|
||||
}, PluginInventorySettingsTab))
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/** Copy dictionaries for the plugin inventory Settings section. */
|
||||
|
||||
/** Simplified Chinese dictionary and key source of truth. */
|
||||
export const zh = {
|
||||
tab: '插件列表',
|
||||
loading: '正在读取插件…',
|
||||
error: '暂时无法读取插件。',
|
||||
retry: '重试',
|
||||
search: '搜索插件',
|
||||
catalog: '插件列表',
|
||||
empty: '暂无插件。',
|
||||
emptySearch: '没有匹配的插件。',
|
||||
enabledTag: '已启用',
|
||||
disabledTag: '已停用',
|
||||
configuration: '配置状态',
|
||||
cordis: 'Cordis 状态',
|
||||
unobserved: '未挂载',
|
||||
pending: '等待依赖',
|
||||
loadingPhase: '加载中',
|
||||
active: '已挂载',
|
||||
failed: '挂载失败',
|
||||
unloading: '卸载中',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** Plugin inventory locale key union. */
|
||||
export type PluginInventoryLocaleKey = keyof typeof zh
|
||||
|
||||
/** English dictionary checked against the Chinese key set. */
|
||||
export const en = {
|
||||
tab: 'Plugin list',
|
||||
loading: 'Reading plugins…',
|
||||
error: 'Plugins are temporarily unavailable.',
|
||||
retry: 'Retry',
|
||||
search: 'Search plugins',
|
||||
catalog: 'Plugin list',
|
||||
empty: 'No plugins are available.',
|
||||
emptySearch: 'No matching plugins.',
|
||||
enabledTag: 'Enabled',
|
||||
disabledTag: 'Disabled',
|
||||
configuration: 'Configuration',
|
||||
cordis: 'Cordis status',
|
||||
unobserved: 'Not mounted',
|
||||
pending: 'Waiting for dependencies',
|
||||
loadingPhase: 'Loading',
|
||||
active: 'Mounted',
|
||||
failed: 'Mount failed',
|
||||
unloading: 'Unloading',
|
||||
} satisfies Record<PluginInventoryLocaleKey, string>
|
||||
6
packages/client/ui-settings-plugin-inventory/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-settings-plugin-inventory/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Host loader entry for the inventory-tab browser implementation exported from `./client`. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the plugin inventory tab. */
|
||||
export function apply(): void {}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Package-owned invariant companion. @module @deepseek-ai/dsh-client-ui-settings-plugin-inventory/invariant */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings-plugin-inventory'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-settings-plugin-inventory-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: this package owns a read-only Settings contribution. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/** Register this package's invariant companion. */
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
Reference in New Issue
Block a user