Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
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
28 changes: 27 additions & 1 deletion scripts/test-fuzzy.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,32 @@ assert.deepStrictEqual(
[0, 10, 20],
'asset relevance scores should be deterministic',
);
passed += 3;
const queryPhrase = [
{ label: 'Captain Footman.mdx', value: 'imports\\units\\CaptainFootman.mdx' },
{ label: 'Footman.mdx', value: 'imports\\units\\Footman.mdx' },
{ label: 'Footman Portrait.mdx', value: 'imports\\units\\FootmanPortrait.mdx' },
];
const phraseResults = queryPhrase
.map((item, index) => ({ ...item, index, score: assetSearchScore('footman captain', item.label, item.value, '', fuzzyMatch) }))
.filter((item) => Number.isFinite(item.score))
.sort((a, b) => a.score - b.score || a.index - b.index);
assert.strictEqual(
phraseResults[0]?.label,
'Captain Footman.mdx',
'asset tokenized scoring should keep the intended winner first',
);
assert.ok(
phraseResults.some((item) => item.label === 'Footman.mdx'),
'lenient multi-token search should keep an asset when one query token is missing',
);
assert.ok(
Number.isFinite(assetSearchScore('captain', 'Captain Footman.mdx', 'imports\\units\\CaptainFootman.mdx', '', fuzzyMatch)),
'a single token should match a multi-token asset label',
);
assert.ok(
Number.isFinite(assetSearchScore('human footman', 'Captain Footman.mdx', 'imports\\units\\CaptainFootman.mdx', '', fuzzyMatch)),
'lenient multi-token search should keep a relevant partial match',
);
passed += 4;

console.log(`fuzzy unit tests passed (${passed} assertions)`);
39 changes: 30 additions & 9 deletions src/features/preview/fuzzy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ export function fuzzyMatch(query: string, text: string): boolean {
* `.toString()`. Its fuzzy matcher is an explicit argument because a bundled function's source
* cannot safely refer back to another symbol in the extension module. Search individual fields
* instead of one joined haystack so a query cannot be assembled from unrelated letters across a
* label, path, and detail.
* label, path, and detail. Multi-token queries are intentionally lenient: matching every token gets
* the best rank, but an asset matching only some tokens remains visible with a coverage penalty.
*/
export function assetSearchScore(
query: string,
Expand All @@ -63,6 +64,7 @@ export function assetSearchScore(
): number {
const q = String(query === null || query === undefined ? '' : query).toLowerCase().trim();
if (!q) return 0;
const tokens = q.split(/\s+/).filter(Boolean);

const rawLabel = String(label === null || label === undefined ? '' : label);
const normalizedValue = String(value === null || value === undefined ? '' : value).replace(/\\/g, '/');
Expand All @@ -72,13 +74,32 @@ export function assetSearchScore(
const labelStem = normalizedLabel.replace(/\.[^.]+$/, '');
const labelWords = rawLabel.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase();
const valueWords = normalizedValue.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase();
const normalizedDetail = String(detail === null || detail === undefined ? '' : detail).toLowerCase();

if (labelStem === q || stem === q) return 0;
if (labelStem.startsWith(q) || stem.startsWith(q)) return 10;
if (normalizedLabel.includes(q) || basename.includes(q)) return 20;
if (labelWords.includes(q)) return 30;
if (valueWords.includes(q)) return 40;
if (String(detail === null || detail === undefined ? '' : detail).toLowerCase().includes(q)) return 50;
if (matchesFuzzy(q, labelStem) || matchesFuzzy(q, stem)) return 80;
return Number.POSITIVE_INFINITY;
const scoreToken = (token: string): number => {
if (labelStem === token || stem === token) return 0;
if (labelStem.startsWith(token) || stem.startsWith(token)) return 10;
if (normalizedLabel.includes(token) || basename.includes(token)) return 20;
if (labelWords.includes(token)) return 30;
if (valueWords.includes(token)) return 40;
if (normalizedDetail.includes(token)) return 50;
if (matchesFuzzy(token, labelStem) || matchesFuzzy(token, stem)) return 80;
return Number.POSITIVE_INFINITY;
};

let total = 0;
let matchedTokens = 0;
for (const token of tokens) {
const score = scoreToken(token);
if (Number.isFinite(score)) {
total += score;
matchedTokens++;
}
}

// Keep useful partial results visible for a lenient picker. A fully matching result always beats
// a partial one, while the per-token quality still orders exact/prefix/substring/fuzzy matches.
if (!matchedTokens) return Number.POSITIVE_INFINITY;
const missingTokens = tokens.length - matchedTokens;
return total / matchedTokens + missingTokens * 100 + (tokens.length - 1);
}
150 changes: 124 additions & 26 deletions src/install/downloader.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,32 @@
'use strict';

import * as fs from 'fs';
import { ClientRequest } from 'http';
import * as path from 'path';
import * as https from 'https';
import * as vscode from 'vscode';
import { NIGHTLY_RELEASE_BY_TAG_API, NIGHTLY_COMMIT_API, WURSTSETUP_RELEASE } from '../paths';
import StreamZip = require('node-stream-zip');

const DOWNLOAD_TIMEOUT_MS = 30_000;
const MAX_REDIRECTS = 5;

function clearTimer(timer: ReturnType<typeof setTimeout> | null): void {
if (timer) clearTimeout(timer);
}

function cleanupDownloadedFile(destination: string): void {
try { fs.unlinkSync(destination); } catch {}
}

function resolveRedirect(baseUrl: string, location: string): string {
try { return new URL(location, baseUrl).toString(); } catch { return location; }
}

export function githubJson<T = any>(url: string): Promise<T> {
return new Promise((resolve, reject) => {
let done = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const req = https.request(url, {
method: 'GET',
headers: {
Expand All @@ -17,18 +35,47 @@ export function githubJson<T = any>(url: string): Promise<T> {
'X-GitHub-Api-Version': '2022-11-28',
},
}, (res) => {
clearTimer(timer);
Comment thread
Frotty marked this conversation as resolved.
Outdated
if (!res.statusCode || res.statusCode >= 400) {
reject(new Error(`GitHub API error: HTTP ${res.statusCode}`));
res.resume();
if (!done) {
done = true;
reject(new Error(`GitHub API error: HTTP ${res.statusCode}`));
}
return;
}
const chunks: Buffer[] = [];
res.on('data', (d) => chunks.push(Buffer.from(d)));
res.on('end', () => {
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
catch (e) { reject(e); }
try {
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
if (!done) {
done = true;
resolve(parsed);
}
} catch (error) {
if (!done) {
done = true;
reject(error);
}
} finally {
clearTimer(timer);
}
});
});
req.on('error', reject);
timer = setTimeout(() => {
req.destroy();
if (!done) {
done = true;
reject(new Error(`GitHub API request timed out after ${DOWNLOAD_TIMEOUT_MS}ms`));
}
}, DOWNLOAD_TIMEOUT_MS);
req.on('error', (error) => {
clearTimer(timer);
if (done) return;
done = true;
reject(error);
});
req.end();
});
}
Expand Down Expand Up @@ -78,46 +125,97 @@ export async function downloadFileWithProgress(
onPct?: (pct: number) => void,
cancellationToken?: vscode.CancellationToken
): Promise<number> {
const maxRedirects = 5;
fs.mkdirSync(path.dirname(destination), { recursive: true });

return new Promise<number>((resolve, reject) => {
let received = 0, total = 0, cancelled = false;
if (cancellationToken) cancellationToken.onCancellationRequested(() => (cancelled = true));
let received = 0;
let total = 0;
let cancelled = false;
let settled = false;
let request: ClientRequest | null = null;
let output: fs.WriteStream | null = null;
let timeout: ReturnType<typeof setTimeout> | null = null;
if (cancellationToken) cancellationToken.onCancellationRequested(() => {
if (settled) return;
cancelled = true;
finishFailure(new Error('Download cancelled by user'));
});

const clearDownloadTimeout = () => clearTimer(timeout);
const finishFailure = (error: Error) => {
if (settled) return;
settled = true;
clearDownloadTimeout();
if (request) request.destroy();
if (output) {
output.destroy();
}
cleanupDownloadedFile(destination);
reject(error);
};

const finishSuccess = () => {
if (settled) return;
clearDownloadTimeout();
try {
const size = fs.statSync(destination).size;
settled = true;
resolve(size);
} catch (error) {
finishFailure(error instanceof Error ? error : new Error(String(error)));
}
};

function requestUrl(currentUrl: string, redirects: number) {
if (cancelled) return reject(new Error('Download cancelled by user'));
if (redirects > maxRedirects) return reject(new Error('Too many redirects'));
const requestUrl = (currentUrl: string, redirects: number) => {
if (settled) return;
if (cancelled) return finishFailure(new Error('Download cancelled by user'));
if (redirects > MAX_REDIRECTS) return finishFailure(new Error('Too many redirects'));

const req = https.get(currentUrl, (res) => {
clearDownloadTimeout();
const req = https.get(currentUrl, { headers: { 'User-Agent': 'wurst4vscode' } }, (res) => {
clearDownloadTimeout();
if ([301, 302, 303, 307, 308].includes(res.statusCode!)) {
const loc = res.headers.location;
if (!loc) return reject(new Error('Redirect without Location header'));
if (!loc) return finishFailure(new Error('Redirect without Location header'));
res.destroy();
return requestUrl(loc, redirects + 1);
return requestUrl(resolveRedirect(currentUrl, String(loc)), redirects + 1);
}
if (res.statusCode !== 200) return reject(new Error(`Download failed: HTTP ${res.statusCode}`));
if (res.statusCode !== 200) {
const status = res.statusCode == null ? 'unknown' : res.statusCode;
res.destroy();
return finishFailure(new Error(`Download failed: HTTP ${status}`));
}

total = parseInt(res.headers['content-length'] || '0', 10);
const fileStream = fs.createWriteStream(destination);
output = fs.createWriteStream(destination);

// eslint-disable-next-line sonarjs/no-nested-functions -- TODO(lint-cleanup): pre-existing Node callback-style download logic; tracked for an async/await refactor rather than a rushed change to this path.
res.on('data', (chunk) => {
if (cancelled) { req.destroy(); return; }
if (cancelled) return finishFailure(new Error('Download cancelled by user'));
received += chunk.length;
if (total > 0 && onPct) onPct((received / total) * 100);
});
res.pipe(fileStream);
// eslint-disable-next-line sonarjs/no-nested-functions -- TODO(lint-cleanup): pre-existing Node callback-style download logic; tracked for an async/await refactor rather than a rushed change to this path.
fileStream.on('finish', () => {
fileStream.close();
if (cancelled) return reject(new Error('Download cancelled by user'));
resolve(fs.statSync(destination).size);
output.on('finish', () => {
output?.close();
if (cancelled) return finishFailure(new Error('Download cancelled by user'));
finishSuccess();
});
// eslint-disable-next-line sonarjs/no-nested-functions -- TODO(lint-cleanup): see above
res.on('error', (err) => { fs.unlink(destination, () => {}); reject(err); });
// eslint-disable-next-line sonarjs/no-nested-functions -- TODO(lint-cleanup): pre-existing Node callback-style download logic; tracked for an async/await refactor rather than a rushed change to this path.
res.on('error', (err) => { finishFailure(err); });
// eslint-disable-next-line sonarjs/no-nested-functions -- TODO(lint-cleanup): pre-existing Node callback-style download logic; tracked for an async/await refactor rather than a rushed change to this path.
output.on('error', (err) => { finishFailure(err); });
res.pipe(output);
});
req.setTimeout(DOWNLOAD_TIMEOUT_MS, () => {
finishFailure(new Error(`Download timed out after ${DOWNLOAD_TIMEOUT_MS}ms`));
});
// eslint-disable-next-line sonarjs/no-nested-functions -- TODO(lint-cleanup): see above
req.on('error', (err) => { fs.unlink(destination, () => {}); reject(err); });
}
req.on('error', (err) => { finishFailure(err); });
request = req;
timeout = setTimeout(() => {
finishFailure(new Error(`Download timed out after ${DOWNLOAD_TIMEOUT_MS}ms`));
}, DOWNLOAD_TIMEOUT_MS);
};

requestUrl(url, 0);
});
Expand Down
6 changes: 4 additions & 2 deletions src/webview/objModEditor/assetBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,9 @@ export function renderAssetGrid() {
: (o.iconPath
? '<span class="object-icon" data-key="ab:' + esc(o.value) + '" data-icon="' + esc(o.iconPath) + '"></span>'
: '<span class="object-icon missing"></span>');
const previewHint = activeTab === 'model' ? ' — Ctrl+click to open full preview' : '';
const previewHint = activeTab
? ' — Ctrl+click to open in previewer'
: '';
return '<button type="button" class="ab-card" data-value="' + esc(o.value) + '" data-search-score="' + score + '" aria-label="' + esc(o.label + ' — ' + o.value) + '" title="' + esc(o.label + ' — ' + o.value + previewHint) + '">' +
icon + '<span class="ab-card-label">' + esc(o.label) + '</span></button>';
}).join('');
Expand Down Expand Up @@ -226,7 +228,7 @@ export function setupAssetBrowser() {
if (grid) grid.addEventListener('click', e => {
if (e.target.closest('#ab-catalog-retry')) { requestAssetCatalog(); return; }
const card = e.target.closest('.ab-card[data-value]');
if (card && assetBrowserUi.activeTab === 'model' && (e.ctrlKey || e.metaKey)) {
if (card && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
vscodeApi.postMessage({ type: 'openAsset', path: card.getAttribute('data-value') || '' });
return;
Expand Down
Loading