From 98051d43f1a5153c460308fe381760cdfd05d6b7 Mon Sep 17 00:00:00 2001 From: 0x127 <141627739+00x127@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:37:21 +0300 Subject: [PATCH] added grep / full-text search across decompiled source, along with text highlighting --- src/index.css | 2 + src/logic/GrepSearch.ts | 167 ++++++++++++++++++++++++++++++++++++++++ src/logic/State.ts | 6 +- src/ui/Code.tsx | 17 +++- src/ui/GrepPanel.tsx | 122 +++++++++++++++++++++++++++++ src/ui/SideBar.tsx | 46 +++++++++-- 6 files changed, 347 insertions(+), 13 deletions(-) create mode 100644 src/logic/GrepSearch.ts create mode 100644 src/ui/GrepPanel.tsx diff --git a/src/index.css b/src/index.css index 2234067..3e32472 100644 --- a/src/index.css +++ b/src/index.css @@ -118,3 +118,5 @@ html, body, #root { .ant-tabs-content { height: 100%; } + +.grep-highlight-decoration { background-color: rgba(255, 200, 0, 0.35); border-radius: 2px; } \ No newline at end of file diff --git a/src/logic/GrepSearch.ts b/src/logic/GrepSearch.ts new file mode 100644 index 0000000..fa293b7 --- /dev/null +++ b/src/logic/GrepSearch.ts @@ -0,0 +1,167 @@ +import { BehaviorSubject, firstValueFrom } from 'rxjs'; +import { minecraftJar } from './MinecraftApi'; +import * as decompileWorker from '../workers/decompile/client'; + +export interface GrepMatch { + className: string; + lineNumber: number; + lineText: string; +} + +export interface GrepOptions { + regex?: boolean; + caseSensitive?: boolean; + wholeWord?: boolean; +} + +export interface GrepProgress { + done: number; + total: number; + phase: 'scanning' | 'decompiling'; +} + +export const grepResults = new BehaviorSubject([]); +export const grepRunning = new BehaviorSubject(false); +export const grepProgress = new BehaviorSubject({ done: 0, total: 0, phase: 'scanning' }); + +let cancelFlag = false; + +export function cancelGrep() { + cancelFlag = true; +} + +function extractConstantPoolStrings(bytes: Uint8Array): string[] { + if (bytes.length < 10) return []; + if (bytes[0] !== 0xCA || bytes[1] !== 0xFE || bytes[2] !== 0xBA || bytes[3] !== 0xBE) return []; + + let offset = 8; + const cpCount = (bytes[offset] << 8) | bytes[offset + 1]; + offset += 2; + + const strings: string[] = []; + const decoder = new TextDecoder('utf-8', { fatal: false }); + + for (let i = 1; i < cpCount; i++) { + if (offset >= bytes.length) break; + const tag = bytes[offset++]; + + switch (tag) { + case 1: { + const len = (bytes[offset] << 8) | bytes[offset + 1]; + offset += 2; + strings.push(decoder.decode(bytes.subarray(offset, offset + len))); + offset += len; + break; + } + case 3: case 4: + offset += 4; break; + case 5: case 6: + offset += 8; i++; break; + case 7: case 8: case 16: case 19: case 20: + offset += 2; break; + case 9: case 10: case 11: case 12: case 17: case 18: + offset += 4; break; + case 15: + offset += 3; break; + default: + return strings; + } + } + + return strings; +} + +function buildPattern(query: string, options: GrepOptions): RegExp { + const flags = options.caseSensitive ? 'g' : 'gi'; + if (options.regex) return new RegExp(query, flags); + let escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + if (options.wholeWord) escaped = `\\b${escaped}\\b`; + return new RegExp(escaped, flags); +} + +const SCAN_BATCH = 50; + +export async function runGrep(query: string, options: GrepOptions = {}) { + if (!query.trim()) return; + + cancelFlag = false; + grepResults.next([]); + grepRunning.next(true); + grepProgress.next({ done: 0, total: 0, phase: 'scanning' }); + + try { + const mcJar = await firstValueFrom(minecraftJar); + const jar = mcJar.jar; + const pattern = buildPattern(query, options); + + const classNames = Object.keys(jar.entries) + .filter(k => k.endsWith('.class') && !k.includes('$')) + .map(k => k.replace('.class', '')); + + const total = classNames.length; + grepProgress.next({ done: 0, total, phase: 'scanning' }); + + const candidates: string[] = []; + let scanned = 0; + + for (let i = 0; i < classNames.length; i += SCAN_BATCH) { + if (cancelFlag) break; + + const batch = classNames.slice(i, Math.min(i + SCAN_BATCH, classNames.length)); + + await Promise.all(batch.map(async className => { + const entry = jar.entries[className + '.class']; + if (!entry) return; + try { + const bytes = await entry.bytes(); + const strings = extractConstantPoolStrings(bytes); + for (let s = 0; s < strings.length; s++) { + pattern.lastIndex = 0; + if (pattern.test(strings[s])) { + candidates.push(className); + return; + } + } + } catch (_e) { /* skip */ } + })); + + scanned += batch.length; + grepProgress.next({ done: scanned, total, phase: 'scanning' }); + await new Promise(r => setTimeout(r, 0)); + } + + if (cancelFlag) return; + + const accumulated: GrepMatch[] = []; + grepProgress.next({ done: 0, total: candidates.length, phase: 'decompiling' }); + + for (let i = 0; i < candidates.length; i++) { + if (cancelFlag) break; + + const className = candidates[i]; + try { + const result = await decompileWorker.decompileClass(className, jar); + const lines = result.source.split('\n'); + for (let ln = 0; ln < lines.length; ln++) { + pattern.lastIndex = 0; + if (pattern.test(lines[ln])) { + accumulated.push({ + className, + lineNumber: ln + 1, + lineText: lines[ln].trim(), + }); + } + } + if (accumulated.length > grepResults.value.length) { + grepResults.next(accumulated.slice()); + } + } catch (_e) { /* skip */ } + + grepProgress.next({ done: i + 1, total: candidates.length, phase: 'decompiling' }); + if (i % 4 === 0) await new Promise(r => setTimeout(r, 0)); + } + + } finally { + grepRunning.next(false); + } +} \ No newline at end of file diff --git a/src/logic/State.ts b/src/logic/State.ts index d06b158..72da7ad 100644 --- a/src/logic/State.ts +++ b/src/logic/State.ts @@ -5,8 +5,6 @@ import { getInitialState } from "./Permalink"; const initialState = getInitialState(); -/// All of the user controled global state should be defined here: - export const selectedMinecraftVersion = new BehaviorSubject(initialState.minecraftVersion); export const mobileDrawerOpen = new BehaviorSubject(false); @@ -17,6 +15,7 @@ export const openTabs = new BehaviorSubject(initialTab ? [initialTab] : [ export const tabHistory = new BehaviorSubject(initialState.file ? [initialState.file] : []); export const searchQuery = new BehaviorSubject(""); export const referencesQuery = new BehaviorSubject(""); +export const grepHighlightQuery = new BehaviorSubject(""); export interface SelectedLines { line: number; @@ -27,9 +26,8 @@ export const selectedLines = new BehaviorSubject(initialSt export const diffView = new BehaviorSubject(!!initialState.diff); export const diffLeftSelectedMinecraftVersion = new BehaviorSubject(initialState.diff?.leftMinecraftVersion ?? null); -// Reset selected lines when file changes (skip initial emission to preserve permalink selection) selectedFile.pipe(pairwise()).subscribe(([previousFile, currentFile]) => { if (previousFile !== currentFile) { selectedLines.next(null); } -}); +}); \ No newline at end of file diff --git a/src/ui/Code.tsx b/src/ui/Code.tsx index 6aba5b2..d9d90da 100644 --- a/src/ui/Code.tsx +++ b/src/ui/Code.tsx @@ -33,7 +33,7 @@ import { pendingTokenJump } from './CodeExtensions'; import { bytecode } from '../logic/Settings'; -import { selectedFile, diffView, openTabs, selectedLines, tabHistory, referencesQuery, mobileDrawerOpen } from '../logic/State'; +import { selectedFile, diffView, openTabs, selectedLines, tabHistory, referencesQuery, mobileDrawerOpen, grepHighlightQuery } from '../logic/State'; const IS_ANDROID_CHROME = /Android/.test(navigator.userAgent) && /Chrome/.test(navigator.userAgent); @@ -52,6 +52,8 @@ const Code = () => { const decorationsCollectionRef = useRef(null); const lineHighlightRef = useRef(null); + const grepHighlightRef = useRef(null); + const grepHighlight = useObservable(grepHighlightQuery); const decompileResultRef = useRef(decompileResult); const classListRef = useRef(classList); @@ -319,6 +321,17 @@ const Code = () => { } }, [decompileResult, tokenJump]); + useEffect(() => { + grepHighlightRef.current?.clear(); + if (!editorRef.current || !decompileResult || !grepHighlight) return; + const model = editorRef.current.getModel(); + if (!model) return; + const matches = model.findMatches(grepHighlight, true, false, false, null, false); + grepHighlightRef.current = editorRef.current.createDecorationsCollection( + matches.map(m => ({ range: m.range, options: { inlineClassName: 'grep-highlight-decoration' } })) + ); + }, [decompileResult, grepHighlight]); + // Handle gutter clicks for line linking useEffect(() => { if (!editorRef.current) return; @@ -395,4 +408,4 @@ const Code = () => { ); }; -export default Code; +export default Code; \ No newline at end of file diff --git a/src/ui/GrepPanel.tsx b/src/ui/GrepPanel.tsx new file mode 100644 index 0000000..9e87b2a --- /dev/null +++ b/src/ui/GrepPanel.tsx @@ -0,0 +1,122 @@ +import { useState } from 'react'; +import { Button, Checkbox, Flex, Input, Progress, Space, Typography } from 'antd'; +import { useObservable } from '../utils/UseObservable'; +import { runGrep, cancelGrep, grepResults, grepRunning, grepProgress } from '../logic/GrepSearch'; +import { openCodeTab } from '../logic/tabs'; +import { grepHighlightQuery } from '../logic/State'; + +const { Text } = Typography; + +const GrepPanel = () => { + const [query, setQuery] = useState(''); + const [useRegex, setUseRegex] = useState(false); + const [caseSensitive, setCaseSensitive] = useState(false); + const [wholeWord, setWholeWord] = useState(false); + + const results = useObservable(grepResults) ?? []; + const running = useObservable(grepRunning) ?? false; + const progress = useObservable(grepProgress) ?? { done: 0, total: 0, phase: 'scanning' }; + + const handleSearch = () => { + if (!query.trim() || running) return; + runGrep(query, { regex: useRegex, caseSensitive, wholeWord }); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleSearch(); + }; + + const handleResultClick = (className: string) => { + grepHighlightQuery.next(query); + openCodeTab(className + '.class'); + }; + + const percent = progress.total > 0 + ? Math.round((progress.done / progress.total) * 100) + : 0; + + const phaseLabel = progress.phase === 'scanning' ? 'Scanning...' : 'Decompiling matches...'; + + return ( + + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + allowClear + disabled={running} + /> + + + setUseRegex(e.target.checked)} disabled={running}> + .* + + setCaseSensitive(e.target.checked)} disabled={running}> + Aa + + setWholeWord(e.target.checked)} disabled={running}> + |W| + + + + + {running ? ( + + ) : ( + + )} + {results.length > 0 && ( + + {results.length} match{results.length !== 1 ? 'es' : ''} + + )} + + + {running && progress.total > 0 && ( +
+ + {phaseLabel} {progress.done}/{progress.total} + + +
+ )} + +
+ {results.length === 0 && !running && query && ( + + No results + + )} + {results.map((match, i) => ( +
handleResultClick(match.className)} + style={{ cursor: 'pointer', padding: '2px 8px', fontSize: '12px', borderRadius: '4px', transition: 'background-color 0.15s' }} + onMouseEnter={e => e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.1)'} + onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'} + > +
+ {match.className.split('/').pop()} + + :{match.lineNumber} + +
+
+ {match.lineText} +
+
+ ))} +
+
+ ); +}; + +export default GrepPanel; \ No newline at end of file diff --git a/src/ui/SideBar.tsx b/src/ui/SideBar.tsx index a91538a..41a2259 100644 --- a/src/ui/SideBar.tsx +++ b/src/ui/SideBar.tsx @@ -1,4 +1,5 @@ -import { Button, Divider, Flex, Input } from "antd"; +import { useState } from "react"; +import { Button, Divider, Flex, Input, Segmented } from "antd"; import Header from "./Header"; import FileList from "./FileList"; import type { InputRef, SearchProps } from "antd/es/input"; @@ -11,6 +12,7 @@ import { ArrowLeftOutlined } from "@ant-design/icons"; import { focusSearchEvent } from "../logic/Keybinds"; import { useEffect, useRef } from "react"; import { searchQuery, referencesQuery } from "../logic/State"; +import GrepPanel from "./GrepPanel"; const { Search } = Input; @@ -19,6 +21,7 @@ const SideBar = () => { const currentReferenceQuery = useObservable(referencesQuery); const focusSearch = useObservable(focusSearchEvent); const searchRef = useRef(null); + const [sidebarTab, setSidebarTab] = useState<'classes' | 'grep'>('classes'); useEffect(() => { if (focusSearch) { @@ -44,6 +47,22 @@ const SideBar = () => { return (
+ + {/* Tab switcher */} + {!showReference && ( + setSidebarTab(v as 'classes' | 'grep')} + options={[ + { label: 'Classes', value: 'classes' }, + { label: 'Grep', value: 'grep' }, + ]} + style={{ marginBottom: 4 }} + /> + )} + {showReference ? ( <>