feat(editor): select all and deselect all in completion column picker
This commit is contained in:
14 files changed
+230
-22
No files matched your search
@@ -4253,12 +4253,14 @@ interface BatchColumnSelectionActionItem {
|
||||
detail: string;
|
||||
boost: number;
|
||||
batchColumnSelectionAction: true;
|
||||
/** Toggles every candidate checkbox instead of inserting the selection. */
|
||||
batchColumnSelectionToggleAll?: true;
|
||||
sessionKey: string;
|
||||
}
|
||||
|
||||
type QueryCompletionOption = Completion & {
|
||||
dbxBatchColumnSelection?: { sessionKey: string; candidateKey: string };
|
||||
dbxBatchColumnSelectionAction?: { sessionKey: string };
|
||||
dbxBatchColumnSelectionAction?: { sessionKey: string; toggleAll?: true };
|
||||
};
|
||||
|
||||
let batchColumnSelectionSession: BatchColumnSelectionSession | null = null;
|
||||
@@ -4279,7 +4281,7 @@ interface BatchColumnSelectionDragState {
|
||||
}
|
||||
|
||||
const batchColumnSelectionCheckboxMarkers = new WeakMap<HTMLElement, BatchColumnSelectionCheckboxMarker>();
|
||||
const batchColumnSelectionActionMarkers = new WeakMap<HTMLElement, string>();
|
||||
const batchColumnSelectionActionMarkers = new WeakMap<HTMLElement, { sessionKey: string; toggleAll: boolean }>();
|
||||
const batchColumnSelectionTooltipParents = new WeakMap<EditorViewType, HTMLElement>();
|
||||
let batchColumnSelectionDragState: BatchColumnSelectionDragState | null = null;
|
||||
let batchColumnSelectionRefreshCleanup: (() => void) | null = null;
|
||||
@@ -4314,19 +4316,37 @@ function setBatchColumnSelectionValue(sessionKey: string, candidateKey: string,
|
||||
if (checkbox) checkbox.checked = selected;
|
||||
}
|
||||
|
||||
function batchColumnSelectionAllSelected(session: BatchColumnSelectionSession): boolean {
|
||||
return session.candidates.length > 0 && session.selectedKeys.size === session.candidates.length;
|
||||
}
|
||||
|
||||
function updateBatchColumnSelectionActionLabel(view: EditorViewType, sessionKey: string) {
|
||||
const session = batchColumnSelectionSession;
|
||||
if (!session || session.key !== sessionKey) return;
|
||||
const label = t("editor.completion.insertSelectedColumns", { count: session.selectedKeys.size });
|
||||
const insertLabel = t("editor.completion.insertSelectedColumns", { count: session.selectedKeys.size });
|
||||
const toggleAllLabel = batchColumnSelectionAllSelected(session) ? t("editor.completion.deselectAllColumns") : t("editor.completion.selectAllColumns");
|
||||
const tooltipParent = batchColumnSelectionTooltipParents.get(view) ?? view.dom;
|
||||
tooltipParent.querySelectorAll<HTMLElement>(".cm-batch-column-selection-action-marker").forEach((marker) => {
|
||||
if (batchColumnSelectionActionMarkers.get(marker) !== sessionKey) return;
|
||||
const markerState = batchColumnSelectionActionMarkers.get(marker);
|
||||
if (!markerState || markerState.sessionKey !== sessionKey) return;
|
||||
const element = marker.closest("li")?.querySelector<HTMLElement>(".cm-completionLabel");
|
||||
if (!element) return;
|
||||
const label = markerState.toggleAll ? toggleAllLabel : insertLabel;
|
||||
if (element.textContent !== label) element.textContent = label;
|
||||
});
|
||||
}
|
||||
|
||||
/** Check or clear every field checkbox of the active batch selection. */
|
||||
function toggleAllBatchColumnSelection(view: EditorViewType, sessionKey: string) {
|
||||
const session = batchColumnSelectionSession;
|
||||
if (!session || session.key !== sessionKey) return;
|
||||
const selectAll = !batchColumnSelectionAllSelected(session);
|
||||
session.selectedKeys = selectAll ? new Set(session.candidates.map((candidate) => candidate.key)) : new Set();
|
||||
updateBatchColumnSelectionActionLabel(view, sessionKey);
|
||||
// Reopen the list so every rendered checkbox and both action rows reflect the new state.
|
||||
scheduleBatchColumnSelectionRefresh(view, sessionKey, session.candidates[0]?.key ?? "");
|
||||
}
|
||||
|
||||
function scheduleBatchColumnSelectionRefresh(view: EditorViewType, sessionKey: string, focusCandidateKey: string, scrollState?: { element: HTMLElement | null; top: number; left: number }) {
|
||||
cancelBatchColumnSelectionRefresh();
|
||||
const preservedScroll =
|
||||
@@ -4684,7 +4704,7 @@ function renderBatchColumnSelectionActionMarker(completion: Completion): Node |
|
||||
const marker = document.createElement("span");
|
||||
marker.className = "cm-batch-column-selection-action-marker";
|
||||
marker.hidden = true;
|
||||
batchColumnSelectionActionMarkers.set(marker, action.sessionKey);
|
||||
batchColumnSelectionActionMarkers.set(marker, { sessionKey: action.sessionKey, toggleAll: action.toggleAll === true });
|
||||
return marker;
|
||||
}
|
||||
|
||||
@@ -4806,19 +4826,32 @@ function buildCompletionResult(items: Array<QueryCompletionItem | BatchColumnSel
|
||||
function buildSqlCompletionResult(items: SqlCompletionItem[], completionContext: SqlCompletionContext, fullDoc: string, position: number) {
|
||||
const replacement = prepareSqlCompletionReplacement(fullDoc, position, completionContext, items);
|
||||
const session = prepareBatchColumnSelectionSession(replacement.items, fullDoc, replacement.from, position);
|
||||
const action: BatchColumnSelectionActionItem | undefined = session
|
||||
? {
|
||||
label: t("editor.completion.insertSelectedColumns", { count: session.selectedKeys.size }),
|
||||
filterText: completionContext.prefix,
|
||||
type: "text",
|
||||
detail: t("editor.completion.insertSelectedColumnsDetail"),
|
||||
// Keep ordinary single-field completion as the default Enter target.
|
||||
boost: -1000,
|
||||
batchColumnSelectionAction: true,
|
||||
sessionKey: session.key,
|
||||
}
|
||||
: undefined;
|
||||
return buildCompletionResult(action ? [...replacement.items, action] : replacement.items, replacement.from, getSqlCompletionResultValidFor(fullDoc, position), completionContext.prefix);
|
||||
const actions: BatchColumnSelectionActionItem[] = session
|
||||
? [
|
||||
{
|
||||
label: batchColumnSelectionAllSelected(session) ? t("editor.completion.deselectAllColumns") : t("editor.completion.selectAllColumns"),
|
||||
filterText: completionContext.prefix,
|
||||
type: "text",
|
||||
detail: t("editor.completion.selectAllColumnsDetail"),
|
||||
// Keep ordinary single-field completion as the default Enter target.
|
||||
boost: -1000,
|
||||
batchColumnSelectionAction: true,
|
||||
batchColumnSelectionToggleAll: true,
|
||||
sessionKey: session.key,
|
||||
},
|
||||
{
|
||||
label: t("editor.completion.insertSelectedColumns", { count: session.selectedKeys.size }),
|
||||
filterText: completionContext.prefix,
|
||||
type: "text",
|
||||
detail: t("editor.completion.insertSelectedColumnsDetail"),
|
||||
// Keep ordinary single-field completion as the default Enter target.
|
||||
boost: -1000,
|
||||
batchColumnSelectionAction: true,
|
||||
sessionKey: session.key,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return buildCompletionResult(actions.length > 0 ? [...replacement.items, ...actions] : replacement.items, replacement.from, getSqlCompletionResultValidFor(fullDoc, position), completionContext.prefix);
|
||||
}
|
||||
|
||||
// CodeMirror's built-in matcher only matches single-character queries against
|
||||
@@ -4885,11 +4918,17 @@ function completionOptionForItem(item: QueryCompletionItem | BatchColumnSelectio
|
||||
return {
|
||||
...labelPresentation,
|
||||
...(sortText ? { sortText } : {}),
|
||||
dbxBatchColumnSelectionAction: { sessionKey: item.sessionKey },
|
||||
dbxBatchColumnSelectionAction: { sessionKey: item.sessionKey, ...(item.batchColumnSelectionToggleAll ? { toggleAll: true as const } : {}) },
|
||||
type: item.type,
|
||||
detail: item.detail,
|
||||
boost: item.boost,
|
||||
apply(view: EditorViewType, _completionItem: unknown, from: number, to: number) {
|
||||
if (item.batchColumnSelectionToggleAll) {
|
||||
// Toggling the selection never edits the document; the checkout rows
|
||||
// (and the insert action) are refreshed in place instead.
|
||||
toggleAllBatchColumnSelection(view, item.sessionKey);
|
||||
return;
|
||||
}
|
||||
applyBatchColumnSelection(view, item, from, to);
|
||||
},
|
||||
};
|
||||
|
||||
+3
-3
@@ -163,7 +163,7 @@ describe("QueryEditor batch column selection", () => {
|
||||
});
|
||||
|
||||
it("marks the insertion action so the completion menu can keep it sticky", () => {
|
||||
expect(source).toContain("dbxBatchColumnSelectionAction: { sessionKey: item.sessionKey }");
|
||||
expect(source).toContain("dbxBatchColumnSelectionAction: { sessionKey: item.sessionKey, ...(item.batchColumnSelectionToggleAll ? { toggleAll: true as const } : {}) }");
|
||||
expect(source).toContain('optionClass: (completion) => ((completion as QueryCompletionOption).dbxBatchColumnSelectionAction ? "cm-batch-column-selection-action" : "")');
|
||||
});
|
||||
|
||||
@@ -175,8 +175,8 @@ describe("QueryEditor batch column selection", () => {
|
||||
expect(source.slice(dragUpdateStart, dragUpdateEnd)).toContain("updateBatchColumnSelectionActionLabel(state.view, state.sessionKey);");
|
||||
expect(source).toContain("const batchColumnSelectionTooltipParents = new WeakMap<EditorViewType, HTMLElement>();");
|
||||
expect(source).toContain("batchColumnSelectionTooltipParents.set(view.value, tooltipParent);");
|
||||
expect(source).toContain("const batchColumnSelectionActionMarkers = new WeakMap<HTMLElement, string>();");
|
||||
expect(source).toContain("batchColumnSelectionActionMarkers.get(marker) !== sessionKey");
|
||||
expect(source).toContain("const batchColumnSelectionActionMarkers = new WeakMap<HTMLElement, { sessionKey: string; toggleAll: boolean }>();");
|
||||
expect(source).toContain("!markerState || markerState.sessionKey !== sessionKey");
|
||||
expect(source).toContain("renderBatchColumnSelectionActionMarker");
|
||||
});
|
||||
|
||||
|
||||
@@ -1378,6 +1378,9 @@ export default withEnglishFallback({
|
||||
starExpansionColumns: "{count} sütun",
|
||||
insertSelectedColumns: "Seçilmiş sütunları daxil et ({count})",
|
||||
insertSelectedColumnsDetail: "Seçim xanaları ilə sütunları seçin, sonra onları birlikdə daxil edin",
|
||||
selectAllColumns: "Bütün sütunları seç",
|
||||
selectAllColumnsDetail: "Bütün sahələri bir dəfə seç",
|
||||
deselectAllColumns: "Seçimi təmizlə",
|
||||
selectColumnsBeforeInsert: "Əvvəlcə ən azı bir sütun seçin",
|
||||
tableAlias: "Cədvəlin ləqəbi",
|
||||
functionDescriptions: {
|
||||
|
||||
@@ -1529,6 +1529,9 @@ export default {
|
||||
starExpansionColumns: "{count} columns",
|
||||
insertSelectedColumns: "Insert selected columns ({count})",
|
||||
insertSelectedColumnsDetail: "Choose columns with the checkboxes, then insert them together",
|
||||
selectAllColumns: "Select all columns",
|
||||
selectAllColumnsDetail: "Check every field at once",
|
||||
deselectAllColumns: "Deselect all",
|
||||
selectColumnsBeforeInsert: "Select at least one column first",
|
||||
tableAlias: "Table alias",
|
||||
functionDescriptions: {
|
||||
|
||||
@@ -1496,6 +1496,9 @@ export default withEnglishFallback({
|
||||
starExpansionColumns: "{count} columnas",
|
||||
insertSelectedColumns: "Insertar columnas seleccionadas ({count})",
|
||||
insertSelectedColumnsDetail: "Selecciona columnas y, después, insértalas juntas",
|
||||
selectAllColumns: "Seleccionar todas las columnas",
|
||||
selectAllColumnsDetail: "Marcar todos los campos a la vez",
|
||||
deselectAllColumns: "Desmarcar todo",
|
||||
selectColumnsBeforeInsert: "Selecciona al menos una columna primero",
|
||||
tableAlias: "Alias de tabla",
|
||||
functionDescriptions: {
|
||||
|
||||
@@ -1494,6 +1494,9 @@ export default withEnglishFallback({
|
||||
starExpansionColumns: "{count} colonne",
|
||||
insertSelectedColumns: "Inserisci colonne selezionate ({count})",
|
||||
insertSelectedColumnsDetail: "Seleziona le colonne e poi inseriscile insieme",
|
||||
selectAllColumns: "Seleziona tutte le colonne",
|
||||
selectAllColumnsDetail: "Seleziona tutti i campi in una volta",
|
||||
deselectAllColumns: "Deseleziona tutto",
|
||||
selectColumnsBeforeInsert: "Seleziona prima almeno una colonna",
|
||||
tableAlias: "Alias della tabella",
|
||||
functionDescriptions: {
|
||||
|
||||
@@ -1514,6 +1514,9 @@ export default withEnglishFallback({
|
||||
starExpansionColumns: "{count}列",
|
||||
insertSelectedColumns: "選択した列を挿入({count})",
|
||||
insertSelectedColumnsDetail: "チェックした列をまとめて挿入します",
|
||||
selectAllColumns: "すべての列を選択",
|
||||
selectAllColumnsDetail: "すべてのフィールドをまとめてチェック",
|
||||
deselectAllColumns: "選択をすべて解除",
|
||||
selectColumnsBeforeInsert: "少なくとも 1 列選択してください",
|
||||
tableAlias: "テーブルエイリアス",
|
||||
functionDescriptions: {
|
||||
|
||||
@@ -1414,6 +1414,9 @@ export default withEnglishFallback({
|
||||
starExpansionColumns: "{count}개 컬럼",
|
||||
insertSelectedColumns: "선택한 열 삽입 ({count})",
|
||||
insertSelectedColumnsDetail: "열을 선택한 후 한 번에 삽입합니다",
|
||||
selectAllColumns: "모든 열 선택",
|
||||
selectAllColumnsDetail: "모든 필드를 한 번에 선택",
|
||||
deselectAllColumns: "전체 선택 해제",
|
||||
selectColumnsBeforeInsert: "먼저 하나 이상의 열을 선택하세요",
|
||||
tableAlias: "테이블 별칭",
|
||||
functionDescriptions: {
|
||||
|
||||
@@ -1495,6 +1495,9 @@ export default withEnglishFallback({
|
||||
starExpansionColumns: "{count} colunas",
|
||||
insertSelectedColumns: "Inserir colunas selecionadas ({count})",
|
||||
insertSelectedColumnsDetail: "Selecione as colunas e insira-as de uma vez",
|
||||
selectAllColumns: "Selecionar todas as colunas",
|
||||
selectAllColumnsDetail: "Marcar todos os campos de uma vez",
|
||||
deselectAllColumns: "Desmarcar tudo",
|
||||
selectColumnsBeforeInsert: "Selecione pelo menos uma coluna primeiro",
|
||||
tableAlias: "Alias da tabela",
|
||||
functionDescriptions: {
|
||||
|
||||
@@ -1380,6 +1380,9 @@ export default withEnglishFallback({
|
||||
starExpansionColumns: "{count} sütun",
|
||||
insertSelectedColumns: "Seçili sütunları ekle ({count})",
|
||||
insertSelectedColumnsDetail: "Onay kutularıyla sütunları seçip birlikte ekleyin",
|
||||
selectAllColumns: "Tüm sütunları seç",
|
||||
selectAllColumnsDetail: "Tüm alanları tek seferde işaretle",
|
||||
deselectAllColumns: "Tüm seçimi kaldır",
|
||||
selectColumnsBeforeInsert: "Önce en az bir sütun seçin",
|
||||
tableAlias: "Tablo takma adı",
|
||||
functionDescriptions: {
|
||||
|
||||
@@ -1444,6 +1444,9 @@ export default withEnglishFallback({
|
||||
starExpansionColumns: "{count} 列",
|
||||
insertSelectedColumns: "插入已选字段({count})",
|
||||
insertSelectedColumnsDetail: "勾选字段后一次性插入",
|
||||
selectAllColumns: "全选字段",
|
||||
selectAllColumnsDetail: "一键勾选全部字段",
|
||||
deselectAllColumns: "取消全选",
|
||||
selectColumnsBeforeInsert: "请至少选择一个字段",
|
||||
tableAlias: "表别名",
|
||||
functionDescriptions: {
|
||||
|
||||
@@ -1497,6 +1497,9 @@ export default withEnglishFallback({
|
||||
starExpansionColumns: "{count} 欄",
|
||||
insertSelectedColumns: "插入已選欄位({count})",
|
||||
insertSelectedColumnsDetail: "勾選欄位後一次插入",
|
||||
selectAllColumns: "全選欄位",
|
||||
selectAllColumnsDetail: "一鍵勾選全部欄位",
|
||||
deselectAllColumns: "取消全選",
|
||||
selectColumnsBeforeInsert: "請至少選擇一個欄位",
|
||||
tableAlias: "資料表別名",
|
||||
functionDescriptions: {
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Regression coverage for #9122: the batch-selection completion list offered a
|
||||
// per-field checkbox plus an "insert selected fields" row, but no way to check
|
||||
// every field at once. See toggleAllBatchColumnSelection in QueryEditor.vue.
|
||||
// happy-dom's global URL polyfill rejects the file: scheme, so resolve the
|
||||
// path with Node's own path/url helpers instead of `new URL(..., import.meta.url)`.
|
||||
const queryEditorSource = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../components/editor/QueryEditor.vue"), "utf8");
|
||||
|
||||
function extractFunction(name: string): string {
|
||||
const start = queryEditorSource.indexOf(`function ${name}(`);
|
||||
if (start < 0) throw new Error(`Missing QueryEditor function: ${name}`);
|
||||
// The signature can carry an inline object return type (its own `{ }`)
|
||||
// before the body starts, so take the *last* brace on the signature line
|
||||
// rather than the first brace after the function name.
|
||||
const signatureEnd = queryEditorSource.indexOf("\n", start);
|
||||
const bodyStart = queryEditorSource.lastIndexOf("{", signatureEnd);
|
||||
let depth = 0;
|
||||
for (let index = bodyStart; index < queryEditorSource.length; index++) {
|
||||
const character = queryEditorSource[index];
|
||||
if (character === "{") depth++;
|
||||
if (character === "}" && --depth === 0) return queryEditorSource.slice(start, index + 1);
|
||||
}
|
||||
throw new Error("Unterminated QueryEditor function: " + name);
|
||||
}
|
||||
|
||||
interface Session {
|
||||
key: string;
|
||||
candidates: Array<{ key: string; apply: string }>;
|
||||
selectedKeys: Set<string>;
|
||||
}
|
||||
|
||||
interface SelectAllHarness {
|
||||
toggleAllBatchColumnSelection: (view: unknown, sessionKey: string) => void;
|
||||
setSession: (session: Session | null) => void;
|
||||
getSelectedKeys: () => Set<string>;
|
||||
refreshCalls: Array<{ sessionKey: string; focusCandidateKey: string }>;
|
||||
}
|
||||
|
||||
function createHarness(): SelectAllHarness {
|
||||
const refreshCalls: Array<{ sessionKey: string; focusCandidateKey: string }> = [];
|
||||
const source = [
|
||||
"let batchColumnSelectionSession = null;",
|
||||
extractFunction("batchColumnSelectionAllSelected"),
|
||||
// The label refresh and list reopen are UI side effects; the selection
|
||||
// decision itself is what this spec covers.
|
||||
"function updateBatchColumnSelectionActionLabel() {}",
|
||||
"function scheduleBatchColumnSelectionRefresh(view, sessionKey, focusCandidateKey) { __onRefresh(sessionKey, focusCandidateKey); }",
|
||||
extractFunction("toggleAllBatchColumnSelection"),
|
||||
].join("\n");
|
||||
const javascript = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.None, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
const factory = new Function(
|
||||
"view",
|
||||
"__onRefresh",
|
||||
`${javascript}
|
||||
return {
|
||||
toggleAllBatchColumnSelection,
|
||||
setSession: (session) => { batchColumnSelectionSession = session; },
|
||||
getSelectedKeys: () => batchColumnSelectionSession.selectedKeys,
|
||||
};`,
|
||||
);
|
||||
const harness = factory({}, (sessionKey: string, focusCandidateKey: string) => {
|
||||
refreshCalls.push({ sessionKey, focusCandidateKey });
|
||||
}) as Omit<SelectAllHarness, "refreshCalls">;
|
||||
return Object.assign(harness, { refreshCalls });
|
||||
}
|
||||
|
||||
function createSession(selected: string[] = []): Session {
|
||||
return {
|
||||
key: "select\u0000wide_table",
|
||||
candidates: [
|
||||
{ key: "c1", apply: "col_01" },
|
||||
{ key: "c2", apply: "col_02" },
|
||||
{ key: "c3", apply: "col_03" },
|
||||
],
|
||||
selectedKeys: new Set(selected),
|
||||
};
|
||||
}
|
||||
|
||||
describe("toggleAllBatchColumnSelection (#9122)", () => {
|
||||
it("selects every candidate field when the selection is empty", () => {
|
||||
const harness = createHarness();
|
||||
const session = createSession();
|
||||
harness.setSession(session);
|
||||
|
||||
harness.toggleAllBatchColumnSelection({}, session.key);
|
||||
|
||||
expect(harness.getSelectedKeys()).toEqual(new Set(["c1", "c2", "c3"]));
|
||||
// The list is reopened so every rendered checkbox shows the new state.
|
||||
expect(harness.refreshCalls).toEqual([{ sessionKey: session.key, focusCandidateKey: "c1" }]);
|
||||
});
|
||||
|
||||
it("selects the remaining fields when only some are checked", () => {
|
||||
const harness = createHarness();
|
||||
const session = createSession(["c2"]);
|
||||
harness.setSession(session);
|
||||
|
||||
harness.toggleAllBatchColumnSelection({}, session.key);
|
||||
|
||||
expect(harness.getSelectedKeys()).toEqual(new Set(["c1", "c2", "c3"]));
|
||||
});
|
||||
|
||||
it("clears the selection once every field is checked", () => {
|
||||
const harness = createHarness();
|
||||
const session = createSession(["c1", "c2", "c3"]);
|
||||
harness.setSession(session);
|
||||
|
||||
harness.toggleAllBatchColumnSelection({}, session.key);
|
||||
|
||||
expect(harness.getSelectedKeys()).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("ignores a stale session key", () => {
|
||||
const harness = createHarness();
|
||||
const session = createSession(["c1"]);
|
||||
harness.setSession(session);
|
||||
|
||||
harness.toggleAllBatchColumnSelection({}, "another-session");
|
||||
|
||||
expect(harness.getSelectedKeys()).toEqual(new Set(["c1"]));
|
||||
expect(harness.refreshCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not reopen the list when no session is active", () => {
|
||||
const harness = createHarness();
|
||||
harness.setSession(null);
|
||||
|
||||
expect(() => harness.toggleAllBatchColumnSelection({}, "select\u0000wide_table")).not.toThrow();
|
||||
expect(harness.refreshCalls).toEqual([]);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
Reference in new issue
Block a user