chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+5
@@ -0,0 +1,5 @@
|
||||
from .profiles import list_profiles, load_profile
|
||||
from .server import FocasMockServer
|
||||
|
||||
__all__ = ["FocasMockServer", "list_profiles", "load_profile"]
|
||||
|
||||
+2677
File diff suppressed because it is too large
Load Diff
+1936
File diff suppressed because it is too large
Load Diff
+1936
File diff suppressed because it is too large
Load Diff
+2407
File diff suppressed because it is too large
Load Diff
+2407
File diff suppressed because it is too large
Load Diff
+1669
File diff suppressed because it is too large
Load Diff
+80
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .data_store import MockDataStore
|
||||
from .export_introspection import write_profiles
|
||||
from .profiles import list_profiles, load_profile
|
||||
from .server import FocasMockServer
|
||||
|
||||
|
||||
def _default_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
root = _default_root()
|
||||
parser = argparse.ArgumentParser(prog="focas-mock")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
serve = sub.add_parser("serve", help="Start the mock server.")
|
||||
serve.add_argument("--host", default="127.0.0.1")
|
||||
serve.add_argument("--port", type=int, default=8193)
|
||||
serve.add_argument("--profile", default="fwlib30i64")
|
||||
serve.add_argument("--data", help="Optional JSON patch file.")
|
||||
|
||||
sub.add_parser("list-profiles", help="List built-in profiles.")
|
||||
|
||||
dump_profile = sub.add_parser("dump-profile", help="Print one built-in profile as JSON.")
|
||||
dump_profile.add_argument("profile")
|
||||
|
||||
extract = sub.add_parser("extract-profiles", help="Regenerate JSON profiles from vendored DLLs.")
|
||||
extract.add_argument("--dll-dir", default=str(root / "vendor" / "fanuc-cnc-api" / "64bit"))
|
||||
extract.add_argument("--fwlib", default=str(root / "upstream" / "fwlib.cs"))
|
||||
extract.add_argument("--out-dir", default=str(root / "src" / "focas_mock" / "builtin_profiles"))
|
||||
return parser
|
||||
|
||||
|
||||
async def _run_server(args: argparse.Namespace) -> None:
|
||||
profile = load_profile(args.profile)
|
||||
store = MockDataStore(profile)
|
||||
if args.data:
|
||||
store.load_patch_file(args.data)
|
||||
server = FocasMockServer(args.host, args.port, profile, store)
|
||||
await server.start()
|
||||
print(f"focas-mock listening on {server.host}:{server.port} with profile {profile['profile_name']}")
|
||||
try:
|
||||
await server.serve_forever()
|
||||
finally:
|
||||
await server.close()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.command == "list-profiles":
|
||||
for profile in list_profiles():
|
||||
print(profile)
|
||||
return
|
||||
|
||||
if args.command == "dump-profile":
|
||||
print(json.dumps(load_profile(args.profile), indent=2))
|
||||
return
|
||||
|
||||
if args.command == "extract-profiles":
|
||||
written = write_profiles(args.dll_dir, args.fwlib, args.out_dir)
|
||||
for path in written:
|
||||
print(path)
|
||||
return
|
||||
|
||||
if args.command == "serve":
|
||||
asyncio.run(_run_server(args))
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
EW_PROTOCOL = -17
|
||||
EW_SOCKET = -16
|
||||
EW_NODLL = -15
|
||||
EW_BUS = -11
|
||||
EW_SYSTEM2 = -10
|
||||
EW_HSSB = -9
|
||||
EW_HANDLE = -8
|
||||
EW_VERSION = -7
|
||||
EW_UNEXP = -6
|
||||
EW_SYSTEM = -5
|
||||
EW_PARITY = -4
|
||||
EW_MMCSYS = -3
|
||||
EW_RESET = -2
|
||||
EW_BUSY = -1
|
||||
EW_OK = 0
|
||||
EW_FUNC = 1
|
||||
EW_LENGTH = 2
|
||||
EW_NUMBER = 3
|
||||
EW_ATTRIB = 4
|
||||
EW_DATA = 5
|
||||
EW_NOOPT = 6
|
||||
EW_PROT = 7
|
||||
EW_OVRFLOW = 8
|
||||
EW_PARAM = 9
|
||||
EW_BUFFER = 10
|
||||
EW_PATH = 11
|
||||
EW_MODE = 12
|
||||
EW_REJECT = 13
|
||||
EW_DTSRVR = 14
|
||||
EW_ALARM = 15
|
||||
EW_STOP = 16
|
||||
EW_PASSWD = 17
|
||||
|
||||
RC_LABELS = {
|
||||
EW_PROTOCOL: "EW_PROTOCOL",
|
||||
EW_SOCKET: "EW_SOCKET",
|
||||
EW_NODLL: "EW_NODLL",
|
||||
EW_BUS: "EW_BUS",
|
||||
EW_SYSTEM2: "EW_SYSTEM2",
|
||||
EW_HSSB: "EW_HSSB",
|
||||
EW_HANDLE: "EW_HANDLE",
|
||||
EW_VERSION: "EW_VERSION",
|
||||
EW_UNEXP: "EW_UNEXP",
|
||||
EW_SYSTEM: "EW_SYSTEM",
|
||||
EW_PARITY: "EW_PARITY",
|
||||
EW_MMCSYS: "EW_MMCSYS",
|
||||
EW_RESET: "EW_RESET",
|
||||
EW_BUSY: "EW_BUSY",
|
||||
EW_OK: "EW_OK",
|
||||
EW_FUNC: "EW_FUNC",
|
||||
EW_LENGTH: "EW_LENGTH",
|
||||
EW_NUMBER: "EW_NUMBER",
|
||||
EW_ATTRIB: "EW_ATTRIB",
|
||||
EW_DATA: "EW_DATA",
|
||||
EW_NOOPT: "EW_NOOPT",
|
||||
EW_PROT: "EW_PROT",
|
||||
EW_OVRFLOW: "EW_OVRFLOW",
|
||||
EW_PARAM: "EW_PARAM",
|
||||
EW_BUFFER: "EW_BUFFER",
|
||||
EW_PATH: "EW_PATH",
|
||||
EW_MODE: "EW_MODE",
|
||||
EW_REJECT: "EW_REJECT",
|
||||
EW_DTSRVR: "EW_DTSRVR",
|
||||
EW_ALARM: "EW_ALARM",
|
||||
EW_STOP: "EW_STOP",
|
||||
EW_PASSWD: "EW_PASSWD",
|
||||
}
|
||||
|
||||
IMPLEMENTED_FOCAS_METHODS = [
|
||||
"cnc_allclibhndl",
|
||||
"cnc_allclibhndl2",
|
||||
"cnc_allclibhndl3",
|
||||
"cnc_freelibhndl",
|
||||
"cnc_sysinfo",
|
||||
"cnc_statinfo",
|
||||
"cnc_rddynamic2",
|
||||
"cnc_actf",
|
||||
"cnc_acts",
|
||||
"cnc_acts2",
|
||||
"cnc_getpath",
|
||||
"cnc_setpath",
|
||||
"cnc_rdaxisname",
|
||||
"cnc_rdspdlname",
|
||||
"cnc_rdparam",
|
||||
"cnc_wrparam",
|
||||
"cnc_rdmacro",
|
||||
"cnc_wrmacro",
|
||||
"cnc_rdalmmsg2",
|
||||
"pmc_rdpmcrng",
|
||||
"pmc_wrpmcrng",
|
||||
"cnc_rdopmsg",
|
||||
"cnc_rdopmode",
|
||||
"cnc_rdprgnum",
|
||||
"cnc_exeprgname2",
|
||||
"cnc_rdexecprog",
|
||||
"cnc_rdseqnum",
|
||||
"cnc_rdblkcount",
|
||||
"cnc_rdproginfo",
|
||||
"cnc_rdprogdir3",
|
||||
"cnc_rdtimer",
|
||||
"cnc_rdspmeter",
|
||||
"cnc_rdsvmeter",
|
||||
"cnc_rdspload",
|
||||
"cnc_rdspgear",
|
||||
"cnc_rdspmaxrpm",
|
||||
"cnc_rddiagnum",
|
||||
"cnc_rddiaginfo",
|
||||
"cnc_diagnoss",
|
||||
]
|
||||
|
||||
ADMIN_METHODS = [
|
||||
"mock_get_state",
|
||||
"mock_patch",
|
||||
"mock_reset",
|
||||
"mock_load_profile",
|
||||
"mock_list_methods",
|
||||
"mock_schedule_alarms",
|
||||
]
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .defaults import make_default_state
|
||||
|
||||
|
||||
def _deep_merge(target: dict[str, Any], patch: Mapping[str, Any]) -> dict[str, Any]:
|
||||
for key, value in patch.items():
|
||||
if isinstance(value, Mapping) and isinstance(target.get(key), dict):
|
||||
_deep_merge(target[key], value)
|
||||
else:
|
||||
target[key] = deepcopy(value)
|
||||
return target
|
||||
|
||||
|
||||
class MockDataStore:
|
||||
def __init__(self, profile: Mapping[str, Any]) -> None:
|
||||
self.profile = dict(profile)
|
||||
self._defaults = make_default_state(profile)
|
||||
self._state = deepcopy(self._defaults)
|
||||
|
||||
@property
|
||||
def state(self) -> dict[str, Any]:
|
||||
return self._state
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
return deepcopy(self._state)
|
||||
|
||||
def reset(self) -> dict[str, Any]:
|
||||
self._state = deepcopy(self._defaults)
|
||||
return self.snapshot()
|
||||
|
||||
def merge_patch(self, patch: Mapping[str, Any]) -> dict[str, Any]:
|
||||
_deep_merge(self._state, patch)
|
||||
return self.snapshot()
|
||||
|
||||
def load_patch_file(self, path: str | Path) -> dict[str, Any]:
|
||||
patch = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
return self.merge_patch(patch)
|
||||
|
||||
def consume_forced_error(self, method: str) -> tuple[int, str] | None:
|
||||
entry = self._state.get("forced_errors", {}).get(method)
|
||||
if not entry:
|
||||
return None
|
||||
if isinstance(entry, int):
|
||||
return entry, f"forced error for {method}"
|
||||
rc = int(entry.get("rc", 0))
|
||||
count = int(entry.get("count", 1))
|
||||
message = str(entry.get("message", f"forced error for {method}"))
|
||||
if count <= 1:
|
||||
self._state["forced_errors"].pop(method, None)
|
||||
else:
|
||||
entry["count"] = count - 1
|
||||
return rc, message
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
def _family_config(profile_name: str) -> tuple[str, int, int, int]:
|
||||
lowered = profile_name.lower()
|
||||
if "30i" in lowered or "ncg" in lowered:
|
||||
return ("30i", 32, 2, 4)
|
||||
if "0id" in lowered or "0dn" in lowered:
|
||||
return ("0i-D", 24, 1, 2)
|
||||
if "fwlibe64" in lowered:
|
||||
return ("e64", 8, 1, 2)
|
||||
return ("generic", 8, 1, 2)
|
||||
|
||||
|
||||
def make_default_state(profile: Mapping[str, Any]) -> dict[str, Any]:
|
||||
profile_name = str(profile.get("profile_name", "FWLIB64"))
|
||||
family, default_axes, max_path, max_spindles = _family_config(profile_name)
|
||||
max_axis = int(profile.get("max_axis_hint") or default_axes)
|
||||
axis_names = [
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"U",
|
||||
"V",
|
||||
"W",
|
||||
"P",
|
||||
"Q",
|
||||
"R",
|
||||
]
|
||||
while len(axis_names) < max_axis:
|
||||
axis_names.append(f"A{len(axis_names) + 1}")
|
||||
|
||||
spindle_names = [f"S{i}" for i in range(1, max_spindles + 1)]
|
||||
programs = [
|
||||
{"number": 1, "comment": "MAIN", "length": 128},
|
||||
{"number": 100, "comment": "TOOLCHANGE", "length": 84},
|
||||
]
|
||||
|
||||
return {
|
||||
"sysinfo": {
|
||||
"addinfo": 0,
|
||||
"max_axis": max_axis,
|
||||
"cnc_type": family[:2].ljust(2),
|
||||
"mt_type": "M ",
|
||||
"series": family[:4].ljust(4),
|
||||
"version": "A1.0",
|
||||
"axes": str(max_axis).rjust(2, "0"),
|
||||
},
|
||||
"statinfo": {
|
||||
"aut": 1,
|
||||
"run": 3,
|
||||
"motion": 1,
|
||||
"mstb": 0,
|
||||
"emergency": 0,
|
||||
"alarm": 0,
|
||||
"edit": 0,
|
||||
},
|
||||
"paths": {"current": 1, "max": max_path},
|
||||
"dynamic": {
|
||||
"alarm": 0,
|
||||
"prgnum": 1,
|
||||
"prgmnum": 1,
|
||||
"seqnum": 120,
|
||||
"actf": 1500,
|
||||
"acts": 3200,
|
||||
"axes": {
|
||||
axis_names[0]: {"absolute": 123456, "machine": 123450, "relative": 6, "distance": 0},
|
||||
axis_names[1]: {"absolute": -22000, "machine": -22010, "relative": 10, "distance": 0},
|
||||
axis_names[2]: {"absolute": 8000, "machine": 7990, "relative": 10, "distance": 0},
|
||||
},
|
||||
},
|
||||
"actf": 1500,
|
||||
"acts": 3200,
|
||||
"acts2": [3200] + [0] * (max_spindles - 1),
|
||||
"axis_names": axis_names[:max_axis],
|
||||
"spindle_names": spindle_names,
|
||||
"parameters": {
|
||||
"6711": {"type": "long", "value": 1, "decimal": 0, "description": "example parameter"},
|
||||
"6712": {"type": "long", "value": 500, "decimal": 0, "description": "example parameter"},
|
||||
},
|
||||
"pmc": {
|
||||
"R": {
|
||||
"100": {"type": "byte", "value": 0},
|
||||
"101": {"type": "byte", "value": 1},
|
||||
"102": {"type": "byte", "value": 0},
|
||||
},
|
||||
},
|
||||
"macros": {
|
||||
"100": {"value": 12345, "decimal": 3},
|
||||
"101": {"value": 98765, "decimal": 3},
|
||||
},
|
||||
"alarms": [
|
||||
{"alm_no": 0, "type": 0, "axis": 0, "msg": ""},
|
||||
],
|
||||
"operator_messages": [
|
||||
{"number": 200, "type": 0, "char_num": 12, "data": "READY"},
|
||||
],
|
||||
"program": {
|
||||
"current": 1,
|
||||
"main": 1,
|
||||
"sequence": 120,
|
||||
"block_count": 42,
|
||||
"executing": "%\nO0001\nG90 G54 G00 X0 Y0\nM30\n%",
|
||||
"executing_path": "//CNC_MEM/USER/PATH1/O0001",
|
||||
"directory": programs,
|
||||
},
|
||||
"spindle": {
|
||||
"meter": [
|
||||
{"name": spindle_names[0], "value": 56, "unit": "%"},
|
||||
{"name": spindle_names[1], "value": 0, "unit": "%"},
|
||||
],
|
||||
"servo_meter": [
|
||||
{"name": axis_names[0], "value": 14, "unit": "%"},
|
||||
{"name": axis_names[1], "value": 8, "unit": "%"},
|
||||
{"name": axis_names[2], "value": 5, "unit": "%"},
|
||||
],
|
||||
"load": [
|
||||
{"name": spindle_names[0], "load": 56, "speed": 3200},
|
||||
{"name": spindle_names[1], "load": 0, "speed": 0},
|
||||
],
|
||||
"gear": [1] * max_spindles,
|
||||
"max_rpm": [6000] * max_spindles,
|
||||
},
|
||||
"timers": {
|
||||
"power_on": 86400,
|
||||
"operating": 7200,
|
||||
"cutting": 3600,
|
||||
"cycle": 95,
|
||||
},
|
||||
"operation_mode": {"mode": 1, "name": "MEM"},
|
||||
"diagnostics": {
|
||||
"300": {"type": "long", "value": 14, "description": "servo load X"},
|
||||
"301": {"type": "long", "value": 8, "description": "servo load Y"},
|
||||
"302": {"type": "long", "value": 5, "description": "servo load Z"},
|
||||
},
|
||||
"forced_errors": {},
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .constants import IMPLEMENTED_FOCAS_METHODS
|
||||
|
||||
|
||||
def parse_fwlib_imports(fwlib_cs_path: str | Path) -> list[str]:
|
||||
text = Path(fwlib_cs_path).read_text(encoding="utf-8", errors="ignore")
|
||||
imports = re.findall(r"extern short\s+([A-Za-z0-9_]+)\s*\(", text)
|
||||
return sorted(set(imports))
|
||||
|
||||
|
||||
def extract_dll_exports(dll_path: str | Path) -> list[str]:
|
||||
import pefile
|
||||
|
||||
pe = pefile.PE(str(dll_path))
|
||||
exports = [entry.name.decode("ascii", "ignore") for entry in pe.DIRECTORY_ENTRY_EXPORT.symbols if entry.name]
|
||||
return sorted(set(exports))
|
||||
|
||||
|
||||
def _infer_metadata(dll_name: str) -> tuple[str, int, int]:
|
||||
lowered = dll_name.lower()
|
||||
if lowered == "fwlib64.dll":
|
||||
return ("generic-ethernet", 8, 1)
|
||||
if lowered == "fwlibe64.dll":
|
||||
return ("embedded-ethernet", 8, 1)
|
||||
if "30i" in lowered:
|
||||
return ("30i/31i/32i", 32, 2)
|
||||
if "ncg" in lowered:
|
||||
return ("ncguide-family", 32, 2)
|
||||
if "0id" in lowered:
|
||||
return ("0i-d-family", 24, 1)
|
||||
if "0dn" in lowered:
|
||||
return ("0-dn-family", 24, 1)
|
||||
return ("unknown", 8, 1)
|
||||
|
||||
|
||||
def build_profile(dll_path: str | Path, fwlib_cs_path: str | Path) -> dict[str, Any]:
|
||||
dll_path = Path(dll_path)
|
||||
exports = extract_dll_exports(dll_path)
|
||||
wrapper_imports = set(parse_fwlib_imports(fwlib_cs_path))
|
||||
series_hint, max_axis_hint, max_path_hint = _infer_metadata(dll_path.name)
|
||||
export_set = set(exports)
|
||||
connection_methods = sorted(symbol for symbol in exports if symbol.startswith("cnc_allclibhndl"))
|
||||
mock_methods = sorted(set(IMPLEMENTED_FOCAS_METHODS) & export_set)
|
||||
wrapper_supported = sorted(wrapper_imports & export_set)
|
||||
return {
|
||||
"profile_name": dll_path.stem,
|
||||
"dll_name": dll_path.name,
|
||||
"series_hint": series_hint,
|
||||
"max_axis_hint": max_axis_hint,
|
||||
"max_path_hint": max_path_hint,
|
||||
"export_count": len(exports),
|
||||
"connection_methods": connection_methods,
|
||||
"mock_methods": mock_methods,
|
||||
"wrapper_supported_count": len(wrapper_supported),
|
||||
"wrapper_supported_methods": wrapper_supported,
|
||||
"exports": exports,
|
||||
"notes": [
|
||||
"Exports extracted directly from the 64-bit DLL PE export directory.",
|
||||
"Wrapper-supported methods are intersected with upstream fwlib.cs extern declarations.",
|
||||
"Axis and path hints are filename-family heuristics, not protocol-level proofs.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def write_profiles(dll_dir: str | Path, fwlib_cs_path: str | Path, out_dir: str | Path) -> list[Path]:
|
||||
dll_dir = Path(dll_dir)
|
||||
out_dir = Path(out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
written: list[Path] = []
|
||||
for dll_path in sorted(dll_dir.glob("*.dll")):
|
||||
profile = build_profile(dll_path, fwlib_cs_path)
|
||||
out_path = out_dir / f"{dll_path.stem}.json"
|
||||
out_path.write_text(json.dumps(profile, indent=2), encoding="utf-8")
|
||||
written.append(out_path)
|
||||
return written
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
PROFILE_DIR = Path(__file__).resolve().parent / "builtin_profiles"
|
||||
|
||||
PROFILE_ALIASES = {
|
||||
"ZeroI_D": "fwlib0iD64",
|
||||
"ZeroI_F": "fwlib0iD64",
|
||||
"ZeroI_MF": "fwlib0iD64",
|
||||
"ZeroI_TF": "fwlib0iD64",
|
||||
"Sixteen_i": "FWLIB64",
|
||||
"Thirty_i": "fwlib30i64",
|
||||
"ThirtyOne_i": "fwlib30i64",
|
||||
"ThirtyTwo_i": "fwlib30i64",
|
||||
"PowerMotion_i": "fwlib0DN64",
|
||||
}
|
||||
|
||||
|
||||
def list_profiles() -> list[str]:
|
||||
return sorted(path.stem for path in PROFILE_DIR.glob("*.json"))
|
||||
|
||||
|
||||
def resolve_profile_name(profile_name: str) -> str:
|
||||
return PROFILE_ALIASES.get(profile_name, profile_name)
|
||||
|
||||
|
||||
def load_profile(profile_name: str) -> dict[str, Any]:
|
||||
profile_name = resolve_profile_name(profile_name)
|
||||
candidates = [
|
||||
PROFILE_DIR / f"{profile_name}.json",
|
||||
PROFILE_DIR / f"{Path(profile_name).stem}.json",
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return json.loads(candidate.read_text(encoding="utf-8"))
|
||||
available = ", ".join(list_profiles())
|
||||
raise FileNotFoundError(f"Unknown profile '{profile_name}'. Available profiles: {available}")
|
||||
+1018
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user