diff --git a/package-lock.json b/package-lock.json index c4bf322..ce08ac8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -162,7 +162,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -3262,7 +3261,6 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -3417,7 +3415,6 @@ "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -3428,7 +3425,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3772,7 +3768,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3979,7 +3974,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -4042,8 +4036,7 @@ "version": "1.11.20", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", @@ -4153,7 +4146,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4715,7 +4707,6 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", - "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -4827,7 +4818,6 @@ "integrity": "sha512-4RuJK2jP08XwqtUu+5yhCbxEauCm6tv2MFHKEMsjbosK2+vy5us82oI3VLuHwbNyZG7ekZA26U2LLHnGR4frIA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "tsgolint": "bin/tsgolint.js" }, @@ -4982,7 +4972,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4992,7 +4981,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5294,7 +5282,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5326,7 +5313,6 @@ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -5377,7 +5363,6 @@ "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", @@ -5597,7 +5582,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, diff --git a/src/index.css b/src/index.css index a5064e8..102ba3f 100644 --- a/src/index.css +++ b/src/index.css @@ -111,7 +111,15 @@ html, body, #root { /* Don't have text overflow in the structure dialog */ .structure-tree { - overflow: auto; + overflow: auto; +} + +.full-text-search-item { + cursor: pointer; +} + +.full-text-search-item:hover { + background-color: rgba(255, 255, 255, 0.1); } .ant-tabs-content { diff --git a/src/logic/Keybinds.ts b/src/logic/Keybinds.ts index 2488596..1fa4e83 100644 --- a/src/logic/Keybinds.ts +++ b/src/logic/Keybinds.ts @@ -1,5 +1,5 @@ import { BehaviorSubject, filter, fromEvent, Observable, tap } from "rxjs"; -import { focusSearch, showStructure, type KeybindSetting } from "./Settings"; +import { focusSearch, fullTextSearchBind, showStructure, type KeybindSetting } from "./Settings"; // Set to true when the user is currently capturing a keybind export const capturingKeybind = new BehaviorSubject(null); @@ -19,4 +19,5 @@ function keyBindEvent(setting: KeybindSetting): Observable { } export const focusSearchEvent = keyBindEvent(focusSearch); -export const showStructureEvent = keyBindEvent(showStructure); \ No newline at end of file +export const showStructureEvent = keyBindEvent(showStructure); +export const fullTextSearchEvent = keyBindEvent(fullTextSearchBind); diff --git a/src/logic/Settings.ts b/src/logic/Settings.ts index b67c372..148fb1d 100644 --- a/src/logic/Settings.ts +++ b/src/logic/Settings.ts @@ -146,6 +146,7 @@ export const bytecode = new BooleanSetting('bytecode', false); export const unifiedDiff = new BooleanSetting('unified_diff', false); export const focusSearch = new KeybindSetting('focus_search', 'Ctrl+ '); export const showStructure = new KeybindSetting('show_structure', 'Ctrl+F12'); +export const fullTextSearchBind = new KeybindSetting('full_text_search', 'Ctrl+Shift+f'); export const preferWasmDecompiler = new BooleanSetting('prefer_wasm_decompiler', true); preferWasmDecompiler.observable diff --git a/src/ui/FullTextSearchModal.tsx b/src/ui/FullTextSearchModal.tsx new file mode 100644 index 0000000..09afe1a --- /dev/null +++ b/src/ui/FullTextSearchModal.tsx @@ -0,0 +1,167 @@ +import React, { useEffect, useRef, useState } from "react"; +import { fullTextSearchEvent } from "../logic/Keybinds"; +import { useObservable } from "../utils/UseObservable"; +import { Button, Flex, Input, List, Modal, type InputRef } from "antd"; +import { BehaviorSubject, catchError, combineLatest, distinctUntilChanged, from, map, of, startWith, switchMap } from "rxjs"; +import { fullTextSearch } from "../workers/full-text-search/client"; +import type { FullTextSearchResult } from "../workers/full-text-search/worker"; +import { openCodeTab } from "../logic/tabs"; + +const SearchState = (r: SearchState) => r; +type SearchState = + | { state: "loading"; } + | { state: "ok"; results: FullTextSearchResult[]; } + | { state: "error"; error: string; }; + +function parseRegexQuery(input: string): { pattern: string; flags: string } | null { + const m = input.match(/^\/(.+?)(?:\/([gimsuy]*))?$/); + if (!m) return null; + try { + new RegExp(m[1], m[2] ?? ""); + return { pattern: m[1], flags: m[2] ?? "" }; + } catch { + return null; + } +} + +const query = new BehaviorSubject(""); +const search$ = combineLatest([fullTextSearch, query]).pipe( + distinctUntilChanged(), + switchMap(([fts, query]) => { + if (query.startsWith("/")) { + if (query.length < 2) return of(SearchState({ + state: "error", + error: "Enter a regex pattern: /pattern/flags" + })); + + const regex = parseRegexQuery(query); + if (!regex) return of(SearchState({ + state: "error", + error: "Invalid regex" + })); + + return from(fts.findByRegex(regex.pattern, regex.flags, { maxTokens: 11 })).pipe( + map(results => SearchState({ state: "ok", results })), + startWith(SearchState({ state: "loading" })), + catchError(error => of(SearchState({ state: "error", error: String(error) })))); + } + + if (query.length < 3) return of(SearchState({ + state: "error", + error: "Query must be at least 3 characters" + })); + + return from(fts.find(query, { maxTokens: 11 })).pipe( + map(results => SearchState({ state: "ok", results })), + startWith(SearchState({ state: "loading" })), + catchError(error => of(SearchState({ state: "error", error: String(error) })))); + })); + +type FullTextSearchResultElementProps = { + result: FullTextSearchResult; +}; + +const FullTextSearchResultElement: React.FC = ({ result }) => { + const [expand, setExpand] = useState(false); + + const sliced = expand ? result.regions : result.regions.slice(0, 5); + const canToggleExpand = expand || sliced.length < result.regions.length; + + return ( + + {canToggleExpand && ( + + )} + {sliced.map((r, i) => ( +
+ {r.snippet} +
+ ))} + + )} + /> + ); +}; + +const FullTextSearchModal = () => { + const showEvent = useObservable(fullTextSearchEvent); + const search = useObservable(search$) ?? { state: "ok", results: [] }; + const [open, setOpen] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + if (showEvent) { + setOpen(true); + } + }, [showEvent]); + + function openResult(result: FullTextSearchResult) { + setOpen(false); + openCodeTab(result.key); + } + + let resultsElement; + if (search.state === "loading") { + resultsElement = (
Loading...
); + } else if (search.state === "error") { + resultsElement = (
{search.error}
); + } else if (search.results.length === 0) { + resultsElement = (
No results
); + } else { + resultsElement = ( + ( + openResult(result)} + className="full-text-search-item" + > + + + )} + /> + ); + } + + return ( + setOpen(false)} + afterOpenChange={open => open && inputRef.current?.focus()} + footer={null} + width="50%" + > + + query.next(q.trim())} + /> +
+ {resultsElement} +
+
+
+ ); +}; +export default FullTextSearchModal; diff --git a/src/ui/Modals.tsx b/src/ui/Modals.tsx index 2ac3ffe..0c43792 100644 --- a/src/ui/Modals.tsx +++ b/src/ui/Modals.tsx @@ -6,6 +6,7 @@ import SettingsModal from "./SettingsModal"; import StructureModal from "./StructureModal"; import { JarDecompilerModal, JarDecompilerProgressModal } from "./JarDecompilerModal"; import IndexProgressNotification from "./IndexProgressNotification"; +import FullTextSearchModal from "./FullTextSearchModal"; const Modals = () => { return ( @@ -19,6 +20,7 @@ const Modals = () => { + ); }; diff --git a/src/ui/SettingsModal.tsx b/src/ui/SettingsModal.tsx index 0f61805..1622ce6 100644 --- a/src/ui/SettingsModal.tsx +++ b/src/ui/SettingsModal.tsx @@ -2,7 +2,7 @@ import { Button, Modal, type CheckboxProps, Form, Tooltip, InputNumber, type Inp import { SettingOutlined, SunOutlined, MoonOutlined, DesktopOutlined } from '@ant-design/icons'; import { Checkbox } from 'antd'; import { useObservable } from "../utils/UseObservable"; -import { BooleanSetting, enableTabs, displayLambdas, focusSearch, KeybindSetting, type KeybindValue, bytecode, showStructure, NumberSetting, preferWasmDecompiler, compactPackages, theme } from "../logic/Settings"; +import { BooleanSetting, enableTabs, displayLambdas, focusSearch, KeybindSetting, type KeybindValue, bytecode, showStructure, NumberSetting, preferWasmDecompiler, compactPackages, fullTextSearchBind, theme } from "../logic/Settings"; import { capturingKeybind, rawKeydownEvent } from "../logic/Keybinds"; import { BehaviorSubject } from "rxjs"; import type React from "react"; @@ -35,9 +35,10 @@ const SettingsModal = () => { - + + ); @@ -105,18 +106,18 @@ export interface NumberOptionProps { testid?: string; } -export const NumberOption: React.FC = ({ setting, title, min, max, testid}) => { +export const NumberOption: React.FC = ({ setting, title, min, max, testid }) => { const value = useObservable(setting.observable); const onChange: InputNumberProps["onChange"] = (e) => { setting.value = e ?? setting.defaultValue; - } + }; return ( - + ); -} +}; interface KeybindOptionProps { setting: KeybindSetting; diff --git a/src/utils/UseObservable.ts b/src/utils/UseObservable.ts index b8fd44e..7995230 100644 --- a/src/utils/UseObservable.ts +++ b/src/utils/UseObservable.ts @@ -2,8 +2,8 @@ import { useState, useEffect } from 'react'; import { Observable, BehaviorSubject } from 'rxjs'; export function useObservable(observable: Observable) { - const [state, setState] = useState(() => - observable instanceof BehaviorSubject ? observable.getValue() : undefined as T + const [state, setState] = useState(() => + observable instanceof BehaviorSubject ? observable.getValue() : undefined ); useEffect(() => { diff --git a/src/workers/decompile/client.ts b/src/workers/decompile/client.ts index 4c63b74..f330326 100644 --- a/src/workers/decompile/client.ts +++ b/src/workers/decompile/client.ts @@ -61,6 +61,14 @@ export async function deleteCache(): Promise { return await worker.clear(); } +export async function onDecompiledSources( + jar: Jar, + callback: (className: string, source: string) => Promise | void +) { + const worker = await findWorker(); + await worker.onDecompiledSources(jar.name, jar.blob, Comlink.proxy(callback)); +} + export type DecompileEntireJarOptions = { threads?: number, splits?: number, diff --git a/src/workers/decompile/worker.ts b/src/workers/decompile/worker.ts index cd9c5c3..5c41e7c 100644 --- a/src/workers/decompile/worker.ts +++ b/src/workers/decompile/worker.ts @@ -83,6 +83,28 @@ export class DecompileWorker { return count; }); + onDecompiledSources = ( + jarName: string, + jarBlob: Blob, + callback: (className: string, source: string) => Promise | void + ) => this.schedule(async () => { + const jar = new DecompileJar(await openJar(jarName, jarBlob)); + const classNames = jar.classes.filter(n => !n.includes("$")); + + const promises: Promise[] = []; + for (const className of classNames) { + const data = jar.proxy[className]; + if (!data) continue; + + promises.push((async () => { + const result = await this.db.results3.get([className, data.checksum, "java"]); + if (result) await callback(result.className, result.source); + })()); + } + + await Promise.all(promises); + }); + decompileMany = ( jarName: string, jarBlob: Blob, diff --git a/src/workers/full-text-search/client.ts b/src/workers/full-text-search/client.ts new file mode 100644 index 0000000..93e8a01 --- /dev/null +++ b/src/workers/full-text-search/client.ts @@ -0,0 +1,67 @@ +import * as Comlink from "comlink"; +import { minecraftJar, type MinecraftJar } from "../../logic/MinecraftApi"; +import { BehaviorSubject, combineLatest, distinctUntilChanged, mergeMap, shareReplay } from "rxjs"; +import type { FullTextSearchOptions, FullTextSearchResult, FullTextSearchWorker } from "./worker"; +import { onDecompiledSources } from "../decompile/client"; + +let currentInstance: FullTextSearch | undefined; +const invalidator = new BehaviorSubject(0); + +export const fullTextSearch = combineLatest([minecraftJar, invalidator]).pipe( + distinctUntilChanged(), + mergeMap(async ([jar, _]) => { + if (currentInstance) { + await currentInstance.destroy(); + } + + const newInstance = new FullTextSearch(jar); + currentInstance = newInstance; + return newInstance; + }), + shareReplay({ bufferSize: 1, refCount: false }) +); + +export function invalidateFullTextSearch() { + invalidator.next(invalidator.value + 1); +} + +export class FullTextSearch { + readonly #jar: MinecraftJar; + constructor(jar: MinecraftJar) { + this.#jar = jar; + } + + #_worker?: Comlink.Remote; + async #worker(): Promise> { + if (this.#_worker) return this.#_worker; + + const worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module", name: "full-text-search" }); + this.#_worker = Comlink.wrap(worker); + await this.#_worker.init(this.#jar.jar.name); + + console.log("Indexing decompiled sources..."); + const startTime = performance.now(); + await onDecompiledSources(this.#jar.jar, async (className, source) => { + // console.log("fts", className); + this.#_worker!.index(className, source); + }); + const elapsedMs = performance.now() - startTime; + console.log(`Finished in ${elapsedMs.toFixed(3)} ms`); + + return this.#_worker; + }; + + async destroy() { + await this.#_worker?.destroy(); + } + + async find(query: string, options?: FullTextSearchOptions): Promise { + const worker = await this.#worker(); + return await worker.find(query, options); + } + + async findByRegex(pattern: string, flags: string, options?: FullTextSearchOptions): Promise { + const worker = await this.#worker(); + return await worker.findByRegex(pattern, flags, options); + } +} diff --git a/src/workers/full-text-search/worker.ts b/src/workers/full-text-search/worker.ts new file mode 100644 index 0000000..e9fbaaa --- /dev/null +++ b/src/workers/full-text-search/worker.ts @@ -0,0 +1,85 @@ +import * as Comlink from "comlink"; + +export interface FullTextSearchOptions { + pre?: string; + post?: string; + ellipsis?: string; + maxTokens?: number; +} + +export interface FullTextSearchRegion { + start: number; + end: number; + snippet: string; +} + +export interface FullTextSearchResult { + key: string; + regions: FullTextSearchRegion[] +} + +export class FullTextSearchWorker { + #sources = new Map(); + #enc = new TextEncoder(); + + init(_name: string): void {} + + destroy() { + close(); + } + + index(key: string, source: string) { + this.#sources.set(key, source); + } + + find(query: string, options?: FullTextSearchOptions): FullTextSearchResult[] { + return this.findByRegex(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i', options); + } + + findByRegex(pattern: string, flags: string, options?: FullTextSearchOptions): FullTextSearchResult[] { + const re = new RegExp(pattern, flags.includes('g') ? flags : flags + 'g'); + const pre = options?.pre ?? "["; + const post = options?.post ?? "]"; + const ellipsis = options?.ellipsis ?? "…"; + const contextChars = 80; + + console.log("Starting search..."); + const startTime = performance.now(); + + const results: FullTextSearchResult[] = []; + + for (const [key, source] of this.#sources) { + re.lastIndex = 0; + const regions: FullTextSearchRegion[] = []; + + let m: RegExpExecArray | null; + while ((m = re.exec(source)) !== null) { + const charStart = m.index; + const charEnd = charStart + m[0].length; + + const byteStart = this.#enc.encode(source.slice(0, charStart)).length; + const byteEnd = byteStart + this.#enc.encode(m[0]).length; + + const snipCharStart = Math.max(0, charStart - contextChars); + const snipCharEnd = Math.min(source.length, charEnd + contextChars); + const snippet = + (snipCharStart > 0 ? ellipsis : '') + + source.slice(snipCharStart, charStart) + + pre + m[0] + post + + source.slice(charEnd, snipCharEnd) + + (snipCharEnd < source.length ? ellipsis : ''); + + regions.push({ start: byteStart, end: byteEnd, snippet }); + } + + if (regions.length > 0) { + results.push({ key, regions }); + } + } + + const elapsedMs = performance.now() - startTime; + console.log(`Finished in ${elapsedMs.toFixed(3)} ms`); + return results; + } +} +Comlink.expose(new FullTextSearchWorker());