Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
167 changes: 167 additions & 0 deletions src/logic/GrepSearch.ts
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;

Copy link
Copy Markdown

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


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);
}
}
6 changes: 2 additions & 4 deletions src/logic/State.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(initialState.minecraftVersion);

export const mobileDrawerOpen = new BehaviorSubject(false);
Expand All @@ -17,6 +15,7 @@ export const openTabs = new BehaviorSubject<Tab[]>(initialTab ? [initialTab] : [
export const tabHistory = new BehaviorSubject<string[]>(initialState.file ? [initialState.file] : []);
export const searchQuery = new BehaviorSubject("");
export const referencesQuery = new BehaviorSubject("");
export const grepHighlightQuery = new BehaviorSubject<string>("");

export interface SelectedLines {
line: number;
Expand All @@ -27,9 +26,8 @@ export const selectedLines = new BehaviorSubject<SelectedLines | null>(initialSt
export const diffView = new BehaviorSubject<boolean>(!!initialState.diff);
export const diffLeftSelectedMinecraftVersion = new BehaviorSubject<string | null>(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);
}
});
});
17 changes: 15 additions & 2 deletions src/ui/Code.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -52,6 +52,8 @@ const Code = () => {

const decorationsCollectionRef = useRef<editor.IEditorDecorationsCollection | null>(null);
const lineHighlightRef = useRef<editor.IEditorDecorationsCollection | null>(null);
const grepHighlightRef = useRef<editor.IEditorDecorationsCollection | null>(null);
const grepHighlight = useObservable(grepHighlightQuery);
const decompileResultRef = useRef(decompileResult);
const classListRef = useRef(classList);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -395,4 +408,4 @@ const Code = () => {
);
};

export default Code;
export default Code;
122 changes: 122 additions & 0 deletions src/ui/GrepPanel.tsx
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'}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be done using a CSS hover selector instead?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nvm, this seems to just be a react thing. Weird framework :/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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;
Loading