diff options
author | V <vendicated@riseup.net> | 2023-05-12 01:40:43 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-05-12 01:40:43 +0200 |
commit | 5c5b009c4180b73603a9c3a6c6663f889a2e2062 (patch) | |
tree | 9f586ff1e4491bbd8d7ac046d60bbae081d1d6f8 /src/components/VencordSettings/PatchHelperTab.tsx | |
parent | 0c54b1fa1d8f9d858baf912bb4b1efd9d3c0ec93 (diff) | |
download | Vencord-5c5b009c4180b73603a9c3a6c6663f889a2e2062.tar.gz Vencord-5c5b009c4180b73603a9c3a6c6663f889a2e2062.tar.bz2 Vencord-5c5b009c4180b73603a9c3a6c6663f889a2e2062.zip |
Settings: Fix resetting scroll/search when getting a ping (#1106)
Diffstat (limited to 'src/components/VencordSettings/PatchHelperTab.tsx')
-rw-r--r-- | src/components/VencordSettings/PatchHelperTab.tsx | 310 |
1 files changed, 310 insertions, 0 deletions
diff --git a/src/components/VencordSettings/PatchHelperTab.tsx b/src/components/VencordSettings/PatchHelperTab.tsx new file mode 100644 index 0000000..d5bd94c --- /dev/null +++ b/src/components/VencordSettings/PatchHelperTab.tsx @@ -0,0 +1,310 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2022 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +import { CheckedTextInput } from "@components/CheckedTextInput"; +import { debounce } from "@utils/debounce"; +import { Margins } from "@utils/margins"; +import { canonicalizeMatch, canonicalizeReplace } from "@utils/patches"; +import { makeCodeblock } from "@utils/text"; +import { ReplaceFn } from "@utils/types"; +import { search } from "@webpack"; +import { Button, Clipboard, Forms, Parser, React, Switch, TextInput } from "@webpack/common"; + +import { SettingsTab, wrapTab } from "./shared"; + +// Do not include diff in non dev builds (side effects import) +if (IS_DEV) { + var differ = require("diff") as typeof import("diff"); +} + +const findCandidates = debounce(function ({ find, setModule, setError }) { + const candidates = search(find); + const keys = Object.keys(candidates); + const len = keys.length; + if (len === 0) + setError("No match. Perhaps that module is lazy loaded?"); + else if (len !== 1) + setError("Multiple matches. Please refine your filter"); + else + setModule([keys[0], candidates[keys[0]]]); +}); + +interface ReplacementComponentProps { + module: [id: number, factory: Function]; + match: string | RegExp; + replacement: string | ReplaceFn; + setReplacementError(error: any): void; +} + +function ReplacementComponent({ module, match, replacement, setReplacementError }: ReplacementComponentProps) { + const [id, fact] = module; + const [compileResult, setCompileResult] = React.useState<[boolean, string]>(); + + const [patchedCode, matchResult, diff] = React.useMemo(() => { + const src: string = fact.toString().replaceAll("\n", ""); + const canonicalMatch = canonicalizeMatch(match); + try { + const canonicalReplace = canonicalizeReplace(replacement, "YourPlugin"); + var patched = src.replace(canonicalMatch, canonicalReplace as string); + setReplacementError(void 0); + } catch (e) { + setReplacementError((e as Error).message); + return ["", [], []]; + } + const m = src.match(canonicalMatch); + return [patched, m, makeDiff(src, patched, m)]; + }, [id, match, replacement]); + + function makeDiff(original: string, patched: string, match: RegExpMatchArray | null) { + if (!match || original === patched) return null; + + const changeSize = patched.length - original.length; + + // Use 200 surrounding characters of context + const start = Math.max(0, match.index! - 200); + const end = Math.min(original.length, match.index! + match[0].length + 200); + // (changeSize may be negative) + const endPatched = end + changeSize; + + const context = original.slice(start, end); + const patchedContext = patched.slice(start, endPatched); + + return differ.diffWordsWithSpace(context, patchedContext); + } + + function renderMatch() { + if (!matchResult) + return <Forms.FormText>Regex doesn't match!</Forms.FormText>; + + const fullMatch = matchResult[0] ? makeCodeblock(matchResult[0], "js") : ""; + const groups = matchResult.length > 1 + ? makeCodeblock(matchResult.slice(1).map((g, i) => `Group ${i + 1}: ${g}`).join("\n"), "yml") + : ""; + + return ( + <> + <div style={{ userSelect: "text" }}>{Parser.parse(fullMatch)}</div> + <div style={{ userSelect: "text" }}>{Parser.parse(groups)}</div> + </> + ); + } + + function renderDiff() { + return diff?.map(p => { + const color = p.added ? "lime" : p.removed ? "red" : "grey"; + return <div style={{ color, userSelect: "text" }}>{p.value}</div>; + }); + } + + return ( + <> + <Forms.FormTitle>Module {id}</Forms.FormTitle> + + {!!matchResult?.[0]?.length && ( + <> + <Forms.FormTitle>Match</Forms.FormTitle> + {renderMatch()} + </>) + } + + {!!diff?.length && ( + <> + <Forms.FormTitle>Diff</Forms.FormTitle> + {renderDiff()} + </> + )} + + {!!diff?.length && ( + <Button className={Margins.top20} onClick={() => { + try { + Function(patchedCode.replace(/^function\(/, "function patchedModule(")); + setCompileResult([true, "Compiled successfully"]); + } catch (err) { + setCompileResult([false, (err as Error).message]); + } + }}>Compile</Button> + )} + + {compileResult && + <Forms.FormText style={{ color: compileResult[0] ? "var(--text-positive)" : "var(--text-danger)" }}> + {compileResult[1]} + </Forms.FormText> + } + </> + ); +} + +function ReplacementInput({ replacement, setReplacement, replacementError }) { + const [isFunc, setIsFunc] = React.useState(false); + const [error, setError] = React.useState<string>(); + + function onChange(v: string) { + setError(void 0); + + if (isFunc) { + try { + const func = (0, eval)(v); + if (typeof func === "function") + setReplacement(() => func); + else + setError("Replacement must be a function"); + } catch (e) { + setReplacement(v); + setError((e as Error).message); + } + } else { + setReplacement(v); + } + } + + React.useEffect( + () => void (isFunc ? onChange(replacement) : setError(void 0)), + [isFunc] + ); + + return ( + <> + <Forms.FormTitle>replacement</Forms.FormTitle> + <TextInput + value={replacement?.toString()} + onChange={onChange} + error={error ?? replacementError} + /> + {!isFunc && ( + <div className="vc-text-selectable"> + <Forms.FormTitle>Cheat Sheet</Forms.FormTitle> + {Object.entries({ + "\\i": "Special regex escape sequence that matches identifiers (varnames, classnames, etc.)", + "$$": "Insert a $", + "$&": "Insert the entire match", + "$`\u200b": "Insert the substring before the match", + "$'": "Insert the substring after the match", + "$n": "Insert the nth capturing group ($1, $2...)", + "$self": "Insert the plugin instance", + }).map(([placeholder, desc]) => ( + <Forms.FormText key={placeholder}> + {Parser.parse("`" + placeholder + "`")}: {desc} + </Forms.FormText> + ))} + </div> + )} + + <Switch + className={Margins.top8} + value={isFunc} + onChange={setIsFunc} + note="'replacement' will be evaled if this is toggled" + hideBorder={true} + > + Treat as Function + </Switch> + </> + ); +} + +function PatchHelper() { + const [find, setFind] = React.useState<string>(""); + const [match, setMatch] = React.useState<string>(""); + const [replacement, setReplacement] = React.useState<string | ReplaceFn>(""); + + const [replacementError, setReplacementError] = React.useState<string>(); + + const [module, setModule] = React.useState<[number, Function]>(); + const [findError, setFindError] = React.useState<string>(); + + const code = React.useMemo(() => { + return ` +{ + find: ${JSON.stringify(find)}, + replacement: { + match: /${match.replace(/(?<!\\)\//g, "\\/")}/, + replace: ${typeof replacement === "function" ? replacement.toString() : JSON.stringify(replacement)} + } +} + `.trim(); + }, [find, match, replacement]); + + function onFindChange(v: string) { + setFindError(void 0); + setFind(v); + if (v.length) { + findCandidates({ find: v, setModule, setError: setFindError }); + } + } + + function onMatchChange(v: string) { + try { + new RegExp(v); + setFindError(void 0); + setMatch(v); + } catch (e: any) { + setFindError((e as Error).message); + } + } + + return ( + <SettingsTab title="Patch Helper"> + <Forms.FormTitle>find</Forms.FormTitle> + <TextInput + type="text" + value={find} + onChange={onFindChange} + error={findError} + /> + + <Forms.FormTitle>match</Forms.FormTitle> + <CheckedTextInput + value={match} + onChange={onMatchChange} + validate={v => { + try { + return (new RegExp(v), true); + } catch (e) { + return (e as Error).message; + } + }} + /> + + <ReplacementInput + replacement={replacement} + setReplacement={setReplacement} + replacementError={replacementError} + /> + + <Forms.FormDivider /> + {module && ( + <ReplacementComponent + module={module} + match={new RegExp(match)} + replacement={replacement} + setReplacementError={setReplacementError} + /> + )} + + {!!(find && match && replacement) && ( + <> + <Forms.FormTitle className={Margins.top20}>Code</Forms.FormTitle> + <div style={{ userSelect: "text" }}>{Parser.parse(makeCodeblock(code, "ts"))}</div> + <Button onClick={() => Clipboard.copy(code)}>Copy to Clipboard</Button> + </> + )} + </SettingsTab> + ); +} + +export default IS_DEV ? wrapTab(PatchHelper, "PatchHelper") : null; |