diff --git a/package-lock.json b/package-lock.json index 990e7d7..1842111 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wurst", - "version": "0.12.14", + "version": "0.12.15", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "wurst", - "version": "0.12.14", + "version": "0.12.15", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "casc-ts": "file:../casc-ts", diff --git a/package.json b/package.json index 2068ddf..e803cbe 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "type": "git", "url": "https://github.com/wurstscript/wurst4vscode.git" }, - "version": "0.12.14", + "version": "0.12.15", "publisher": "peterzeller", "engines": { "vscode": "^1.109.0" @@ -491,7 +491,7 @@ }, { "viewType": "wurst.wpmPreview", - "displayName": "WC3 Pathing Map", + "displayName": "WC3 Pathing Map (editable)", "selector": [ { "filenamePattern": "*.wpm" @@ -891,6 +891,7 @@ "test:webview": "node ./scripts/test-webview.js", "test:e2e:models:local": "node ./scripts/model-thumbnail-e2e.js", "test:e2e:objmod-thumbs:local": "node ./scripts/objmod-thumbnail-e2e.js", + "test:e2e:asset-browser-code:local": "node ./scripts/objmod-thumbnail-e2e.js --code-only", "test:e2e:objmod-clipboard:local": "node ./scripts/objmod-clipboard-e2e.js", "test:wc3-previews": "node ./scripts/wc3-preview-smoke.js", "test:fuzzy": "node ./scripts/test-fuzzy.js", diff --git a/scripts/objmod-thumbnail-e2e.js b/scripts/objmod-thumbnail-e2e.js index 5e9e4ac..309dfcf 100644 --- a/scripts/objmod-thumbnail-e2e.js +++ b/scripts/objmod-thumbnail-e2e.js @@ -1,15 +1,17 @@ 'use strict'; /** - * Local-only VS Code extension e2e for objmod asset-browser thumbnails. + * Local-only VS Code extension e2e for objmod thumbnails and both asset-browser variants. * * Enable explicitly, never in CI: * $env:WURST_OBJMOD_E2E='1' * npm run test:e2e:objmod-thumbs:local + * npm run test:e2e:asset-browser-code:local * * Optional knobs: * WURST_OBJMOD_E2E_PROJECT defaults to ./e2e * WURST_OBJMOD_E2E_FILE defaults to ./e2e/war3map.w3u + * WURST_OBJMOD_E2E_CODE_FILE optional .wurst file used for the code-launched asset-browser check * WURST_OBJMOD_E2E_CODE Code.exe path, if it cannot be found * WURST_OBJMOD_E2E_COUNT max visible thumbnails to assert, default all visible * WURST_OBJMOD_E2E_SEARCH optional model-catalog search query @@ -29,6 +31,7 @@ const path = require('path'); const root = path.resolve(__dirname, '..'); const enabled = process.env.WURST_OBJMOD_E2E === '1' || process.env.WURST_LOCAL_E2E === '1'; +const codeOnly = process.argv.includes('--code-only'); if (!enabled) { console.log('local objmod thumbnail e2e skipped (set WURST_OBJMOD_E2E=1 to enable)'); @@ -43,6 +46,7 @@ const defaultProjectPath = path.join(root, 'e2e'); const defaultObjmodFile = path.join(defaultProjectPath, 'war3map.w3u'); let projectPath = process.env.WURST_OBJMOD_E2E_PROJECT || defaultProjectPath; let objmodFile = process.env.WURST_OBJMOD_E2E_FILE || defaultObjmodFile; +let codeAssetFile = process.env.WURST_OBJMOD_E2E_CODE_FILE || ''; const sampleCountRaw = process.env.WURST_OBJMOD_E2E_COUNT; const sampleCount = sampleCountRaw ? Number(sampleCountRaw) : 0; const sampleLimit = Number.isFinite(sampleCount) && sampleCount > 0 ? sampleCount : Number.POSITIVE_INFINITY; @@ -108,7 +112,9 @@ function writeGeneratedObjmodFixture() { }; fs.writeFileSync(path.join(dir, 'war3map.w3a'), serializeObjMod(main)); fs.writeFileSync(path.join(dir, 'war3mapSkin.w3a'), serializeObjMod(skin)); - return { dir, file: path.join(dir, 'war3map.w3a') }; + const codeFile = path.join(dir, 'AssetBrowserE2e.wurst'); + fs.writeFileSync(codeFile, 'package AssetBrowserE2e\n\nconstant TEST_MODEL = "imports\\\\units\\\\Footman.mdx"\n'); + return { dir, file: path.join(dir, 'war3map.w3a'), codeFile }; } let generatedFixtureDir = ''; @@ -117,10 +123,13 @@ if (!process.env.WURST_OBJMOD_E2E_PROJECT && !process.env.WURST_OBJMOD_E2E_FILE) generatedFixtureDir = generated.dir; projectPath = generated.dir; objmodFile = generated.file; + codeAssetFile = generated.codeFile; } assert.ok(projectPath && fs.existsSync(projectPath), 'Set WURST_OBJMOD_E2E_PROJECT to a real Wurst project folder, or keep ./e2e present.'); assert.ok(objmodFile && fs.existsSync(objmodFile), 'Set WURST_OBJMOD_E2E_FILE to a real .w3u/.w3a/... file, or keep ./e2e/war3map.w3u present.'); +if (codeAssetFile) assert.ok(fs.existsSync(codeAssetFile), `WURST_OBJMOD_E2E_CODE_FILE does not exist: ${codeAssetFile}`); +if (codeOnly) assert.ok(codeAssetFile, 'The code-only e2e needs the generated fixture or WURST_OBJMOD_E2E_CODE_FILE.'); function log(message) { console.log(`[objmod-thumb-e2e] ${message}`); @@ -190,6 +199,19 @@ function waitForExit(child, timeoutMs = 5000) { }); } +function bringVsCodeWindowToForeground(userDataDir) { + if (process.platform !== 'win32') return; + const result = childProcess.spawnSync('powershell.exe', [ + '-NoProfile', + '-File', path.join(__dirname, 'bring-to-foreground.ps1'), + '-Needle', userDataDir, + ], { encoding: 'utf8', windowsHide: true, timeout: 10000 }); + if (process.env.WURST_E2E_VERBOSE === '1') { + const stderrSuffix = result.stderr ? ' stderr=' + result.stderr.trim() : ''; + console.log(`[verbose] bringVsCodeWindowToForeground: ${(result.stdout || '').trim() || '(no matching window)'}${stderrSuffix}`); + } +} + function windowsCodePidsForUserDataDir(userDataDir) { if (process.platform !== 'win32' || !userDataDir) return []; const result = childProcess.spawnSync('powershell.exe', [ @@ -379,9 +401,11 @@ class CdpClient { } // eslint-disable-next-line sonarjs/cognitive-complexity -- TODO(lint-cleanup): pre-existing, tracked for a dedicated decomposition pass rather than a rushed refactor here. -async function waitForWebviewContext(client) { +async function waitForWebviewContext(client, requireObjmod = true) { const contexts = new Map(); const attachedTargets = new Set(); + const sessionByTargetId = new Map(); + let pageSessionId = ''; client.on('Runtime.executionContextCreated', ({ context }, sessionId) => { if (context && context.id && sessionId) contexts.set(`${sessionId}:${context.id}`, { sessionId, context }); }); @@ -395,6 +419,7 @@ async function waitForWebviewContext(client) { const attached = await client.send('Target.attachToTarget', { targetId: target.targetId, flatten: true }); if (attached?.sessionId) { attachedTargets.add(target.targetId); + sessionByTargetId.set(target.targetId, attached.sessionId); await client.send('Runtime.enable', {}, attached.sessionId); await client.send('Log.enable', {}, attached.sessionId).catch(() => undefined); } @@ -402,19 +427,30 @@ async function waitForWebviewContext(client) { attachedTargets.add(target.targetId); } } + for (const target of targets?.targetInfos || []) { + if (target.type === 'page' && sessionByTargetId.has(target.targetId)) { + pageSessionId = sessionByTargetId.get(target.targetId); + } + } + if (!requireObjmod && pageSessionId) { + return { pageSessionId, contexts, attachedTargets, sessionByTargetId }; + } for (const { sessionId, context } of contexts.values()) { const result = await client.send('Runtime.evaluate', { contextId: context.id, expression: '!!window.__wurstModelThumbDebug', returnByValue: true, }, sessionId).catch(() => undefined); - if (result?.result?.value) return { sessionId, contextId: context.id }; + if (result?.result?.value) { + return { sessionId, contextId: context.id, pageSessionId, contexts, attachedTargets, sessionByTargetId }; + } } await new Promise((resolve) => setTimeout(resolve, 100)); } const targets = await client.send('Target.getTargets').catch(() => undefined); const summary = (targets?.targetInfos || []).map((target) => `${target.type}:${target.title || target.url || target.targetId}`).join(' | '); - throw new Error(`Timed out waiting for objmod webview debug hook. Targets: ${summary}`); + const wanted = requireObjmod ? 'objmod webview debug hook' : 'extension-host workbench'; + throw new Error(`Timed out waiting for ${wanted}. Targets: ${summary}`); } async function evalInContext(client, sessionId, contextId, expression) { @@ -441,6 +477,156 @@ async function waitForEval(client, sessionId, contextId, expression, predicate, throw new Error(`Timed out waiting for ${label}. Last value: ${JSON.stringify(last)}`); } +async function pressKeyCombo(client, sessionId, keys) { + for (const key of keys) { + await client.send('Input.dispatchKeyEvent', { + type: 'rawKeyDown', + modifiers: key.modifiers || 0, + key: key.key, + code: key.code, + windowsVirtualKeyCode: key.vk, + nativeVirtualKeyCode: key.vk, + }, sessionId); + } + for (const key of [...keys].reverse()) { + await client.send('Input.dispatchKeyEvent', { + type: 'keyUp', + modifiers: 0, + key: key.key, + code: key.code, + windowsVirtualKeyCode: key.vk, + nativeVirtualKeyCode: key.vk, + }, sessionId); + } +} + +async function typeText(client, sessionId, value) { + for (const ch of value) { + await client.send('Input.dispatchKeyEvent', { type: 'char', text: ch, key: ch, unmodifiedText: ch }, sessionId); + await new Promise((resolve) => setTimeout(resolve, 15)); + } +} + +async function pressEnter(client, sessionId) { + await client.send('Input.dispatchKeyEvent', { type: 'rawKeyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, sessionId); + await client.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, sessionId); +} + +async function evalInSession(client, sessionId, expression) { + const result = await client.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, sessionId); + if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || 'workbench evaluation failed'); + return result.result.value; +} + +async function waitForSessionEval(client, sessionId, expression, predicate, label, waitMs = timeoutMs) { + const deadline = Date.now() + waitMs; + let last; + while (Date.now() < deadline) { + last = await evalInSession(client, sessionId, expression); + if (predicate(last)) return last; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for ${label}. Last value: ${JSON.stringify(last)}`); +} + +async function attachUntrackedTargets(client, tracking) { + const targets = await client.send('Target.getTargets').catch(() => undefined); + for (const target of targets?.targetInfos || []) { + if (!target.targetId || tracking.attachedTargets.has(target.targetId)) continue; + if (!['page', 'iframe', 'webview'].includes(target.type)) continue; + try { + const attached = await client.send('Target.attachToTarget', { targetId: target.targetId, flatten: true }); + if (!attached?.sessionId) continue; + tracking.attachedTargets.add(target.targetId); + tracking.sessionByTargetId.set(target.targetId, attached.sessionId); + await client.send('Runtime.enable', {}, attached.sessionId); + await client.send('Log.enable', {}, attached.sessionId).catch(() => undefined); + } catch { + tracking.attachedTargets.add(target.targetId); + } + } +} + +async function waitForCodeAssetBrowserContext(client, tracking, waitMs = timeoutMs) { + const deadline = Date.now() + waitMs; + while (Date.now() < deadline) { + await attachUntrackedTargets(client, tracking); + for (const { sessionId, context } of tracking.contexts.values()) { + const result = await client.send('Runtime.evaluate', { + contextId: context.id, + expression: '!!window.__wurstCodeAssetBrowserDebug', + returnByValue: true, + }, sessionId).catch(() => undefined); + if (result?.result?.value) return { sessionId, contextId: context.id }; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error('Timed out waiting for the code-launched asset browser webview.'); +} + +async function assertCodeAssetBrowserSearch(client, pageSessionId, tracking, userDataDir) { + if (!codeAssetFile) { + log('code-launched asset-browser check skipped (set WURST_OBJMOD_E2E_CODE_FILE for custom fixtures)'); + return; + } + assert.ok(pageSessionId, 'extension-host workbench target should be attached'); + log('opening code-launched asset browser from a real Wurst CodeLens'); + await client.send('Page.bringToFront', {}, pageSessionId).catch(() => undefined); + bringVsCodeWindowToForeground(userDataDir); + await new Promise((resolve) => setTimeout(resolve, 300)); + const CTRL = 2; + await pressKeyCombo(client, pageSessionId, [ + { key: 'Control', code: 'ControlLeft', vk: 17, modifiers: CTRL }, + { key: 'p', code: 'KeyP', vk: 80, modifiers: CTRL }, + ]); + await new Promise((resolve) => setTimeout(resolve, 300)); + await typeText(client, pageSessionId, path.basename(codeAssetFile)); + await new Promise((resolve) => setTimeout(resolve, 300)); + await pressEnter(client, pageSessionId); + + const codeLensExpression = `(function () { + return Array.from(document.querySelectorAll('.codelens-decoration, .codelens-decoration a')) + .some(function (node) { return String(node.textContent || '').indexOf('Browse model') >= 0; }); + })()`; + await waitForSessionEval(client, pageSessionId, codeLensExpression, (value) => value === true, 'Browse model CodeLens', 15000); + const clicked = await evalInSession(client, pageSessionId, `(function () { + var node = Array.from(document.querySelectorAll('.codelens-decoration a, .codelens-decoration')) + .find(function (candidate) { return String(candidate.textContent || '').indexOf('Browse model') >= 0; }); + if (!node) return false; + node.click(); + return true; + })()`); + assert.equal(clicked, true, 'Browse model CodeLens should be clickable'); + + const codeBrowser = await waitForCodeAssetBrowserContext(client, tracking, 30000); + await evalInContext(client, codeBrowser.sessionId, codeBrowser.contextId, 'window.__wurstCodeAssetBrowserDebug.search("footman")'); + const state = await waitForEval( + client, + codeBrowser.sessionId, + codeBrowser.contextId, + 'window.__wurstCodeAssetBrowserDebug.state()', + (value) => value && value.query === 'footman' && Array.isArray(value.results) && value.results.length > 0, + 'code-launched footman asset search results', + 15000, + ); + assert.equal(state.activeTab, 'model', 'model string CodeLens should open the Models tab'); + assert.ok( + state.results.some((entry) => /footman/i.test(`${entry.label} ${entry.value}`)), + `code-launched asset search should return Footman: ${JSON.stringify(state.results)}`, + ); + assert.ok( + state.results.every((entry) => /footm[ae]n/i.test(`${entry.label} ${entry.value}`)), + `code-launched asset search should not contain unrelated fuzzy noise: ${JSON.stringify(state.results)}`, + ); + for (let i = 1; i < state.results.length; i++) { + assert.ok( + state.results[i - 1].score <= state.results[i].score, + `code-launched asset search scores should be sorted: ${JSON.stringify(state.results)}`, + ); + } + log(`code-launched footman search returned ${state.results.length} relevance-sorted results`); +} + async function assertTooltipFont(client, sessionId, contextId) { const status = await evalInContext(client, sessionId, contextId, `(async function () { var family = 'WurstProjectTooltip'; @@ -787,13 +973,14 @@ async function main() { WURST_OBJMOD_E2E_PROJECT: projectPath, WURST_OBJMOD_E2E_FILE: objmodFile, }; + if (codeOnly) delete childEnv.WURST_OBJMOD_E2E_FILE; delete childEnv.ELECTRON_RUN_AS_NODE; log(`code=${code}`); log(`project=${projectPath}`); log(`file=${objmodFile}`); log(`devtoolsPort=${devtoolsPort}`); - const child = spawnCode(code, [ + const launchArgs = [ '--new-window', '--skip-welcome', '--skip-release-notes', @@ -804,8 +991,9 @@ async function main() { `--extensions-dir=${extensionsDir}`, `--extensionDevelopmentPath=${root}`, projectPath, - objmodFile, - ], childEnv); + codeOnly ? codeAssetFile : objmodFile, + ]; + const child = spawnCode(code, launchArgs, childEnv); let stderr = ''; let passed = false; @@ -829,8 +1017,14 @@ async function main() { log('connecting DevTools WebSocket'); client = new CdpClient(version.webSocketDebuggerUrl); await client.connect(); - log('waiting for objmod webview'); - const { sessionId, contextId } = await waitForWebviewContext(client); + log(codeOnly ? 'waiting for extension-host workbench' : 'waiting for objmod webview'); + const tracking = await waitForWebviewContext(client, !codeOnly); + if (codeOnly) { + await assertCodeAssetBrowserSearch(client, tracking.pageSessionId, tracking, userDataDir); + passed = true; + return; + } + const { sessionId, contextId, pageSessionId } = tracking; log('asserting objmod editor basics'); await assertObjmodEditorBasics(client, sessionId, contextId); if (fontOnly) { @@ -931,6 +1125,7 @@ async function main() { assert.equal(failures.length, 0, failures.join('\n')); const maxObserved = numbers.length ? `${Math.max(...numbers)}ms` : 'n/a'; log(`passed ${initialKeys.length} completed thumbnails, max observed=${maxObserved}`); + await assertCodeAssetBrowserSearch(client, pageSessionId, tracking, userDataDir); passed = true; return; } diff --git a/scripts/test-fuzzy.js b/scripts/test-fuzzy.js index 76837d2..fb01a85 100644 --- a/scripts/test-fuzzy.js +++ b/scripts/test-fuzzy.js @@ -20,6 +20,16 @@ const { fuzzyMatch, assetSearchScore } = mod.exports; assert.strictEqual(typeof fuzzyMatch, 'function', 'fuzzyMatch should be exported'); assert.strictEqual(typeof assetSearchScore, 'function', 'assetSearchScore should be exported'); +// The code-launched asset picker serializes both functions into an isolated webview. This must not +// leave the scorer reaching back into its original CommonJS/webpack module closure. +const isolatedFuzzyMatch = new Function(`return (${fuzzyMatch.toString()});`)(); +const isolatedAssetSearchScore = new Function(`return (${assetSearchScore.toString()});`)(); +assert.equal( + isolatedAssetSearchScore('footman', 'Footman.mdx', 'units\\human\\Footman.mdx', '', isolatedFuzzyMatch), + 0, + 'serialized asset scorer should run with only its explicit fuzzy matcher dependency', +); + let passed = 0; function ok(query, text, expected, msg) { const got = fuzzyMatch(query, text); @@ -67,7 +77,7 @@ const footmanCandidates = [ { label: 'AltarOfKings - altarofkings', value: 'buildings\\human\\AltarOfKings\\AltarOfKings.mdx' }, ]; const footmanResults = footmanCandidates - .map((item, index) => ({ ...item, index, score: assetSearchScore('footman', item.label, item.value) })) + .map((item, index) => ({ ...item, index, score: assetSearchScore('footman', item.label, item.value, '', fuzzyMatch) })) .filter((item) => Number.isFinite(item.score)) .sort((a, b) => a.score - b.score || a.index - b.index); assert.deepStrictEqual( @@ -80,6 +90,6 @@ assert.deepStrictEqual( [0, 10, 20], 'asset relevance scores should be deterministic', ); -passed += 2; +passed += 3; console.log(`fuzzy unit tests passed (${passed} assertions)`); diff --git a/scripts/test-webview.js b/scripts/test-webview.js index c2d4351..9daadee 100644 --- a/scripts/test-webview.js +++ b/scripts/test-webview.js @@ -222,12 +222,13 @@ function installObjModStateDom(persistedState) { const els = { tree: new FakeElement(), details: new FakeElement(), search: new FakeElement() }; global.document = { getElementById: (id) => els[id] || null }; let state = persistedState || {}; + const messages = []; global.acquireVsCodeApi = () => ({ - postMessage: () => {}, + postMessage: (message) => { messages.push(message); }, getState: () => state, setState: (next) => { state = next; }, }); - return { getState: () => state }; + return { getState: () => state, getMessages: () => messages }; } // state.ts is meant to make a reopened editor (a webview reload after our external-change auto-reload @@ -235,9 +236,13 @@ function installObjModStateDom(persistedState) { // a blank slate — see the persistUi effect and the restoredSelectedKey logic there. function testObjModStateRestoresAndPersistsUiState() { moduleCache.clear(); - const objects = [{ key: 'Custom:0' }, { key: 'Custom:1' }]; + const objects = [ + { key: 'Custom:0', identity: 'Custom:hfoo|h001' }, + { key: 'Custom:1', identity: 'Custom:hrif|h002' }, + ]; const dom = installObjModStateDom({ - selectedKey: 'Custom:1', + selectedKey: 'Custom:0', // deliberately stale after an external reorder + selectedIdentity: 'Custom:hrif|h002', query: 'foo', fieldQuery: 'dmg', showTechnical: true, @@ -270,13 +275,19 @@ function testObjModStateRestoresAndPersistsUiState() { assert.equal(persistedAfter.treeScrollTop, 240, 'persisting one field must not drop the others'); assert.equal(persistedAfter.detailsScrollTop, 150, 'persisting one field must not drop the others'); assert.equal(persistedAfter.selectedKey, 'Custom:1', 'unrelated restored fields must survive a later persist'); + assert.equal(persistedAfter.selectedIdentity, 'Custom:hrif|h002', 'selection persistence must use stable rawcodes, not an array index'); + assert.ok(dom.getMessages().some(message => message.type === 'selectionChanged' && message.identity === 'Custom:hrif|h002'), + 'the stable selection identity must be sent to the host for workspace-relative persistence'); assert.equal(persistedAfter.listW, 321, 'fields unrelated to reactive ui state (e.g. splitter width) must not be clobbered'); } function testObjModStatePendingJumpOverridesRestoredSelection() { moduleCache.clear(); - const objects = [{ key: 'Custom:0' }, { key: 'Custom:1' }]; - installObjModStateDom({ selectedKey: 'Custom:1' }); + const objects = [ + { key: 'Custom:0', identity: 'Custom:hfoo|h001' }, + { key: 'Custom:1', identity: 'Custom:hrif|h002' }, + ]; + installObjModStateDom({ selectedKey: 'Custom:1', selectedIdentity: 'Custom:hrif|h002' }); global.window.__OBJMOD_INITIAL__ = { objects, selectedKey: 'Custom:0', isPendingJump: true, extended: false }; const state = loadTsModule('src/webview/objModEditor/state.ts'); @@ -285,8 +296,8 @@ function testObjModStatePendingJumpOverridesRestoredSelection() { function testObjModStateIgnoresStaleRestoredSelection() { moduleCache.clear(); - const objects = [{ key: 'Custom:0' }]; // 'Custom:99' below no longer exists in this file - installObjModStateDom({ selectedKey: 'Custom:99' }); + const objects = [{ key: 'Custom:0', identity: 'Custom:hfoo|h001' }]; + installObjModStateDom({ selectedKey: 'Custom:99', selectedIdentity: 'Custom:old0|old1' }); global.window.__OBJMOD_INITIAL__ = { objects, selectedKey: 'Custom:0', isPendingJump: false, extended: false }; const state = loadTsModule('src/webview/objModEditor/state.ts'); @@ -300,8 +311,8 @@ function testObjModStateIgnoresStaleRestoredSelection() { function testObjModTreeRenderPreservesScrollPosition() { moduleCache.clear(); const objects = [ - { key: 'Custom:0', group: 'Custom', race: 'human', displayName: 'Alpha', baseId: 'a000' }, - { key: 'Custom:1', group: 'Custom', race: 'human', displayName: 'Beta', baseId: 'b000' }, + { key: 'Custom:0', identity: 'Custom:a000|a001', group: 'Custom', race: 'human', displayName: 'Alpha', baseId: 'a000' }, + { key: 'Custom:1', identity: 'Custom:b000|b001', group: 'Custom', race: 'human', displayName: 'Beta', baseId: 'b000' }, ]; installObjModStateDom({ treeScrollTop: 240 }); global.window.__OBJMOD_INITIAL__ = { objects, selectedKey: '', isPendingJump: false, extended: false }; @@ -628,7 +639,7 @@ function testNonBlockingStartupAndForcedReinstallWiring() { assert.ok(!extension.includes('ensureInstalledOrOfferMigration(true)'), 'manual install/update must not use the no-op ensure path'); assert.ok(!languageServer.includes('await maybeOfferUpdate(context)'), 'update checks must not delay language-client startup'); assert.ok(languageServer.includes('void maybeOfferUpdate((update) =>'), 'update checks should still run in the background and update the status item'); - assert.ok(languageServer.includes("'$(cloud-download) WurstScript Update'"), 'the status item must indicate when an update is available'); + assert.ok(languageServer.includes("'$(circle-filled) WurstScript Update'"), 'the status item must indicate when an update is available'); assert.ok(!installer.includes("{ modal: true, detail }, 'Update', 'Later'"), 'the automatic update notification must not be modal'); assert.ok(installer.includes("'Update', 'Later'"), 'the non-modal update notification must retain its actions'); assert.ok(installer.includes("execFile(java, ['-jar', COMPILER_JAR, '--version']"), 'version detection must use an asynchronous child process'); @@ -752,7 +763,7 @@ function testAssetBrowserForwardsModelTextures() { 'asset browser must not silently drop model texture requests' ); assert.ok( - script.includes('assetSearchScore(query, item.label, item.value, item.detail)'), + script.includes('assetSearchScore(query, item.label, item.value, item.detail, fuzzyMatch)'), 'code and object-data asset pickers should share the relevance scorer' ); assert.ok( @@ -794,7 +805,7 @@ function testThumbnailLifecycleGuards() { assert.ok(objmod.includes('fetch(initial.thumbnailWorkerUri'), 'the worker bundle must be fetched before creating its Blob URL'); assert.ok(!objmod.includes('new Worker(initial.thumbnailWorkerUri)'), 'VS Code resource URLs cannot be passed directly to the Worker constructor'); assert.ok(assetBrowser.includes('modelThumbEnsureInit()'), 'opening or selecting the model asset browser should prewarm the thumbnail worker'); - assert.ok(assetBrowser.includes("import { assetSearchScore } from '../../features/preview/fuzzy'"), 'objmod asset search should use the shared relevance scorer'); + assert.ok(assetBrowser.includes("import { assetSearchScore, fuzzyMatch } from '../../features/preview/fuzzy'"), 'objmod asset search should use the shared relevance scorer'); const ensureInit = /export function modelThumbEnsureInit\(\) \{([\s\S]*?)\n\}/.exec(objmod)?.[1] || ''; assert.ok(!ensureInit.includes('mpvViewer()'), 'worker startup failure must not fall back to rendering on the objmod UI thread'); assert.ok(webpack.includes("mdxThumbnailWorker: './src/webview/mdxThumbnailWorker.ts'"), 'the isolated thumbnail worker must be bundled'); @@ -1017,6 +1028,27 @@ function testObjModEditorTypeAndRecoveryGuards() { assert.ok(host.includes('wtsEdits: Array.from(doc.wtsEdits)'), 'objmod backups must include staged WTS edits'); assert.ok(host.includes('currentRevision = beforeRevision'), 'undo must restore a history identity, not decrement a depth'); assert.ok(!host.includes('doc.editDepth'), 'branch-unsafe edit depth tracking must not return'); + assert.ok(host.includes('watcher.onDidDelete(onEvent)'), 'external-change detection must cover Git-style file replacement'); + assert.ok(host.includes('id="refresh-editor"'), 'the object editor must expose a manual refresh action'); + assert.ok(host.includes('preferredSelectionIdentity'), 'reloading must resolve selection by stable object identity'); + assert.ok(host.includes('objModSelectionPathKey(doc.uri)'), 'selection must be stored per workspace-relative document path'); +} + +function testWpmEditorInlineScriptAndRecoveryGuards() { + const host = fs.readFileSync(path.join(root, 'src/features/wpmPreview.ts'), 'utf8'); + const match = host.match(/ `; } -// ── Registration ────────────────────────────────────────────────────────────── +// ── Editable document ───────────────────────────────────────────────────────── -export function registerWpmPreview(_context: vscode.ExtensionContext): vscode.Disposable[] { - return [ - registerParsedPreviewer({ - viewType: 'wurst.wpmPreview', - parse: (data) => parseWpm(data), - render: (parsed, fileName) => parsed.error - ? buildPage({ - csp: "default-src 'none'; style-src 'unsafe-inline';", - title: escapeHtml(fileName), - body: `
+class WpmDocument implements vscode.CustomDocument { + currentRevision = 0; + savedRevision = 0; + nextRevision = 1; + webview?: vscode.Webview; + + constructor(readonly uri: vscode.Uri, public file: WpmFile) {} + + dispose(): void {} +} + +interface WpmCellChange { + index: number; + before: number; + after: number; +} + +class WpmEditorProvider implements vscode.CustomEditorProvider { + private readonly _onDidChange = new vscode.EventEmitter>(); + readonly onDidChangeCustomDocument = this._onDidChange.event; + + async openCustomDocument(uri: vscode.Uri, openContext: vscode.CustomDocumentOpenContext): Promise { + const source = openContext.backupId ? vscode.Uri.parse(openContext.backupId) : uri; + const doc = new WpmDocument(uri, parseWpm(Buffer.from(await vscode.workspace.fs.readFile(source)))); + if (openContext.backupId) { + doc.currentRevision = 1; + doc.nextRevision = 2; + } + return doc; + } + + async resolveCustomEditor(doc: WpmDocument, panel: vscode.WebviewPanel): Promise { + panel.webview.options = { enableScripts: true, localResourceRoots: [] }; + doc.webview = panel.webview; + panel.onDidDispose(() => { if (doc.webview === panel.webview) doc.webview = undefined; }); + panel.webview.onDidReceiveMessage((message) => this.handleMessage(message, doc)); + this.render(doc, panel.webview); + } + + private render(doc: WpmDocument, webview: vscode.Webview): void { + const fileName = doc.uri.path.slice(doc.uri.path.lastIndexOf('/') + 1); + webview.html = doc.file.error + ? buildPage({ + csp: "default-src 'none'; style-src 'unsafe-inline';", + title: escapeHtml(fileName), + body: `
Failed to parse WPM - ${escapeHtml(parsed.error)} + ${escapeHtml(doc.file.error)}
`, - }) - : buildWpmHtml(parsed, fileName), - webviewOptions: { enableScripts: true, localResourceRoots: [] }, - panelOptions: { retainContextWhenHidden: true }, - }), - ]; + }) + : buildWpmHtml(doc.file, fileName, doc.currentRevision !== doc.savedRevision); + } + + private handleMessage(message: unknown, doc: WpmDocument): void { + if (!message || typeof message !== 'object') return; + const msg = message as { type?: string; changes?: Array<{ index?: number; value?: number }> }; + if (msg.type !== 'editCells' || !Array.isArray(msg.changes)) return; + + const requested = new Map(); + for (const change of msg.changes) { + if (Number.isInteger(change.index) && Number.isInteger(change.value) && + (change.index as number) >= 0 && (change.index as number) < doc.file.data.length && + (change.value as number) >= 0 && (change.value as number) <= 0xff) { + requested.set(change.index as number, change.value as number); + } + } + const changes: WpmCellChange[] = []; + requested.forEach((after, index) => { + const before = doc.file.data[index]; + if (after !== before) changes.push({ index, before, after }); + }); + if (!changes.length) return; + + const beforeRevision = doc.currentRevision; + const afterRevision = doc.nextRevision++; + this.applyCells(doc, changes, false); + doc.currentRevision = afterRevision; + this.postDirtyState(doc); + this._onDidChange.fire({ + document: doc, + label: `${changes.every((change) => change.after === 0) ? 'Erase' : 'Paint'} ${changes.length} pathing cell${changes.length === 1 ? '' : 's'}`, + undo: () => { + this.applyCells(doc, changes, true); + doc.currentRevision = beforeRevision; + this.postDirtyState(doc); + }, + redo: () => { + this.applyCells(doc, changes, false); + doc.currentRevision = afterRevision; + this.postDirtyState(doc); + }, + }); + } + + private applyCells(doc: WpmDocument, changes: WpmCellChange[], useBefore: boolean): void { + const patches = changes.map((change) => { + const value = useBefore ? change.before : change.after; + doc.file.data[change.index] = value; + return { index: change.index, value }; + }); + void doc.webview?.postMessage({ type: 'applyCells', changes: patches }); + } + + private postDirtyState(doc: WpmDocument): void { + void doc.webview?.postMessage({ type: 'dirtyStateChanged', isDirty: doc.currentRevision !== doc.savedRevision }); + } + + async saveCustomDocument(doc: WpmDocument): Promise { + try { + await this.writeWpm(doc, doc.uri); + doc.savedRevision = doc.currentRevision; + this.postDirtyState(doc); + } catch (err) { + void showErrorWithLogs(`Pathing map not saved: ${err instanceof Error ? err.message : String(err)}`, err); + throw err; + } + } + + async saveCustomDocumentAs(doc: WpmDocument, target: vscode.Uri): Promise { + await vscode.workspace.fs.writeFile(target, serializeValidatedWpm(doc.file, target.path)); + } + + private async writeWpm(doc: WpmDocument, uri: vscode.Uri): Promise { + const bytes = serializeValidatedWpm(doc.file, uri.path); + try { + const existing = Buffer.from(await vscode.workspace.fs.readFile(uri)); + if (existing.equals(bytes)) return; + } catch { /* missing → write */ } + await vscode.workspace.fs.writeFile(uri, bytes); + } + + async revertCustomDocument(doc: WpmDocument): Promise { + doc.file = parseWpm(Buffer.from(await vscode.workspace.fs.readFile(doc.uri))); + doc.currentRevision = 0; + doc.savedRevision = 0; + doc.nextRevision = 1; + if (doc.webview) this.render(doc, doc.webview); + } + + async backupCustomDocument(doc: WpmDocument, context: vscode.CustomDocumentBackupContext): Promise { + await vscode.workspace.fs.writeFile(context.destination, serializeValidatedWpm(doc.file, doc.uri.path)); + return { + id: context.destination.toString(), + delete: () => vscode.workspace.fs.delete(context.destination).then(() => undefined, () => undefined), + }; + } +} + +/** Safety gate: never write a WPM that does not reproduce the complete edited grid. */ +function serializeValidatedWpm(file: WpmFile, name: string): Buffer { + if (file.error) throw new Error(`Refusing to save ${name}: the source file did not parse (${file.error}).`); + const bytes = serializeWpm(file); + const reparsed = parseWpm(bytes); + if (reparsed.error) throw new Error(`Refusing to save ${name}: serialized data did not re-parse (${reparsed.error}).`); + if (reparsed.version !== file.version || reparsed.width !== file.width || reparsed.height !== file.height || + !reparsed.data.equals(file.data) || !reparsed.tail?.equals(file.tail ?? Buffer.alloc(0))) { + throw new Error(`Refusing to save ${name}: round-trip verification failed.`); + } + return bytes; +} + +// ── Registration ────────────────────────────────────────────────────────────── + +export function registerWpmPreview(_context: vscode.ExtensionContext): vscode.Disposable[] { + return [vscode.window.registerCustomEditorProvider( + 'wurst.wpmPreview', + new WpmEditorProvider(), + { supportsMultipleEditorsPerDocument: false, webviewOptions: { retainContextWhenHidden: true } }, + )]; } diff --git a/src/languageServer.ts b/src/languageServer.ts index 4170918..84918a8 100644 --- a/src/languageServer.ts +++ b/src/languageServer.ts @@ -59,7 +59,8 @@ export async function startLanguageClient(context: ExtensionContext): Promise { - sb.text = availableUpdate ? '$(cloud-download) WurstScript Update' : '$(check) WurstScript'; + sb.text = availableUpdate ? '$(circle-filled) WurstScript Update' : '$(check) WurstScript'; + sb.color = availableUpdate ? '#3794ff' : undefined; sb.tooltip = [ availableUpdate ? 'A newer WurstScript version is available.' : 'WurstScript language server is running.', `Version: ${installedVersion}`, diff --git a/src/webview/objModEditor/assetBrowser.ts b/src/webview/objModEditor/assetBrowser.ts index e1196f8..39f1e14 100644 --- a/src/webview/objModEditor/assetBrowser.ts +++ b/src/webview/objModEditor/assetBrowser.ts @@ -1,4 +1,4 @@ -import { assetSearchScore } from '../../features/preview/fuzzy'; +import { assetSearchScore, fuzzyMatch } from '../../features/preview/fuzzy'; import { esc } from '../objModWebviewUtils'; import { effect, signal } from '../signals'; import { detailCache, details, ui, vscodeApi, iconLoader, assetBrowserUi } from './state'; @@ -123,7 +123,7 @@ export function renderAssetGrid() { const o = opts[index]; if (sourceFilter === 'import' && o.source !== 'import') continue; if (sourceFilter === 'wc3' && o.source === 'import') continue; - const score = assetSearchScore(query, o.label, o.value, o.detail || ''); + const score = assetSearchScore(query, o.label, o.value, o.detail || '', fuzzyMatch); if (!Number.isFinite(score)) continue; ranked.push({ option: o, score, index }); } diff --git a/src/webview/objModEditor/state.ts b/src/webview/objModEditor/state.ts index 8a5be65..90ae8e0 100644 --- a/src/webview/objModEditor/state.ts +++ b/src/webview/objModEditor/state.ts @@ -51,11 +51,10 @@ export const search = document.getElementById('search') as HTMLInputElement; // A cross-file rawcode jump (see locateObjectAcrossSiblings in objModPreview.ts) always wins over a // restored selection — the user just asked to look at a specific object, so honor that over whatever -// was open last time. Otherwise prefer the restored key, falling back to it only if that object still -// exists in this file (it may have been deleted by an edit made elsewhere since). -const restoredSelectedKey = typeof persisted.selectedKey === 'string' && objects.some(obj => obj.key === persisted.selectedKey) - ? persisted.selectedKey - : ''; +// was open last time. Otherwise resolve the stable rawcode identity to this parse's transient array +// key, falling back only if that object was deleted by an edit made elsewhere. +const restoredSelectedIdentity = typeof persisted.selectedIdentity === 'string' ? persisted.selectedIdentity : ''; +const restoredSelectedKey = objects.find(obj => obj.identity === restoredSelectedIdentity)?.key || ''; const initialSelectedKey = initial.isPendingJump ? initial.selectedKey : (restoredSelectedKey || initial.selectedKey || ''); // Cross-cutting state that's reassigned (not just mutated) from more than one module. Real ES module @@ -139,12 +138,14 @@ export const assetBrowserUi = { // free. Merges onto whatever's already persisted (e.g. the splitter width in objModEditorWebview.ts) // rather than replacing it outright. effect(() => { + const selectedIdentity = objects.find(obj => obj.key === ui.selectedKey)?.identity || ''; const collapsed = Array.from(collapsedNodes); const hiddenCategories = Array.from(ui.hiddenCategories); collapsedNodes.version; // tracked: re-persist when a tree branch is collapsed/expanded ui.hiddenCategories.version; // tracked: re-persist when the category filter changes vscodeApi.setState(Object.assign({}, vscodeApi.getState() || {}, { selectedKey: ui.selectedKey, + selectedIdentity, query: ui.query, fieldQuery: ui.fieldQuery, showTechnical: ui.showTechnical, @@ -157,3 +158,10 @@ effect(() => { hiddenCategories: hiddenCategories, })); }, 'state.persistUi'); + +// The host stores only this stable identity, keyed by the document's workspace-relative path. That +// survives closing/reopening the editor and still resolves after Git reorders object arrays. +effect(() => { + const identity = objects.find(obj => obj.key === ui.selectedKey)?.identity; + if (identity) vscodeApi.postMessage({ type: 'selectionChanged', identity }); +}, 'state.rememberSelection'); diff --git a/src/webview/objModEditor/types.ts b/src/webview/objModEditor/types.ts index d69791c..2bfb62f 100644 --- a/src/webview/objModEditor/types.ts +++ b/src/webview/objModEditor/types.ts @@ -1,5 +1,6 @@ export interface ObjModObject { key: string; + identity: string; baseId: string; newId?: string; displayName: string; diff --git a/src/webview/objModEditorWebview.ts b/src/webview/objModEditorWebview.ts index 5b01b48..84385ff 100644 --- a/src/webview/objModEditorWebview.ts +++ b/src/webview/objModEditorWebview.ts @@ -201,6 +201,12 @@ document.addEventListener('keydown', e => { const editableBadge = document.getElementById('editable-badge'); if (editableBadge) editableBadge.addEventListener('click', saveNow); +const refreshEditor = document.getElementById('refresh-editor'); +if (refreshEditor) refreshEditor.addEventListener('click', () => { + commitActiveEditor(); + vscodeApi.postMessage({ type: 'refresh' }); +}); + // Density is a document-wide spacing scale (tree, header, field table all retune at once), so it's a // single class on driving the :root / body.density-cozy variable pair in objModPreview.ts — // nothing has to re-render. The effect runs immediately on creation, which is what applies a restored