дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(vcs): clone from the repositories of your GitHub account (#1125)
The Clone Repository dialog lists GitHub next to Repository URL: the repositories of the account gh is signed in to, searchable; picking one fills URL and directory, double-click or Enter clones. Clone URL follows gh's git_protocol. Clone sources are a workbench contribution (registerCloneSource); gh_account_repos needs no project. Closes #750
Цей коміт міститься в:
1 джерело
9988ce4113
коміт
a3bbbf90a4
16 files changed
+668
-55
No files matched your search
@@ -0,0 +1,75 @@
|
||||
.pane {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.login {
|
||||
color: var(--fg-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
.search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
border-radius: var(--radius-control, 4px);
|
||||
box-shadow: inset 0 0 0 1px var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.list > * {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.rowIcon {
|
||||
flex: none;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.name {
|
||||
flex: none;
|
||||
max-width: 60%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tag {
|
||||
flex: none;
|
||||
color: var(--fg-faint);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.description {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fg-faint);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.muted {
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const ghAccountRepos = vi.fn();
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/platform/ipc")>()),
|
||||
isTauri: () => true,
|
||||
commands: { ghAccountRepos: () => ghAccountRepos() },
|
||||
}));
|
||||
|
||||
import type { GhAccountRepo } from "@/bindings";
|
||||
import { GitHubCloneSource, matchRepos } from "./GitHubCloneSource";
|
||||
|
||||
const repo = (nameWithOwner: string, extra: Partial<GhAccountRepo> = {}): GhAccountRepo => ({
|
||||
nameWithOwner,
|
||||
description: null,
|
||||
private: false,
|
||||
fork: false,
|
||||
archived: false,
|
||||
cloneUrl: `https://github.com/${nameWithOwner}.git`,
|
||||
...extra,
|
||||
});
|
||||
const repos = [repo("me/app", { private: true, description: "The app" }), repo("org/lib", { fork: true })];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
ghAccountRepos.mockReset();
|
||||
});
|
||||
|
||||
describe("GitHub clone source", () => {
|
||||
it("filters by owner/name", () => {
|
||||
expect(matchRepos(repos, "").map((r) => r.repo.nameWithOwner)).toEqual(["me/app", "org/lib"]);
|
||||
expect(matchRepos(repos, "olib").map((r) => r.repo.nameWithOwner)).toEqual(["org/lib"]);
|
||||
});
|
||||
|
||||
it("lists the account's repositories; a click picks one, Enter and double-click clone it", async () => {
|
||||
ghAccountRepos.mockResolvedValue({ status: "ok", data: { kind: "ready", login: "me", repos } });
|
||||
const onUrl = vi.fn();
|
||||
const onClone = vi.fn();
|
||||
const { rerender } = render(<GitHubCloneSource url="" onUrl={onUrl} onClone={onClone} disabled={false} />);
|
||||
await screen.findByText("me");
|
||||
fireEvent.mouseDown(screen.getByRole("option", { name: /org\/lib/ }));
|
||||
fireEvent.click(screen.getByRole("option", { name: /org\/lib/ }));
|
||||
expect(onClone).not.toHaveBeenCalled();
|
||||
expect(onUrl).toHaveBeenLastCalledWith("https://github.com/org/lib.git");
|
||||
rerender(
|
||||
<GitHubCloneSource url="https://github.com/org/lib.git" onUrl={onUrl} onClone={onClone} disabled={false} />,
|
||||
);
|
||||
fireEvent.keyDown(screen.getByRole("textbox", { name: "Search repositories" }), { key: "ArrowUp" });
|
||||
expect(onUrl).toHaveBeenLastCalledWith("https://github.com/me/app.git");
|
||||
fireEvent.doubleClick(screen.getByText("me/app"));
|
||||
expect(onClone).toHaveBeenCalledWith("https://github.com/me/app.git");
|
||||
fireEvent.keyDown(screen.getByRole("textbox", { name: "Search repositories" }), { key: "Enter" });
|
||||
expect(onClone).toHaveBeenLastCalledWith("https://github.com/org/lib.git");
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search repositories" }), { target: { value: "zzz" } });
|
||||
expect(screen.getByText("No matching repositories")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says how to sign in when gh is not logged in, and retries", async () => {
|
||||
ghAccountRepos.mockResolvedValueOnce({ status: "ok", data: { kind: "notLoggedIn", message: "x" } });
|
||||
render(<GitHubCloneSource url="" onUrl={() => {}} onClone={() => {}} disabled={false} />);
|
||||
expect(await screen.findByText("Not logged in to GitHub")).toBeInTheDocument();
|
||||
ghAccountRepos.mockResolvedValueOnce({ status: "ok", data: { kind: "ready", login: "me", repos } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
await waitFor(() => expect(screen.getByRole("option", { name: /me\/app/ })).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/** Clone Repository › GitHub: the repositories of the account gh is signed in to. */
|
||||
import { useEffect, useMemo, useState, type KeyboardEvent } from "react";
|
||||
import type { GhAccountRepo, GhAccountRepos } from "@/bindings";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
HighlightedText,
|
||||
Icon,
|
||||
List,
|
||||
SearchField,
|
||||
Spinner,
|
||||
T,
|
||||
fuzzyMatch,
|
||||
listNavigate,
|
||||
} from "@/ui";
|
||||
import type { CloneSourceProps } from "@/workbench/contributions";
|
||||
import { openInBrowser } from "../data/local";
|
||||
import styles from "./GitHubCloneSource.module.css";
|
||||
|
||||
type Row = { repo: GhAccountRepo; indices: number[] | null };
|
||||
|
||||
/** Repositories matching `query` (fuzzy, on `owner/name`), in the account's order. */
|
||||
export function matchRepos(repos: readonly GhAccountRepo[], query: string): Row[] {
|
||||
const q = query.trim();
|
||||
if (!q) return repos.map((repo) => ({ repo, indices: null }));
|
||||
return repos.flatMap((repo) => {
|
||||
const indices = fuzzyMatch(repo.nameWithOwner, q);
|
||||
return indices ? [{ repo, indices }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function GitHubCloneSource({ url, onUrl, onClone, disabled }: CloneSourceProps) {
|
||||
const [state, setState] = useState<GhAccountRepos | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
call(commands.ghAccountRepos())
|
||||
.then((s) => alive && setState(s))
|
||||
.catch((e) => alive && setState({ kind: "error", message: e instanceof Error ? e.message : String(e) }));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [attempt]);
|
||||
|
||||
const rows = useMemo(() => (state?.kind === "ready" ? matchRepos(state.repos, query) : []), [state, query]);
|
||||
const active = rows.findIndex((r) => r.repo.cloneUrl === url);
|
||||
const retry = (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setState(null);
|
||||
setAttempt((n) => n + 1);
|
||||
}}
|
||||
>
|
||||
<T>Retry</T>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!state)
|
||||
return (
|
||||
<div className={styles.center}>
|
||||
<Spinner />
|
||||
<span className={styles.muted}>
|
||||
<T>Loading repositories…</T>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
if (state.kind === "notInstalled")
|
||||
return (
|
||||
<EmptyState
|
||||
title="GitHub CLI is not installed"
|
||||
actions={
|
||||
<>
|
||||
<Button size="small" onClick={() => void openInBrowser("https://cli.github.com")}>
|
||||
<T>Install gh…</T>
|
||||
</Button>
|
||||
{retry}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<T>GitHub repositories are listed with the</T> <code>gh</code> <T>command line tool. Install it, then run</T>{" "}
|
||||
<code>gh auth login</code>.
|
||||
</EmptyState>
|
||||
);
|
||||
if (state.kind === "notLoggedIn")
|
||||
return (
|
||||
<EmptyState title="Not logged in to GitHub" actions={retry}>
|
||||
<T>Run</T> <code>gh auth login</code> <T>in a terminal, then retry.</T>
|
||||
</EmptyState>
|
||||
);
|
||||
if (state.kind === "error")
|
||||
return (
|
||||
<EmptyState title="Could not reach GitHub" actions={retry}>
|
||||
{state.message}
|
||||
</EmptyState>
|
||||
);
|
||||
|
||||
const pick = (i: number) => {
|
||||
const row = rows[i];
|
||||
if (row) onUrl(row.repo.cloneUrl);
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && rows[active]) {
|
||||
e.preventDefault();
|
||||
onClone(rows[active].repo.cloneUrl);
|
||||
return;
|
||||
}
|
||||
const next = listNavigate(e.key, { items: rows, active: Math.max(active, 0) });
|
||||
if (next !== null) {
|
||||
e.preventDefault();
|
||||
pick(next);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.pane}>
|
||||
<div className={styles.header}>
|
||||
<Icon name="github" size={16} />
|
||||
<span className={styles.login}>{state.login}</span>
|
||||
<SearchField
|
||||
className={styles.search}
|
||||
aria-label="Search repositories"
|
||||
placeholder={tx("Search repositories")}
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={disabled}
|
||||
data-autofocus
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.list}>
|
||||
<List<Row>
|
||||
aria-label="Repositories"
|
||||
items={rows}
|
||||
getKey={(r) => r.repo.nameWithOwner}
|
||||
activeIndex={active}
|
||||
onActiveChange={pick}
|
||||
focusable={false}
|
||||
rowHeight={26}
|
||||
empty={
|
||||
<span className={styles.muted}>
|
||||
<T>{state.repos.length ? "No matching repositories" : "No repositories"}</T>
|
||||
</span>
|
||||
}
|
||||
renderItem={(r) => (
|
||||
<span
|
||||
className={styles.row}
|
||||
title={r.repo.description ?? undefined}
|
||||
onDoubleClick={() => onClone(r.repo.cloneUrl)}
|
||||
>
|
||||
<Icon name={r.repo.private ? "lock" : "github"} size={14} className={styles.rowIcon} />
|
||||
<span className={styles.name}>
|
||||
{r.indices ? <HighlightedText text={r.repo.nameWithOwner} indices={r.indices} /> : r.repo.nameWithOwner}
|
||||
</span>
|
||||
{r.repo.fork && (
|
||||
<span className={styles.tag}>
|
||||
<T>fork</T>
|
||||
</span>
|
||||
)}
|
||||
{r.repo.archived && (
|
||||
<span className={styles.tag}>
|
||||
<T>archived</T>
|
||||
</span>
|
||||
)}
|
||||
{r.repo.description && <span className={styles.description}>{r.repo.description}</span>}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,13 @@ import { registerActions, type Action } from "@/platform/actions";
|
||||
import { getContext, setContext } from "@/platform/context";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { maybeEditorService, registerEditorKind, registerOverlay, registerToolWindow } from "@/workbench/contributions";
|
||||
import {
|
||||
maybeEditorService,
|
||||
registerCloneSource,
|
||||
registerEditorKind,
|
||||
registerOverlay,
|
||||
registerToolWindow,
|
||||
} from "@/workbench/contributions";
|
||||
import { activateReviewDiff, toggleReviewDiffViewed } from "./diff";
|
||||
import { reloadList } from "./data/list";
|
||||
import { checkoutPullRequest, clearDiffCache, copyUrl, openInBrowser, openInWorktree } from "./data/local";
|
||||
@@ -304,6 +310,13 @@ function actions(): Action[] {
|
||||
export function activate(): () => void {
|
||||
setContext("githubPrTab", "list");
|
||||
const disposers = [
|
||||
registerCloneSource({
|
||||
id: "github",
|
||||
label: "GitHub",
|
||||
icon: "github",
|
||||
order: 10,
|
||||
component: lazyComponent(() => import("./clone/GitHubCloneSource").then((m) => m.GitHubCloneSource)),
|
||||
}),
|
||||
registerToolWindow({
|
||||
id: WINDOW,
|
||||
title: "Pull Requests",
|
||||
|
||||
@@ -155,3 +155,24 @@
|
||||
padding-left: 98px;
|
||||
}
|
||||
}
|
||||
.sidebar .sourceItem {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: default;
|
||||
}
|
||||
.sidebar .sourceItem[data-selected] {
|
||||
background: var(--bg-tree-selected);
|
||||
}
|
||||
.sidebar .sourceItem:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.sourcePane {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { tx } from "@/platform/i18n";
|
||||
import { call, commands, isCancelled, isTauri, nextRequestId } from "@/platform/ipc";
|
||||
import { openProject } from "@/platform/project";
|
||||
import { Checkbox, Dialog, Icon, IconButton, Input, ProgressBar, Select, T, showContextMenu } from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { cloneSourceList, notifications, useCloneSources } from "@/workbench/contributions";
|
||||
import { openDialog } from "./dialogs";
|
||||
import { progressFraction } from "./model";
|
||||
import logo from "../../../src-tauri/icons/128x128.png";
|
||||
@@ -115,7 +115,11 @@ export function showCloneDialog(initialUrl = "") {
|
||||
}
|
||||
|
||||
function CloneDialog({ onClose, initialUrl }: { onClose: () => void; initialUrl: string }) {
|
||||
const [url, setUrl] = useState(initialUrl);
|
||||
/** Sidebar selection: "url" (Repository URL) or a contributed source's id; each keeps its own URL. */
|
||||
const [sourceId, setSourceId] = useState("url");
|
||||
const [urls, setUrls] = useState<Record<string, string>>({ url: initialUrl });
|
||||
const url = urls[sourceId] ?? "";
|
||||
const setUrl = (value: string) => setUrls((m) => ({ ...m, [sourceId]: value }));
|
||||
const [parent, setParent] = useState("");
|
||||
/** Directory edited by hand: stop deriving it from the URL. */
|
||||
const [customDir, setCustomDir] = useState<string | null>(null);
|
||||
@@ -127,6 +131,8 @@ function CloneDialog({ onClose, initialUrl }: { onClose: () => void; initialUrl:
|
||||
const request = useRef<number | null>(null);
|
||||
const urlField = useRef<HTMLDivElement>(null);
|
||||
const [recentUrls] = useState(readRecentUrls);
|
||||
const sources = cloneSourceList(useCloneSources((s) => s.sources));
|
||||
const source = sources.find((s) => s.id === sourceId);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -168,9 +174,10 @@ function CloneDialog({ onClose, initialUrl }: { onClose: () => void; initialUrl:
|
||||
else onClose();
|
||||
};
|
||||
|
||||
const clone = async () => {
|
||||
if (invalid || busy) return;
|
||||
const target = dir.trim();
|
||||
const clone = async (picked?: string) => {
|
||||
const cloneUrl = picked ?? url;
|
||||
const target = (customDir ?? joinPath(parent, cloneDirName(cloneUrl))).trim();
|
||||
if (validateClone(cloneUrl, target) || (shallow && depth === null) || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const id = nextRequestId();
|
||||
@@ -181,9 +188,9 @@ function CloneDialog({ onClose, initialUrl }: { onClose: () => void; initialUrl:
|
||||
if (text) setProgress({ line: text, fraction: progressFraction(text) });
|
||||
};
|
||||
try {
|
||||
await call(commands.vcsClone(id, url.trim(), target, depth, channel));
|
||||
await call(commands.vcsClone(id, cloneUrl.trim(), target, depth, channel));
|
||||
writeParent(parentOf(target));
|
||||
rememberUrl(url.trim());
|
||||
rememberUrl(cloneUrl.trim());
|
||||
onClose();
|
||||
try {
|
||||
await openProject(target);
|
||||
@@ -226,12 +233,22 @@ function CloneDialog({ onClose, initialUrl }: { onClose: () => void; initialUrl:
|
||||
>
|
||||
<div className={styles.layout}>
|
||||
<aside className={styles.sidebar} aria-label={tx("Clone source")}>
|
||||
<div className={styles.sourceItem} data-selected="">
|
||||
<Icon name="branch" size={22} />
|
||||
<span className={styles.sourceName}>
|
||||
<T>Repository URL</T>
|
||||
</span>
|
||||
</div>
|
||||
{[{ id: "url", label: "Repository URL", icon: "branch" }, ...sources].map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={styles.sourceItem}
|
||||
data-selected={s.id === sourceId ? "" : undefined}
|
||||
aria-pressed={s.id === sourceId}
|
||||
disabled={busy}
|
||||
onClick={() => setSourceId(s.id)}
|
||||
>
|
||||
<Icon name={s.icon} size={22} />
|
||||
<span className={styles.sourceName}>
|
||||
<T>{s.label}</T>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<form
|
||||
@@ -241,45 +258,53 @@ function CloneDialog({ onClose, initialUrl }: { onClose: () => void; initialUrl:
|
||||
void clone();
|
||||
}}
|
||||
>
|
||||
<div className={styles.versionRow}>
|
||||
<label id="vcs-clone-version-label">
|
||||
<T>Version control:</T>
|
||||
</label>
|
||||
<Select
|
||||
aria-label="Version control"
|
||||
className={styles.versionSelect}
|
||||
value="git"
|
||||
options={[{ value: "git", label: "Git" }]}
|
||||
onChange={() => {}}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldRow}>
|
||||
<label htmlFor="vcs-clone-url">
|
||||
<T>URL:</T>
|
||||
</label>
|
||||
<div ref={urlField} className={styles.comboField}>
|
||||
<Input
|
||||
id="vcs-clone-url"
|
||||
data-autofocus
|
||||
variant="bare"
|
||||
className={styles.fieldInput}
|
||||
value={url}
|
||||
disabled={busy}
|
||||
placeholder="https://github.com/user/repository.git"
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
className={styles.fieldButton}
|
||||
title={recentUrls.length ? "Recent repository URLs" : "No recent repository URLs"}
|
||||
disabled={busy || !recentUrls.length}
|
||||
onClick={showUrlHistory}
|
||||
/>
|
||||
{source ? (
|
||||
<div className={styles.sourcePane}>
|
||||
<source.component url={url} onUrl={setUrl} onClone={(u) => void clone(u)} disabled={busy} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.versionRow}>
|
||||
<label id="vcs-clone-version-label">
|
||||
<T>Version control:</T>
|
||||
</label>
|
||||
<Select
|
||||
aria-label="Version control"
|
||||
className={styles.versionSelect}
|
||||
value="git"
|
||||
options={[{ value: "git", label: "Git" }]}
|
||||
onChange={() => {}}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldRow}>
|
||||
<label htmlFor="vcs-clone-url">
|
||||
<T>URL:</T>
|
||||
</label>
|
||||
<div ref={urlField} className={styles.comboField}>
|
||||
<Input
|
||||
id="vcs-clone-url"
|
||||
data-autofocus
|
||||
variant="bare"
|
||||
className={styles.fieldInput}
|
||||
value={url}
|
||||
disabled={busy}
|
||||
placeholder="https://github.com/user/repository.git"
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
className={styles.fieldButton}
|
||||
title={recentUrls.length ? "Recent repository URLs" : "No recent repository URLs"}
|
||||
disabled={busy || !recentUrls.length}
|
||||
onClick={showUrlHistory}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={styles.fieldRow}>
|
||||
<label htmlFor="vcs-clone-dir">
|
||||
|
||||
Reference in new issue
Заблокувати користувача