From 702ab66ce541c86fd886b1aedf62489c5ebb9c13 Mon Sep 17 00:00:00 2001 From: Frotty Date: Mon, 10 Aug 2026 10:46:44 +0200 Subject: [PATCH 1/7] Improve objmod tooltip previews --- .vscodeignore | 1 + package.json | 8 +++ scripts/objmod-thumbnail-e2e.js | 91 ++++++++++++++++++++++++ scripts/test-vsix-contents.js | 6 +- scripts/test-webview.js | 29 +++++++- src/features/objModPreview.ts | 34 ++++++--- src/webview/objModEditor/detailsPanel.ts | 7 +- src/webview/objModEditor/fieldDisplay.ts | 11 ++- 8 files changed, 170 insertions(+), 17 deletions(-) diff --git a/.vscodeignore b/.vscodeignore index 60e73d9..a7eb124 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -7,6 +7,7 @@ node_modules/** scripts/** e2e/** wc3data/** +resources/wc3-knowledge-base.json test/** tests/** __tests__/** diff --git a/package.json b/package.json index 3972a59..159ae1b 100644 --- a/package.json +++ b/package.json @@ -790,6 +790,14 @@ "scope": "resource", "description": "Optional path to a .ttf font, relative to the workspace root, for custom glyphs in WC3 object-editor tooltip previews. Leave empty to use the default VS Code font." }, + "wurst.objModTooltipWidth": { + "type": "number", + "default": 280, + "minimum": 160, + "maximum": 1200, + "scope": "resource", + "description": "Maximum width in CSS pixels of WC3 object-editor tooltip preview boxes. This can be set independently per workspace or workspace folder." + }, "wurst.gameExePath": { "type": "string", "default": "", diff --git a/scripts/objmod-thumbnail-e2e.js b/scripts/objmod-thumbnail-e2e.js index 0d7c3ee..d19ee2e 100644 --- a/scripts/objmod-thumbnail-e2e.js +++ b/scripts/objmod-thumbnail-e2e.js @@ -15,6 +15,7 @@ * WURST_OBJMOD_E2E_SEARCH optional model-catalog search query * WURST_OBJMOD_E2E_MAX_MS max warm per-thumbnail lifecycle, default 200ms * WURST_OBJMOD_E2E_TIMEOUT_MS total wait timeout, default 90000 + * WURST_OBJMOD_E2E_FONT_ONLY stop after verifying a configured tooltip font */ const assert = require('assert'); @@ -50,6 +51,7 @@ const maxThumbnailMs = Number(process.env.WURST_OBJMOD_E2E_MAX_MS || 200); // wait up to 30 seconds before creating their first renderer. Leave enough time after that for // extension activation, CASC catalog loading, and the uncached worker renders. const timeoutMs = Number(process.env.WURST_OBJMOD_E2E_TIMEOUT_MS || 90000); +const fontOnly = process.env.WURST_OBJMOD_E2E_FONT_ONLY === '1'; function writeGeneratedObjmodFixture() { const { serializeObjMod } = require('casc-ts/formats'); @@ -289,6 +291,14 @@ class CdpClient { this.nextId = 1; this.pending = new Map(); this.listeners = new Map(); + this.browserDiagnostics = []; + this.on('Log.entryAdded', ({ entry }) => { + if (entry?.text) this.browserDiagnostics.push(entry.text); + }); + this.on('Runtime.consoleAPICalled', ({ type, args }) => { + const message = (args || []).map((arg) => arg.value ?? arg.description ?? '').join(' '); + if (message) this.browserDiagnostics.push(`${type}: ${message}`); + }); } async connect() { @@ -369,6 +379,7 @@ async function waitForWebviewContext(client) { if (attached?.sessionId) { attachedTargets.add(target.targetId); await client.send('Runtime.enable', {}, attached.sessionId); + await client.send('Log.enable', {}, attached.sessionId).catch(() => undefined); } } catch { attachedTargets.add(target.targetId); @@ -413,6 +424,70 @@ async function waitForEval(client, sessionId, contextId, expression, predicate, throw new Error(`Timed out waiting for ${label}. Last value: ${JSON.stringify(last)}`); } +async function assertTooltipFont(client, sessionId, contextId) { + const status = await evalInContext(client, sessionId, contextId, `(async function () { + var family = 'WurstProjectTooltip'; + var face = Array.from(document.fonts).find(function (candidate) { return candidate.family === family; }); + if (!face) return { configured: false }; + var resource = {}; + try { + var rules = Array.from(document.styleSheets).flatMap(function (sheet) { return Array.from(sheet.cssRules || []); }); + var fontRule = rules.find(function (rule) { return rule.cssText && rule.cssText.indexOf(family) >= 0 && rule.style && rule.style.src; }); + resource.src = fontRule ? fontRule.style.src : ''; + var match = resource.src.match(/url\\(["']?([^"')]+)["']?\\)/); + if (match) { + var response = await fetch(match[1]); + var bytes = await response.arrayBuffer(); + resource.ok = response.ok; + resource.status = response.status; + resource.type = response.type; + resource.bytes = bytes.byteLength; + resource.magic = Array.from(new Uint8Array(bytes.slice(0, 4))); + } + } catch (error) { + resource.error = String(error); + } + try { + var loadedFaces = await document.fonts.load('16px "WurstProjectTooltip"'); + var target = document.querySelector('.tt-collapsed-box, .tt-preview'); + var computed = target ? getComputedStyle(target).fontFamily : ''; + var outside = Array.from(document.querySelectorAll('td.id, td.num, td.value, td.label, .cell-edit-val')) + .filter(function (candidate) { return !candidate.closest('.tt-collapsed-box, .tt-preview'); }) + .slice(0, 50) + .map(function (candidate) { + return { + selector: candidate.tagName.toLowerCase() + '.' + candidate.className, + font: getComputedStyle(candidate).fontFamily, + text: String(candidate.textContent || '').trim().slice(0, 40), + }; + }); + return { + configured: true, + loaded: loadedFaces.some(function (candidate) { return candidate.family === family; }), + status: face.status, + computed: computed, + applied: target ? computed.indexOf(family) >= 0 : null, + editorFont: getComputedStyle(document.documentElement).getPropertyValue('--vscode-editor-font-family').trim(), + outsideUsingTooltipFont: outside.filter(function (candidate) { return candidate.font.indexOf(family) >= 0; }), + resource: resource, + }; + } catch (error) { + return { configured: true, loaded: false, status: face.status, error: String(error), resource: resource }; + } + })()`); + if (!status.configured) { + log('tooltip font not configured; font assertion skipped'); + return; + } + status.diagnostics = client.browserDiagnostics.filter((message) => /font|ots/i.test(message)).slice(-20); + assert.equal(status.error, undefined, `configured tooltip font failed to load: ${JSON.stringify(status)}`); + assert.equal(status.loaded, true, `configured tooltip font did not produce a loaded face: ${JSON.stringify(status)}`); + assert.equal(status.status, 'loaded', `configured tooltip font has unexpected status: ${JSON.stringify(status)}`); + assert.equal(status.applied, true, `configured tooltip font is not applied to the tooltip box: ${JSON.stringify(status)}`); + assert.deepEqual(status.outsideUsingTooltipFont, [], `configured tooltip font leaked outside tooltip boxes: ${JSON.stringify(status)}`); + log(`tooltip font loaded and scoped (${status.computed}); editor font=${status.editorFont || '(default)'}`); +} + async function assertObjmodEditorBasics(client, sessionId, contextId) { await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.forceNarrowLayout(true)'); @@ -574,6 +649,17 @@ async function assertObjmodEditorBasics(client, sessionId, contextId) { assert.ok(customRows.some((row) => String(row.fieldId).toLowerCase() === 'anam' && row.overridden), 'custom object should keep modified fields'); } + await waitForEval( + client, + sessionId, + contextId, + '!!document.querySelector(".tt-collapsed-box, .tt-preview")', + (value) => value === true, + 'rendered tooltip box', + 15000, + ); + await assertTooltipFont(client, sessionId, contextId); + await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.openModelAssetBrowser()'); await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.searchModelAssetBrowser("LordaeronTree")'); const assetState = await waitForEval( @@ -692,6 +778,11 @@ async function main() { const { sessionId, contextId } = await waitForWebviewContext(client); log('asserting objmod editor basics'); await assertObjmodEditorBasics(client, sessionId, contextId); + if (fontOnly) { + log('font-only check passed'); + passed = true; + return; + } log('asserting model thumbnails'); await evalInContext(client, sessionId, contextId, 'window.__wurstModelThumbDebug.openModelAssetBrowser()'); if (searchQuery) { diff --git a/scripts/test-vsix-contents.js b/scripts/test-vsix-contents.js index 0f6bf9f..e8b770a 100644 --- a/scripts/test-vsix-contents.js +++ b/scripts/test-vsix-contents.js @@ -34,9 +34,13 @@ const forbidden = [ const leaked = files.filter((file) => forbidden.some((pattern) => pattern.test(file))); assert.deepStrictEqual(leaked, [], `Test/development files would be packaged:\n${leaked.join('\n')}`); -for (const required of ['package.json', 'README.md', 'dist/extension.js', 'resources/wc3-knowledge-base.json']) { +for (const required of ['package.json', 'README.md', 'dist/extension.js']) { assert(files.includes(required), `Required release file is missing: ${required}`); } +assert( + !files.includes('resources/wc3-knowledge-base.json'), + 'Compiler knowledge-base JSON must not be bundled in the extension', +); const readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8'); const readmeImages = [...readme.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)].map((match) => match[1]); diff --git a/scripts/test-webview.js b/scripts/test-webview.js index 827c3b6..766d290 100644 --- a/scripts/test-webview.js +++ b/scripts/test-webview.js @@ -915,11 +915,36 @@ function testObjModTooltipFontWiring() { assert.ok(host.includes('webview.asWebviewUri(fontUri)'), 'project fonts must be converted to webview resource URIs'); assert.ok(host.includes('font-src ${context.webview.cspSource}'), 'objmod CSP must permit the project font resource'); assert.ok(host.includes('@font-face'), 'objmod should declare the project font for tooltip previews'); - assert.ok(host.includes(".tt-collapsed-box,\n.tt-preview"), 'the custom font should be scoped to tooltip previews'); + assert.ok(!host.includes("Buffer.from(bytes).toString('base64')"), 'project fonts should not be embedded as large data URLs'); + assert.ok(host.includes(".tt-collapsed-box,\n.tt-preview {"), 'the custom font should be assigned only to tooltip boxes'); + assert.ok(!host.includes('.tt-collapsed-box *'), 'the custom font should not use descendant-wide override selectors'); assert.ok(packageJson.includes('wurst.objModTooltipFont'), 'the tooltip font setting should be contributed by the extension'); assert.equal(packageData.contributes.configuration.properties['wurst.objModTooltipFont'].default, '', 'the tooltip font setting must default to disabled'); } +function testObjModTooltipWidthWiring() { + const host = fs.readFileSync(path.join(root, 'src/features/objModPreview.ts'), 'utf8'); + const packageData = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + const setting = packageData.contributes.configuration.properties['wurst.objModTooltipWidth']; + + assert.equal(setting.default, 280, 'tooltip width should preserve the existing default'); + assert.equal(setting.minimum, 160, 'tooltip width should reject unusably narrow values'); + assert.equal(setting.maximum, 1200, 'tooltip width should have a sensible upper bound'); + assert.equal(setting.scope, 'resource', 'tooltip width should be remembered per workspace folder'); + assert.ok(host.includes("get(TOOLTIP_WIDTH_SETTING, DEFAULT_TOOLTIP_WIDTH_PX)"), 'objmod should read the configured tooltip width for the document resource'); + assert.ok(host.includes('--wc3-tip-width: ${tooltipWidthPx}px;'), 'objmod should apply the configured width to tooltip preview CSS'); + assert.ok(!host.includes('--wc3-tip-width: 280px;'), 'tooltip preview width should not remain hard-coded'); +} + +function testObjModTooltipPreviewHeaders() { + const fieldDisplay = fs.readFileSync(path.join(root, 'src/webview/objModEditor/fieldDisplay.ts'), 'utf8'); + const detailsPanel = fs.readFileSync(path.join(root, 'src/webview/objModEditor/detailsPanel.ts'), 'utf8'); + + assert.ok(fieldDisplay.includes("replace(/^(?:unit|building)\\s*\\/\\s*/i, '')"), 'unit and building tooltip markers should be hidden in previews'); + assert.ok(detailsPanel.includes('renderWc3Colors(original)'), 'tooltip editing should restore the unmodified raw value'); + assert.ok(detailsPanel.includes('renderWc3Colors(tooltipPreviewText(value))'), 'tooltip collapse should restore the cleaned preview'); +} + function testObjModEditorTypeAndRecoveryGuards() { const host = fs.readFileSync(path.join(root, 'src/features/objModPreview.ts'), 'utf8'); const webviewFiles = [ @@ -963,6 +988,8 @@ async function main() { await testIssueReportingPrivacyAndDeduplication(); testObjModSaveCommitsFocusedEditor(); testObjModTooltipFontWiring(); + testObjModTooltipWidthWiring(); + testObjModTooltipPreviewHeaders(); console.log('webview harness tests passed'); } diff --git a/src/features/objModPreview.ts b/src/features/objModPreview.ts index 0e7d864..a2a9ab3 100644 --- a/src/features/objModPreview.ts +++ b/src/features/objModPreview.ts @@ -48,6 +48,10 @@ const TYPE_LABELS: Record = { const TOOLTIP_FONT_SETTING = 'objModTooltipFont'; const TOOLTIP_FONT_FAMILY = 'WurstProjectTooltip'; +const TOOLTIP_WIDTH_SETTING = 'objModTooltipWidth'; +const DEFAULT_TOOLTIP_WIDTH_PX = 280; +const MIN_TOOLTIP_WIDTH_PX = 160; +const MAX_TOOLTIP_WIDTH_PX = 1200; // Inline string fields in objmod files are length-capped by the World Editor; longer // values must be externalized into war3map.wts as a TRIGSTR_ reference. The exact cap @@ -1618,8 +1622,11 @@ async function existingTtfUri(uri: vscode.Uri): Promise } } -/** Resolve the project font used by tooltip previews, without exposing arbitrary filesystem paths. */ -async function resolveTooltipFontUri(documentUri: vscode.Uri, webview: vscode.Webview): Promise { +/** Resolve the configured project font through VS Code's supported webview resource pipeline. */ +async function resolveTooltipFontUri( + documentUri: vscode.Uri, + webview: vscode.Webview, +): Promise { const workspaceFolder = vscode.workspace.getWorkspaceFolder(documentUri); if (!workspaceFolder) return undefined; const root = workspaceFolder.uri.fsPath; @@ -1637,6 +1644,7 @@ async function resolveTooltipFontUri(documentUri: vscode.Uri, webview: vscode.We console.warn(`[wurst-objmod] tooltip font not found or not a .ttf file: ${configured}`); return undefined; } + console.info(`[wurst-objmod] using tooltip font: ${configuredPath}`); return webview.asWebviewUri(fontUri).toString(); } @@ -1653,6 +1661,12 @@ async function buildHtml( const typeLabel = TYPE_LABELS[parsed.ext.slice(1)] ?? parsed.ext.slice(1).toUpperCase(); const triggerStrings = loadTriggerStringsForUri(context.uri); const { objects, metadataSource } = await buildModel(parsed, triggerStrings); + const tooltipFontUri = await resolveTooltipFontUri(context.uri, context.webview); + const configuredTooltipWidth = vscode.workspace.getConfiguration('wurst', context.uri) + .get(TOOLTIP_WIDTH_SETTING, DEFAULT_TOOLTIP_WIDTH_PX); + const tooltipWidthPx = Number.isFinite(configuredTooltipWidth) + ? Math.min(MAX_TOOLTIP_WIDTH_PX, Math.max(MIN_TOOLTIP_WIDTH_PX, Math.round(configuredTooltipWidth))) + : DEFAULT_TOOLTIP_WIDTH_PX; // A cross-reference jump (see locateObjectAcrossSiblings) stashes the object to land on here before // opening/revealing this file — consumed once so a later plain reopen falls back to the first object. // `isPendingJump` tells the webview this really is a deliberate navigation (state.ts uses it to @@ -1694,15 +1708,17 @@ async function buildHtml( ? `
Warcraft III game data not found — showing raw field ids only (no icons, categories, or friendly labels). Checked the default install locations${registrySuffix}; if Warcraft III is installed somewhere unusual, set the "wurst.wc3path" setting to its folder and reopen this file. Run "Wurst: Show WC3 Data Log" from the Command Palette to see exactly what was checked.
` : ''; - const tooltipFontUri = await resolveTooltipFontUri(context.uri, context.webview); const tooltipFontCss = tooltipFontUri ? ` @font-face { font-family: '${TOOLTIP_FONT_FAMILY}'; src: url(${JSON.stringify(tooltipFontUri)}) format('truetype'); - font-display: swap; + font-style: normal; + font-weight: normal; } .tt-collapsed-box, -.tt-preview { font-family: '${TOOLTIP_FONT_FAMILY}', var(--vscode-font-family, sans-serif); } +.tt-preview { + font-family: '${TOOLTIP_FONT_FAMILY}', var(--vscode-font-family, sans-serif); +} ` : ''; return buildPage({ @@ -1717,11 +1733,9 @@ async function buildHtml( preview is intentionally dark in every theme — using --input-bg would hide light text in light themes. */ --wc3-tip-bg: #000; --wc3-tip-fg: #fff; - /* Approximate in-game unit-tooltip width, so text wraps roughly where it would in Warcraft III - itself instead of stretching to fill whatever the details column happens to be. This is a best - effort figure (the game's actual wrap width also shifts a little with the player's UI console - scale) rather than a verified exact pixel match. */ - --wc3-tip-width: 280px; + /* The game's effective wrap width varies with UI scale and custom font metrics, so this maximum is + configurable per workspace/folder through wurst.objModTooltipWidth. */ + --wc3-tip-width: ${tooltipWidthPx}px; /* Rendered size of each gold corner/edge border tile (see the .tt-collapsed-box, .tt-preview rule) — the box's own padding is set to clear this, so the frame sits flush at the edge instead of cutting across the text. */ diff --git a/src/webview/objModEditor/detailsPanel.ts b/src/webview/objModEditor/detailsPanel.ts index 83527f3..289a295 100644 --- a/src/webview/objModEditor/detailsPanel.ts +++ b/src/webview/objModEditor/detailsPanel.ts @@ -3,7 +3,7 @@ import { esc, renderWc3Colors } from '../objModWebviewUtils'; import { batch, effect, untracked } from '../signals'; import { details, detailCache, pendingDetails, failedDetails, ui, vscodeApi, iconLoader, initial, objects } from './state'; import { categoryLabel, categoryKey, objectIconHtml, detailsTitleHtml, matches, selectObject } from './objectTree'; -import { valueCell, postEdit, setModValue, editorHtml, collapsedView, normalizeNumberValue, needsColorEditor, tooltipToolbarHtml } from './fieldDisplay'; +import { valueCell, postEdit, setModValue, editorHtml, collapsedView, normalizeNumberValue, needsColorEditor, tooltipToolbarHtml, tooltipPreviewText } from './fieldDisplay'; import { observeModelThumbs } from './modelThumbnails'; import { wireColorBar, setCaretEnd, richToWc3, forcePlainTextPaste, forceWc3ColorCopy, wrapColor, applyRichColor, updateColorSwatch, containsNode } from './richTextEditor'; import { openAssetBrowser } from './assetBrowser'; @@ -418,6 +418,7 @@ export function enterTooltipEdit(collapsed, mi, clickEvent) { // editing so typing doesn't start by appending to that literal text. No real content existed to click // into, so the captured range (if any) is meaningless here too. if (!original) { body.innerHTML = ''; range = null; } + else body.innerHTML = renderWc3Colors(original); body.contentEditable = 'true'; body.spellcheck = false; body.classList.add('edit-rich'); // reused by Ctrl+S / undo-vs-native-undo detection elsewhere @@ -607,7 +608,7 @@ export function exitTooltipEdit(commit) { const mods = detailCache.get(ui.selectedKey) || []; const mod = mods[mi]; const value = mod && mod.editValue != null ? String(mod.editValue) : ''; - body.innerHTML = value ? renderWc3Colors(value) : '(empty)'; + body.innerHTML = value ? renderWc3Colors(tooltipPreviewText(value)) : '(empty)'; } export function markModified(el, mod) { @@ -748,7 +749,7 @@ export function updateFieldCell(mods, mod) { const rich = details.querySelector('.edit-rich[data-mi="' + mi + '"]'); if (rich) rich.innerHTML = renderWc3Colors(el.value); const pv = details.querySelector('.tt-preview[data-preview-for="' + mi + '"]'); - if (pv) pv.innerHTML = renderWc3Colors(el.value); + if (pv) pv.innerHTML = renderWc3Colors(tooltipPreviewText(el.value)); return; } const rich = details.querySelector('.edit-rich[data-mi="' + mi + '"]'); diff --git a/src/webview/objModEditor/fieldDisplay.ts b/src/webview/objModEditor/fieldDisplay.ts index 11ade89..56fa963 100644 --- a/src/webview/objModEditor/fieldDisplay.ts +++ b/src/webview/objModEditor/fieldDisplay.ts @@ -13,6 +13,13 @@ export function hasColorMarkup(v) { return s.indexOf('|c') !== -1 || s.indexOf('|n') !== -1 || s.indexOf('|r') !== -1 || s.indexOf(String.fromCharCode(10)) !== -1; } +// The game-facing unit/building tooltip templates often prefix their first line with a redundant +// object-kind marker (for example, "Unit / Spawn rate: 23s"). Keep the raw value intact, but omit +// that marker from the visual preview so the object's name and tree category provide the context. +export function tooltipPreviewText(v) { + return String(v == null ? '' : v).replace(/^(?:unit|building)\s*\/\s*/i, ''); +} + // Only genuine display-text fields get the color tools: tooltips/descriptions/tips, or any value // that already uses WC3 color codes / newlines. Short codes (hotkeys), names, comma rawcode lists // etc. get a plain input — no color bloat. @@ -321,7 +328,7 @@ export function editorHtml(mod, mi) { export function collapsedView(mod, mi) { const dv = mod.editValue == null ? (mod.currentValue == null ? '' : String(mod.currentValue)) : String(mod.editValue); if (needsColorEditor(mod)) { - const body = dv ? renderWc3Colors(dv) : '(empty)'; + const body = dv ? renderWc3Colors(tooltipPreviewText(dv)) : '(empty)'; return '
' + '
' + body + '
' + (mod.source ? sourcePill(mod) : '') + '
'; @@ -340,7 +347,7 @@ export function valueCell(mod, mi) { if (mod.missingWts) extra += ' (externalized – war3map.wts missing)'; const ro = mod.currentValue == null ? '' : String(mod.currentValue); if (hasColorMarkup(ro)) { - return '
' + renderWc3Colors(ro) + '
' + extra; + return '
' + renderWc3Colors(tooltipPreviewText(ro)) + '
' + extra; } return decoratedValueHtml(mod, mi, ro) + extra; } From 7bd9de581faa20d016677f01e4fc5867c1462a3b Mon Sep 17 00:00:00 2001 From: Frotty Date: Mon, 10 Aug 2026 10:47:35 +0200 Subject: [PATCH 2/7] 0.12.12 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6ed794b..5e2df67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wurst", - "version": "0.12.11", + "version": "0.12.12", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "wurst", - "version": "0.12.11", + "version": "0.12.12", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "casc-ts": "file:../casc-ts", diff --git a/package.json b/package.json index 159ae1b..f08e2ba 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "type": "git", "url": "https://github.com/wurstscript/wurst4vscode.git" }, - "version": "0.12.11", + "version": "0.12.12", "publisher": "peterzeller", "engines": { "vscode": "^1.109.0" From 612bd96cb3eb42da026e309035e075726e9544ca Mon Sep 17 00:00:00 2001 From: Frotty Date: Mon, 10 Aug 2026 14:12:42 +0200 Subject: [PATCH 3/7] Improve MPQ diagnostics and error reporting --- package.json | 26 +++++++-- scripts/test-diagnostics.js | 35 ++++++++++++ src/diagnostics.ts | 60 +++++++++++++++++++++ src/extension.ts | 9 +++- src/features/inlineImageDecorations.ts | 11 ++-- src/features/issueReporting.ts | 9 ++++ src/features/mpqViewer.ts | 17 +++--- src/features/preview/cascStorage.ts | 45 +++++++++++++--- src/features/preview/wc3Data.ts | 4 +- src/languageServer.ts | 73 +++++++++++++++++++++----- 10 files changed, 251 insertions(+), 38 deletions(-) create mode 100644 scripts/test-diagnostics.js create mode 100644 src/diagnostics.ts diff --git a/package.json b/package.json index f08e2ba..14d2475 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,11 @@ "onCustomEditor:wurst.objModPreview", "onCustomEditor:wurst.soundPreview", "onCommand:wurst.previewMap", - "onCommand:wurst.reportIssue" + "onCommand:wurst.reportIssue", + "onCommand:wurst.showDiagnosticsActions", + "onCommand:wurst.openWurstHome", + "onCommand:wurst.copyDiagnostics", + "onCommand:wurst.showLogs" ], "main": "./dist/extension", "browser": "./dist/web/extension.js", @@ -694,7 +698,22 @@ }, { "command": "wurst.showLogs", - "title": "Open VSCode output panel for Wurst logs", + "title": "Open Wurst output", + "category": "wurst" + }, + { + "command": "wurst.showDiagnosticsActions", + "title": "Wurst: Show diagnostics actions", + "category": "wurst" + }, + { + "command": "wurst.openWurstHome", + "title": "Wurst: Open Wurst home", + "category": "wurst" + }, + { + "command": "wurst.copyDiagnostics", + "title": "Wurst: Copy diagnostics", "category": "wurst" }, { @@ -858,8 +877,9 @@ "lint": "eslint .", "compile-web": "webpack", "watch-web": "webpack --watch", - "test": "npm run test:fuzzy && npm run test:image-decoders && npm run test:webview", + "test": "npm run test:fuzzy && npm run test:image-decoders && npm run test:diagnostics && npm run test:webview", "test:image-decoders": "node ./scripts/test-image-decoders.js", + "test:diagnostics": "node ./scripts/test-diagnostics.js", "test:vsix-contents": "node ./scripts/test-vsix-contents.js", "sync:wc3-knowledge-base": "node ./scripts/sync-wc3-knowledge-base.js", "test:webview": "node ./scripts/test-webview.js", diff --git a/scripts/test-diagnostics.js b/scripts/test-diagnostics.js new file mode 100644 index 0000000..eed0bf3 --- /dev/null +++ b/scripts/test-diagnostics.js @@ -0,0 +1,35 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const ts = require('typescript'); + +const root = path.resolve(__dirname, '..'); +const source = fs.readFileSync(path.join(root, 'src', 'diagnostics.ts'), 'utf8'); +const js = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const mod = { exports: {} }; +new Function('exports', 'module', 'require', js)(mod.exports, mod, require); + +const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'wurst-diagnostics-')); +fs.mkdirSync(path.join(tempHome, 'logs')); +fs.writeFileSync( + path.join(tempHome, 'logs', 'languageServer.log'), + Array.from({ length: 125 }, (_, index) => `language-server-${index + 1}`).join('\n') +); + +mod.exports.appendDiagnostic('WC3 data', 'PKExplode: invalid literal size byte 40\n at explode (pkware.ts:42:7)'); +mod.exports.appendDiagnostic('MPQ', 'MPQ archive opened'); +mod.exports.appendDiagnostic('Inline icons', 'thumb generation failed'); +const report = mod.exports.buildDiagnosticsText(tempHome); + +assert(report.includes('PKExplode: invalid literal size byte 40')); +assert(report.includes('at explode (pkware.ts:42:7)')); +assert(report.includes('MPQ archive opened')); +assert(report.includes('language-server-125')); +assert(!report.includes('language-server-25\n')); +assert(report.split('\n').filter((line) => line.includes('language-server-')).length === 100); +console.log('diagnostics tests passed (bounded tails and stack traces)'); diff --git a/src/diagnostics.ts b/src/diagnostics.ts new file mode 100644 index 0000000..df84f91 --- /dev/null +++ b/src/diagnostics.ts @@ -0,0 +1,60 @@ +'use strict'; + +import * as fs from 'fs'; +import * as path from 'path'; + +export const MAX_DIAGNOSTIC_LINES = 100; + +export type DiagnosticSource = 'WC3 data' | 'MPQ' | 'Inline icons' | 'VS Code extension'; + +const recentLines = new Map(); + +/** Keep extension-side diagnostics available even though VS Code output channels are write-only. */ +export function appendDiagnostic(source: DiagnosticSource, message: string): void { + const lines = recentLines.get(source) ?? []; + for (const line of String(message).split(/\r?\n/)) { + lines.push(line); + } + if (lines.length > MAX_DIAGNOSTIC_LINES) { + lines.splice(0, lines.length - MAX_DIAGNOSTIC_LINES); + } + recentLines.set(source, lines); +} + +export function formatDiagnosticError(error: unknown): string { + if (error instanceof Error) { + return error.stack ?? `${error.name}: ${error.message}`; + } + return String(error); +} + +function readTail(filePath: string): string[] { + try { + const text = fs.readFileSync(filePath, 'utf8'); + const lines = text.split(/\r?\n/); + while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); + return lines.slice(-MAX_DIAGNOSTIC_LINES); + } catch (error) { + return [`[unavailable: ${formatDiagnosticError(error)}]`]; + } +} + +function section(title: string, lines: string[]): string[] { + return [`--- ${title} (last ${MAX_DIAGNOSTIC_LINES} lines) ---`, ...(lines.length ? lines : ['[no entries recorded]'])]; +} + +/** Build a compact, copy/paste-friendly report for remote diagnostics. */ +export function buildDiagnosticsText(wurstHome: string): string { + const lines: string[] = [ + 'WurstScript diagnostics', + `Generated: ${new Date().toISOString()}`, + `Wurst home: ${wurstHome}`, + '', + ]; + lines.push(...section('WC3 data / CASC', recentLines.get('WC3 data') ?? []), ''); + lines.push(...section('MPQ archive viewer', recentLines.get('MPQ') ?? []), ''); + lines.push(...section('Inline icons', recentLines.get('Inline icons') ?? []), ''); + lines.push(...section('Wurst VS Code extension output', recentLines.get('VS Code extension') ?? []), ''); + lines.push(...section('languageServer.log', readTail(path.join(wurstHome, 'logs', 'languageServer.log')))); + return lines.join('\n'); +} diff --git a/src/extension.ts b/src/extension.ts index 81fdd03..7e65190 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,7 +4,7 @@ import * as vscode from 'vscode'; import { workspace, ExtensionContext } from 'vscode'; import { initPathManager } from './install/pathManager'; import { installWithRetry } from './install/installer'; -import { startLanguageClient, stopLanguageServerIfRunning } from './languageServer'; +import { registerWurstDiagnosticsCommands, startLanguageClient, stopLanguageServerIfRunning } from './languageServer'; import { findConflictingWurstProcesses, forceStopWurstProcesses, @@ -27,6 +27,7 @@ import { registerMapPreview } from './features/mapPreview'; import { registerAgentsGuideOffer } from './features/agentsGuide'; import { openIssueReport } from './features/issueReporting'; import { registerCascDiagnosticsCommand } from './features/preview/cascStorage'; +import { appendDiagnostic, formatDiagnosticError } from './diagnostics'; export async function activate(context: ExtensionContext) { console.log('Wurst extension activated!'); @@ -47,6 +48,7 @@ export async function activate(context: ExtensionContext) { context.subscriptions.push(registerMapPreview(context)); context.subscriptions.push(registerAgentsGuideOffer(context)); context.subscriptions.push(registerCascDiagnosticsCommand()); + registerWurstDiagnosticsCommands(context); registerBasicCommands(context); openObjModE2eFixture(); @@ -89,6 +91,7 @@ function registerBasicCommands(context: ExtensionContext) { await vscode.commands.executeCommand('workbench.action.reloadWindow'); } catch (e: any) { if (e instanceof InstallCoordinationCancelledError) return; + appendDiagnostic('VS Code extension', `Install/update failed: ${formatDiagnosticError(e)}`); vscode.window.showErrorMessage(`Install/Update failed: ${e?.message || e}`); } }), @@ -126,6 +129,7 @@ function registerBasicCommands(context: ExtensionContext) { try { await createNewWurstProject(); } catch (e: any) { + appendDiagnostic('VS Code extension', `New project creation failed: ${formatDiagnosticError(e)}`); vscode.window.showErrorMessage(`Failed to create Wurst project: ${e?.message ?? String(e)}`); } }), @@ -148,7 +152,8 @@ async function startLanguageClientWhenWorkspaceIsOpen(context: ExtensionContext) try { await startLanguageClient(context); } catch (err) { - console.error('Failed to start language client:', err); + appendDiagnostic('VS Code extension', `Failed to start language client: ${formatDiagnosticError(err)}`); + console.error('Failed to start language client:', formatDiagnosticError(err)); vscode.window.showWarningMessage(`Wurst language features disabled: ${err}`); } } diff --git a/src/features/inlineImageDecorations.ts b/src/features/inlineImageDecorations.ts index 69f68f8..2bea94d 100644 --- a/src/features/inlineImageDecorations.ts +++ b/src/features/inlineImageDecorations.ts @@ -22,6 +22,7 @@ import { scaleDown, } from './imageAssetSupport'; import { AssetIndex, getAssetIndex, invalidateAssetIndex } from '../utils/assetIndex'; +import { appendDiagnostic, formatDiagnosticError } from '../diagnostics'; // ── Config ──────────────────────────────────────────────────────────────────── @@ -85,8 +86,10 @@ function log(message: string): void { if (logEpoch === 0) logEpoch = Date.now(); const ms = Date.now() - logEpoch; const ts = `+${ms}ms`; + const line = `[inline-icons] ${ts} ${message}`; + appendDiagnostic('Inline icons', line); try { - output.appendLine(`[inline-icons] ${ts} ${message}`); + output.appendLine(line); } catch { return; } @@ -177,7 +180,7 @@ async function getThumbnailUri(fsPath: string): Promise log(`thumb generated: ${path.basename(fsPath)} -> ${previewPath}`); return vscode.Uri.file(previewPath); } catch (error) { - log(`thumb failed: ${fsPath} :: ${error instanceof Error ? error.message : String(error)}`); + log(`thumb failed: ${fsPath} :: ${formatDiagnosticError(error)}`); return undefined; } } @@ -584,7 +587,7 @@ async function updateDecorations(editor: vscode.TextEditor): Promise { clearMissingRanges(active, assetPath); safeSetDecorations(active, getMdxFoundType(), [...mdxFoundRangesByPath.values()].flat()); } catch (error) { - log(`casc model error: ${assetPath} :: ${error instanceof Error ? error.message : String(error)}`); + log(`casc model error: ${assetPath} :: ${formatDiagnosticError(error)}`); } finally { extracting.delete(assetPath); } @@ -648,7 +651,7 @@ async function updateDecorations(editor: vscode.TextEditor): Promise { }); } } catch (error) { - log(`casc error: ${assetPath} :: ${error instanceof Error ? error.message : String(error)}`); + log(`casc error: ${assetPath} :: ${formatDiagnosticError(error)}`); } finally { extracting.delete(assetPath); } diff --git a/src/features/issueReporting.ts b/src/features/issueReporting.ts index d985c7f..f10b79c 100644 --- a/src/features/issueReporting.ts +++ b/src/features/issueReporting.ts @@ -2,6 +2,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; +import { appendDiagnostic } from '../diagnostics'; const ISSUE_URL = 'https://github.com/wurstscript/wurst4vscode/issues/new'; const seenFailures = new Set(); @@ -96,6 +97,14 @@ function failureKey(issue: ExtensionIssue): string { /** Offer a non-modal, privacy-preserving report action once per failure shape and session. */ export function offerIssueReport(issue: ExtensionIssue): void { + const resourceSuffix = issue.resource ? ` resource=${resourceName(issue.resource)}` : ''; + appendDiagnostic( + 'VS Code extension', + [ + `Preview failure [${issue.area}]${resourceSuffix}: ${issue.message}`, + issue.details ?? '', + ].filter(Boolean).join('\n'), + ); const enabled = vscode.workspace.getConfiguration('wurst').get('issueReportingHints', true); const key = failureKey(issue); if (!enabled || promptActive || seenFailures.has(key)) return; diff --git a/src/features/mpqViewer.ts b/src/features/mpqViewer.ts index 79a85de..03d4bba 100644 --- a/src/features/mpqViewer.ts +++ b/src/features/mpqViewer.ts @@ -1,6 +1,7 @@ 'use strict'; import * as vscode from 'vscode'; +import { appendDiagnostic, formatDiagnosticError } from '../diagnostics'; import * as path from 'path'; import * as os from 'os'; import * as fs from 'fs'; @@ -18,6 +19,7 @@ function getOut(): vscode.OutputChannel { } function log(msg: string): void { console.log('[MpqViewer] ' + msg); + appendDiagnostic('MPQ', msg); getOut().appendLine(msg); } @@ -72,7 +74,7 @@ async function extractTriggerStringsSidecar( fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.writeFileSync(outPath, data); } catch (e) { - log(`Could not extract ${wtsEntry.name} sidecar: ${e instanceof Error ? e.message : String(e)}`); + log(`Could not extract ${wtsEntry.name} sidecar: ${formatDiagnosticError(e)}`); } } @@ -148,7 +150,7 @@ class MpqViewerProvider implements vscode.CustomReadonlyEditorProvider void): Promise { const entries = await fs.promises.readdir(root, { withFileTypes: true }); const files = new Map(entries.filter((entry) => entry.isFile()).map((entry) => [entry.name.toLowerCase(), entry.name])); + const record = (message: string): void => { + log(message); + channelLog(message); + }; // Low priority first. The last archive containing a path wins, matching // the classic client: base RoC -> TFT -> locale -> patch overlay. const archiveNames = ['war3.mpq', 'war3local.mpq', 'war3x.mpq', 'war3xlocal.mpq', 'war3patch.mpq']; @@ -70,10 +75,10 @@ class MpqGameStorage implements GameStorage { if (!actualName) continue; const archivePath = path.join(root, actualName); try { - const storage = await MpqStorage.openAsync(archivePath, log); + const storage = await MpqStorage.openAsync(archivePath, record); archives.push({ name: actualName, storage }); } catch (error) { - log(`MPQ open failed: ${archivePath}: ${String(error)}`); + record(`MPQ open failed: ${archivePath}: ${formatDiagnosticError(error)}`); } } if (!archives.length) throw new Error(`No readable WC3 MPQ archives found in ${root}`); @@ -88,7 +93,16 @@ class MpqGameStorage implements GameStorage { for (let i = this.archives.length - 1; i >= 0; i--) { const archive = this.archives[i]; if (await archive.storage.hasFileAsync(normalized)) { - return archive.storage.readFileAsync(normalized); + try { + return await archive.storage.readFileAsync(normalized); + } catch (error) { + const detail = formatDiagnosticError(error); + const wrapped = new Error(`MPQ ${archive.name} failed to read ${normalized}: ${detail}`); + if (wrapped.stack && detail.includes('\n')) { + wrapped.stack += `\nCaused by:\n${detail}`; + } + throw wrapped; + } } } throw new Error(`File not found in WC3 MPQ storage: ${filePath}`); @@ -144,7 +158,9 @@ export function getCascOutputChannel(): vscode.OutputChannel { function channelLog(message: string): void { const iso = new Date().toISOString(); - getCascOutputChannel().appendLine(`[${iso.slice(11, 23)}] ${message}`); + const line = `[${iso.slice(11, 23)}] ${message}`; + appendDiagnostic('WC3 data', line); + getCascOutputChannel().appendLine(line); } function normalizeWindowsDriveRoot(value: string | undefined): string | null { @@ -450,8 +466,9 @@ async function getCascStorageInstance(wc3Root: string, log: (msg: string) => voi channelLog(`storage opened (${cascStorageInstance.fileCount} files)`); return cascStorageInstance; } catch (e) { - log(`CASC open failed: ${String(e)}`); - channelLog(`storage open failed: ${String(e)}`); + const detail = formatDiagnosticError(e); + log(`CASC open failed: ${detail}`); + channelLog(`storage open failed: ${detail}`); cascStorageRoot = null; return null; } finally { @@ -479,7 +496,9 @@ async function getGameStorageInstance(root: GameDataRoot, log: (msg: string) => mpqStorageInstance = await MpqGameStorage.openAsync(root.root, log); return mpqStorageInstance; } catch (error) { - log(`MPQ game storage open failed: ${String(error)}`); + const detail = formatDiagnosticError(error); + log(`MPQ game storage open failed: ${detail}`); + channelLog(`game storage open failed: ${detail}`); mpqStorageRoot = null; return null; } finally { @@ -513,7 +532,11 @@ async function gameReadDirect(root: GameDataRoot, gamePath: string, log: (msg: s const buf = await storage.readFileAsync(gamePath); if (!buf || buf.length === 0) return null; return buf; - } catch { + } catch (error) { + const detail = formatDiagnosticError(error); + const message = `${root.kind.toUpperCase()} read failed: ${gamePath}: ${detail}`; + log(message); + channelLog(message); return null; } } @@ -746,11 +769,17 @@ function stripCascContainerPrefix(cascPath: string): string | undefined { } function defaultCascLog(message: string): void { + channelLog(message); if (process.env.WURST_CASC_DEBUG === '1') { console.log(`[wurst-casc] ${message}`); } } +/** Shared logger for game-data consumers that do not have a feature-specific output channel. */ +export function logGameData(message: string): void { + defaultCascLog(message); +} + /** Try to read a texture file from the local filesystem relative to the MDX file. * Returns the buffer and the actual path found (may differ in extension). */ export function findLocalTexture(texPath: string, mdxFsPath: string): { buf: Buffer; foundPath: string } | null { diff --git a/src/features/preview/wc3Data.ts b/src/features/preview/wc3Data.ts index 11751b3..7749931 100644 --- a/src/features/preview/wc3Data.ts +++ b/src/features/preview/wc3Data.ts @@ -9,7 +9,7 @@ * string tables (WorldEditStrings etc.). */ -import { findGameAsset } from './cascStorage'; +import { findGameAsset, logGameData } from './cascStorage'; export interface SlkTable { rows: Map>; @@ -77,7 +77,7 @@ export const DESTRUCTABLE_PROFILE_PATHS = ['Units\\DestructableSkin.txt']; export const DOODAD_PROFILE_PATHS = ['Doodads\\DoodadSkins.txt']; export async function readGameData(assetPath: string): Promise { - return findGameAsset(assetPath, (msg) => console.log(`[wurst-wc3-data] ${msg}`)); + return findGameAsset(assetPath, (msg) => logGameData(`[wurst-wc3-data] ${msg}`)); } const DEFAULT_WATER_LEVEL_UNITS = 89.6; diff --git a/src/languageServer.ts b/src/languageServer.ts index f9acb2c..b8bb542 100644 --- a/src/languageServer.ts +++ b/src/languageServer.ts @@ -4,20 +4,75 @@ import * as fs from 'fs'; import * as vscode from 'vscode'; import { workspace, ExtensionContext } from 'vscode'; import { LanguageClient, LanguageClientOptions, ServerOptions, Executable } from 'vscode-languageclient/node'; -import { RUNTIME_DIR, COMPILER_JAR } from './paths'; +import { RUNTIME_DIR, COMPILER_JAR, WURST_HOME } from './paths'; import { getBundledJava, checkCustomJavaVersion, getInstalledVersionString, ensureInstalledOrOfferMigration, maybeOfferUpdate } from './install/installer'; import { registerCommands } from './features/commands'; import { registerFileCreation } from './features/fileCreation'; +import { appendDiagnostic, buildDiagnosticsText, formatDiagnosticError } from './diagnostics'; let clientRef: LanguageClient | null = null; export async function stopLanguageServerIfRunning(): Promise { if (!clientRef) return false; - try { await clientRef.stop(); } catch {} + try { + await clientRef.stop(); + } catch (error) { + appendDiagnostic('VS Code extension', `Language server stop failed: ${formatDiagnosticError(error)}`); + } clientRef = null; return true; } +function showLanguageServerOutput(): void { + try { + if (clientRef) { + clientRef.outputChannel.show(); + return; + } + void vscode.commands.executeCommand('workbench.action.output.toggleOutput'); + } catch (error) { + appendDiagnostic('VS Code extension', `Could not show language server output: ${formatDiagnosticError(error)}`); + void vscode.commands.executeCommand('workbench.action.output.toggleOutput'); + } +} + +async function copyDiagnostics(): Promise { + try { + await vscode.env.clipboard.writeText(buildDiagnosticsText(WURST_HOME)); + void vscode.window.showInformationMessage('Copied Wurst diagnostics to the clipboard.'); + } catch (error) { + const detail = formatDiagnosticError(error); + appendDiagnostic('VS Code extension', `Could not copy diagnostics: ${detail}`); + void vscode.window.showErrorMessage(`Could not copy Wurst diagnostics: ${detail}`); + } +} + +async function openWurstHome(): Promise { + try { + await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(WURST_HOME)); + } catch (error) { + const detail = formatDiagnosticError(error); + appendDiagnostic('VS Code extension', `Could not open Wurst home: ${detail}`); + void vscode.window.showErrorMessage(`Could not open Wurst home: ${detail}`); + } +} + +export function registerWurstDiagnosticsCommands(context: ExtensionContext): void { + context.subscriptions.push( + vscode.commands.registerCommand('wurst.openWurstHome', () => openWurstHome()), + vscode.commands.registerCommand('wurst.copyDiagnostics', () => copyDiagnostics()), + vscode.commands.registerCommand('wurst.showLogs', () => showLanguageServerOutput()), + vscode.commands.registerCommand('wurst.showDiagnosticsActions', async () => { + const choice = await vscode.window.showQuickPick([ + { label: '$(folder-opened) Open Wurst home', command: 'wurst.openWurstHome' }, + { label: '$(copy) Copy diagnostics', command: 'wurst.copyDiagnostics' }, + { label: '$(output) Open Wurst output', command: 'wurst.showLogs' }, + ], { placeHolder: 'Wurst diagnostics' }); + if (choice) await vscode.commands.executeCommand(choice.command); + }) + ); +} + export async function startLanguageClient(context: ExtensionContext): Promise { if (clientRef) return; @@ -45,25 +100,19 @@ export async function startLanguageClient(context: ExtensionContext): Promise { - try { client.outputChannel.show(); } - catch { vscode.commands.executeCommand('workbench.action.output.toggleOutput'); } - }) - ); - client.onNotification('wurst/updateGamePath', (params) => { workspace.getConfiguration().update('wurst.wc3path', params); }); From 9db47928550014cd734d3881d704b6a44fd9130f Mon Sep 17 00:00:00 2001 From: Frotty Date: Mon, 10 Aug 2026 14:32:54 +0200 Subject: [PATCH 4/7] Address MPQ diagnostics review feedback --- scripts/test-diagnostics.js | 2 +- src/extension.ts | 5 ++- src/features/commands.ts | 55 ++++++++++++++++++++++++++ src/{ => features}/diagnostics.ts | 19 +++++++-- src/features/inlineImageDecorations.ts | 2 +- src/features/issueReporting.ts | 2 +- src/features/mpqViewer.ts | 5 ++- src/features/preview/cascStorage.ts | 13 +++++- src/languageServer.ts | 54 +------------------------ 9 files changed, 93 insertions(+), 64 deletions(-) rename src/{ => features}/diagnostics.ts (74%) diff --git a/scripts/test-diagnostics.js b/scripts/test-diagnostics.js index eed0bf3..272985a 100644 --- a/scripts/test-diagnostics.js +++ b/scripts/test-diagnostics.js @@ -7,7 +7,7 @@ const path = require('path'); const ts = require('typescript'); const root = path.resolve(__dirname, '..'); -const source = fs.readFileSync(path.join(root, 'src', 'diagnostics.ts'), 'utf8'); +const source = fs.readFileSync(path.join(root, 'src', 'features', 'diagnostics.ts'), 'utf8'); const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, }).outputText; diff --git a/src/extension.ts b/src/extension.ts index 7e65190..403c061 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,7 +4,7 @@ import * as vscode from 'vscode'; import { workspace, ExtensionContext } from 'vscode'; import { initPathManager } from './install/pathManager'; import { installWithRetry } from './install/installer'; -import { registerWurstDiagnosticsCommands, startLanguageClient, stopLanguageServerIfRunning } from './languageServer'; +import { startLanguageClient, stopLanguageServerIfRunning } from './languageServer'; import { findConflictingWurstProcesses, forceStopWurstProcesses, @@ -25,9 +25,10 @@ import { registerTriggerPreview } from './features/triggerPreview'; import { registerMapDataPreview } from './features/mapDataPreview'; import { registerMapPreview } from './features/mapPreview'; import { registerAgentsGuideOffer } from './features/agentsGuide'; +import { registerWurstDiagnosticsCommands } from './features/commands'; import { openIssueReport } from './features/issueReporting'; import { registerCascDiagnosticsCommand } from './features/preview/cascStorage'; -import { appendDiagnostic, formatDiagnosticError } from './diagnostics'; +import { appendDiagnostic, formatDiagnosticError } from './features/diagnostics'; export async function activate(context: ExtensionContext) { console.log('Wurst extension activated!'); diff --git a/src/features/commands.ts b/src/features/commands.ts index 0935e44..285d2b3 100644 --- a/src/features/commands.ts +++ b/src/features/commands.ts @@ -4,8 +4,63 @@ import * as vscode from 'vscode'; import * as fs from 'fs'; import { LanguageClient, ExecuteCommandParams, ExecuteCommandRequest } from 'vscode-languageclient/node'; import { workspace, window } from 'vscode'; +import { WURST_HOME } from '../paths'; +import { appendDiagnostic, buildDiagnosticsText, formatDiagnosticError } from './diagnostics'; + +let diagnosticsClient: LanguageClient | null = null; + +function showLanguageServerOutput(): void { + try { + if (diagnosticsClient) { + diagnosticsClient.outputChannel.show(); + return; + } + void vscode.commands.executeCommand('workbench.action.output.toggleOutput'); + } catch (error) { + appendDiagnostic('VS Code extension', `Could not show language server output: ${formatDiagnosticError(error)}`); + void vscode.commands.executeCommand('workbench.action.output.toggleOutput'); + } +} + +async function copyDiagnostics(): Promise { + try { + await vscode.env.clipboard.writeText(buildDiagnosticsText(WURST_HOME)); + void vscode.window.showInformationMessage('Copied Wurst diagnostics to the clipboard.'); + } catch (error) { + const detail = formatDiagnosticError(error); + appendDiagnostic('VS Code extension', `Could not copy diagnostics: ${detail}`); + void vscode.window.showErrorMessage(`Could not copy Wurst diagnostics: ${detail}`); + } +} + +async function openWurstHome(): Promise { + try { + await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(WURST_HOME)); + } catch (error) { + const detail = formatDiagnosticError(error); + appendDiagnostic('VS Code extension', `Could not open Wurst home: ${detail}`); + void vscode.window.showErrorMessage(`Could not open Wurst home: ${detail}`); + } +} + +export function registerWurstDiagnosticsCommands(context: vscode.ExtensionContext): void { + context.subscriptions.push( + vscode.commands.registerCommand('wurst.openWurstHome', () => openWurstHome()), + vscode.commands.registerCommand('wurst.copyDiagnostics', () => copyDiagnostics()), + vscode.commands.registerCommand('wurst.showLogs', () => showLanguageServerOutput()), + vscode.commands.registerCommand('wurst.showDiagnosticsActions', async () => { + const choice = await vscode.window.showQuickPick([ + { label: '$(folder-opened) Open Wurst home', command: 'wurst.openWurstHome' }, + { label: '$(copy) Copy diagnostics', command: 'wurst.copyDiagnostics' }, + { label: '$(output) Open Wurst output', command: 'wurst.showLogs' }, + ], { placeHolder: 'Wurst diagnostics' }); + if (choice) await vscode.commands.executeCommand(choice.command); + }), + ); +} export function registerCommands(client: LanguageClient): vscode.Disposable { + diagnosticsClient = client; let _lastMapConfig: string | undefined = undefined; // Accepts both archive files (*.w3x, *.w3m) and folder-mode directories (*.w3x/, *.w3m/) diff --git a/src/diagnostics.ts b/src/features/diagnostics.ts similarity index 74% rename from src/diagnostics.ts rename to src/features/diagnostics.ts index df84f91..caf1426 100644 --- a/src/diagnostics.ts +++ b/src/features/diagnostics.ts @@ -30,10 +30,21 @@ export function formatDiagnosticError(error: unknown): string { function readTail(filePath: string): string[] { try { - const text = fs.readFileSync(filePath, 'utf8'); - const lines = text.split(/\r?\n/); - while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); - return lines.slice(-MAX_DIAGNOSTIC_LINES); + const stat = fs.statSync(filePath); + const bytesPerLine = 256; + const maxBytes = MAX_DIAGNOSTIC_LINES * bytesPerLine; + const start = Math.max(0, stat.size - maxBytes); + const fd = fs.openSync(filePath, 'r'); + try { + const buffer = Buffer.alloc(stat.size - start); + fs.readSync(fd, buffer, 0, buffer.length, start); + const lines = buffer.toString('utf8').split(/\r?\n/); + while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); + if (start > 0 && lines.length > 0) lines.shift(); + return lines.slice(-MAX_DIAGNOSTIC_LINES); + } finally { + fs.closeSync(fd); + } } catch (error) { return [`[unavailable: ${formatDiagnosticError(error)}]`]; } diff --git a/src/features/inlineImageDecorations.ts b/src/features/inlineImageDecorations.ts index 2bea94d..5093c10 100644 --- a/src/features/inlineImageDecorations.ts +++ b/src/features/inlineImageDecorations.ts @@ -22,7 +22,7 @@ import { scaleDown, } from './imageAssetSupport'; import { AssetIndex, getAssetIndex, invalidateAssetIndex } from '../utils/assetIndex'; -import { appendDiagnostic, formatDiagnosticError } from '../diagnostics'; +import { appendDiagnostic, formatDiagnosticError } from './diagnostics'; // ── Config ──────────────────────────────────────────────────────────────────── diff --git a/src/features/issueReporting.ts b/src/features/issueReporting.ts index f10b79c..c051d69 100644 --- a/src/features/issueReporting.ts +++ b/src/features/issueReporting.ts @@ -2,7 +2,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; -import { appendDiagnostic } from '../diagnostics'; +import { appendDiagnostic } from './diagnostics'; const ISSUE_URL = 'https://github.com/wurstscript/wurst4vscode/issues/new'; const seenFailures = new Set(); diff --git a/src/features/mpqViewer.ts b/src/features/mpqViewer.ts index 03d4bba..826ffc9 100644 --- a/src/features/mpqViewer.ts +++ b/src/features/mpqViewer.ts @@ -1,7 +1,7 @@ 'use strict'; import * as vscode from 'vscode'; -import { appendDiagnostic, formatDiagnosticError } from '../diagnostics'; +import { appendDiagnostic, formatDiagnosticError } from './diagnostics'; import * as path from 'path'; import * as os from 'os'; import * as fs from 'fs'; @@ -199,8 +199,9 @@ class MpqViewerProvider implements vscode.CustomReadonlyEditorProvider void): Promise { const storage = await getGameStorageInstance(root, log); if (!storage) return null; + try { + if (!await storage.hasFileAsync(gamePath)) return null; + } catch (error) { + const detail = formatDiagnosticError(error); + const message = `${root.kind.toUpperCase()} lookup failed: ${gamePath}: ${detail}`; + log(message); + channelLog(message); + return null; + } try { const buf = await storage.readFileAsync(gamePath); if (!buf || buf.length === 0) return null; @@ -634,6 +643,7 @@ export async function findCascTexture(texPath: string, log: (msg: string) => voi } } rememberMiss(cascTextureMissCache, missKey); + log(`${gameRoot!.kind.toUpperCase()} texture not found after ${candidates.length} candidates: ${texPath}`); return null; } @@ -725,6 +735,7 @@ export async function findCascAsset(assetPath: string, log: (msg: string) => voi } rememberMiss(cascAssetMissCache, normalized); + log(`${gameRoot.kind.toUpperCase()} asset not found after ${candidates.length} candidates: ${assetPath}`); return null; } diff --git a/src/languageServer.ts b/src/languageServer.ts index b8bb542..210efd9 100644 --- a/src/languageServer.ts +++ b/src/languageServer.ts @@ -4,11 +4,11 @@ import * as fs from 'fs'; import * as vscode from 'vscode'; import { workspace, ExtensionContext } from 'vscode'; import { LanguageClient, LanguageClientOptions, ServerOptions, Executable } from 'vscode-languageclient/node'; -import { RUNTIME_DIR, COMPILER_JAR, WURST_HOME } from './paths'; +import { RUNTIME_DIR, COMPILER_JAR } from './paths'; import { getBundledJava, checkCustomJavaVersion, getInstalledVersionString, ensureInstalledOrOfferMigration, maybeOfferUpdate } from './install/installer'; import { registerCommands } from './features/commands'; import { registerFileCreation } from './features/fileCreation'; -import { appendDiagnostic, buildDiagnosticsText, formatDiagnosticError } from './diagnostics'; +import { appendDiagnostic, formatDiagnosticError } from './features/diagnostics'; let clientRef: LanguageClient | null = null; @@ -23,56 +23,6 @@ export async function stopLanguageServerIfRunning(): Promise { return true; } -function showLanguageServerOutput(): void { - try { - if (clientRef) { - clientRef.outputChannel.show(); - return; - } - void vscode.commands.executeCommand('workbench.action.output.toggleOutput'); - } catch (error) { - appendDiagnostic('VS Code extension', `Could not show language server output: ${formatDiagnosticError(error)}`); - void vscode.commands.executeCommand('workbench.action.output.toggleOutput'); - } -} - -async function copyDiagnostics(): Promise { - try { - await vscode.env.clipboard.writeText(buildDiagnosticsText(WURST_HOME)); - void vscode.window.showInformationMessage('Copied Wurst diagnostics to the clipboard.'); - } catch (error) { - const detail = formatDiagnosticError(error); - appendDiagnostic('VS Code extension', `Could not copy diagnostics: ${detail}`); - void vscode.window.showErrorMessage(`Could not copy Wurst diagnostics: ${detail}`); - } -} - -async function openWurstHome(): Promise { - try { - await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(WURST_HOME)); - } catch (error) { - const detail = formatDiagnosticError(error); - appendDiagnostic('VS Code extension', `Could not open Wurst home: ${detail}`); - void vscode.window.showErrorMessage(`Could not open Wurst home: ${detail}`); - } -} - -export function registerWurstDiagnosticsCommands(context: ExtensionContext): void { - context.subscriptions.push( - vscode.commands.registerCommand('wurst.openWurstHome', () => openWurstHome()), - vscode.commands.registerCommand('wurst.copyDiagnostics', () => copyDiagnostics()), - vscode.commands.registerCommand('wurst.showLogs', () => showLanguageServerOutput()), - vscode.commands.registerCommand('wurst.showDiagnosticsActions', async () => { - const choice = await vscode.window.showQuickPick([ - { label: '$(folder-opened) Open Wurst home', command: 'wurst.openWurstHome' }, - { label: '$(copy) Copy diagnostics', command: 'wurst.copyDiagnostics' }, - { label: '$(output) Open Wurst output', command: 'wurst.showLogs' }, - ], { placeHolder: 'Wurst diagnostics' }); - if (choice) await vscode.commands.executeCommand(choice.command); - }) - ); -} - export async function startLanguageClient(context: ExtensionContext): Promise { if (clientRef) return; From 97838d3b682300d455f24473356285249400e71b Mon Sep 17 00:00:00 2001 From: Frotty Date: Mon, 10 Aug 2026 15:04:11 +0200 Subject: [PATCH 5/7] Preserve tooltip caret position --- src/webview/objModEditor/detailsPanel.ts | 10 ++++---- src/webview/objModEditor/richTextEditor.ts | 29 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/webview/objModEditor/detailsPanel.ts b/src/webview/objModEditor/detailsPanel.ts index 289a295..19dd495 100644 --- a/src/webview/objModEditor/detailsPanel.ts +++ b/src/webview/objModEditor/detailsPanel.ts @@ -5,7 +5,7 @@ import { details, detailCache, pendingDetails, failedDetails, ui, vscodeApi, ico import { categoryLabel, categoryKey, objectIconHtml, detailsTitleHtml, matches, selectObject } from './objectTree'; import { valueCell, postEdit, setModValue, editorHtml, collapsedView, normalizeNumberValue, needsColorEditor, tooltipToolbarHtml, tooltipPreviewText } from './fieldDisplay'; import { observeModelThumbs } from './modelThumbnails'; -import { wireColorBar, setCaretEnd, richToWc3, forcePlainTextPaste, forceWc3ColorCopy, wrapColor, applyRichColor, updateColorSwatch, containsNode } from './richTextEditor'; +import { wireColorBar, setCaretEnd, setCaretAtTextOffset, textOffsetAtRange, richToWc3, forcePlainTextPaste, forceWc3ColorCopy, wrapColor, applyRichColor, updateColorSwatch, containsNode } from './richTextEditor'; import { openAssetBrowser } from './assetBrowser'; import { showModelPreview } from './modelPreviewPanel'; @@ -407,17 +407,17 @@ export function enterTooltipEdit(collapsed, mi, clickEvent) { // Same node before and after — the click's caret position is already valid for the body once it's // made editable, no coordinate remapping needed. - let range: Range | null = null; + let caretOffset: number | null = null; if (clickEvent && typeof document.caretRangeFromPoint === 'function') { const r = document.caretRangeFromPoint(clickEvent.clientX, clickEvent.clientY); - if (r && body.contains(r.startContainer)) range = r; + if (r && body.contains(r.startContainer)) caretOffset = textOffsetAtRange(body, r); } const original = mod.editValue == null ? '' : String(mod.editValue); // An empty field's collapsed body holds a "(empty)" placeholder (see collapsedView) — clear it before // editing so typing doesn't start by appending to that literal text. No real content existed to click // into, so the captured range (if any) is meaningless here too. - if (!original) { body.innerHTML = ''; range = null; } + if (!original) { body.innerHTML = ''; caretOffset = null; } else body.innerHTML = renderWc3Colors(original); body.contentEditable = 'true'; body.spellcheck = false; @@ -487,7 +487,7 @@ export function enterTooltipEdit(collapsed, mi, clickEvent) { rawArea.addEventListener('keydown', onEscapeOrSubmit); body.focus({ preventScroll: true }); - if (range) { const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } + if (caretOffset !== null) setCaretAtTextOffset(body, caretOffset); else setCaretEnd(body); const bar = toolbar.querySelector('.tt-bar'); diff --git a/src/webview/objModEditor/richTextEditor.ts b/src/webview/objModEditor/richTextEditor.ts index 2f9c869..33ac180 100644 --- a/src/webview/objModEditor/richTextEditor.ts +++ b/src/webview/objModEditor/richTextEditor.ts @@ -82,6 +82,35 @@ export function setCaretEnd(el) { sel.addRange(range); } +export function textOffsetAtRange(root, range) { + const before = range.cloneRange(); + before.selectNodeContents(root); + before.setEnd(range.startContainer, range.startOffset); + return before.toString().length; +} + +export function setCaretAtTextOffset(root, offset) { + const target = Math.max(0, Number(offset) || 0); + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let remaining = target; + let node; + while ((node = walker.nextNode())) { + const length = node.nodeValue ? node.nodeValue.length : 0; + if (remaining <= length) { + const range = document.createRange(); + range.setStart(node, remaining); + range.collapse(true); + const sel = window.getSelection(); + if (!sel) return; + sel.removeAllRanges(); + sel.addRange(range); + return; + } + remaining -= length; + } + setCaretEnd(root); +} + export function containsNode(parent, node) { while (node) { if (node === parent) return true; From 83890fda144311ce2a6d38baca5cc71ad0d84d8c Mon Sep 17 00:00:00 2001 From: Frotty Date: Mon, 10 Aug 2026 15:53:16 +0200 Subject: [PATCH 6/7] Polish startup errors and tooltip caret --- src/languageServer.ts | 5 +++-- src/webview/objModEditor/detailsPanel.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/languageServer.ts b/src/languageServer.ts index 210efd9..ea7eb39 100644 --- a/src/languageServer.ts +++ b/src/languageServer.ts @@ -50,8 +50,9 @@ export async function startLanguageClient(context: ExtensionContext): Promise Date: Mon, 10 Aug 2026 16:06:09 +0200 Subject: [PATCH 7/7] Refine tooltip caret mapping --- scripts/test-webview.js | 2 +- src/webview/objModEditor/detailsPanel.ts | 19 ++++++++++++------- src/webview/objModEditor/fieldDisplay.ts | 14 ++++++++++---- src/webview/objModEditor/richTextEditor.ts | 12 ++++++++++-- 4 files changed, 33 insertions(+), 14 deletions(-) diff --git a/scripts/test-webview.js b/scripts/test-webview.js index 766d290..234da28 100644 --- a/scripts/test-webview.js +++ b/scripts/test-webview.js @@ -942,7 +942,7 @@ function testObjModTooltipPreviewHeaders() { assert.ok(fieldDisplay.includes("replace(/^(?:unit|building)\\s*\\/\\s*/i, '')"), 'unit and building tooltip markers should be hidden in previews'); assert.ok(detailsPanel.includes('renderWc3Colors(original)'), 'tooltip editing should restore the unmodified raw value'); - assert.ok(detailsPanel.includes('renderWc3Colors(tooltipPreviewText(value))'), 'tooltip collapse should restore the cleaned preview'); + assert.ok(detailsPanel.includes('renderWc3Colors(tooltipPreviewText(value, isTooltipTemplateField(mod)))'), 'tooltip collapse should restore the cleaned preview only for tooltip fields'); } function testObjModEditorTypeAndRecoveryGuards() { diff --git a/src/webview/objModEditor/detailsPanel.ts b/src/webview/objModEditor/detailsPanel.ts index ad0b12a..98c8be0 100644 --- a/src/webview/objModEditor/detailsPanel.ts +++ b/src/webview/objModEditor/detailsPanel.ts @@ -3,9 +3,9 @@ import { esc, renderWc3Colors } from '../objModWebviewUtils'; import { batch, effect, untracked } from '../signals'; import { details, detailCache, pendingDetails, failedDetails, ui, vscodeApi, iconLoader, initial, objects } from './state'; import { categoryLabel, categoryKey, objectIconHtml, detailsTitleHtml, matches, selectObject } from './objectTree'; -import { valueCell, postEdit, setModValue, editorHtml, collapsedView, normalizeNumberValue, needsColorEditor, tooltipToolbarHtml, tooltipPreviewText } from './fieldDisplay'; +import { valueCell, postEdit, setModValue, editorHtml, collapsedView, normalizeNumberValue, needsColorEditor, tooltipToolbarHtml, tooltipPreviewText, isTooltipTemplateField } from './fieldDisplay'; import { observeModelThumbs } from './modelThumbnails'; -import { wireColorBar, setCaretEnd, setCaretAtTextOffset, textOffsetAtRange, richToWc3, forcePlainTextPaste, forceWc3ColorCopy, wrapColor, applyRichColor, updateColorSwatch, containsNode } from './richTextEditor'; +import { wireColorBar, setCaretEnd, setCaretAtTextOffset, textOffsetAtRange, textRangePrefersNext, richToWc3, forcePlainTextPaste, forceWc3ColorCopy, wrapColor, applyRichColor, updateColorSwatch, containsNode } from './richTextEditor'; import { openAssetBrowser } from './assetBrowser'; import { showModelPreview } from './modelPreviewPanel'; @@ -408,13 +408,18 @@ export function enterTooltipEdit(collapsed, mi, clickEvent) { // Same node before and after — the click's caret position is already valid for the body once it's // made editable, no coordinate remapping needed. let caretOffset: number | null = null; + let caretPrefersNext = false; if (clickEvent && typeof document.caretRangeFromPoint === 'function') { const r = document.caretRangeFromPoint(clickEvent.clientX, clickEvent.clientY); - if (r && body.contains(r.startContainer)) caretOffset = textOffsetAtRange(body, r); + if (r && body.contains(r.startContainer)) { + caretOffset = textOffsetAtRange(body, r); + caretPrefersNext = textRangePrefersNext(r); + } } const original = mod.editValue == null ? '' : String(mod.editValue); - const hiddenPrefixLength = original.length - tooltipPreviewText(original).length; + const stripTemplatePrefix = isTooltipTemplateField(mod); + const hiddenPrefixLength = stripTemplatePrefix ? original.length - tooltipPreviewText(original).length : 0; // An empty field's collapsed body holds a "(empty)" placeholder (see collapsedView) — clear it before // editing so typing doesn't start by appending to that literal text. No real content existed to click // into, so the captured range (if any) is meaningless here too. @@ -488,7 +493,7 @@ export function enterTooltipEdit(collapsed, mi, clickEvent) { rawArea.addEventListener('keydown', onEscapeOrSubmit); body.focus({ preventScroll: true }); - if (caretOffset !== null) setCaretAtTextOffset(body, caretOffset + hiddenPrefixLength); + if (caretOffset !== null) setCaretAtTextOffset(body, caretOffset + hiddenPrefixLength, caretPrefersNext); else setCaretEnd(body); const bar = toolbar.querySelector('.tt-bar'); @@ -609,7 +614,7 @@ export function exitTooltipEdit(commit) { const mods = detailCache.get(ui.selectedKey) || []; const mod = mods[mi]; const value = mod && mod.editValue != null ? String(mod.editValue) : ''; - body.innerHTML = value ? renderWc3Colors(tooltipPreviewText(value)) : '(empty)'; + body.innerHTML = value ? renderWc3Colors(tooltipPreviewText(value, isTooltipTemplateField(mod))) : '(empty)'; } export function markModified(el, mod) { @@ -750,7 +755,7 @@ export function updateFieldCell(mods, mod) { const rich = details.querySelector('.edit-rich[data-mi="' + mi + '"]'); if (rich) rich.innerHTML = renderWc3Colors(el.value); const pv = details.querySelector('.tt-preview[data-preview-for="' + mi + '"]'); - if (pv) pv.innerHTML = renderWc3Colors(tooltipPreviewText(el.value)); + if (pv) pv.innerHTML = renderWc3Colors(tooltipPreviewText(el.value, isTooltipTemplateField(mod))); return; } const rich = details.querySelector('.edit-rich[data-mi="' + mi + '"]'); diff --git a/src/webview/objModEditor/fieldDisplay.ts b/src/webview/objModEditor/fieldDisplay.ts index 56fa963..9c21eb7 100644 --- a/src/webview/objModEditor/fieldDisplay.ts +++ b/src/webview/objModEditor/fieldDisplay.ts @@ -16,8 +16,14 @@ export function hasColorMarkup(v) { // The game-facing unit/building tooltip templates often prefix their first line with a redundant // object-kind marker (for example, "Unit / Spawn rate: 23s"). Keep the raw value intact, but omit // that marker from the visual preview so the object's name and tree category provide the context. -export function tooltipPreviewText(v) { - return String(v == null ? '' : v).replace(/^(?:unit|building)\s*\/\s*/i, ''); +export function isTooltipTemplateField(mod) { + const label = String(mod?.label || '').toLowerCase(); + return label.indexOf('tooltip') !== -1 || label.indexOf('tip') !== -1; +} + +export function tooltipPreviewText(v, stripTemplatePrefix = true) { + const text = String(v == null ? '' : v); + return stripTemplatePrefix ? text.replace(/^(?:unit|building)\s*\/\s*/i, '') : text; } // Only genuine display-text fields get the color tools: tooltips/descriptions/tips, or any value @@ -328,7 +334,7 @@ export function editorHtml(mod, mi) { export function collapsedView(mod, mi) { const dv = mod.editValue == null ? (mod.currentValue == null ? '' : String(mod.currentValue)) : String(mod.editValue); if (needsColorEditor(mod)) { - const body = dv ? renderWc3Colors(tooltipPreviewText(dv)) : '(empty)'; + const body = dv ? renderWc3Colors(tooltipPreviewText(dv, isTooltipTemplateField(mod))) : '(empty)'; return '
' + '
' + body + '
' + (mod.source ? sourcePill(mod) : '') + '
'; @@ -347,7 +353,7 @@ export function valueCell(mod, mi) { if (mod.missingWts) extra += ' (externalized – war3map.wts missing)'; const ro = mod.currentValue == null ? '' : String(mod.currentValue); if (hasColorMarkup(ro)) { - return '
' + renderWc3Colors(tooltipPreviewText(ro)) + '
' + extra; + return '
' + renderWc3Colors(tooltipPreviewText(ro, isTooltipTemplateField(mod))) + '
' + extra; } return decoratedValueHtml(mod, mi, ro) + extra; } diff --git a/src/webview/objModEditor/richTextEditor.ts b/src/webview/objModEditor/richTextEditor.ts index 33ac180..f2cc02c 100644 --- a/src/webview/objModEditor/richTextEditor.ts +++ b/src/webview/objModEditor/richTextEditor.ts @@ -89,14 +89,14 @@ export function textOffsetAtRange(root, range) { return before.toString().length; } -export function setCaretAtTextOffset(root, offset) { +export function setCaretAtTextOffset(root, offset, preferNext) { const target = Math.max(0, Number(offset) || 0); const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); let remaining = target; let node; while ((node = walker.nextNode())) { const length = node.nodeValue ? node.nodeValue.length : 0; - if (remaining <= length) { + if (remaining < length || (!preferNext && remaining === length)) { const range = document.createRange(); range.setStart(node, remaining); range.collapse(true); @@ -111,6 +111,14 @@ export function setCaretAtTextOffset(root, offset) { setCaretEnd(root); } +export function textRangePrefersNext(range) { + const container = range.startContainer; + if (container.nodeType === Node.TEXT_NODE) { + return range.startOffset >= (container.nodeValue ? container.nodeValue.length : 0); + } + return !!container.childNodes?.[range.startOffset]; +} + export function containsNode(parent, node) { while (node) { if (node === parent) return true;