diff --git a/README.md b/README.md index 0e4a215..d769455 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,9 @@ Reqly can copy a local file into the item's `artifacts/` folder or link a relati Create a child requirement, such as "Display the warning below 10% capacity", and relate it to `REQ-0001` with `required-by`. Reqly maintains the parent's inverse `requires` relation automatically. -Now the Requirements view shows the product requirement as a parent-first tree. The same view also places linked verifications beneath their requirements. +Now the Requirements view shows the product requirement as a parent-first tree. The same view also places linked verifications beneath their requirements. Use its search action to filter requirements and verifications by partial text; separate search terms use AND logic, and matching paths open automatically. -For purely visual organization, create a folder and use its `contains` relation to place requirements, verifications, or nested folders beneath it. Folder relations are non-normative: rearranging the tree does not change requirement fingerprints. A folder's icon color and check state are derived from the items it contains. +For purely visual organization, create a folder from the Requirements view root, or use the requirement context menu to create one and place that requirement in it immediately. Folder relations are non-normative: rearranging the tree does not change requirement fingerprints. A folder's icon color and check state are derived from the items it contains. ### 5. Add evidence @@ -72,7 +72,7 @@ The verification document contains `Procedure`, `Expected Result`, and `Evidence Later, the battery specification changes. Edit the parent requirement or its linked normative data. Reqly detects that the child or verification was based on an older dependency fingerprint and puts it in the impact queue. -Review the affected items, update the requirement or repeat the test, then acknowledge the impact. Reqly refreshes the fingerprint. The Impact Queue toolbar can acknowledge all pending fingerprint changes at once, while a requirement's context menu can acknowledge its own subtree. Draft-parent, broken-reference, and cycle warnings remain until their underlying condition is fixed. +Review the affected items, update the requirement or repeat the test, then acknowledge the impact. Reqly refreshes the fingerprint. The Impact Queue toolbar can acknowledge all pending fingerprint changes at once, while a requirement's context menu can acknowledge its own subtree. Broken-reference and cycle warnings remain in the queue until their underlying condition is fixed; draft-parent warnings remain available in diagnostics and on the requirement tree. Selecting items in the Requirements or Impact Queue tree reuses VS Code's preview editor, preventing navigation from leaving a trail of open tabs. Reqly never stages, commits, pushes, or rewrites Git history; use your normal Git workflow to review and commit the Markdown and YAML changes. diff --git a/packages/vscode/README.md b/packages/vscode/README.md index a100c9f..4323f67 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -1,3 +1,3 @@ # Reqly for VS Code -Reqly is delivered entirely as a VS Code extension and keeps product requirements, verification procedures, traceability, and linked product documents close to the code. Open a repository containing `.reqly/AGENTS.md` to use the parent-first requirements explorer, linked verifications, organizational folders, computed verification icons, impact queue, diagnostics, and Markdown editor assistance. Tree selections reuse VS Code's preview tab. Use a requirement's context menu to create and link a new sub-requirement or verification, and use a folder's context menu to add existing or new items through its non-normative `contains` relation. The Impact Queue toolbar acknowledges all stale fingerprints; a requirement action limits that acknowledgement to its subtree. Agent integrations are provided by the standalone `@mrpatpat/reqly-mcp` package. Removing a local artifact deletes its referenced file after confirmation. Deleting an item removes its folder and all relations to it after a separate destructive confirmation. Configuration defaults are bundled with the extension. +Reqly is delivered entirely as a VS Code extension and keeps product requirements, verification procedures, traceability, and linked product documents close to the code. Open a repository containing `.reqly/AGENTS.md` to use the parent-first requirements explorer, linked verifications, organizational folders, computed verification icons, impact queue, diagnostics, and Markdown editor assistance. Tree selections reuse VS Code's preview tab. Use the Requirements view search action to filter requirements and verifications by partial text; spaces use AND logic and matching paths are expanded automatically. Clear the search from the view toolbar. Use the Requirements view toolbar to create a root folder, or a requirement's context menu to create a folder and place that requirement in it immediately. A folder's context menu can add existing or new items through its non-normative `contains` relation. The Impact Queue toolbar acknowledges all stale fingerprints; a requirement action limits that acknowledgement to its subtree. Agent integrations are provided by the standalone `@mrpatpat/reqly-mcp` package. Removing a local artifact deletes its referenced file after confirmation. Deleting an item removes its folder and all relations to it after a separate destructive confirmation. Configuration defaults are bundled with the extension. diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 8e64179..b625be5 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -55,6 +55,8 @@ "commands": [ { "command": "reqly.init", "title": "Reqly: Initialize Project" }, { "command": "reqly.refresh", "title": "Reqly: Refresh" }, + { "command": "reqly.search", "title": "Reqly: Search Requirements", "icon": "$(search)" }, + { "command": "reqly.clearSearch", "title": "Reqly: Clear Search", "icon": "$(clear-all)" }, { "command": "reqly.newRequirement", "title": "Reqly: New Requirement" }, { "command": "reqly.newFolder", "title": "Reqly: New Folder", "icon": "$(new-folder)" }, { "command": "reqly.addFolderItem", "title": "Reqly: Add Item to Folder" }, @@ -74,6 +76,8 @@ ], "menus": { "view/title": [ + { "command": "reqly.search", "when": "view == reqly.requirements", "group": "navigation" }, + { "command": "reqly.clearSearch", "when": "view == reqly.requirements && reqly.searchActive", "group": "navigation" }, { "command": "reqly.newRequirement", "when": "view == reqly.requirements", "group": "navigation" }, { "command": "reqly.newFolder", "when": "view == reqly.requirements", "group": "navigation" }, { "command": "reqly.newVerification", "when": "view == reqly.requirements", "group": "navigation" }, @@ -81,9 +85,10 @@ { "command": "reqly.refresh", "when": "view =~ /reqly\\./", "group": "navigation" } ], "view/item/context": [ - { "command": "reqly.newSubRequirement", "when": "viewItem == reqlyRequirement", "group": "reqly@1" }, - { "command": "reqly.newVerification", "when": "viewItem == reqlyRequirement", "group": "reqly@2" }, - { "command": "reqly.acknowledgeSubtreeImpacts", "when": "viewItem == reqlyRequirement", "group": "reqly@3" }, + { "command": "reqly.newFolder", "when": "viewItem == reqlyRequirement", "group": "reqly@1" }, + { "command": "reqly.newSubRequirement", "when": "viewItem == reqlyRequirement", "group": "reqly@2" }, + { "command": "reqly.newVerification", "when": "viewItem == reqlyRequirement", "group": "reqly@3" }, + { "command": "reqly.acknowledgeSubtreeImpacts", "when": "viewItem == reqlyRequirement", "group": "reqly@4" }, { "command": "reqly.addFolderItem", "when": "viewItem == reqlyFolder", "group": "reqly@1" }, { "command": "reqly.previewItem", "when": "viewItem =~ /reqly(Folder|Item|Requirement)/", "group": "inline" }, { "command": "reqly.setStatus", "when": "viewItem =~ /reqly(Item|Requirement)/", "group": "reqly" }, diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index b74c88b..cfcfd9c 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -63,35 +63,129 @@ class RequirementsProvider implements vscode.TreeDataProvider { readonly onDidChangeTreeData = this.changed.event; private repository?: ReqlyRepository; private statuses = new Map(); - set(repository: ReqlyRepository | undefined, statuses: ItemStatus[] = []): void { this.repository = repository; this.statuses = new Map(statuses.map((status) => [status.id, status])); this.changed.fire(); } + private searchTerms: string[] = []; + private searchMatches = new Set(); + private searchVisible = new Set(); + + get searchQuery(): string { return this.searchTerms.join(" "); } + get searchActive(): boolean { return this.searchTerms.length > 0; } + + set(repository: ReqlyRepository | undefined, statuses: ItemStatus[] = []): void { + this.repository = repository; + this.statuses = new Map(statuses.map((status) => [status.id, status])); + this.rebuildSearch(); + this.changed.fire(); + } + + setSearch(query: string): void { + this.searchTerms = query.trim().split(/\s+/).filter(Boolean).map((term) => term.toLowerCase()); + this.rebuildSearch(); + this.changed.fire(); + } + + private rebuildSearch(): void { + this.searchMatches.clear(); + this.searchVisible.clear(); + if (!this.repository || !this.searchTerms.length) return; + const records = [...this.repository.records.values()]; + for (const record of records) { + if (record.type === "folder") continue; + const text = `${record.data.id} ${record.data.title}`.toLowerCase(); + if (this.searchTerms.every((term) => text.includes(term))) this.searchMatches.add(record.data.id); + } + this.searchVisible = new Set(this.searchMatches); + const parents = new Map(); + for (const record of records) { + for (const child of this.repository.hierarchyChildren(record.data.id)) { + const related = parents.get(child.data.id) ?? []; + if (!related.some((candidate) => candidate.data.id === record.data.id)) related.push(record); + parents.set(child.data.id, related); + } + } + const queue = [...this.searchMatches]; + while (queue.length) { + const id = queue.shift()!; + for (const parent of parents.get(id) ?? []) { + if (this.searchVisible.has(parent.data.id)) continue; + this.searchVisible.add(parent.data.id); + queue.push(parent.data.id); + } + } + } + + private isVisible(record: ReqlyRecord): boolean { return !this.searchActive || this.searchVisible.has(record.data.id); } + private childrenOf(id: string): ReqlyRecord[] { return this.repository?.hierarchyChildren(id) ?? []; } + + private colorFor(record: ReqlyRecord, visiting = new Set()): ItemColor { + const repository = this.repository; + if (!repository) return "blue"; + const own = ownItemColor(record, record.type !== "verification" ? repository.verificationState(record.data.id) : undefined); + if (visiting.has(record.data.id)) return own; + const childColors = this.childrenOf(record.data.id).map((child) => this.colorFor(child, new Set(visiting).add(record.data.id))); + if (childColors.includes("blue")) return "blue"; + if (childColors.includes("red")) return "red"; + return childColors.length ? "green" : own; + } + + private node(record: ReqlyRecord, lineage: string[]): ItemNode { + const children = this.childrenOf(record.data.id); + const collapsible = children.some((child) => !lineage.includes(child.data.id) && this.isVisible(child)); + return new ItemNode(record, (this.statuses.get(record.data.id)?.health ?? []).filter((value) => value !== "clean"), this.repository?.verificationState(record.data.id), this.colorFor(record), lineage, collapsible); + } + getTreeItem(item: ItemNode): vscode.TreeItem { return item; } + getChildren(element?: ItemNode): ItemNode[] { if (!this.repository) return []; const repository = this.repository; const records = [...this.repository.records.values()]; - const childrenOf = (id: string) => repository.hierarchyChildren(id); - const colorFor = (record: ReqlyRecord, visiting = new Set()): ItemColor => { - const own = ownItemColor(record, record.type !== "verification" ? repository.verificationState(record.data.id) : undefined); - if (visiting.has(record.data.id)) return own; - const childColors = childrenOf(record.data.id).map((child) => colorFor(child, new Set(visiting).add(record.data.id))); - if (childColors.includes("blue")) return "blue"; - if (childColors.includes("red")) return "red"; - return childColors.length ? "green" : own; - }; let candidates: ReqlyRecord[]; let lineage: string[]; if (element) { lineage = [...element.lineage, element.record.data.id]; - candidates = childrenOf(element.record.data.id).filter((record) => !lineage.includes(record.data.id)); + candidates = this.childrenOf(element.record.data.id).filter((record) => !lineage.includes(record.data.id)); } else { lineage = []; const roots = records.filter((record) => !repository.hasHierarchyParent(record)); const reachable = new Set(); const queue = roots.map((record) => record.data.id); - while (queue.length) { const id = queue.shift()!; if (reachable.has(id)) continue; reachable.add(id); queue.push(...childrenOf(id).map((record) => record.data.id)); } + while (queue.length) { const id = queue.shift()!; if (reachable.has(id)) continue; reachable.add(id); queue.push(...this.childrenOf(id).map((record) => record.data.id)); } candidates = [...roots, ...records.filter((record) => !reachable.has(record.data.id))]; } return [...new Map(candidates.map((record) => [record.data.id, record])).values()] + .filter((record) => this.isVisible(record)) .sort((a, b) => a.data.id.localeCompare(b.data.id)) - .map((record) => new ItemNode(record, (this.statuses.get(record.data.id)?.health ?? []).filter((value) => value !== "clean"), repository.verificationState(record.data.id), colorFor(record), lineage, childrenOf(record.data.id).some((child) => !lineage.includes(child.data.id)))); + .map((record) => this.node(record, lineage)); + } + + getParent(element: ItemNode): ItemNode | undefined { + const parentId = element.lineage[element.lineage.length - 1]; + const parent = parentId ? this.repository?.records.get(parentId) : undefined; + return parent ? this.node(parent, element.lineage.slice(0, -1)) : undefined; + } + + searchResults(): ItemNode[] { + if (!this.repository || !this.searchActive) return []; + const results: ItemNode[] = []; const found = new Set(); const visited = new Set(); + const visit = (nodes: ItemNode[]): void => { + for (const node of nodes) { + const key = [...node.lineage, node.record.data.id].join("\u0000"); + if (visited.has(key)) continue; + visited.add(key); + if (this.searchMatches.has(node.record.data.id) && !found.has(node.record.data.id)) { found.add(node.record.data.id); results.push(node); } + if (node.collapsibleState !== vscode.TreeItemCollapsibleState.None) visit(this.getChildren(node)); + } + }; + visit(this.getChildren()); + return results; + } + + revealPath(element: ItemNode): ItemNode[] { + const path = [element]; let current = element; + while (current.lineage.length) { + const parent = this.getParent(current); + if (!parent) break; + path.unshift(parent); current = parent; + } + return path; } } @@ -99,7 +193,7 @@ class ImpactsProvider implements vscode.TreeDataProvider { private readonly changed = new vscode.EventEmitter(); readonly onDidChangeTreeData = this.changed.event; private entries: ImpactEntry[] = []; set(repository: ReqlyRepository | undefined, diagnostics: Diagnostic[]): void { - this.entries = repository ? diagnostics.filter((item) => ["PARENT_UPDATE_PENDING", "VERIFICATION_UPDATE_PENDING", "PARENT_DRAFT", "BROKEN_REFERENCE", "RELATION_CYCLE"].includes(item.code) && item.itemId && repository.records.has(item.itemId)).map((item) => ({ record: repository.get(item.itemId!), relatedId: ["PARENT_UPDATE_PENDING", "VERIFICATION_UPDATE_PENDING", "PARENT_DRAFT"].includes(item.code) ? item.relatedId : undefined, message: item.message })) : []; + this.entries = repository ? diagnostics.filter((item) => ["PARENT_UPDATE_PENDING", "VERIFICATION_UPDATE_PENDING", "BROKEN_REFERENCE", "RELATION_CYCLE"].includes(item.code) && item.itemId && repository.records.has(item.itemId)).map((item) => ({ record: repository.get(item.itemId!), relatedId: ["PARENT_UPDATE_PENDING", "VERIFICATION_UPDATE_PENDING"].includes(item.code) ? item.relatedId : undefined, message: item.message })) : []; this.changed.fire(); } getTreeItem(item: ImpactNode): vscode.TreeItem { return item; } @@ -185,7 +279,9 @@ class ExtensionState { export async function activate(context: vscode.ExtensionContext): Promise { const requirements = new RequirementsProvider(); const impacts = new ImpactsProvider(); const collection = vscode.languages.createDiagnosticCollection("reqly"); const state = new ExtensionState(requirements, impacts, collection); - context.subscriptions.push(collection, vscode.window.registerTreeDataProvider("reqly.requirements", requirements), vscode.window.registerTreeDataProvider("reqly.impacts", impacts)); + const requirementsView = vscode.window.createTreeView("reqly.requirements", { treeDataProvider: requirements, showCollapseAll: true }); + context.subscriptions.push(collection, requirementsView, vscode.window.registerTreeDataProvider("reqly.impacts", impacts)); + void vscode.commands.executeCommand("setContext", "reqly.searchActive", false); context.subscriptions.push(vscode.languages.registerCodeLensProvider({ language: "markdown", pattern: "**/{requirements,verifications,folders}/**/index.md" }, new ReqlyCodeLensProvider(state))); context.subscriptions.push(vscode.languages.registerDefinitionProvider("markdown", { @@ -211,8 +307,10 @@ export async function activate(context: vscode.ExtensionContext): Promise const register = (command: string, callback: (...args: any[]) => unknown) => context.subscriptions.push(vscode.commands.registerCommand(command, callback)); register("reqly.refresh", () => state.refresh()); register("reqly.init", async () => { const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; if (!root) return; await initRepository(root); await state.refresh(); }); + register("reqly.search", async () => searchRequirementsFromUi(requirements, requirementsView)); + register("reqly.clearSearch", async () => clearRequirementsSearch(requirements, requirementsView)); register("reqly.newRequirement", async () => createFromUi(state)); - register("reqly.newFolder", async () => createFolderFromUi(state)); + register("reqly.newFolder", async (node?: ItemNode | string) => createFolderFromUi(state, node)); register("reqly.addFolderItem", async (node: ItemNode | string) => addFolderItemFromUi(state, node)); register("reqly.newSubRequirement", async (node: ItemNode | string) => createSubRequirementFromUi(state, node)); register("reqly.newVerification", async (node?: ItemNode | string) => node ? createVerificationForUi(state, node) : createVerificationFromUi(state)); @@ -246,6 +344,25 @@ async function openItem(state: ExtensionState, value: ItemNode | ImpactNode | st const record = resolveRecord(state, value); if (record) await vscode.window.showTextDocument(vscode.Uri.file(record.filePath), { preview }); } +async function searchRequirementsFromUi(provider: RequirementsProvider, view: vscode.TreeView): Promise { + const query = await vscode.window.showInputBox({ title: "Search Requirements and Verifications", prompt: "Partial matches are supported; spaces mean AND", value: provider.searchQuery }); + if (query === undefined) return; + provider.setSearch(query); + view.description = provider.searchActive ? `Search: ${provider.searchQuery}` : undefined; + await vscode.commands.executeCommand("setContext", "reqly.searchActive", provider.searchActive); + if (!provider.searchActive) return; + const results = provider.searchResults(); + if (!results.length) { void vscode.window.showInformationMessage(`Reqly: No requirements or verifications match "${query.trim()}".`); return; } + await new Promise((resolve) => setTimeout(resolve, 0)); + for (const result of results) for (const node of provider.revealPath(result)) await view.reveal(node, { expand: true, focus: false, select: false }); +} + +async function clearRequirementsSearch(provider: RequirementsProvider, view: vscode.TreeView): Promise { + provider.setSearch(""); + view.description = undefined; + await vscode.commands.executeCommand("setContext", "reqly.searchActive", false); +} + async function createFromUi(state: ExtensionState): Promise { const itemId = await promptCreateItem(state, "requirement"); await state.refresh(); if (itemId) await openItem(state, itemId); @@ -273,9 +390,21 @@ async function createVerificationFromUi(state: ExtensionState): Promise { if (itemId) await openItem(state, itemId); } -async function createFolderFromUi(state: ExtensionState): Promise { - const itemId = await promptCreateItem(state, "folder"); await state.refresh(); - if (itemId) await openItem(state, itemId); +async function createFolderFromUi(state: ExtensionState, value?: ItemNode | string): Promise { + if (!state.repository) return; + const parent = value ? resolveRecord(state, value) : undefined; + if (value && (!parent || parent.type !== "requirement" || !await ensureSaved(parent))) return; + const itemId = await promptCreateItem(state, "folder"); + if (!itemId) return; + await state.refresh(); + if (parent) { + const folder = state.repository.records.get(itemId); + const currentParent = state.repository.records.get(parent.data.id); + if (!folder || !currentParent) return; + await setRelation(state.repository, folder.data.id, "add", { type: "contains", target: currentParent.data.id }, folder.version); + await state.refresh(); + } + await openItem(state, itemId); } async function createVerificationForUi(state: ExtensionState, value: ItemNode | string): Promise {