mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-11 17:20:27 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fabce49e61 | ||
|
|
c49cd874af | ||
|
|
c82e5da0d3 |
No files matched your search
@@ -184,6 +184,30 @@ pub fn launch_fingerprint(
|
||||
Ok(Some(LaunchFingerprint { version: 2, parameters, files }))
|
||||
}
|
||||
|
||||
impl LaunchFingerprint {
|
||||
/// Relative paths whose content changed while `(length, mtime)` stayed the same
|
||||
/// compared to `previous` (timestamp-preserving external edits, coarse-granularity
|
||||
/// filesystems). Timestamp-incremental builders (Maven, the Kotlin plugin) would
|
||||
/// skip these sources as "unchanged", so the rebuild the fingerprint triggers
|
||||
/// compiles nothing and the stale outputs get re-recorded as fresh. Callers
|
||||
/// re-stamp the returned paths before building. Inputs without a content hash
|
||||
/// (directories, files outside the project) are never reported.
|
||||
pub fn mtime_preserved_edits(&self, current: &Self) -> Vec<PathBuf> {
|
||||
let mut edits = Vec::new();
|
||||
for (path, current_stamp) in ¤t.files {
|
||||
let Some(current_stamp) = current_stamp else { continue };
|
||||
let Some(previous_stamp) = self.files.get(path).and_then(|stamp| stamp.as_ref()) else { continue };
|
||||
let (len, mtime, Some(current_hash)) = current_stamp else { continue };
|
||||
let (prev_len, prev_mtime, Some(prev_hash)) = previous_stamp else { continue };
|
||||
if current_hash != prev_hash && len == prev_len && mtime == prev_mtime {
|
||||
edits.push(path.clone());
|
||||
}
|
||||
}
|
||||
edits.sort();
|
||||
edits
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -282,6 +306,60 @@ mod tests {
|
||||
assert_ne!(before, after, "a same-length, same-mtime content change must invalidate the fingerprint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mtime_preserved_edits_only_reports_lying_timestamps() {
|
||||
fn stamp(len: u64, hash: &str) -> Option<Stamp> {
|
||||
Some((len, SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42), Some(hash.into())))
|
||||
}
|
||||
fn prints(
|
||||
previous: BTreeMap<PathBuf, Option<Stamp>>,
|
||||
current: BTreeMap<PathBuf, Option<Stamp>>,
|
||||
) -> Vec<PathBuf> {
|
||||
let previous = LaunchFingerprint { version: 2, parameters: String::new(), files: previous };
|
||||
let current = LaunchFingerprint { version: 2, parameters: String::new(), files: current };
|
||||
previous.mtime_preserved_edits(¤t)
|
||||
}
|
||||
// Same length and mtime, different content: the builder would skip it.
|
||||
assert_eq!(
|
||||
prints([("a.java".into(), stamp(5, "old"))].into(), [("a.java".into(), stamp(5, "new"))].into()),
|
||||
vec![PathBuf::from("a.java")]
|
||||
);
|
||||
// A normal edit bumps the mtime: the builder already sees it.
|
||||
let mut touched = stamp(5, "new").unwrap();
|
||||
touched.1 += std::time::Duration::from_secs(1);
|
||||
assert!(
|
||||
prints([("a.java".into(), stamp(5, "old"))].into(), [("a.java".into(), Some(touched))].into()).is_empty()
|
||||
);
|
||||
// Added, deleted, and hash-less inputs (directories, files outside the
|
||||
// project) carry no comparable content: never reported.
|
||||
assert!(
|
||||
prints(
|
||||
[("gone.java".into(), stamp(5, "old")), ("dir".into(), None)].into(),
|
||||
[("new.java".into(), stamp(5, "new")), ("dir".into(), None)].into()
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lying_source_is_found_against_a_real_fingerprint() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let source = root.join("lib/src/main/kotlin/Channel.kt");
|
||||
let before = fingerprint(root, &model).unwrap();
|
||||
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
|
||||
// Same 5-byte length, different content, preserved mtime.
|
||||
write(root, "lib/src/main/kotlin/Channel.kt", "valve");
|
||||
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
|
||||
let after = fingerprint(root, &model).unwrap();
|
||||
assert_eq!(before.mtime_preserved_edits(&after), [PathBuf::from("lib/src/main/kotlin/Channel.kt")]);
|
||||
// A normal edit (mtime advances) needs no repair.
|
||||
write(root, "lib/src/main/kotlin/Channel.kt", "valve!");
|
||||
let normal = fingerprint(root, &model).unwrap();
|
||||
assert!(before.mtime_preserved_edits(&normal).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -88,8 +88,28 @@ impl Session {
|
||||
Ok(None)
|
||||
}
|
||||
};
|
||||
let before = fingerprint()?;
|
||||
let mut before = fingerprint()?;
|
||||
let previous = fs::read(&record).ok().and_then(|bytes| serde_json::from_slice::<Outputs>(&bytes).ok());
|
||||
// Timestamp-incremental builders (Maven, the Kotlin plugin) skip sources whose
|
||||
// mtime did not advance. An external edit that preserves both length and mtime
|
||||
// invalidates the fingerprint but would still compile to nothing below, and the
|
||||
// stale outputs would be re-recorded as fresh. Re-stamp those sources first so
|
||||
// the rebuild sees them; then re-scan so the record written afterwards matches.
|
||||
let restamp: Vec<PathBuf> = match (before.as_ref(), previous.as_ref().and_then(|o| o.fingerprint.as_ref())) {
|
||||
(Some(current), Some(recorded)) => recorded.mtime_preserved_edits(current),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
if !restamp.is_empty() {
|
||||
let now = std::time::SystemTime::now();
|
||||
for rel in &restamp {
|
||||
let path = root.join(rel);
|
||||
if let Err(error) = fs::File::options().write(true).open(&path).and_then(|file| file.set_modified(now))
|
||||
{
|
||||
tracing::debug!(path = %path.display(), %error, "Cannot re-stamp externally edited source");
|
||||
}
|
||||
}
|
||||
before = fingerprint()?;
|
||||
}
|
||||
if let Some(outputs) = previous.as_ref()
|
||||
&& policy != BuildPolicy::Rebuild
|
||||
&& outputs.reusable(&before, &snapshots)
|
||||
@@ -234,6 +254,22 @@ mod tests {
|
||||
ide_build::load(root, &root.join("cache"), None)
|
||||
}
|
||||
|
||||
/// Like [`fixture`], but the build script skips sources whose mtime did not advance,
|
||||
/// mirroring Maven's (and the Kotlin plugin's) timestamp-incremental compilation.
|
||||
fn fixture_timestamp_incremental(root: &Path) -> ide_build::BuildModel {
|
||||
fs::write(
|
||||
root.join("pom.xml"),
|
||||
r#"<project><groupId>demo</groupId><artifactId>app</artifactId><version>1</version></project>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let script = root.join("mvnw");
|
||||
fs::write(&script, "#!/bin/sh\necho build \"$@\" >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\nif [ ! -f target/classes/Main.class ] || [ src/main/kotlin/Main.kt -nt target/classes/Main.class ]; then\ncp src/main/kotlin/Main.kt target/classes/Main.class\nfi\n").unwrap();
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
fs::create_dir_all(root.join("src/main/kotlin")).unwrap();
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "first").unwrap();
|
||||
ide_build::load(root, &root.join("cache"), None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_bypasses_snapshots_and_error_continuation_retains_previous_classes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -357,6 +393,45 @@ mod tests {
|
||||
assert_eq!(fs::read_to_string(rebuilt.snapshot.join("0/Main.class")).unwrap(), "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_after_mtime_preserving_external_edit_actually_recompiles() {
|
||||
// The content hash triggers a rebuild for a same-length, same-mtime external
|
||||
// edit, but a timestamp-incremental builder (real Maven) would still compile
|
||||
// nothing and the stale snapshot would be re-recorded as fresh. Dropping the
|
||||
// session simulates the IDE restart from the report.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture_timestamp_incremental(root);
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
assert_eq!(fs::read_to_string(first.snapshot.join("0/Main.class")).unwrap(), "first");
|
||||
// Pin the old output's mtime so the `-nt` check below is deterministic even on
|
||||
// coarse-granularity filesystems.
|
||||
fs::File::options()
|
||||
.write(true)
|
||||
.open(root.join("target/classes/Main.class"))
|
||||
.unwrap()
|
||||
.set_modified(std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42))
|
||||
.unwrap();
|
||||
drop(session);
|
||||
|
||||
let source = root.join("src/main/kotlin/Main.kt");
|
||||
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
|
||||
fs::write(&source, "other").unwrap(); // 5 bytes, same length as "first"
|
||||
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
|
||||
|
||||
let mut session = Session::new(cache.clone());
|
||||
let rebuilt = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
assert_ne!(rebuilt.snapshot, first.snapshot);
|
||||
assert_eq!(
|
||||
fs::read_to_string(rebuilt.snapshot.join("0/Main.class")).unwrap(),
|
||||
"other",
|
||||
"the re-stamped source must actually recompile instead of re-recording stale outputs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_drop_removes_superseded_snapshots_and_preserves_records_across_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"--border-right-margin": "#2a313b",
|
||||
"--bg-editor-selection": "#2d7772",
|
||||
"--bg-editor-selection-inactive": "#255854",
|
||||
"--fg-editor-selection": "inherit",
|
||||
"--bg-text-search-result": "#114957",
|
||||
"--bg-text-search-result-current": "#165e70",
|
||||
"--bg-matched-brace": "#38424f",
|
||||
|
||||
+4
-3
@@ -214,7 +214,7 @@ The full table is `UI_KEYS` in [`src/features/plugins/intellijTheme.ts`](../src/
|
||||
|---|---|
|
||||
| `--fg-editor`, `--bg-editor` | attribute `TEXT` (foreground, background) |
|
||||
| `--caret`, `--bg-current-line` | colour `CARET_COLOR`, `CARET_ROW_COLOR` |
|
||||
| `--bg-editor-selection` | colour `SELECTION_BACKGROUND` |
|
||||
| `--bg-editor-selection`, `--fg-editor-selection` | colour `SELECTION_BACKGROUND`, `SELECTION_FOREGROUND` |
|
||||
| `--fg-line-number`, `--fg-line-number-current` | colour `LINE_NUMBERS_COLOR`, `LINE_NUMBER_ON_CARET_ROW_COLOR` |
|
||||
| `--bg-editor-gutter` | colour `EDITOR_GUTTER_BACKGROUND`, else the editor background (as the new UI paints it; `GUTTER_BACKGROUND` is the classic UI's) |
|
||||
| `--border-indent-guide`, `--border-right-margin`, `--fg-whitespace` | colour `INDENT_GUIDE`, `RIGHT_MARGIN_COLOR`, `WHITESPACES` |
|
||||
@@ -268,7 +268,7 @@ from `type`, then map:
|
||||
| `--fg-editor` | `editor.foreground` |
|
||||
| `--caret` | `editorCursor.foreground` |
|
||||
| `--bg-current-line` | `editor.lineHighlightBackground` |
|
||||
| `--bg-editor-selection`, `--bg-editor-selection-inactive` | `editor.selectionBackground`, `editor.inactiveSelectionBackground` |
|
||||
| `--bg-editor-selection`, `--bg-editor-selection-inactive`, `--fg-editor-selection` | `editor.selectionBackground`, `editor.inactiveSelectionBackground`, `editor.selectionForeground` |
|
||||
| `--fg-line-number`, `--fg-line-number-current` | `editorLineNumber.foreground`, `editorLineNumber.activeForeground` |
|
||||
| `--border-indent-guide`, `--border-indent-guide-active` | `editorIndentGuide.background1`, `editorIndentGuide.activeBackground1` |
|
||||
| `--fg-whitespace` | `editorWhitespace.foreground` |
|
||||
@@ -329,7 +329,7 @@ a token.
|
||||
|
||||
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
|
||||
|
||||
255 themeable tokens: 125 in the UI theme file, 130 in the editor colour scheme file.
|
||||
256 themeable tokens: 125 in the UI theme file, 131 in the editor colour scheme file.
|
||||
|
||||
### Surfaces (UI theme)
|
||||
|
||||
@@ -499,6 +499,7 @@ a token.
|
||||
| `--border-right-margin` | `#323438` | `#ebecf0` |
|
||||
| `--bg-editor-selection` | `#214283` | `#a6d2ff` |
|
||||
| `--bg-editor-selection-inactive` | `#1d3460` | `#c9e3ff` |
|
||||
| `--fg-editor-selection` | `inherit` | `inherit` |
|
||||
| `--bg-text-search-result` | `#114957` | `#fcd47e` |
|
||||
| `--bg-text-search-result-current` | `#165e70` | `#f2bf57` |
|
||||
| `--bg-matched-brace` | `#43454a` | `#93d9d9` |
|
||||
|
||||
@@ -159,6 +159,17 @@ describe("IntelliJ theme import", () => {
|
||||
expect(s).toBeNull();
|
||||
});
|
||||
|
||||
it("maps the selection foreground colour (#2446)", () => {
|
||||
const s = convertIcls(`<scheme name="Sel" version="142" parent_scheme="Default"><colors>
|
||||
<option name="SELECTION_BACKGROUND" value="214283" />
|
||||
<option name="SELECTION_FOREGROUND" value="112233" />
|
||||
</colors></scheme>`);
|
||||
expect(s.tokens).toMatchObject({
|
||||
"--bg-editor-selection": "#214283",
|
||||
"--fg-editor-selection": "#112233",
|
||||
});
|
||||
});
|
||||
|
||||
it("only targets tokens that exist in tokens.css", () => {
|
||||
const declared = new Set([...css.matchAll(/(--[a-z0-9-]+)\s*:/g)].map((m) => m[1]!));
|
||||
// Read with a fallback by the editor (no tokens.css default), still themeable.
|
||||
|
||||
@@ -38,6 +38,8 @@ const plugin = ViewPlugin.fromClass(
|
||||
const theme = EditorView.baseTheme({
|
||||
".cm-openidea-selected": {
|
||||
backgroundColor: "var(--editor-selection-inactive, var(--bg-editor-selection-inactive))",
|
||||
// Unset (the default `inherit`): the declaration drops and the syntax colours show through.
|
||||
color: "var(--fg-editor-selection)",
|
||||
},
|
||||
"&.cm-focused .cm-openidea-selected": {
|
||||
backgroundColor: "var(--editor-selection, var(--bg-editor-selection))",
|
||||
|
||||
@@ -89,6 +89,7 @@ export const ICLS_COLORS: Record<string, string[]> = {
|
||||
CARET_COLOR: ["--caret"],
|
||||
CARET_ROW_COLOR: ["--bg-current-line"],
|
||||
SELECTION_BACKGROUND: ["--bg-editor-selection"],
|
||||
SELECTION_FOREGROUND: ["--fg-editor-selection"],
|
||||
LINE_NUMBERS_COLOR: ["--fg-line-number"],
|
||||
LINE_NUMBER_ON_CARET_ROW_COLOR: ["--fg-line-number-current"],
|
||||
// The new UI paints the gutter with this, else with the editor's background; `GUTTER_BACKGROUND` is the classic UI's.
|
||||
|
||||
@@ -121,9 +121,11 @@ describe("theme tokens", () => {
|
||||
/**
|
||||
* Tokens the palette themes (Solarized, One) take from their light / dark base
|
||||
* on purpose: colours the user assigns (project and data source colours), the
|
||||
* platform's caption close and macOS window buttons, and the tint strength.
|
||||
* platform's caption close and macOS window buttons, the tint strength, and
|
||||
* the selection foreground, whose base default preserves syntax colours.
|
||||
*/
|
||||
const INHERITED_FROM_BASE = /^--(project-color-|db-color-|caption-close-|fg-caption-close-|tint-strength$)/;
|
||||
const INHERITED_FROM_BASE =
|
||||
/^--(project-color-|db-color-|caption-close-|fg-caption-close-|tint-strength$|fg-editor-selection$)/;
|
||||
|
||||
describe("palette themes", () => {
|
||||
const ISLANDS = ["light", "dark", "dark-hc"];
|
||||
|
||||
@@ -339,6 +339,8 @@ export const viewerTheme = EditorView.theme(
|
||||
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, ::selection, .cm-openidea-selected, &.cm-focused .cm-openidea-selected":
|
||||
{
|
||||
backgroundColor: "var(--bg-selected-strong)",
|
||||
// Unset (the default `inherit`): the declaration drops and the syntax colours show through.
|
||||
color: "var(--fg-editor-selection)",
|
||||
},
|
||||
".cm-activeLine": { backgroundColor: "var(--bg-current-line)" },
|
||||
".cm-gutters": {
|
||||
|
||||
@@ -116,6 +116,30 @@ describe("color schemes", () => {
|
||||
expect(pythonItalic).toContain("--syn-python-static-italic: italic !important");
|
||||
});
|
||||
|
||||
it("leaves selected text in syntax colours until a selection foreground is set (#2446)", () => {
|
||||
expect(ATTRIBUTES.find((a) => a.id === "selectionForeground")).toMatchObject({
|
||||
name: "Selection foreground",
|
||||
path: ["General", "Editor"],
|
||||
fg: "--fg-editor-selection",
|
||||
});
|
||||
// The `inherit` default is not a colour: the settings page shows no foreground.
|
||||
expect(resolvedAttribute("light", "selectionForeground")).toEqual({});
|
||||
expect(resolvedAttribute("dark", "selectionForeground")).toEqual({});
|
||||
const customs = {
|
||||
"custom-sel": {
|
||||
name: "Sel",
|
||||
parent: "dark" as const,
|
||||
attributes: { selectionForeground: { foreground: "#112233" } },
|
||||
},
|
||||
};
|
||||
expect(resolvedAttribute("custom-sel", "selectionForeground", customs)).toMatchObject({
|
||||
foreground: "#112233",
|
||||
});
|
||||
expect(schemeCss("dark", { dark: { selectionForeground: { foreground: "#112233" } } })).toContain(
|
||||
"--fg-editor-selection: #112233 !important",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies an explicit scheme over a plugin theme of the same base", () => {
|
||||
const root = document.documentElement;
|
||||
root.dataset.theme = "dark";
|
||||
|
||||
@@ -61,6 +61,12 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [
|
||||
{ id: "caret", name: "Caret", path: ["General", "Editor"], fg: "--caret" },
|
||||
{ id: "caretRow", name: "Caret row", path: ["General", "Editor"], bg: "--bg-current-line" },
|
||||
{ id: "selection", name: "Selection background", path: ["General", "Editor"], bg: "--bg-editor-selection" },
|
||||
{
|
||||
id: "selectionForeground",
|
||||
name: "Selection foreground",
|
||||
path: ["General", "Editor"],
|
||||
fg: "--fg-editor-selection",
|
||||
},
|
||||
{
|
||||
id: "selectionInactive",
|
||||
name: "Selection background (inactive)",
|
||||
|
||||
@@ -34,6 +34,19 @@ describe(".icls schemes", () => {
|
||||
expect(parseIcls(xml).scheme.attributes).toMatchObject(scheme.attributes);
|
||||
});
|
||||
|
||||
it("round trips the selection foreground through SELECTION_FOREGROUND (#2446)", () => {
|
||||
const { scheme } = parseIcls(`<scheme name="Sel" parent_scheme="Default"><colors>
|
||||
<option name="SELECTION_BACKGROUND" value="214283"/>
|
||||
<option name="SELECTION_FOREGROUND" value="ffffff"/>
|
||||
</colors></scheme>`);
|
||||
expect(scheme.attributes.selection).toEqual({ background: "#214283" });
|
||||
expect(scheme.attributes.selectionForeground).toEqual({ foreground: "#ffffff" });
|
||||
const { id, customs } = importScheme({}, scheme);
|
||||
const xml = exportIcls(id, customs);
|
||||
expect(xml).toContain('<option name="SELECTION_FOREGROUND" value="ffffff" />');
|
||||
expect(parseIcls(xml).scheme.attributes.selectionForeground).toEqual({ foreground: "#ffffff" });
|
||||
});
|
||||
|
||||
it("resolves explicit attribute inheritance independent of XML order and stops cycles", () => {
|
||||
const { scheme } = parseIcls(`<scheme name="Inherited"><attributes>
|
||||
<option name="PHP_PARAMETER" baseAttributes="PHP_VAR"/>
|
||||
|
||||
@@ -179,6 +179,7 @@ const COLOR_NAMES: Record<string, [string, "foreground" | "background"]> = {
|
||||
CARET_COLOR: ["caret", "foreground"],
|
||||
CARET_ROW_COLOR: ["caretRow", "background"],
|
||||
SELECTION_BACKGROUND: ["selection", "background"],
|
||||
SELECTION_FOREGROUND: ["selectionForeground", "foreground"],
|
||||
INACTIVE_SELECTION_BACKGROUND: ["selectionInactive", "background"],
|
||||
GUTTER_BACKGROUND: ["gutter", "background"],
|
||||
LINE_NUMBERS_COLOR: ["lineNumber", "foreground"],
|
||||
|
||||
@@ -184,6 +184,7 @@ html {
|
||||
--border-right-margin: #323438;
|
||||
--bg-editor-selection: #214283;
|
||||
--bg-editor-selection-inactive: #1d3460;
|
||||
--fg-editor-selection: inherit;
|
||||
--bg-text-search-result: #114957;
|
||||
--bg-text-search-result-current: #165e70;
|
||||
--bg-matched-brace: #43454a;
|
||||
@@ -599,6 +600,7 @@ html {
|
||||
--border-right-margin: #ebecf0;
|
||||
--bg-editor-selection: #a6d2ff;
|
||||
--bg-editor-selection-inactive: #c9e3ff;
|
||||
--fg-editor-selection: inherit;
|
||||
--bg-text-search-result: #fcd47e;
|
||||
--bg-text-search-result-current: #f2bf57;
|
||||
--bg-matched-brace: #93d9d9;
|
||||
|
||||
Reference in new issue
Block a user