diff --git a/.vscodeignore b/.vscodeignore index e8335426..2e1354a1 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -27,4 +27,6 @@ tsconfig_test.json # <— unignore webview assets !src/manage/webviews/html/** !src/manage/webviews/css/** -!src/manage/webviews/webviewScripts/** \ No newline at end of file +!src/manage/webviews/webviewScripts/** +!src/requirements/webviews/css/** +!src/requirements/webviews/webviewScripts/** \ No newline at end of file diff --git a/docs/reqs2x/reqs2x_documentation.md b/docs/reqs2x/reqs2x_documentation.md index bf0ff9a1..9fadc680 100644 --- a/docs/reqs2x/reqs2x_documentation.md +++ b/docs/reqs2x/reqs2x_documentation.md @@ -34,8 +34,7 @@ Remove any existing versions of the VectorCAST VS-Code extension. ### VS Code Extensions -1. Install the **Excel Viewer** extension (`GrapeCity.gc-excelviewer`) to view/edit Excel sheets directly in VS Code (optional). Alternatively, use MS Excel to view/edit Excel sheets. -2. Install the **VectorCAST Text Explorer** VS-Code extension from the Microsoft Marketplace. +1. Install the **VectorCAST Text Explorer** VS-Code extension from the Microsoft Marketplace. ### Configuration 1. Activate the extension: Press `Ctrl+Shift+P`, search for **Vectorcast Test Explorer**, and press `Enter`. @@ -94,7 +93,7 @@ Remove any existing versions of the VectorCAST VS-Code extension. ## 2. Generating Tests from Requirements -The demo release includes an Excel file with requirements and requirements-to-code traceability. +The demo release ships with requirements including requirements-to-code traceability. To use them, import them into the environment which will set up a requirements gateway (RGW). The RGW is the single source of truth — the extension reads from and writes to it directly. ![Reqs2x demo requirements](./screenshots/VectorCAST_Reqs2x_demo_requirements.png) @@ -102,13 +101,10 @@ The demo release includes an Excel file with requirements and requirements-to-co ### Initial Setup 1. Click the **Flask icon** (Test Explorer) on the left sidebar to show the environment tree. -2. Right-click `TUTORIAL_C` and select **VectorCAST -> Show Requirements**. The requirements webview will appear. -3. Right-click `TUTORIAL_C` and select **VectorCAST -> Populate RGW from Requirements**. - * *Note: This creates a requirements gateway and populates it from the Excel sheet. Wait for the notification (approx. 3-5 seconds).* - * If you do not have the requirements-to-code traceability for a function, Reqs2x will generate it automatically with your approval. +2. Right-click `TUTORIAL_C` and select **VectorCAST -> Import Requirements**. Choose the file `reqs-TUTORIAL_C/reqs.xlsx`. This will load the requirements into the environment. +2. Right-click `TUTORIAL_C` and select **VectorCAST -> Show Requirements**. The requirements webview will appear, rendered directly from the RGW. ![Reqs2x demo show requirements](./screenshots/VectorCAST_Reqs2x_show_requirements.png) ![Reqs2x demo requirements webview](./screenshots/VectorCAST_Reqs2x_requirements_webview.png) -![Reqs2x demo populate RGW](./screenshots/VectorCAST_Reqs2x_populate_rgw.png) ### Test Generation 1. Right-click `TUTORIAL_C` and select **VectorCAST -> Generate Tests from Requirements**. @@ -143,12 +139,11 @@ The demo release includes an Excel file with requirements and requirements-to-co 1. **Modify requirement**: - * Open `reqs.xlsx` using the Excel Viewer. - * Find `FR27` (Add Included Dessert). + * Edit the requirement directly in the requirements gateway (RGW) in the Requirements view. Open it by selecting ``VectorCAST -> Show Requirements`. + * Find `FR27` (Add Included Dessert). * Change the free dessert for `steak, caesar salad and mixed drink` from `pie` to `cake`. * Change the free dessert for `lobster, green salad and wine` from `cake` to `pie`. - * Save (`Ctrl+S`) and close the tab. - * Right-click `TUTORIAL_C` and select **VectorCAST -> Populate RGW from Requirements** to push the change to the requirements gateway. You will be prompted to accept replacing the old requirements gateway with the new one. + * Save the change. The extension always reads from the RGW, so no further sync step is required. 2. **Update tests**: * In Test Explorer, find the `Add_Included_Dessert` node. diff --git a/package.json b/package.json index 32402e3e..9fa3e65e 100644 --- a/package.json +++ b/package.json @@ -242,7 +242,8 @@ { "command": "vectorcastTestExplorer.generateRequirements", "category": "VectorCAST Test Explorer", - "title": "Generate Requirements" + "title": "Generate Requirements", + "enablement": "testId not in vectorcastTestExplorer.vcastRequirementsAvailable" }, { "command": "vectorcastTestExplorer.insertBasisPathTestsFromEditor", @@ -307,7 +308,8 @@ { "command": "vectorcastTestExplorer.generateTestsFromRequirements", "category": "VectorCAST Test Explorer", - "title": "Generate Tests from Requirements" + "title": "Generate Tests from Requirements", + "enablement": "testId in vectorcastTestExplorer.vcastRequirementsAvailable" }, { "command": "vectorcastTestExplorer.viewMCDCReport", @@ -317,22 +319,26 @@ { "command": "vectorcastTestExplorer.showRequirements", "category": "VectorCAST Test Explorer", - "title": "Show Requirements" + "title": "Show Requirements", + "enablement": "testId in vectorcastTestExplorer.vcastRequirementsAvailable" }, { "command": "vectorcastTestExplorer.removeRequirements", "category": "VectorCAST Test Explorer", - "title": "Remove Requirements" + "title": "Remove Requirements", + "enablement": "testId in vectorcastTestExplorer.vcastRequirementsAvailable" }, { - "command": "vectorcastTestExplorer.importRequirementsFromGateway", + "command": "vectorcastTestExplorer.importRequirements", "category": "VectorCAST Test Explorer", - "title": "Import Requirements from Gateway" + "title": "Import Requirements", + "enablement": "testId not in vectorcastTestExplorer.vcastRequirementsAvailable" }, { - "command": "vectorcastTestExplorer.populateRequirementsGateway", + "command": "vectorcastTestExplorer.exportRequirements", "category": "VectorCAST Test Explorer", - "title": "Populate RGW from Requirements" + "title": "Export Requirements", + "enablement": "testId not in vectorcastTestExplorer.vcastEnviroList || testId in vectorcastTestExplorer.vcastRequirementsAvailable" }, { "command": "vectorcastTestExplorer.testLLMConfiguration", @@ -927,12 +933,12 @@ "when": "never" }, { - "command": "vectorcastTestExplorer.importRequirementsFromGateway", - "when": "never" + "command": "vectorcastTestExplorer.importRequirements", + "when": "vectorcastTestExplorer.reqs2xFeatureEnabled" }, { - "command": "vectorcastTestExplorer.populateRequirementsGateway", - "when": "never" + "command": "vectorcastTestExplorer.exportRequirements", + "when": "vectorcastTestExplorer.reqs2xFeatureEnabled" }, { "command": "vectorcastTestExplorer.testLLMConfiguration", @@ -1184,19 +1190,24 @@ "when": "testId in vectorcastTestExplorer.globalProjectCompilers" }, { - "command": "vectorcastTestExplorer.importRequirementsFromGateway", + "command": "vectorcastTestExplorer.generateRequirements", "group": "vcast@8", - "when": "testId =~ /^vcast:.*$/ && testId in vectorcastTestExplorer.vcastEnviroList && vectorcastTestExplorer.reqs2xFeatureEnabled && testId not in vectorcastTestExplorer.vcastRequirementsAvailable && vectorcastTestExplorer.generateRequirementsEnabled" + "when": "testId =~ /^vcast:[^|]+$/ && testId in vectorcastTestExplorer.vcastEnviroList && vectorcastTestExplorer.reqs2xFeatureEnabled" }, { - "command": "vectorcastTestExplorer.generateRequirements", + "command": "vectorcastTestExplorer.importRequirements", "group": "vcast@8", - "when": "testId =~ /^vcast:.*$/ && testId in vectorcastTestExplorer.vcastEnviroList && vectorcastTestExplorer.reqs2xFeatureEnabled && testId not in vectorcastTestExplorer.vcastRequirementsAvailable && vectorcastTestExplorer.generateRequirementsEnabled" + "when": "testId =~ /^vcast:[^|]+$/ && testId in vectorcastTestExplorer.vcastEnviroList && vectorcastTestExplorer.reqs2xFeatureEnabled" }, { "command": "vectorcastTestExplorer.showRequirements", "group": "vcast@9", - "when": "testId =~ /^vcast:.*$/ && testId in vectorcastTestExplorer.vcastEnviroList && vectorcastTestExplorer.reqs2xFeatureEnabled && testId in vectorcastTestExplorer.vcastRequirementsAvailable && vectorcastTestExplorer.generateRequirementsEnabled" + "when": "testId =~ /^vcast:[^|]+$/ && testId in vectorcastTestExplorer.vcastEnviroList && vectorcastTestExplorer.reqs2xFeatureEnabled" + }, + { + "command": "vectorcastTestExplorer.exportRequirements", + "group": "vcast@9", + "when": "testId =~ /^vcast:[^|]+$/ && testId in vectorcastTestExplorer.vcastEnviroList && vectorcastTestExplorer.reqs2xFeatureEnabled" }, { "command": "vectorcastTestExplorer.generateTestsFromRequirements", @@ -1206,12 +1217,7 @@ { "command": "vectorcastTestExplorer.removeRequirements", "group": "vcast@9", - "when": "testId =~ /^vcast:.*$/ && vectorcastTestExplorer.reqs2xFeatureEnabled && testId in vectorcastTestExplorer.vcastRequirementsAvailable && vectorcastTestExplorer.generateRequirementsEnabled" - }, - { - "command": "vectorcastTestExplorer.populateRequirementsGateway", - "group": "vcast@9", - "when": "testId =~ /^vcast:.*$/ && vectorcastTestExplorer.reqs2xFeatureEnabled && testId in vectorcastTestExplorer.vcastRequirementsAvailable && vectorcastTestExplorer.generateRequirementsEnabled" + "when": "testId =~ /^vcast:[^|]+$/ && vectorcastTestExplorer.reqs2xFeatureEnabled" }, { "command": "vectorcastTestExplorer.viewResults", @@ -1303,9 +1309,7 @@ "dependencies": { "@vscode/vsce": "^2.32.0", "axios": "^1.7.7", - "convert-excel-to-json": "^1.7.0", "crc-32": "^1.2.0", - "csv-parse": "^5.6.0", "glob": "^7.1.7", "hasbin": "^1.2.3", "jsonc-parser": "^3.2.1", @@ -1315,7 +1319,6 @@ "tslib": "^1.9.3", "vscode-languageclient": "^5.2.1", "vscode-languageserver": "^5.2.1", - "xlsx": "^0.18.5", "xo": "^0.58.0" }, "extensionDependencies": [], diff --git a/src/extension.ts b/src/extension.ts index 7d859e6c..bedabb52 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -125,22 +125,36 @@ import { } from "./vcastInstallation"; import { + clearVcastRepositoryInConfig, findRelevantRequirementGateway, - generateRequirementsHtml, - parseRequirementsFromFile, - performLLMProviderUsableCheck, - requirementsFileWatcher, +} from "./requirements/rgwPath"; +import { + setupRequirementsFileWatchers, updateRequirementsAvailability, -} from "./requirements/requirementsUtils"; +} from "./requirements/availability"; +import { performLLMProviderUsableCheck } from "./requirements/llmProvider"; +import { + applyEditPolicy, + inferTraceability, + readRGWBundle, + RGWBundle, + RGWStaleWriteError, + writeRGWBundle, +} from "./requirements/rgwIo"; +import { generateRequirementsHtml } from "./requirements/webview/template"; +import type { + FromWebview, + ToWebview, +} from "./requirements/webview/messages"; import { - GENERATE_REQUIREMENTS_ENABLED, + exportRequirements, generateRequirements, generateTestsFromRequirements, - importRequirementsFromGateway, + importRequirements, initializeReqs2X, - populateRequirementsGateway, } from "./requirements/requirementsOperations"; +import { maybeOfferLegacyMigration } from "./requirements/legacyMigration"; import { generateNewCodedTestFile, @@ -165,10 +179,9 @@ import { import fs = require("fs"); import { compilerTagList, - getNonce, - resolveWebviewBase, setCompilerList, } from "./manage/manageSrc/manageUtils"; +import { getNonce, resolveWebviewBase } from "./webviewUtils"; const path = require("path"); @@ -292,6 +305,147 @@ async function getEnvironmentListIncludingUnbuilt( }); } +/** + * Pick the environment path to act on for a command. Right-click invocations + * pass `{ id }` from a tree node; command-palette invocations pass nothing, + * so we show a quick-pick of envs in the workspace. Returns null if the user + * dismisses the picker or the workspace has no env files. + */ +/** + * Open the source file backing a trace mapping. If `functionName` is given + * and the unit has a matching entry with a startLine, jump to that line; + * otherwise just open the file. Used by the requirements editor's "Open + * source" button. + */ +/** + * Resolve a source-file location from VectorCAST envData. Returns the URI + * for the unit's source file and the line of the named function (or 0 if + * `functionName` is null or its `startLine` isn't known). Returns null if + * envData lacks unit info or the unit isn't present in the env. + * + * Shared by the test-pane "Open Source File" command and the requirements + * editor's per-card "Open source" button — same matching rule (unit name + * == source-file basename, sans extension). + */ +function resolveSourceLocation( + envData: any, + unitName: string, + functionName: string | null +): { uri: vscode.Uri; lineNumber: number } | null { + const units = envData?.unitData; + if (!Array.isArray(units)) return null; + const unitInfo = units.find((u: any) => { + if (!u?.path) return false; + return path.basename(u.path, path.extname(u.path)) === unitName; + }); + if (!unitInfo) return null; + let lineNumber = 0; + if (functionName && Array.isArray(unitInfo.functionList)) { + const fn = unitInfo.functionList.find( + (f: any) => f?.name === functionName && f?.startLine !== undefined + ); + if (fn) lineNumber = fn.startLine; + } + return { uri: vscode.Uri.file(unitInfo.path), lineNumber }; +} + +async function openSourceForTrace( + enviroPath: string, + unitName: string, + functionName: string | null, + referenceColumn: vscode.ViewColumn | undefined +): Promise { + if (!unitName) return; + let envData: any; + try { + envData = await getEnvironmentData(enviroPath); + } catch { + vscode.window.showErrorMessage( + "Could not query the environment to resolve the source file." + ); + return; + } + if (!envData?.unitData) { + vscode.window.showErrorMessage( + "Environment data has no unit information." + ); + return; + } + + const located = resolveSourceLocation(envData, unitName, functionName); + if (!located) { + vscode.window.showErrorMessage( + `Unit "${unitName}" not found in this environment.` + ); + return; + } + const { uri, lineNumber } = located; + const position = new vscode.Position(Math.max(0, lineNumber - 1), 0); + const selection = new vscode.Range(position, position); + + // If the file is already open in a tab in some *other* column, reveal + // that tab. Tabs in the webview's own column are ignored — switching to + // them would hide the requirements view. If no other-column tab exists, + // fall back to Beside so a new editor opens next to the webview. + let targetColumn: vscode.ViewColumn | undefined; + for (const group of vscode.window.tabGroups.all) { + if (group.viewColumn === referenceColumn) continue; + const hit = group.tabs.find( + (tab) => + tab.input instanceof vscode.TabInputText && + tab.input.uri.fsPath === uri.fsPath + ); + if (hit) { + targetColumn = group.viewColumn; + break; + } + } + targetColumn ??= vscode.ViewColumn.Beside; + + const document = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: false, + selection, + viewColumn: targetColumn, + }); +} + +async function resolveEnviroPathForCommand( + args: any +): Promise { + if (args?.id) { + const testNode: testNodeType = getTestNode(args.id); + return testNode?.enviroPath ?? null; + } + + const folders = workspace.workspaceFolders; + if (!folders || folders.length === 0) { + vscode.window.showErrorMessage("No workspace open."); + return null; + } + + const envPaths = await getEnvironmentListIncludingUnbuilt(folders[0].uri.fsPath); + if (envPaths.length === 0) { + vscode.window.showErrorMessage( + "No VectorCAST environments found in the workspace." + ); + return null; + } + + if (envPaths.length === 1) return envPaths[0]; + + const picked = await vscode.window.showQuickPick( + envPaths.map((p) => ({ + label: path.basename(p), + description: path.relative(folders[0].uri.fsPath, p), + envPath: p, + })), + { placeHolder: "Select environment" } + ); + return picked ? picked.envPath : null; +} + async function activationLogic(context: vscode.ExtensionContext) { // remove developer env variables decodeAndRemoveDeveloperEnvs(); @@ -313,14 +467,9 @@ async function activationLogic(context: vscode.ExtensionContext) { // start the language server activateLanguageServerClient(context); - // Enable/disable the requirement generation component of the extension - vscode.commands.executeCommand( - "setContext", - "vectorcastTestExplorer.generateRequirementsEnabled", - GENERATE_REQUIREMENTS_ENABLED - ); - - // Initialize requirements availability for all environments + // Initialize requirements availability for all environments, then keep it + // in sync with out-of-band changes (RGW deleted in a terminal, CCAST_.CFG + // edited by hand, etc.). if (workspace.workspaceFolders && workspace.workspaceFolders.length > 0) { const envPaths = await getEnvironmentListIncludingUnbuilt( workspace.workspaceFolders[0].uri.fsPath @@ -329,8 +478,15 @@ async function activationLogic(context: vscode.ExtensionContext) { updateRequirementsAvailability(envPath); } } + setupRequirementsFileWatchers(context); initializeReqs2X(context); + + // One-shot prompt: legacy reqs.xlsx / reqs.csv → RGW. Runs after + // initializeReqs2X so the panreq executable path is resolved (no-op + // otherwise). Fire and forget — we don't want migration to block the + // rest of activation. + void maybeOfferLegacyMigration(context); } function configureExtension(context: vscode.ExtensionContext) { @@ -512,29 +668,23 @@ function configureExtension(context: vscode.ExtensionContext) { ); context.subscriptions.push(generateRequirementsTestsCommand); - let importRequirementsFromGatewayCommand = vscode.commands.registerCommand( - "vectorcastTestExplorer.importRequirementsFromGateway", - (args: any) => { - if (args) { - const testNode: testNodeType = getTestNode(args.id); - const enviroPath = testNode.enviroPath; - importRequirementsFromGateway(enviroPath); - } + let importRequirementsCommand = vscode.commands.registerCommand( + "vectorcastTestExplorer.importRequirements", + async (args: any) => { + const enviroPath = await resolveEnviroPathForCommand(args); + if (enviroPath) await importRequirements(enviroPath); } ); - context.subscriptions.push(importRequirementsFromGatewayCommand); + context.subscriptions.push(importRequirementsCommand); - let populateRequirementsGatewayCommand = vscode.commands.registerCommand( - "vectorcastTestExplorer.populateRequirementsGateway", + let exportRequirementsCommand = vscode.commands.registerCommand( + "vectorcastTestExplorer.exportRequirements", async (args: any) => { - if (args) { - const testNode: testNodeType = getTestNode(args.id); - const enviroPath = testNode.enviroPath; - await populateRequirementsGateway(enviroPath); - } + const enviroPath = await resolveEnviroPathForCommand(args); + if (enviroPath) await exportRequirements(enviroPath); } ); - context.subscriptions.push(populateRequirementsGatewayCommand); + context.subscriptions.push(exportRequirementsCommand); let testLLMConfigurationCommand = vscode.commands.registerCommand( "vectorcastTestExplorer.testLLMConfiguration", @@ -915,7 +1065,7 @@ function configureExtension(context: vscode.ExtensionContext) { const addTestsuiteToCompiler = vscode.commands.registerCommand( "vectorcastTestExplorer.addTestsuiteToCompiler", async (node: any) => { - const manageWebviewSrcDir = resolveWebviewBase(context); + const manageWebviewSrcDir = resolveWebviewBase(context, "manage", "webviews"); const panel = vscode.window.createWebviewPanel( "addTestsuiteToCompiler", "Add Testsuite to Compiler", @@ -966,7 +1116,7 @@ function configureExtension(context: vscode.ExtensionContext) { context: vscode.ExtensionContext, panel: vscode.WebviewPanel ): Promise { - const base = resolveWebviewBase(context); + const base = resolveWebviewBase(context, "manage", "webviews"); // on-disk locations const cssOnDisk = vscode.Uri.file( @@ -1240,70 +1390,41 @@ function configureExtension(context: vscode.ExtensionContext) { let openSourceFileFromTestpaneCommand = vscode.commands.registerCommand( "vectorcastTestExplorer.openSourceFileFromTestpaneCommand", async (args: any) => { - if (args) { - const testNode: testNodeType = getTestNode(args.id); - if (testNode) { - const enviroPath = testNode.enviroPath; - const unitName = testNode.unitName; - const functionName = testNode.functionName; - const envData = await getEnvironmentData(enviroPath); - - if (envData.unitData) { - for (const unitInfo of envData.unitData) { - // Extract unit name from path to match against unitName - const pathBasename = path.basename( - unitInfo.path, - path.extname(unitInfo.path) - ); - - if (pathBasename === unitName) { - const sourcePath = unitInfo.path; - const uri = vscode.Uri.file(sourcePath); - - // Determine the line number to open at (0 = top default) - let lineNumber = 0; - - // If functionName is defined, try to find it in the function list - if (functionName && unitInfo.functionList) { - for (const func of unitInfo.functionList) { - if ( - func.name === functionName && - func.startLine !== undefined - ) { - lineNumber = func.startLine; - break; - } - } - } - - // Open the document at the specified line - const document = await vscode.workspace.openTextDocument(uri); - const position = new vscode.Position( - Math.max(0, lineNumber - 1), - 0 - ); - const selection = new vscode.Range(position, position); - - await vscode.window.showTextDocument(document, { - preview: false, // open as a real tab - preserveFocus: false, - selection: selection, - }); + if (!args) return; + const testNode: testNodeType = getTestNode(args.id); + if (!testNode) { + vscode.window.showErrorMessage( + `Unable to open Source File for Node: ${args.id}` + ); + return; + } - break; - } - } - } else { - vscode.window.showErrorMessage( - `Could not find environment data for: ${enviroPath}` - ); - } - } else { - vscode.window.showErrorMessage( - `Unable to open Source File for Node: ${args.id}` - ); - } + const envData = await getEnvironmentData(testNode.enviroPath); + if (!envData?.unitData) { + vscode.window.showErrorMessage( + `Could not find environment data for: ${testNode.enviroPath}` + ); + return; } + + const located = resolveSourceLocation( + envData, + testNode.unitName, + testNode.functionName ?? null + ); + if (!located) return; + + const document = await vscode.workspace.openTextDocument(located.uri); + const position = new vscode.Position( + Math.max(0, located.lineNumber - 1), + 0 + ); + const selection = new vscode.Range(position, position); + await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: false, + selection, + }); } ); context.subscriptions.push(openSourceFileFromTestpaneCommand); @@ -1311,55 +1432,162 @@ function configureExtension(context: vscode.ExtensionContext) { let showRequirementsCommand = vscode.commands.registerCommand( "vectorcastTestExplorer.showRequirements", async (args: any) => { - if (args) { - const testNode: testNodeType = getTestNode(args.id); - const enviroPath = testNode.enviroPath; + if (!args) return; + const testNode: testNodeType = getTestNode(args.id); + const enviroPath = testNode.enviroPath; + + let bundle: RGWBundle | null; + try { + bundle = readRGWBundle(enviroPath); + } catch (err) { + vscode.window.showErrorMessage(`Failed to load requirements: ${err}`); + return; + } - const parentDir = path.dirname(enviroPath); - const enviroNameWithExt = path.basename(enviroPath); - // remove ".env" if present - const enviroNameWithoutExt = enviroNameWithExt.replace(/\.env$/, ""); - const envReqsFolderPath = path.join( - parentDir, - `reqs-${enviroNameWithoutExt}` + if (!bundle) { + vscode.window.showErrorMessage( + "No requirements gateway found. Generate requirements first." ); + return; + } - const csvPath = path.join(envReqsFolderPath, "reqs.csv"); - const xlsxPath = path.join(envReqsFolderPath, "reqs.xlsx"); - - let filePath = ""; - let fileType = ""; - if (fs.existsSync(xlsxPath)) { - filePath = xlsxPath; - fileType = "Excel"; - } else if (fs.existsSync(csvPath)) { - filePath = csvPath; - fileType = "CSV"; - } else { - vscode.window.showErrorMessage( - "Requirements file not found. Generate requirements first." - ); - return; + const loaded: RGWBundle = bundle; + + // Best-effort: pull units + their functions from the env so the + // traceability fields render as dropdowns. If the env can't be queried + // (unbuilt / data server down), fall back to free-text inputs. + let unitsToFunctions: Record | null = null; + try { + const envData = await getEnvironmentData(enviroPath); + const testData = envData?.testData; + if (Array.isArray(testData)) { + unitsToFunctions = {}; + for (const unit of testData) { + // Skip synthetic test-pane nodes ("Compound Tests", + // "Initialization Tests", etc.) — they have no source path. + if (!unit?.name || !unit.path) continue; + const fns: string[] = []; + if (Array.isArray(unit.functions)) { + for (const f of unit.functions) { + if (f?.name) fns.push(f.name); + } + } + unitsToFunctions[unit.name] = fns; + } } + } catch { + // ignore; webview will fall back to free-text inputs + } - try { - const panel = vscode.window.createWebviewPanel( - "requirementsReport", - "Requirements Report", - vscode.ViewColumn.One, - { enableScripts: true } - ); - - panel.webview.html = `

Loading ${fileType} requirements...

`; - const requirements = await parseRequirementsFromFile(filePath); - const htmlContent = generateRequirementsHtml(requirements); - panel.webview.html = htmlContent; - } catch (err) { - vscode.window.showErrorMessage( - `Error generating requirements report: ${err}` - ); + const webviewBaseDir = resolveWebviewBase(context, "requirements", "webviews"); + const panel = vscode.window.createWebviewPanel( + "requirementsReport", + "Requirements Report", + vscode.ViewColumn.One, + { + enableScripts: true, + enableFindWidget: true, // Ctrl+F text-find inside the webview + retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.file(webviewBaseDir)], } - } + ); + const nonce = getNonce(); + panel.webview.html = generateRequirementsHtml( + panel.webview, + webviewBaseDir, + nonce, + loaded, + unitsToFunctions + ); + + let currentBundle: RGWBundle = loaded; + + // Save / infer can complete after the user closes the panel — let the + // underlying operation finish but skip the postMessage so we don't trip + // "Webview is disposed". + let disposed = false; + panel.onDidDispose( + () => { + disposed = true; + }, + null, + context.subscriptions + ); + const post = (msg: ToWebview) => { + if (disposed) return; + panel.webview.postMessage(msg); + }; + + panel.webview.onDidReceiveMessage( + async (msg: FromWebview) => { + if (msg?.type === "save") { + try { + // Single source of truth for the edit policy lives in + // applyEditPolicy: it redacts any field the loaded bundle + // reports as locked, so a tampered webview can't sneak edits + // through. + const safeUpdates = applyEditPolicy(currentBundle, msg.updates); + const newMtimes = await writeRGWBundle( + enviroPath, + currentBundle.gatewayPath, + safeUpdates, + msg.expectedMtimes + ); + currentBundle = { + ...currentBundle, + requirements: safeUpdates.requirements, + traceability: safeUpdates.traceability, + mtimes: newMtimes, + }; + post({ + type: "saved", + mtimes: newMtimes, + requirements: safeUpdates.requirements, + traceability: safeUpdates.traceability, + }); + } catch (err) { + const message = + err instanceof RGWStaleWriteError + ? `${err.message} Reopen the requirements view to reload and re-apply your edits.` + : `Failed to save requirements: ${err}`; + vscode.window.showErrorMessage(message); + post({ type: "save-failed", message }); + } + } else if (msg?.type === "infer-traceability") { + try { + const refreshed = await inferTraceability( + enviroPath, + currentBundle.gatewayPath + ); + if (!refreshed) { + // Cancelled by user — re-enable the webview buttons silently. + post({ type: "infer-cancelled" }); + return; + } + currentBundle = refreshed; + post({ + type: "inferred", + mtimes: refreshed.mtimes, + requirements: refreshed.requirements, + traceability: refreshed.traceability, + }); + } catch (err) { + const message = `Failed to infer traceability: ${err}`; + vscode.window.showErrorMessage(message); + post({ type: "infer-failed", message }); + } + } else if (msg?.type === "open-source") { + await openSourceForTrace( + enviroPath, + msg.unit, + msg.function, + panel.viewColumn + ); + } + }, + undefined, + context.subscriptions + ); } ); context.subscriptions.push(showRequirementsCommand); @@ -1372,7 +1600,7 @@ function configureExtension(context: vscode.ExtensionContext) { const enviroPath = testNode.enviroPath; const message = - "This will remove all generated requirements files. This action cannot be undone."; + "This will delete the requirements gateway and clear VCAST_REPOSITORY from CCAST_.CFG. This action cannot be undone."; const choice = await vscode.window.showWarningMessage( message, "Remove", @@ -1381,63 +1609,46 @@ function configureExtension(context: vscode.ExtensionContext) { if (choice === "Remove") { const parentDir = path.dirname(enviroPath); - const enviroNameWithExt = path.basename(enviroPath); - // remove ".env" if present - const enviroNameWithoutExt = enviroNameWithExt.replace(/\.env$/, ""); + const enviroNameWithoutExt = path + .basename(enviroPath) + .replace(/\.env$/, ""); const envReqsFolderPath = path.join( parentDir, `reqs-${enviroNameWithoutExt}` ); - const filesToRemove = [ - path.join(envReqsFolderPath, "reqs.csv"), - path.join(envReqsFolderPath, "reqs.xlsx"), - path.join(envReqsFolderPath, "reqs_converted.csv"), - path.join(envReqsFolderPath, "reqs.html"), - path.join(envReqsFolderPath, "reqs2tests.tst"), - ]; - - // Remove files - for (const file of filesToRemove) { - if (fs.existsSync(file)) { - try { - fs.unlinkSync(file); - } catch (err) { - vscode.window.showErrorMessage( - `Failed to remove ${file}: ${err}` - ); - } + const gatewayPath = findRelevantRequirementGateway(enviroPath); + if (gatewayPath && fs.existsSync(gatewayPath)) { + try { + fs.rmSync(gatewayPath, { recursive: true, force: true }); + } catch (err) { + vscode.window.showErrorMessage( + `Failed to remove requirements gateway: ${err}` + ); } } - const generatedRepositoryPath = path.join( - envReqsFolderPath, - "generated_requirement_repository" - ); - const actualRepositoryPath = - findRelevantRequirementGateway(enviroPath); + clearVcastRepositoryInConfig(enviroPath); + + const tstPath = path.join(parentDir, "reqs2tests.tst"); + if (fs.existsSync(tstPath)) { + try { + fs.unlinkSync(tstPath); + } catch (err) { + vscode.window.showErrorMessage( + `Failed to remove ${tstPath}: ${err}` + ); + } + } - // Separately prompt for repository directory removal if ( - fs.existsSync(generatedRepositoryPath) && - path.relative(generatedRepositoryPath, actualRepositoryPath) === "" + fs.existsSync(envReqsFolderPath) && + fs.readdirSync(envReqsFolderPath).length === 0 ) { - const repoMessage = - "Would you also like to remove the auto-generated requirements gateway too?"; - const repoChoice = await vscode.window.showWarningMessage( - repoMessage, - "Yes", - "No" - ); - - if (repoChoice === "Yes") { - try { - fs.rmdirSync(generatedRepositoryPath, { recursive: true }); - } catch (err) { - vscode.window.showErrorMessage( - `Failed to remove repository directory: ${err}` - ); - } + try { + fs.rmdirSync(envReqsFolderPath); + } catch { + // best-effort } } @@ -1622,7 +1833,7 @@ async function installPreActivationEventHandlers( const importEnviroToProject = vscode.commands.registerCommand( "vectorcastTestExplorer.importEnviroToProject", async (_args: vscode.Uri, argList: vscode.Uri[]) => { - const manageWebviewSrcDir = resolveWebviewBase(context); + const manageWebviewSrcDir = resolveWebviewBase(context, "manage", "webviews"); const panel = vscode.window.createWebviewPanel( "importEnviroToProject", "Import Environment to Project", @@ -1708,7 +1919,7 @@ async function installPreActivationEventHandlers( const addEnviroToProject = vscode.commands.registerCommand( "vectorcastTestExplorer.addEnviroToProject", async (_projectNode: any) => { - const manageWebviewSrcDir = resolveWebviewBase(context); + const manageWebviewSrcDir = resolveWebviewBase(context, "manage", "webviews"); const panel = vscode.window.createWebviewPanel( "addEnviroToProject", "Add Environment To Project", @@ -1815,7 +2026,7 @@ async function installPreActivationEventHandlers( panel: vscode.WebviewPanel, argList: vscode.Uri[] ): Promise { - const base = resolveWebviewBase(context); + const base = resolveWebviewBase(context, "manage", "webviews"); // on-disk resource locations const cssOnDisk = vscode.Uri.file(path.join(base, "css", "importEnv.css")); @@ -1868,7 +2079,7 @@ async function installPreActivationEventHandlers( const newEnviroInProjectVCASTCommand = vscode.commands.registerCommand( "vectorcastTestExplorer.newEnviroInProjectVCAST", async (_args: vscode.Uri, argList: vscode.Uri[]) => { - const manageWebviewSrcDir = resolveWebviewBase(context); + const manageWebviewSrcDir = resolveWebviewBase(context, "manage", "webviews"); const panel = vscode.window.createWebviewPanel( "newEnvProject", "Create Environment in Project", @@ -1948,7 +2159,7 @@ async function installPreActivationEventHandlers( panel: vscode.WebviewPanel, argList: vscode.Uri[] ): Promise { - const base = resolveWebviewBase(context); + const base = resolveWebviewBase(context, "manage", "webviews"); const cssOnDisk = vscode.Uri.file( path.join(base, "css", "newEnvProject.css") ); @@ -2003,7 +2214,7 @@ async function installPreActivationEventHandlers( } const workspaceRoot = workspaceFolders[0].uri.fsPath; - const baseDir = resolveWebviewBase(context); + const baseDir = resolveWebviewBase(context, "manage", "webviews"); const panel = vscode.window.createWebviewPanel( "newProject", "Create New Project", @@ -2093,7 +2304,7 @@ async function installPreActivationEventHandlers( panel: vscode.WebviewPanel, workspaceRoot: string ): Promise { - const base = resolveWebviewBase(context); + const base = resolveWebviewBase(context, "manage", "webviews"); const cssOnDisk = vscode.Uri.file(path.join(base, "css", "newProject.css")); const scriptOnDisk = vscode.Uri.file( path.join(base, "webviewScripts", "newProject.js") @@ -2140,7 +2351,7 @@ async function installPreActivationEventHandlers( } // Create webview panel - const baseDir = resolveWebviewBase(context); + const baseDir = resolveWebviewBase(context, "manage", "webviews"); const panel = vscode.window.createWebviewPanel( "newCompiler", "Create Compiler in Project", @@ -2210,7 +2421,7 @@ async function installPreActivationEventHandlers( panel: vscode.WebviewPanel, projectPath: string ): Promise { - const base = resolveWebviewBase(context); + const base = resolveWebviewBase(context, "manage", "webviews"); const cssOnDisk = vscode.Uri.file( path.join(base, "css", "newCompiler.css") ); @@ -2257,10 +2468,6 @@ async function installPreActivationEventHandlers( // this method is called when your extension is deactivated export async function deactivate() { - if (requirementsFileWatcher) { - requirementsFileWatcher.dispose(); - } - await serverProcessController(serverStateType.stopped); // delete the server log if it exists await deleteServerLog(); diff --git a/src/manage/manageSrc/manageUtils.ts b/src/manage/manageSrc/manageUtils.ts index bbcdac8f..61344e8a 100644 --- a/src/manage/manageSrc/manageUtils.ts +++ b/src/manage/manageSrc/manageUtils.ts @@ -101,73 +101,6 @@ function findTestItemRecursively( return found; } -/** - * Generates a cryptographically-strong random nonce string. - * - * We use this nonce to whitelist our injected `. + * Escapes `<`, `>`, and U+2028/U+2029 so the literal can't break out of the + * script tag (a requirement title containing `` would otherwise + * terminate the block early). The output remains valid JSON. + */ +function serializeStateForScriptTag(state: unknown): string { + return JSON.stringify(state) + .replace(//g, "\\u003e") + .replace(/\u2028/g, "\\u2028") + .replace(/\u2029/g, "\\u2029"); +} + +/** + * Build the full webview HTML. Loads CSS + JS from the `webviews/` media + * folder via webview-relative URIs (a generated nonce gates ` + + +`; +} diff --git a/src/requirements/webviews/css/requirements.css b/src/requirements/webviews/css/requirements.css new file mode 100644 index 00000000..c60361fb --- /dev/null +++ b/src/requirements/webviews/css/requirements.css @@ -0,0 +1,246 @@ +body { + font-family: -apple-system, Segoe UI, sans-serif; + margin: 20px; + padding-bottom: 80px; +} + +h1 { margin-top: 0; } +h2 { + margin-top: 24px; + border-bottom: 1px solid var(--vscode-panel-border, #ddd); + padding-bottom: 4px; +} + +.banner { + padding: 10px 14px; + border-radius: 6px; + margin-bottom: 18px; + font-size: 0.95em; +} +.banner-editable { + background: var(--vscode-inputValidation-infoBackground, #eef5ff); + border: 1px solid var(--vscode-inputValidation-infoBorder, #9cc); +} +.banner-readonly { + background: var(--vscode-inputValidation-warningBackground, #fff5e6); + border: 1px solid var(--vscode-inputValidation-warningBorder, #d89); +} + +.req { + border: 1px solid var(--vscode-panel-border, #ddd); + border-radius: 6px; + padding: 12px 14px; + margin: 10px 0; + background: var(--vscode-editor-background, #fff); + position: relative; +} +.req--pending-added { + border-color: var(--vscode-inputValidation-infoBorder, #4ec9b0); + border-style: dashed; +} +.req--pending-removal { + opacity: 0.45; +} +.req--pending-removal .req-id, +.req--pending-removal .field input, +.req--pending-removal .field textarea, +.req--pending-removal .field select { + text-decoration: line-through; +} +.req-header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; +} +.req-remove-btn, +.req-restore-btn { + background: transparent; + border: 1px solid transparent; + color: var(--vscode-descriptionForeground, #888); + cursor: pointer; + border-radius: 4px; + padding: 2px 8px; + font: inherit; + line-height: 1; +} +.req-remove-btn:hover, +.req-restore-btn:hover { + border-color: var(--vscode-input-border, #ccc); + color: var(--vscode-foreground, #000); +} +.field input.invalid { + outline: 2px solid var(--vscode-inputValidation-errorBorder, #be1100); +} +.field-error { + font-size: 0.8em; + color: var(--vscode-inputValidation-errorBorder, #be1100); + margin-top: 2px; +} +.open-source-row { + margin-top: 6px; + text-align: right; +} +.open-source-btn { + background: transparent; + border: 1px solid var(--vscode-input-border, #ccc); + color: var(--vscode-foreground, #000); + cursor: pointer; + border-radius: 4px; + padding: 2px 10px; + font-size: 0.85em; + font-family: inherit; +} +.open-source-btn:hover:not(:disabled) { + background: var(--vscode-button-secondaryBackground, var(--vscode-editorWidget-background, #eee)); +} +.open-source-btn:disabled { + opacity: 0.4; + cursor: default; +} +.req-id { + font-weight: 600; + color: var(--vscode-textLink-foreground, #2980b9); +} +.req-meta { + font-size: 0.8em; + color: var(--vscode-descriptionForeground, #888); +} + +.field { margin-top: 8px; } +.field label { + display: block; + font-size: 0.8em; + color: var(--vscode-descriptionForeground, #666); + margin-bottom: 2px; +} +.field input, +.field textarea, +.field select { + width: 100%; + box-sizing: border-box; + background: var(--vscode-input-background, #fff); + color: var(--vscode-input-foreground, #000); + border: 1px solid var(--vscode-input-border, #ccc); + padding: 4px 6px; + font: inherit; +} +.field textarea { + resize: vertical; + min-height: 48px; +} +.field input:disabled, +.field textarea:disabled, +.field select:disabled { + opacity: 0.7; + cursor: default; +} +.trace-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.dirty { + outline: 2px solid var(--vscode-inputValidation-infoBorder, #9cc); +} + +#search-bar { + position: sticky; + top: 0; + z-index: 50; + background: var(--vscode-editor-background, #fff); + padding: 4px 0 6px; + margin-bottom: 4px; + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + font-size: 0.9em; +} +#search-input, +#filter-unit, +#filter-function { + background: var(--vscode-input-background, #fff); + color: var(--vscode-input-foreground, #000); + border: 1px solid var(--vscode-input-border, #ccc); + padding: 3px 8px; + font: inherit; + border-radius: 3px; +} +#search-input { + flex: 1; + min-width: 140px; +} +#search-input:focus, +#filter-unit:focus, +#filter-function:focus { + outline: 1px solid var(--vscode-focusBorder, #0078d4); +} +#filter-unit, +#filter-function { + max-width: 180px; +} +#search-count { + font-size: 0.85em; + color: var(--vscode-descriptionForeground, #888); + white-space: nowrap; + margin-left: auto; +} + +.req--hidden, +.h2--hidden { + display: none; +} + +#rgw-pill { + position: fixed; + bottom: 16px; + left: 16px; + max-width: 50vw; + padding: 5px 12px; + font-size: 0.8em; + color: var(--vscode-descriptionForeground, #666); + background: var(--vscode-editorWidget-background, var(--vscode-editor-background, #f3f3f3)); + border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border, #ddd)); + border-radius: 999px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.85; + z-index: 100; +} + +#save-toolbar { + position: fixed; + bottom: 16px; + right: 16px; + background: var(--vscode-editorWidget-background, var(--vscode-editor-background, #fff)); + border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border, #ddd)); + padding: 10px 14px; + border-radius: 8px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2); + display: flex; + gap: 12px; + align-items: center; + z-index: 100; +} + +#save-btn, +#infer-btn, +#add-btn { + padding: 8px 18px; + font-size: 1em; + background: var(--vscode-button-background, #0078d4); + color: var(--vscode-button-foreground, #fff); + border: 0; + border-radius: 4px; + cursor: pointer; + font-family: inherit; +} +#save-btn[disabled], +#infer-btn[disabled], +#add-btn[disabled] { + opacity: 0.5; + cursor: default; +} diff --git a/src/requirements/webviews/webviewScripts/cards.js b/src/requirements/webviews/webviewScripts/cards.js new file mode 100644 index 00000000..a3eb3a2d --- /dev/null +++ b/src/requirements/webviews/webviewScripts/cards.js @@ -0,0 +1,500 @@ +// DOM construction + grouping + edit handling for requirement cards. + +import { + state, + dirty, + removed, + added, + reqsBody, + setDirty, + refreshButtonStates, + existingKeys, + nextTempIdValue, +} from "./state.js"; +import { rebuildFilterDropdowns, applyFilter } from "./filter.js"; + +// ---------- Low-level DOM builders --------------------------------------- + +function buildField(label, child) { + const div = document.createElement("div"); + div.className = "field"; + const lbl = document.createElement("label"); + lbl.textContent = label; + div.appendChild(lbl); + div.appendChild(child); + return div; +} + +function buildTraceField(field, current, options, refAttrs) { + let el; + if (!options) { + el = document.createElement("input"); + el.type = "text"; + el.value = current ?? ""; + } else { + el = document.createElement("select"); + const noneOpt = document.createElement("option"); + noneOpt.value = ""; + noneOpt.textContent = "(none)"; + if (!current) noneOpt.selected = true; + el.appendChild(noneOpt); + + let found = !current; + for (const o of options) { + const opt = document.createElement("option"); + opt.value = o; + opt.textContent = o; + if (o === current) { + opt.selected = true; + found = true; + } + el.appendChild(opt); + } + if (!found) { + const opt = document.createElement("option"); + opt.value = current; + opt.textContent = current + " (not in env)"; + opt.selected = true; + el.appendChild(opt); + } + } + el.dataset.scope = "trace"; + el.dataset.field = field; + Object.assign(el.dataset, refAttrs); + return el; +} + +function buildTraceRow(currentUnit, currentFn, refAttrs) { + const traceRow = document.createElement("div"); + traceRow.className = "field trace-row"; + + const unitOptions = state.unitsToFunctions + ? Object.keys(state.unitsToFunctions) + : null; + const unitField = buildTraceField( + "unit", + currentUnit ?? "", + unitOptions, + refAttrs + ); + const fnOptions = state.unitsToFunctions + ? state.unitsToFunctions[currentUnit ?? ""] ?? [] + : null; + const fnField = buildTraceField( + "function", + currentFn ?? "", + fnOptions, + refAttrs + ); + + const unitWrap = document.createElement("div"); + const unitLabel = document.createElement("label"); + unitLabel.textContent = "Traceability: unit"; + unitWrap.appendChild(unitLabel); + unitWrap.appendChild(unitField); + + const fnWrap = document.createElement("div"); + const fnLabel = document.createElement("label"); + fnLabel.textContent = "Traceability: function"; + fnWrap.appendChild(fnLabel); + fnWrap.appendChild(fnField); + + traceRow.appendChild(unitWrap); + traceRow.appendChild(fnWrap); + return traceRow; +} + +function buildOpenSourceRow(unit) { + const row = document.createElement("div"); + row.className = "open-source-row"; + const btn = document.createElement("button"); + btn.className = "open-source-btn"; + btn.dataset.action = "open-source"; + btn.textContent = "↗ Open source"; + btn.title = "Open the unit's source file at the function definition."; + btn.disabled = !unit; + row.appendChild(btn); + return row; +} + +function buildRemoveButton(reqId) { + const btn = document.createElement("button"); + btn.className = "req-remove-btn"; + btn.dataset.action = "remove"; + btn.dataset.reqId = reqId; + btn.title = "Remove this requirement"; + btn.textContent = "×"; + return btn; +} + +// ---------- Card builders ------------------------------------------------ + +export function buildCard(entry) { + const policy = state.policy; + const lockedBodies = !policy.bodiesEditable; + + const card = document.createElement("div"); + card.className = "req"; + card.dataset.reqId = entry.id; + if (removed.has(entry.id)) card.classList.add("req--pending-removal"); + + const header = document.createElement("div"); + header.className = "req-header"; + + const id = document.createElement("div"); + id.className = "req-id"; + id.textContent = entry.id; + header.appendChild(id); + + const meta = document.createElement("div"); + meta.className = "req-meta"; + const lastMod = entry.req.last_modified ?? ""; + meta.textContent = + (lastMod ? "modified: " + lastMod + " · " : "") + + "source: " + + entry.source; + if (policy.bodiesEditable) { + meta.appendChild(document.createTextNode(" ")); + meta.appendChild(buildRemoveButton(entry.id)); + } + header.appendChild(meta); + card.appendChild(header); + + const titleInput = document.createElement("input"); + titleInput.type = "text"; + titleInput.dataset.reqId = entry.id; + titleInput.dataset.scope = "req"; + titleInput.dataset.field = "title"; + titleInput.value = entry.req.title ?? ""; + if (lockedBodies) titleInput.disabled = true; + card.appendChild(buildField("Title", titleInput)); + + const descArea = document.createElement("textarea"); + descArea.dataset.reqId = entry.id; + descArea.dataset.scope = "req"; + descArea.dataset.field = "description"; + descArea.value = entry.req.description ?? ""; + if (lockedBodies) descArea.disabled = true; + card.appendChild(buildField("Description", descArea)); + + card.appendChild( + buildTraceRow(entry.trace.unit, entry.trace.function, { reqId: entry.id }) + ); + card.appendChild(buildOpenSourceRow(entry.trace.unit ?? "")); + return card; +} + +export function buildPendingAddCard(pending) { + const card = document.createElement("div"); + card.className = "req req--pending-added"; + card.dataset.tempId = pending.tempId; + + const header = document.createElement("div"); + header.className = "req-header"; + + const id = document.createElement("div"); + id.className = "req-id"; + id.textContent = "[New requirement]"; + header.appendChild(id); + + const meta = document.createElement("div"); + meta.className = "req-meta"; + const discardBtn = document.createElement("button"); + discardBtn.className = "req-remove-btn"; + discardBtn.dataset.action = "discard-added"; + discardBtn.dataset.tempId = pending.tempId; + discardBtn.title = "Discard this new requirement"; + discardBtn.textContent = "×"; + meta.appendChild(discardBtn); + header.appendChild(meta); + card.appendChild(header); + + const keyInput = document.createElement("input"); + keyInput.type = "text"; + keyInput.dataset.tempId = pending.tempId; + keyInput.dataset.field = "key"; + keyInput.value = pending.key; + keyInput.placeholder = "Required: unique key under which this is stored"; + const keyField = buildField("Key", keyInput); + const keyError = document.createElement("div"); + keyError.className = "field-error"; + keyError.dataset.tempId = pending.tempId; + keyError.dataset.role = "key-error"; + keyField.appendChild(keyError); + card.appendChild(keyField); + + const titleInput = document.createElement("input"); + titleInput.type = "text"; + titleInput.dataset.tempId = pending.tempId; + titleInput.dataset.field = "title"; + titleInput.value = pending.title; + card.appendChild(buildField("Title", titleInput)); + + const descArea = document.createElement("textarea"); + descArea.dataset.tempId = pending.tempId; + descArea.dataset.field = "description"; + descArea.value = pending.description; + card.appendChild(buildField("Description", descArea)); + + card.appendChild( + buildTraceRow(pending.unit, pending.function, { tempId: pending.tempId }) + ); + card.appendChild(buildOpenSourceRow(pending.unit ?? "")); + + validatePendingKey(pending, keyInput, keyError); + return card; +} + +// ---------- Grouping + body rebuild -------------------------------------- + +/** + * Flatten the bundle in `state` into ordered groups keyed by + * `function || unit || source`. Single source of truth for grouping — + * the wire protocol doesn't ship pre-grouped data, so this is what + * everyone (initial load + every saved/inferred refresh) uses. + */ +function groupRequirementsFromState() { + const flat = []; + for (const [source, bucket] of Object.entries(state.requirements)) { + for (const [id, req] of Object.entries(bucket)) { + flat.push({ + source, + id, + req, + trace: state.traceability[id] || { + unit: null, + function: null, + lines: null, + }, + }); + } + } + const buckets = {}; + const order = []; + for (const entry of flat) { + const key = + entry.trace.function || + entry.trace.unit || + entry.source || + "Unknown"; + if (!buckets[key]) { + buckets[key] = []; + order.push(key); + } + buckets[key].push(entry); + } + return order.map((name) => ({ name, entries: buckets[name] })); +} + +export function rebuildBody() { + const groups = groupRequirementsFromState(); + const frag = document.createDocumentFragment(); + for (const group of groups) { + const h2 = document.createElement("h2"); + h2.textContent = group.name; + frag.appendChild(h2); + for (const entry of group.entries) { + frag.appendChild(buildCard(entry)); + } + } + // Pending-added cards live below the persisted groups so the user + // sees them clearly until they save. + for (const pending of added) { + frag.appendChild(buildPendingAddCard(pending)); + } + reqsBody.replaceChildren(frag); + rebuildFilterDropdowns(); + applyFilter(); +} + +// ---------- Pending-key validation --------------------------------------- + +export function validatePendingKey(pending, inputEl, errorEl) { + const key = pending.key.trim(); + let problem = ""; + if (!key) { + problem = "Key is required."; + } else if (existingKeys().has(key)) { + problem = "Collides with an existing requirement key."; + } else if (added.some((a) => a !== pending && a.key.trim() === key)) { + problem = "Two pending new requirements share this key."; + } + pending.keyValid = problem === ""; + inputEl.classList.toggle("invalid", !pending.keyValid); + if (errorEl) errorEl.textContent = problem; +} + +export function revalidateAllPendingKeys() { + for (const pending of added) { + const card = reqsBody.querySelector( + `.req--pending-added[data-temp-id="${pending.tempId}"]` + ); + if (!card) continue; + const inputEl = card.querySelector('[data-field="key"]'); + const errorEl = card.querySelector('[data-role="key-error"]'); + if (inputEl) validatePendingKey(pending, inputEl, errorEl); + } +} + +// ---------- Field-change handling ---------------------------------------- + +function refreshFunctionOptions(card, selectedUnit, currentFunction) { + const fnSelect = card.querySelector( + '[data-scope="trace"][data-field="function"]' + ); + if (!fnSelect || fnSelect.tagName !== "SELECT") return; + const map = state.unitsToFunctions || {}; + const fns = selectedUnit && map[selectedUnit] ? map[selectedUnit] : []; + const desired = currentFunction != null ? String(currentFunction) : ""; + + while (fnSelect.firstChild) fnSelect.removeChild(fnSelect.firstChild); + const noneOpt = document.createElement("option"); + noneOpt.value = ""; + noneOpt.textContent = "(none)"; + if (!desired) noneOpt.selected = true; + fnSelect.appendChild(noneOpt); + + let found = !desired; + for (const fn of fns) { + const opt = document.createElement("option"); + opt.value = fn; + opt.textContent = fn; + if (fn === desired) { + opt.selected = true; + found = true; + } + fnSelect.appendChild(opt); + } + if (!found) { + const opt = document.createElement("option"); + opt.value = desired; + opt.textContent = desired + " (not in env)"; + opt.selected = true; + fnSelect.appendChild(opt); + } +} + +function pendingForTempId(tempId) { + return added.find((a) => a.tempId === tempId); +} + +function updateOpenSourceForCard(card, unit) { + if (!card) return; + const btn = card.querySelector(".open-source-btn"); + if (btn) btn.disabled = !unit; +} + +export function handleFieldChange(el) { + // Pending-added card: route the edit into the `added` entry directly. + if (el.dataset.tempId) { + const pending = pendingForTempId(el.dataset.tempId); + if (!pending) return; + const field = el.dataset.field; + const value = el.value; + if (field === "key") { + pending.key = value; + const card = el.closest(".req"); + const errorEl = card?.querySelector('[data-role="key-error"]'); + validatePendingKey(pending, el, errorEl); + } else if (field === "title" || field === "description") { + pending[field] = value; + } else if (field === "unit" || field === "function") { + pending[field] = value === "" ? null : value; + if (field === "unit") { + // Drop the function when the unit changes — keeping a stale + // function would create mismatched traceability the user almost + // certainly didn't intend. + const card = el.closest(".req"); + pending.function = null; + refreshFunctionOptions(card, pending.unit, null); + updateOpenSourceForCard(card, pending.unit); + } + } + refreshButtonStates(); + return; + } + + // Existing card edit. + const reqId = el.dataset.reqId; + if (removed.has(reqId)) return; // ignore edits to soft-deleted cards + const scope = el.dataset.scope; + const field = el.dataset.field; + let value = el.value; + if (field === "unit" || field === "function") { + value = value === "" ? null : value; + } + setDirty(reqId, scope, field, value); + el.classList.add("dirty"); + + if (field === "unit") { + // Same rule as for pending cards: drop the function on unit change. + const card = el.closest(".req"); + refreshFunctionOptions(card, value, null); + setDirty(reqId, "trace", "function", null); + const fnSelect = card.querySelector( + '[data-scope="trace"][data-field="function"]' + ); + if (fnSelect) fnSelect.classList.add("dirty"); + updateOpenSourceForCard(card, value); + } + + // The filter reads input values directly at apply time, so a fresh + // edit becomes searchable immediately without any per-edit bookkeeping + // here. We don't re-apply the filter on edit because doing so could + // yank a card the user is actively editing out from under them. +} + +// ---------- Add / remove ------------------------------------------------- + +export function appendPendingAdd() { + const pending = { + tempId: nextTempIdValue(), + key: "", + title: "", + description: "", + unit: null, + function: null, + keyValid: false, + }; + added.push(pending); + reqsBody.appendChild(buildPendingAddCard(pending)); + refreshButtonStates(); +} + +export function toggleRemoveExisting(reqId) { + const card = reqsBody.querySelector(`.req[data-req-id="${reqId}"]`); + if (!card) return; + if (removed.has(reqId)) { + removed.delete(reqId); + card.classList.remove("req--pending-removal"); + const btn = card.querySelector(".req-remove-btn"); + if (btn) { + btn.textContent = "×"; + btn.title = "Remove this requirement"; + } + } else { + removed.add(reqId); + card.classList.add("req--pending-removal"); + const btn = card.querySelector(".req-remove-btn"); + if (btn) { + btn.textContent = "↩"; + btn.title = "Restore this requirement"; + } + } + // A soft-delete or restore can flip the validity of pending-add keys + // that were colliding/uncolliding with the toggled requirement. + revalidateAllPendingKeys(); + refreshButtonStates(); +} + +export function discardPendingAdd(tempId) { + const idx = added.findIndex((a) => a.tempId === tempId); + if (idx === -1) return; + added.splice(idx, 1); + const card = reqsBody.querySelector( + `.req--pending-added[data-temp-id="${tempId}"]` + ); + if (card) card.remove(); + revalidateAllPendingKeys(); + refreshButtonStates(); +} diff --git a/src/requirements/webviews/webviewScripts/filter.js b/src/requirements/webviews/webviewScripts/filter.js new file mode 100644 index 00000000..d120cd4d --- /dev/null +++ b/src/requirements/webviews/webviewScripts/filter.js @@ -0,0 +1,163 @@ +// Search + unit/function filter for the requirements editor. + +import { + state, + filterUnit, + filterFunction, + searchInput, + searchCount, + reqsBody, +} from "./state.js"; + +// Sentinel value for "(Not set)" in the unit/function dropdowns. Real +// values are arbitrary strings; this one starts with NUL so it can't +// collide with a legitimate identifier. +export const NOT_SET = "\u0000not-set"; + +/** + * Populate the unit and function dropdowns. Options come from the env's + * canonical list (so a brand-new unit shows up before anything traces to + * it — that's exactly when filtering down to "(Not set)" is most useful) + * plus any "(not in env)" stragglers that legitimately appear in current + * traceability. Both dropdowns also offer "(Any)" and "(Not set)" — the + * latter is what to pick when looking for requirements that still need + * traceability. + */ +export function rebuildFilterDropdowns() { + if (!filterUnit || !filterFunction) return; + + const units = new Set(); + const fns = new Set(); + + const map = state.unitsToFunctions || {}; + for (const u of Object.keys(map)) { + units.add(u); + for (const f of map[u] || []) fns.add(f); + } + for (const trace of Object.values(state.traceability)) { + if (trace?.unit) units.add(trace.unit); + if (trace?.function) fns.add(trace.function); + } + + const fillSelect = (sel, label, items) => { + const previous = sel.value; + while (sel.firstChild) sel.removeChild(sel.firstChild); + + const any = document.createElement("option"); + any.value = ""; + any.textContent = `(Any ${label})`; + sel.appendChild(any); + + const none = document.createElement("option"); + none.value = NOT_SET; + none.textContent = "(Not set)"; + sel.appendChild(none); + + for (const v of [...items].sort()) { + const opt = document.createElement("option"); + opt.value = v; + opt.textContent = v; + sel.appendChild(opt); + } + + sel.value = [...sel.options].some((o) => o.value === previous) + ? previous + : ""; + }; + + fillSelect(filterUnit, "unit", units); + fillSelect(filterFunction, "function", fns); +} + +/** + * Hide cards that fail any of the active filters: the search text + * (substring AND on whitespace tokens, against the card's live inputs), + * the unit dropdown, and the function dropdown. "(Not set)" matches cards + * whose corresponding trace field is empty. Pending-add cards are always + * shown so a half-typed new requirement doesn't disappear under the user. + * Group headings collapse when all their cards are hidden. + */ +export function applyFilter() { + if (!searchInput) return; + const q = searchInput.value.trim().toLowerCase(); + const tokens = q ? q.split(/\s+/) : []; + const matchesText = (haystack) => + tokens.every((t) => haystack.includes(t)); + + const unitFilter = filterUnit ? filterUnit.value : ""; + const fnFilter = filterFunction ? filterFunction.value : ""; + const matchesField = (val, filter) => { + if (!filter) return true; + if (filter === NOT_SET) return !val; + return val === filter; + }; + + let currentHeader = null; + let currentHeaderHasVisible = false; + let totalReal = 0; + let visibleReal = 0; + + for (const el of reqsBody.children) { + if (el.tagName === "H2") { + if (currentHeader) { + currentHeader.classList.toggle( + "h2--hidden", + !currentHeaderHasVisible + ); + } + currentHeader = el; + currentHeaderHasVisible = false; + } else if (el.classList.contains("req")) { + if (el.classList.contains("req--pending-added")) { + el.classList.remove("req--hidden"); + continue; + } + totalReal++; + // Pull the searchable fields off the live inputs — that way a + // mid-edit value is searchable immediately without an extra + // bookkeeping step in the field-change path. + const reqId = el.dataset.reqId || ""; + const titleEl = el.querySelector('[data-field="title"]'); + const descEl = el.querySelector('[data-field="description"]'); + const unitEl = el.querySelector( + '[data-scope="trace"][data-field="unit"]' + ); + const fnEl = el.querySelector( + '[data-scope="trace"][data-field="function"]' + ); + const unitVal = unitEl ? unitEl.value : ""; + const fnVal = fnEl ? fnEl.value : ""; + const haystack = ( + reqId + + " " + + (titleEl ? titleEl.value : "") + + " " + + (descEl ? descEl.value : "") + + " " + + unitVal + + " " + + fnVal + ).toLowerCase(); + const visible = + (tokens.length === 0 || matchesText(haystack)) && + matchesField(unitVal, unitFilter) && + matchesField(fnVal, fnFilter); + el.classList.toggle("req--hidden", !visible); + if (visible) { + currentHeaderHasVisible = true; + visibleReal++; + } + } + } + if (currentHeader) { + currentHeader.classList.toggle("h2--hidden", !currentHeaderHasVisible); + } + + if (searchCount) { + const filtersActive = + tokens.length > 0 || !!unitFilter || !!fnFilter; + searchCount.textContent = filtersActive + ? `${visibleReal} of ${totalReal}` + : ""; + } +} diff --git a/src/requirements/webviews/webviewScripts/requirements.js b/src/requirements/webviews/webviewScripts/requirements.js new file mode 100644 index 00000000..38aee619 --- /dev/null +++ b/src/requirements/webviews/webviewScripts/requirements.js @@ -0,0 +1,116 @@ +// Requirements editor webview entry. Wires DOM events to the four feature +// modules: +// +// state.js — pending edits (dirty / removed / added) + DOM refs +// cards.js — DOM building + grouping + edit handlers + add/remove +// filter.js — search + unit/function dropdowns +// save.js — save payload + refresh handler +// +// State (`window.__rgwState`) is injected as a JSON blob by the extension +// at load time. The cards section is empty in the rendered HTML — every +// element comes from the script via safe DOM APIs (createElement / +// textContent). +// +// Wire protocol (see src/requirements/webview/messages.ts): +// FROM webview: { type: "save" | "infer-traceability" | "open-source" } +// TO webview: { type: "saved" | "inferred" | "save-failed" | "infer-failed" | "infer-cancelled" } + +import { + vscode, + saveBtn, + inferBtn, + addBtn, + reqsBody, + searchInput, + filterUnit, + filterFunction, + refreshButtonStates, +} from "./state.js"; +import { + appendPendingAdd, + toggleRemoveExisting, + discardPendingAdd, + handleFieldChange, + rebuildBody, +} from "./cards.js"; +import { applyFilter } from "./filter.js"; +import { postSave, postInfer, applyRefreshedBundle } from "./save.js"; + +// ---------- Event delegation on the cards container ---------------------- + +reqsBody.addEventListener("input", (e) => { + const t = e.target; + if (t && t.matches && t.matches("[data-field]") && t.tagName !== "SELECT") { + handleFieldChange(t); + } +}); +reqsBody.addEventListener("change", (e) => { + const t = e.target; + if (t && t.matches && t.matches("[data-field]") && t.tagName === "SELECT") { + handleFieldChange(t); + } +}); +reqsBody.addEventListener("click", (e) => { + const t = e.target; + if (!t || !t.matches) return; + + if (t.matches(".req-remove-btn")) { + const action = t.dataset.action; + if (action === "remove") { + toggleRemoveExisting(t.dataset.reqId); + } else if (action === "discard-added") { + discardPendingAdd(t.dataset.tempId); + } + return; + } + + if (t.matches(".open-source-btn") && !t.disabled) { + const card = t.closest(".req"); + if (!card) return; + const unitEl = card.querySelector( + '[data-scope="trace"][data-field="unit"]' + ); + const fnEl = card.querySelector( + '[data-scope="trace"][data-field="function"]' + ); + const unit = unitEl ? unitEl.value : ""; + const fn = fnEl ? fnEl.value : ""; + if (!unit) return; + vscode.postMessage({ + type: "open-source", + unit, + function: fn === "" ? null : fn, + }); + } +}); + +// ---------- Toolbar + filter listeners ----------------------------------- + +if (addBtn) addBtn.addEventListener("click", appendPendingAdd); +saveBtn.addEventListener("click", postSave); +inferBtn.addEventListener("click", postInfer); + +if (searchInput) searchInput.addEventListener("input", applyFilter); +if (filterUnit) filterUnit.addEventListener("change", applyFilter); +if (filterFunction) filterFunction.addEventListener("change", applyFilter); + +// ---------- Extension → webview messages --------------------------------- + +window.addEventListener("message", (event) => { + const msg = event.data; + if (msg.type === "saved" || msg.type === "inferred") { + applyRefreshedBundle(msg); + } else if ( + msg.type === "save-failed" || + msg.type === "infer-failed" || + msg.type === "infer-cancelled" + ) { + refreshButtonStates(); + } +}); + +// ---------- Initial render ----------------------------------------------- +// rebuildBody() builds cards, populates filter dropdowns, and applies the +// (empty) filter. Buttons start in their initial states. +rebuildBody(); +refreshButtonStates(); diff --git a/src/requirements/webviews/webviewScripts/save.js b/src/requirements/webviews/webviewScripts/save.js new file mode 100644 index 00000000..20101ff8 --- /dev/null +++ b/src/requirements/webviews/webviewScripts/save.js @@ -0,0 +1,108 @@ +// Save-payload assembly + handler for refresh messages from the extension. + +import { + state, + dirty, + removed, + added, + vscode, + saveBtn, + inferBtn, + addBtn, + refreshButtonStates, +} from "./state.js"; +import { rebuildBody } from "./cards.js"; + +export function buildSaveUpdates() { + const updates = { + requirements: JSON.parse(JSON.stringify(state.requirements)), + traceability: JSON.parse(JSON.stringify(state.traceability)), + }; + + // 1. Drop soft-removed entries from every bucket and the trace map. + for (const reqId of removed) { + for (const bucket of Object.keys(updates.requirements)) { + if (updates.requirements[bucket][reqId]) { + delete updates.requirements[bucket][reqId]; + } + } + delete updates.traceability[reqId]; + } + + // 2. Apply in-place dirty patches (skipping any that are also removed). + for (const [reqId, patch] of dirty.entries()) { + if (removed.has(reqId)) continue; + if (state.policy.bodiesEditable && Object.keys(patch.req).length > 0) { + for (const bucket of Object.keys(updates.requirements)) { + if (updates.requirements[bucket][reqId]) { + Object.assign(updates.requirements[bucket][reqId], patch.req); + break; + } + } + } + if (Object.keys(patch.trace).length > 0) { + const existing = updates.traceability[reqId] || { + unit: null, + function: null, + lines: null, + }; + updates.traceability[reqId] = { ...existing, ...patch.trace }; + } + } + + // 3. Append pending-added entries to the first bucket. The save flow on + // the extension side normalizes everything into a single CSV-keyed + // bucket anyway, so the choice here doesn't matter. + let bucketKey = Object.keys(updates.requirements)[0]; + if (!bucketKey) { + bucketKey = "[CSV] [" + state.gatewayPath + "]"; + updates.requirements[bucketKey] = {}; + } + for (const pending of added) { + const key = pending.key.trim(); + updates.requirements[bucketKey][key] = { + id: key, + title: pending.title, + description: pending.description, + }; + if (pending.unit != null || pending.function != null) { + updates.traceability[key] = { + unit: pending.unit ?? null, + function: pending.function ?? null, + lines: null, + }; + } + } + + return updates; +} + +export function postSave() { + const updates = buildSaveUpdates(); + saveBtn.disabled = true; + inferBtn.disabled = true; + if (addBtn) addBtn.disabled = true; + vscode.postMessage({ + type: "save", + updates, + expectedMtimes: state.mtimes, + }); +} + +export function postInfer() { + inferBtn.disabled = true; + saveBtn.disabled = true; + if (addBtn) addBtn.disabled = true; + vscode.postMessage({ type: "infer-traceability" }); +} + +export function applyRefreshedBundle(msg) { + dirty.clear(); + removed.clear(); + added.length = 0; + state.mtimes = msg.mtimes; + state.requirements = msg.requirements; + state.traceability = msg.traceability; + rebuildBody(); + refreshButtonStates(); +} diff --git a/src/requirements/webviews/webviewScripts/state.js b/src/requirements/webviews/webviewScripts/state.js new file mode 100644 index 00000000..96a86d1a --- /dev/null +++ b/src/requirements/webviews/webviewScripts/state.js @@ -0,0 +1,63 @@ +// Shared mutable state for the requirements editor webview. +// Lives in one module so cards.js / filter.js / save.js / the entry script +// all reference the same singletons via ES-module live bindings. + +export const vscode = acquireVsCodeApi(); +export const state = window.__rgwState; + +// Pending edits split three ways: +// dirty — in-place edits to existing requirements (key -> {req, trace}) +// removed — keys of existing requirements marked for soft-deletion +// added — pending new requirements not yet on disk +// All three flush on Save and reset on a successful saved/inferred. +export const dirty = new Map(); +export const removed = new Set(); +export const added = []; // {tempId, key, title, description, unit, function, keyValid} + +let nextTempId = 1; +export function nextTempIdValue() { + return "__pending_" + nextTempId++ + "__"; +} + +// DOM refs the rest of the modules rely on. Looked up once at module load — +// `