-
Notifications
You must be signed in to change notification settings - Fork 41
Added grep / full-text search across decompiled source, along with text highlighting for the grep results #117
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<GrepMatch[]>([]); | ||
| export const grepRunning = new BehaviorSubject<boolean>(false); | ||
| export const grepProgress = new BehaviorSubject<GrepProgress>({ 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); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HTMLInputElement>) => { | ||
| 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 ( | ||
| <Flex vertical gap={6} style={{ height: '100%', overflow: 'hidden' }}> | ||
| <Input | ||
| placeholder="Search in source..." | ||
| value={query} | ||
| onChange={e => setQuery(e.target.value)} | ||
| onKeyDown={handleKeyDown} | ||
| allowClear | ||
| disabled={running} | ||
| /> | ||
|
|
||
| <Flex gap={8} wrap="wrap" style={{ fontSize: '12px' }}> | ||
| <Checkbox checked={useRegex} onChange={e => setUseRegex(e.target.checked)} disabled={running}> | ||
| <Text style={{ fontSize: '12px' }}>.*</Text> | ||
| </Checkbox> | ||
| <Checkbox checked={caseSensitive} onChange={e => setCaseSensitive(e.target.checked)} disabled={running}> | ||
| <Text style={{ fontSize: '12px' }}>Aa</Text> | ||
| </Checkbox> | ||
| <Checkbox checked={wholeWord} onChange={e => setWholeWord(e.target.checked)} disabled={running}> | ||
| <Text style={{ fontSize: '12px' }}>|W|</Text> | ||
| </Checkbox> | ||
| </Flex> | ||
|
|
||
| <Space> | ||
| {running ? ( | ||
| <Button size="small" danger onClick={cancelGrep}>Stop</Button> | ||
| ) : ( | ||
| <Button size="small" type="primary" onClick={handleSearch} disabled={!query.trim()}> | ||
| Search | ||
| </Button> | ||
| )} | ||
| {results.length > 0 && ( | ||
| <Text style={{ fontSize: '12px' }}> | ||
| {results.length} match{results.length !== 1 ? 'es' : ''} | ||
| </Text> | ||
| )} | ||
| </Space> | ||
|
|
||
| {running && progress.total > 0 && ( | ||
| <div> | ||
| <Text style={{ fontSize: '11px', color: 'rgba(255,255,255,0.5)' }}> | ||
| {phaseLabel} {progress.done}/{progress.total} | ||
| </Text> | ||
| <Progress | ||
| percent={percent} | ||
| size="small" | ||
| showInfo={false} | ||
| strokeColor={progress.phase === 'scanning' ? '#52c41a' : '#1677ff'} | ||
| /> | ||
| </div> | ||
| )} | ||
|
|
||
| <div style={{ flexGrow: 1, overflowY: 'auto' }}> | ||
| {results.length === 0 && !running && query && ( | ||
| <Text style={{ fontSize: '12px', color: 'rgba(255,255,255,0.4)', padding: '4px 8px', display: 'block' }}> | ||
| No results | ||
| </Text> | ||
| )} | ||
| {results.map((match, i) => ( | ||
| <div | ||
| key={i} | ||
| onClick={() => 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'} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't this be done using a CSS hover selector instead? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nvm, this seems to just be a react thing. Weird framework :/
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree that using CSS would've been better. |
||
| > | ||
| <div style={{ color: 'rgba(255,255,255,0.9)', fontWeight: 500 }}> | ||
| {match.className.split('/').pop()} | ||
| <span style={{ color: 'rgba(255,255,255,0.4)', fontWeight: 'normal' }}> | ||
| :{match.lineNumber} | ||
| </span> | ||
| </div> | ||
| <div style={{ color: 'rgba(255,255,255,0.5)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> | ||
| {match.lineText} | ||
| </div> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| </Flex> | ||
| ); | ||
| }; | ||
|
|
||
| export default GrepPanel; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of having some global flag for cancellation, is there a way we could use something like AbortController?
It might also allow us to use Promise.all here too instead of a manual setTimeout