дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
fix(editor): Skip cancelled LSP requests and limit generated-code reformat
- Requests cancelled while waiting for pending edits are not sent. - Code generated by an action is reformatted per changed hunk, so an added import doesn't reformat the code between it and the generated lines. - A long busy spell caused by a settle refresh re-runs it only once. - Files dropped on a file row of another changelist move to that changelist. - Python Implement methods: a nested class without bases ends the lookup, class bodies continue through unindented string lines, and `@abstractproperty` stubs become `@property`. - Resend diagnostics goes through the host ops, and custom servers get `max_java`.
Цей коміт міститься в:
1 джерело
a645632b23
коміт
aaf4d12ba6
15 files changed
+204
-69
No files matched your search
@@ -241,6 +241,11 @@ pub fn lsp_servers(host: &Host, window: &str) -> IdeResult<Vec<ServerInfo>> {
|
||||
Ok(host.project(window)?.lsp.servers())
|
||||
}
|
||||
|
||||
pub fn lsp_resend_diagnostics(host: &Host, window: &str) -> IdeResult<()> {
|
||||
host.project(window)?.lsp.resend_diagnostics();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResult<()> {
|
||||
host.project(window)?.lsp.restart(server).await
|
||||
}
|
||||
@@ -349,6 +354,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
lsp_type_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await
|
||||
});
|
||||
d.add("lsp_servers", |c| async move { lsp_servers(&c.host, &c.window) });
|
||||
d.add("lsp_resend_diagnostics", |c| async move { lsp_resend_diagnostics(&c.host, &c.window) });
|
||||
d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await });
|
||||
d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?) });
|
||||
d.add("lsp_execute_command", |c| async move {
|
||||
|
||||
@@ -80,18 +80,8 @@ fn is_name_leaf(kind: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Node kinds whose `name` field is a use, not a declaration.
|
||||
const USE_PARENTS: &[&str] = &[
|
||||
"invocation",
|
||||
"call",
|
||||
"access",
|
||||
"scoped",
|
||||
"reference",
|
||||
"expression",
|
||||
"selector",
|
||||
"qualified",
|
||||
"use_",
|
||||
"import",
|
||||
];
|
||||
const USE_PARENTS: &[&str] =
|
||||
&["invocation", "call", "access", "scoped", "reference", "expression", "selector", "qualified", "use_", "import"];
|
||||
|
||||
/// Whether the identifier leaf under `cursor` is the name a declaration
|
||||
/// introduces (`fn name`, `let pattern`, `int *declarator`, parameters,
|
||||
|
||||
@@ -342,6 +342,7 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi
|
||||
}),
|
||||
connect: None,
|
||||
fallback: false,
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,8 +313,7 @@ pub fn lsp_servers(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn lsp_resend_diagnostics(window: Window, state: State<'_, AppState>) -> IdeResult<()> {
|
||||
project!(window, state).lsp.resend_diagnostics();
|
||||
Ok(())
|
||||
ide_host::ops::lsp::lsp_resend_diagnostics(&state.host, window.label())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { IdeEvent } from "@/bindings";
|
||||
|
||||
const listeners = vi.hoisted(() => new Set<(e: IdeEvent) => void>());
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
isTauri: () => true,
|
||||
onIdeEvent: (_type: string, fn: (e: IdeEvent) => void) => {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { onServerSettled } = await import("./availability");
|
||||
|
||||
const status = (s: string) => {
|
||||
for (const fn of listeners) fn({ type: "languageServer", server: 1, status: s } as unknown as IdeEvent);
|
||||
};
|
||||
|
||||
describe("onServerSettled", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
listeners.clear();
|
||||
});
|
||||
|
||||
it("re-runs a callback that keeps the server busy only once", () => {
|
||||
vi.useFakeTimers();
|
||||
const fn = vi.fn();
|
||||
onServerSettled(fn);
|
||||
status("starting");
|
||||
status("ready");
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
status("busy");
|
||||
vi.advanceTimersByTime(2000);
|
||||
status("ready");
|
||||
}
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
// Indexing that starts later is a new settle.
|
||||
vi.advanceTimersByTime(10_000);
|
||||
status("busy");
|
||||
vi.advanceTimersByTime(5000);
|
||||
status("ready");
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("ignores short busy spells", () => {
|
||||
vi.useFakeTimers();
|
||||
const fn = vi.fn();
|
||||
onServerSettled(fn);
|
||||
status("busy");
|
||||
vi.advanceTimersByTime(100);
|
||||
status("ready");
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -49,23 +49,36 @@ export function onLspRefresh(kind: LspRefreshKind, fn: () => void): () => void {
|
||||
/** A busy spell at least this long is indexing / a build, not the server answering a request. */
|
||||
const LONG_BUSY_MS = 1500;
|
||||
|
||||
/** A busy spell starting this soon after `fn` ran may be `fn`'s own requests. */
|
||||
const ECHO_MS = 2000;
|
||||
|
||||
/**
|
||||
* Calls `fn` when a server becomes ready after starting or after a long busy
|
||||
* spell (indexing), when earlier answers may have been incomplete. Short busy
|
||||
* spells are ignored: jdtls reports progress for the very requests a caller
|
||||
* sends, and re-asking on each of them would never stop.
|
||||
* sends, and re-asking on each of them would never stop. For the same reason
|
||||
* a long spell that began right after `fn` ran (slow requests `fn` sent, or
|
||||
* indexing right after start) re-runs it only once in a row.
|
||||
*/
|
||||
export function onServerSettled(fn: () => void): () => void {
|
||||
if (!isTauri()) return () => {};
|
||||
const since = new Map<number, number>();
|
||||
const fired = new Map<number, { at: number; echoed: boolean }>();
|
||||
return onIdeEvent("languageServer", (e) => {
|
||||
if (e.status === "starting") since.set(e.server, -Infinity);
|
||||
else if (e.status === "busy") {
|
||||
if (e.status === "starting") {
|
||||
since.set(e.server, -Infinity);
|
||||
fired.delete(e.server);
|
||||
} else if (e.status === "busy") {
|
||||
if (!since.has(e.server)) since.set(e.server, Date.now());
|
||||
} else if (e.status === "ready") {
|
||||
const t = since.get(e.server);
|
||||
since.delete(e.server);
|
||||
if (t !== undefined && Date.now() - t >= LONG_BUSY_MS) fn();
|
||||
if (t === undefined || Date.now() - t < LONG_BUSY_MS) return;
|
||||
const last = fired.get(e.server);
|
||||
const echo = !!last && t - last.at < ECHO_MS;
|
||||
if (echo && last.echoed) return;
|
||||
fired.set(e.server, { at: Date.now(), echoed: echo });
|
||||
fn();
|
||||
} else since.delete(e.server);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -418,7 +418,10 @@ async function lspCompletionSource(ctx: CompletionContext): Promise<CompletionRe
|
||||
});
|
||||
let list;
|
||||
try {
|
||||
list = await lspRequest(() => commands.lspCompletion(id, doc.doc, offsetToPos(ctx.state.doc, ctx.pos), trigger));
|
||||
list = await lspRequest(
|
||||
() => commands.lspCompletion(id, doc.doc, offsetToPos(ctx.state.doc, ctx.pos), trigger),
|
||||
() => inflight === id,
|
||||
);
|
||||
} catch (e) {
|
||||
if (isUnavailable(e)) {
|
||||
serverBackoff.mark(doc.doc);
|
||||
|
||||
@@ -51,7 +51,10 @@ export async function requestHover(view: EditorView, pos: number): Promise<Hover
|
||||
if (hoverRequest !== null && isTauri()) void commands.cancelRequest(hoverRequest);
|
||||
const id = (hoverRequest = nextRequestId());
|
||||
try {
|
||||
const h = await lspRequest(() => commands.lspHover(id, doc.doc, offsetToPos(view.state.doc, pos)));
|
||||
const h = await lspRequest(
|
||||
() => commands.lspHover(id, doc.doc, offsetToPos(view.state.doc, pos)),
|
||||
() => hoverRequest === id,
|
||||
);
|
||||
return h && !isEmptyMarkup(h.contents) ? h : null;
|
||||
} finally {
|
||||
if (hoverRequest === id) hoverRequest = null;
|
||||
|
||||
@@ -163,8 +163,9 @@ export async function requestInline(view: EditorView, automatic: boolean): Promi
|
||||
const id = (inflight = nextRequestId());
|
||||
const caret = sel.main.head;
|
||||
try {
|
||||
const items = await lspRequest(() =>
|
||||
commands.lspInlineCompletion(id, doc.doc, offsetToPos(state.doc, caret), automatic),
|
||||
const items = await lspRequest(
|
||||
() => commands.lspInlineCompletion(id, doc.doc, offsetToPos(state.doc, caret), automatic),
|
||||
() => inflight === id,
|
||||
);
|
||||
if (view.state.doc !== state.doc || !view.state.selection.eq(sel)) return false;
|
||||
const suggestions = toSuggestions(view.state, caret, items);
|
||||
|
||||
@@ -129,10 +129,18 @@ describe("listedCodeActions", () => {
|
||||
describe("changedLines", () => {
|
||||
const text = (s: string) => EditorState.create({ doc: s }).doc;
|
||||
it("spans the lines an edit inserted", () => {
|
||||
expect(changedLines(text("class A {\n}\n"), text("class A {\n f() {\n }\n}\n"))).toEqual({ from: 2, to: 3 });
|
||||
expect(changedLines(text("class A {\n}\n"), text("class A {\n f() {\n }\n}\n"))).toEqual([{ from: 2, to: 3 }]);
|
||||
});
|
||||
it("is null for equal texts", () => {
|
||||
expect(changedLines(text("a\nb"), text("a\nb"))).toBeNull();
|
||||
it("keeps separate edits apart", () => {
|
||||
const before = text("package p;\n\nclass A {\n int a;\n int b;\n void f() {\n }\n}\n");
|
||||
const after = text("package p;\n\nimport java.util.List;\n\nclass A {\n int a;\n int b;\n void f() {\n List<String> x = null;\n x.size();\n }\n}\n");
|
||||
expect(changedLines(before, after)).toEqual([
|
||||
{ from: 3, to: 4 },
|
||||
{ from: 9, to: 10 },
|
||||
]);
|
||||
});
|
||||
it("is empty for equal texts", () => {
|
||||
expect(changedLines(text("a\nb"), text("a\nb"))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { StateEffect, StateField, type Extension, type Text } from "@codemirror/
|
||||
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
|
||||
import { forEachDiagnostic } from "@codemirror/lint";
|
||||
import { indentUnit } from "@codemirror/language";
|
||||
import { diff } from "@codemirror/merge";
|
||||
import { commands, isCancelled, type CodeAction } from "@/platform/ipc";
|
||||
import { contributedIntentions, intentionProviders, whenSynced } from "@/editor/api";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
@@ -153,14 +154,16 @@ export async function applyCodeAction(view: EditorView, action: CodeAction) {
|
||||
*/
|
||||
async function reformatGenerated(view: EditorView, before: Text) {
|
||||
for (let i = 0; i < 100 && view.state.doc === before; i++) await new Promise((r) => setTimeout(r, 20));
|
||||
const after = view.state.doc;
|
||||
const lines = changedLines(before, after);
|
||||
if (!lines || lines.to - lines.from < 1) return;
|
||||
const shift = indentUnderBrace(after, lines.from, lines.to, view.state.facet(indentUnit));
|
||||
// A server that generated code without its context formats ranges the same
|
||||
// way (back to column 0): its range formatting is skipped then.
|
||||
if (shift.length) view.dispatch({ changes: shift, userEvent: "format", scrollIntoView: false });
|
||||
else await formatLines(view, after.line(lines.from).from, after.line(lines.to).to);
|
||||
// Bottom up, so the line numbers of the hunks above stay valid.
|
||||
for (const lines of changedLines(before, view.state.doc).reverse()) {
|
||||
if (lines.to - lines.from < 1) continue;
|
||||
const doc = view.state.doc;
|
||||
const shift = indentUnderBrace(doc, lines.from, lines.to, view.state.facet(indentUnit));
|
||||
// A server that generated code without its context formats ranges the same
|
||||
// way (back to column 0): its range formatting is skipped then.
|
||||
if (shift.length) view.dispatch({ changes: shift, userEvent: "format", scrollIntoView: false });
|
||||
else if (!(await formatLines(view, doc.line(lines.from).from, doc.line(lines.to).to)) && view.state.doc !== doc) return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,20 +190,28 @@ export function indentUnderBrace(doc: Text, fromLine: number, toLine: number, un
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 1-based lines of `after` that differ from `before` (common head and tail lines excluded); null if equal. */
|
||||
export function changedLines(before: Text, after: Text): { from: number; to: number } | null {
|
||||
if (before.eq(after)) return null;
|
||||
let head = 0;
|
||||
while (head < before.lines && head < after.lines && before.line(head + 1).text === after.line(head + 1).text) head++;
|
||||
let tail = 0;
|
||||
while (
|
||||
tail < before.lines - head &&
|
||||
tail < after.lines - head &&
|
||||
before.line(before.lines - tail).text === after.line(after.lines - tail).text
|
||||
)
|
||||
tail++;
|
||||
const from = Math.min(head + 1, after.lines);
|
||||
return { from, to: Math.max(from, after.lines - tail) };
|
||||
/**
|
||||
* 1-based line spans of `after` that an edit changed, top to bottom: an added
|
||||
* import and the code using it are separate spans, so the user's code between
|
||||
* them is left alone.
|
||||
*/
|
||||
export function changedLines(before: Text, after: Text): Array<{ from: number; to: number }> {
|
||||
if (before.eq(after)) return [];
|
||||
const spans: Array<{ from: number; to: number }> = [];
|
||||
const a = before.toString();
|
||||
const b = after.toString();
|
||||
for (const c of diff(a, b)) {
|
||||
// Text inserted at a line's end or start (`{⏎ added`, `added⏎`) leaves that line as it was.
|
||||
const lead = b[c.fromB] === "\n" && (c.toA === c.fromA || a[c.fromA] === "\n");
|
||||
const trail = c.toB > c.fromB && b[c.toB - 1] === "\n" && (c.toA === c.fromA || a[c.toA - 1] === "\n");
|
||||
const from = after.lineAt(lead ? c.fromB + 1 : c.fromB).number;
|
||||
const end = trail ? c.toB - 1 : c.toB;
|
||||
const to = Math.max(from, after.lineAt(end).number);
|
||||
const last = spans[spans.length - 1];
|
||||
if (last && from <= last.to + 1) last.to = Math.max(last.to, to);
|
||||
else spans.push({ from, to });
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,12 +20,15 @@ type Result<T> = { status: "ok"; data: T } | { status: "error"; error: IdeError
|
||||
/**
|
||||
* Runs an LSP command. Synchronous throws (no Tauri runtime in a plain browser)
|
||||
* become `unavailable` rejections so callers have a single error path.
|
||||
* `wanted` is checked once pending edits are synced: a request cancelled
|
||||
* meanwhile is not sent (the backend ignores cancelling an id it hasn't seen).
|
||||
*/
|
||||
export async function lspRequest<T>(run: () => Promise<Result<T>>): Promise<T> {
|
||||
export async function lspRequest<T>(run: () => Promise<Result<T>>, wanted?: () => boolean): Promise<T> {
|
||||
if (!isTauri()) throw new IpcError({ kind: "unavailable", message: "No language server available" });
|
||||
try {
|
||||
// Edits typed just before (⌘B right after typing) must reach the server first.
|
||||
await whenAllSynced();
|
||||
if (wanted && !wanted()) throw new IpcError({ kind: "cancelled", message: "Cancelled" });
|
||||
return await call(run());
|
||||
} catch (e) {
|
||||
if (e instanceof IpcError) throw e;
|
||||
|
||||
@@ -43,6 +43,28 @@ describe("python members", () => {
|
||||
expect(members[1]!.lines).toEqual(["def area(", " self, scale: float = 1.0", ") -> float:"]);
|
||||
});
|
||||
|
||||
it("stops at a nested class without bases", () => {
|
||||
const nested = ["class Outer(Base):", " class Inner:", " x = 1", ""];
|
||||
expect(enclosingClass(nested, 3)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a body going through an unindented string", () => {
|
||||
const lines = [
|
||||
"class A(ABC):",
|
||||
" @abc.abstractproperty",
|
||||
" def doc(self):",
|
||||
' return """',
|
||||
"text",
|
||||
'"""',
|
||||
" @abstractmethod",
|
||||
" def run(self): ...",
|
||||
"x = 1",
|
||||
];
|
||||
const members = classMembers(lines, 1);
|
||||
expect(members.map((m) => m.lines)).toEqual([["@property", "def doc(self):"], ["def run(self):"]]);
|
||||
expect(members.every((m) => m.abstract)).toBe(true);
|
||||
});
|
||||
|
||||
it("writes stubs at the body's indentation", () => {
|
||||
const [name] = classMembers(src, 4);
|
||||
expect(memberStubs([name!], " ", " ")).toBe(" @property\n def name(self) -> str:\n pass");
|
||||
|
||||
@@ -14,12 +14,13 @@ import { readFileLines } from "./files";
|
||||
import { showHint } from "./hint";
|
||||
|
||||
const CLASS_HEADER = /^(\s*)class\s+\w+\s*\((.*)\)\s*:/;
|
||||
const CLASS = /^\s*class\s/;
|
||||
const DEF = /^\s*(?:async\s+)?def\s+(\w+)/;
|
||||
|
||||
const indentOf = (line: string) => line.length - line.trimStart().length;
|
||||
const isPython = (view: EditorView) => docOf(view)?.uiMode === "Python";
|
||||
|
||||
/** 1-based number of the class header line enclosing `line` (or on it), with bases. */
|
||||
/** 1-based number of the class header line enclosing `line` (or on it); null unless it has bases. */
|
||||
export function enclosingClass(lines: readonly string[], line: number): number | null {
|
||||
let limit = Infinity;
|
||||
for (let n = line; n >= 1; n--) {
|
||||
@@ -27,7 +28,7 @@ export function enclosingClass(lines: readonly string[], line: number): number |
|
||||
if (!text.trim()) continue;
|
||||
const indent = indentOf(text);
|
||||
if (n !== line && indent >= limit) continue;
|
||||
if (CLASS_HEADER.test(text)) return n;
|
||||
if (CLASS.test(text)) return CLASS_HEADER.test(text) ? n : null;
|
||||
limit = Math.min(limit, indent);
|
||||
if (indent === 0 && n !== line) return null;
|
||||
}
|
||||
@@ -38,8 +39,19 @@ export function enclosingClass(lines: readonly string[], line: number): number |
|
||||
function bodyEnd(lines: readonly string[], header: number): number {
|
||||
const indent = indentOf(lines[header - 1]!);
|
||||
let last = header;
|
||||
let quote: string | null = null;
|
||||
for (let n = header + 1; n <= lines.length; n++) {
|
||||
const text = lines[n - 1]!;
|
||||
// Lines inside a triple-quoted string belong to it, however indented.
|
||||
const inString = quote !== null;
|
||||
for (const q of text.match(/"""|'''/g) ?? []) {
|
||||
if (quote === null) quote = q;
|
||||
else if (q === quote) quote = null;
|
||||
}
|
||||
if (inString) {
|
||||
last = n;
|
||||
continue;
|
||||
}
|
||||
if (!text.trim()) continue;
|
||||
if (indentOf(text) <= indent) break;
|
||||
last = n;
|
||||
@@ -88,7 +100,12 @@ export function classMembers(lines: readonly string[], header: number): PythonMe
|
||||
out.push({
|
||||
name: def[1]!,
|
||||
abstract,
|
||||
lines: [...decorators.filter((d) => !/^@(abc\.)?abstractmethod\b/.test(d)), ...sig],
|
||||
lines: [
|
||||
...decorators
|
||||
.filter((d) => !/^@(abc\.)?abstractmethod\b/.test(d))
|
||||
.map((d) => d.replace(/^@(abc\.)?abstractproperty\b/, "@property")),
|
||||
...sig,
|
||||
],
|
||||
});
|
||||
}
|
||||
decorators = [];
|
||||
|
||||
@@ -427,27 +427,25 @@ function ChangesPanel() {
|
||||
</span>
|
||||
);
|
||||
// Pointer-driven (HTML5 drag and drop never reaches the Tauri webview):
|
||||
// files and folders drag, changelist rows take them.
|
||||
// files and folders drag; any row takes them into its changelist.
|
||||
const list = n.changelist;
|
||||
const dnd =
|
||||
list === undefined
|
||||
? {}
|
||||
: n.type === "group"
|
||||
? {
|
||||
ref: (el: HTMLSpanElement | null) =>
|
||||
el
|
||||
? registerDropTarget(el, {
|
||||
accepts: (type) => type === CHANGES_MIME,
|
||||
over: () => setDropTarget(list),
|
||||
leave: () => setDropTarget(null),
|
||||
drop: (data) => {
|
||||
setDropTarget(null);
|
||||
moveToChangelist(data.payload.split("\n"), list);
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
}
|
||||
: {
|
||||
: {
|
||||
ref: (el: HTMLSpanElement | null) =>
|
||||
el
|
||||
? registerDropTarget(el, {
|
||||
accepts: (type) => type === CHANGES_MIME,
|
||||
over: () => setDropTarget(list),
|
||||
leave: () => setDropTarget(null),
|
||||
drop: (data) => {
|
||||
setDropTarget(null);
|
||||
moveToChangelist(data.payload.split("\n"), list);
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
...(n.type !== "group" && {
|
||||
onPointerDown: (e: PointerEvent<HTMLSpanElement>) => {
|
||||
const paths =
|
||||
selected.has(n.key) && selected.size > 1
|
||||
@@ -458,7 +456,8 @@ function ChangesPanel() {
|
||||
payload: paths.map(stripSlash).join("\n"),
|
||||
});
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
|
||||
Reference in new issue
Заблокувати користувача