
-
One sidebar.
Every command.
+
One sidebar.
Every command.
AI-powered.
CommandTree discovers all runnable commands in your VS Code workspace and puts them in a single, beautiful tree view. GitHub Copilot describes each command in plain language so you know what it does before you run it.
From 4962a2a238d100c2ec7c0e24f2ef22f230aa4a9d Mon Sep 17 00:00:00 2001
From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com>
Date: Mon, 9 Feb 2026 20:23:16 +1100
Subject: [PATCH 02/30] Revert stuff
---
src/semantic/summariser.ts | 7 +--
src/test/helpers/helpers.ts | 109 +++++++++++++++++++++++++++++++++++-
2 files changed, 111 insertions(+), 5 deletions(-)
diff --git a/src/semantic/summariser.ts b/src/semantic/summariser.ts
index 166d2ec..5339360 100644
--- a/src/semantic/summariser.ts
+++ b/src/semantic/summariser.ts
@@ -8,10 +8,10 @@ import * as vscode from 'vscode';
import type { Result } from '../models/TaskItem';
import { ok, err } from '../models/TaskItem';
import { logger } from '../utils/logger';
-import { resolveModel, AUTO_MODEL_ID } from './modelSelection';
+import { resolveModel } from './modelSelection';
import type { ModelSelectionDeps, ModelRef } from './modelSelection';
export type { ModelRef, ModelSelectionDeps } from './modelSelection';
-export { resolveModel, AUTO_MODEL_ID };
+export { resolveModel, AUTO_MODEL_ID } from './modelSelection';
const MAX_CONTENT_LENGTH = 4000;
const MODEL_RETRY_COUNT = 10;
@@ -86,8 +86,7 @@ function formatModelDetail(m: vscode.LanguageModelChat): string {
async function promptModelPicker(
models: readonly vscode.LanguageModelChat[]
): Promise
{
- const concrete = models.filter(m => m.id !== AUTO_MODEL_ID);
- const items = concrete.map(m => ({
+ const items = models.map(m => ({
label: m.name,
description: m.id,
detail: formatModelDetail(m),
diff --git a/src/test/helpers/helpers.ts b/src/test/helpers/helpers.ts
index 43f2fd7..a93f83f 100644
--- a/src/test/helpers/helpers.ts
+++ b/src/test/helpers/helpers.ts
@@ -1,10 +1,13 @@
import * as vscode from 'vscode';
import * as path from 'path';
+import * as fs from 'fs';
import { CommandTreeProvider } from '../../CommandTreeProvider';
+import { QuickTasksProvider } from '../../QuickTasksProvider';
import { CommandTreeItem } from '../../models/TaskItem';
import type { TaskItem, TaskType } from '../../models/TaskItem';
export const EXTENSION_ID = 'nimblesite.commandtree';
+export const TREE_VIEW_ID = 'commandtree';
export interface TestContext {
extension: vscode.Extension;
@@ -37,6 +40,40 @@ export async function activateExtension(): Promise {
};
}
+export function getTreeView(): vscode.TreeView | undefined {
+ // The tree view is registered internally, we interact via commands
+ return undefined;
+}
+
+export async function executeCommand(command: string, ...args: unknown[]): Promise {
+ return await vscode.commands.executeCommand(command, ...args);
+}
+
+export async function refreshTasks(): Promise {
+ await executeCommand('commandtree.refresh');
+ // Wait for async discovery to complete
+ await sleep(500);
+}
+
+export async function filterTasks(_filterText: string): Promise {
+ // We need to mock the input box since we can't interact with UI in tests
+ // Instead, we'll test the filtering logic through the provider directly
+ await executeCommand('commandtree.filter');
+}
+
+export async function filterByTag(_tag: string): Promise {
+ // _tag is used for API compatibility - the actual tag filtering happens via UI
+ await executeCommand('commandtree.filterByTag');
+}
+
+export async function clearFilter(): Promise {
+ await executeCommand('commandtree.clearFilter');
+}
+
+export async function runTask(taskItem: unknown): Promise {
+ await executeCommand('commandtree.run', taskItem);
+}
+
export async function sleep(ms: number): Promise {
await new Promise(resolve => { setTimeout(resolve, ms); });
}
@@ -61,7 +98,49 @@ export function getExtensionPath(relativePath: string): string {
return path.join(extension.extensionPath, relativePath);
}
+export function writeFile(filePath: string, content: string): void {
+ const fullPath = getFixturePath(filePath);
+ const dir = path.dirname(fullPath);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+ fs.writeFileSync(fullPath, content, 'utf8');
+}
+
+export function deleteFile(filePath: string): void {
+ const fullPath = getFixturePath(filePath);
+ if (fs.existsSync(fullPath)) {
+ fs.unlinkSync(fullPath);
+ }
+}
+
+export function readFile(filePath: string): string {
+ const fullPath = getFixturePath(filePath);
+ return fs.readFileSync(fullPath, 'utf8');
+}
+
+export function fileExists(filePath: string): boolean {
+ const fullPath = getFixturePath(filePath);
+ return fs.existsSync(fullPath);
+}
+
+export async function waitForCondition(
+ condition: () => Promise,
+ timeout = 5000,
+ interval = 100
+): Promise {
+ const startTime = Date.now();
+ while (Date.now() - startTime < timeout) {
+ if (await condition()) {
+ return;
+ }
+ await sleep(interval);
+ }
+ throw new Error(`Condition not met within ${timeout}ms`);
+}
+
export function getCommandTreeProvider(): CommandTreeProvider {
+ // Access the tree data provider through the extension's exports
const extension = vscode.extensions.getExtension(EXTENSION_ID);
if (extension === undefined) {
throw new Error('Extension not found');
@@ -81,7 +160,23 @@ export async function getTreeChildren(provider: CommandTreeProvider, parent?: Co
return await provider.getChildren(parent);
}
-export { CommandTreeProvider, CommandTreeItem };
+export function getQuickTasksProvider(): QuickTasksProvider {
+ const extension = vscode.extensions.getExtension(EXTENSION_ID);
+ if (extension === undefined) {
+ throw new Error('Extension not found');
+ }
+ if (!extension.isActive) {
+ throw new Error('Extension not active');
+ }
+ const extensionExports = extension.exports as { quickTasksProvider?: QuickTasksProvider } | undefined;
+ const provider = extensionExports?.quickTasksProvider;
+ if (!provider) {
+ throw new Error('QuickTasksProvider not exported from extension');
+ }
+ return provider;
+}
+
+export { CommandTreeProvider, CommandTreeItem, QuickTasksProvider };
export function getLabelString(label: string | vscode.TreeItemLabel | undefined): string {
if (label === undefined) {
@@ -126,6 +221,18 @@ export function getTooltipText(item: CommandTreeItem): string {
return "";
}
+export async function captureTerminalOutput(terminalName: string, timeout = 5000): Promise {
+ // Find the terminal by name
+ const terminal = vscode.window.terminals.find(t => t.name === terminalName);
+ if (!terminal) {
+ throw new Error(`Terminal "${terminalName}" not found`);
+ }
+ // Note: VS Code API doesn't provide direct access to terminal output
+ // This is a limitation of the VS Code API
+ await sleep(timeout);
+ return '';
+}
+
export function createMockTaskItem(overrides: Partial<{
id: string;
label: string;
From b0c9f807ae299773d8657dc3093e07606dcd819c Mon Sep 17 00:00:00 2001
From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com>
Date: Mon, 9 Feb 2026 20:30:30 +1100
Subject: [PATCH 03/30] Make it faster
---
src/runners/TaskRunner.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/runners/TaskRunner.ts b/src/runners/TaskRunner.ts
index 3cc7e3d..fa37f15 100644
--- a/src/runners/TaskRunner.ts
+++ b/src/runners/TaskRunner.ts
@@ -18,7 +18,7 @@ function showError(message: string): void {
*/
export type RunMode = 'newTerminal' | 'currentTerminal';
-const SHELL_INTEGRATION_TIMEOUT_MS = 500;
+const SHELL_INTEGRATION_TIMEOUT_MS = 50;
/**
* Executes commands based on their type.
From 5651581623c8069b457ec0936976379b8cb4ef04 Mon Sep 17 00:00:00 2001
From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com>
Date: Mon, 9 Feb 2026 21:11:57 +1100
Subject: [PATCH 04/30] Get test coverage up
---
package.json | 35 +-
src/CommandTreeProvider.ts | 154 +----
src/QuickTasksProvider.ts | 4 +-
src/config/TagConfig.ts | 4 +-
src/db/db.ts | 290 +++++++++
src/db/lifecycle.ts | 81 +++
src/extension.ts | 119 +---
src/models/TaskItem.ts | 24 +-
src/semantic/adapters.ts | 103 ---
src/semantic/db.ts | 580 -----------------
src/semantic/embedder.ts | 86 ---
src/semantic/embeddingPipeline.ts | 109 ----
src/semantic/index.ts | 98 ---
src/semantic/lifecycle.ts | 144 -----
src/semantic/modelSelection.ts | 68 --
src/semantic/similarity.ts | 49 --
src/semantic/store.ts | 171 -----
src/semantic/summariser.ts | 256 --------
src/semantic/summaryPipeline.ts | 208 ------
src/semantic/types.ts | 7 -
src/semantic/vscodeAdapters.ts | 105 ---
src/test/e2e/commands.e2e.test.ts | 20 +-
src/test/e2e/copilot.e2e.test.ts | 200 ------
src/test/e2e/markdown.e2e.test.ts | 10 +
src/test/e2e/quicktasks.e2e.test.ts | 68 +-
src/test/e2e/semantic.e2e.test.ts | 605 ------------------
src/test/e2e/summaries.e2e.test.ts | 234 -------
src/test/e2e/tagconfig.e2e.test.ts | 37 +-
.../unit/command-registration.unit.test.ts | 137 ----
src/test/unit/embedding-provider.unit.test.ts | 192 ------
src/test/unit/embedding-storage.unit.test.ts | 103 ---
src/test/unit/model-selection.unit.test.ts | 212 ------
src/test/unit/similarity.unit.test.ts | 201 ------
src/tree/folderTree.ts | 21 +-
34 files changed, 516 insertions(+), 4219 deletions(-)
create mode 100644 src/db/db.ts
create mode 100644 src/db/lifecycle.ts
delete mode 100644 src/semantic/adapters.ts
delete mode 100644 src/semantic/db.ts
delete mode 100644 src/semantic/embedder.ts
delete mode 100644 src/semantic/embeddingPipeline.ts
delete mode 100644 src/semantic/index.ts
delete mode 100644 src/semantic/lifecycle.ts
delete mode 100644 src/semantic/modelSelection.ts
delete mode 100644 src/semantic/similarity.ts
delete mode 100644 src/semantic/store.ts
delete mode 100644 src/semantic/summariser.ts
delete mode 100644 src/semantic/summaryPipeline.ts
delete mode 100644 src/semantic/types.ts
delete mode 100644 src/semantic/vscodeAdapters.ts
delete mode 100644 src/test/e2e/copilot.e2e.test.ts
delete mode 100644 src/test/e2e/semantic.e2e.test.ts
delete mode 100644 src/test/e2e/summaries.e2e.test.ts
delete mode 100644 src/test/unit/command-registration.unit.test.ts
delete mode 100644 src/test/unit/embedding-provider.unit.test.ts
delete mode 100644 src/test/unit/embedding-storage.unit.test.ts
delete mode 100644 src/test/unit/model-selection.unit.test.ts
delete mode 100644 src/test/unit/similarity.unit.test.ts
diff --git a/package.json b/package.json
index 09e883c..9cc0474 100644
--- a/package.json
+++ b/package.json
@@ -111,19 +111,6 @@
"title": "Remove Tag",
"icon": "$(close)"
},
- {
- "command": "commandtree.semanticSearch",
- "title": "Semantic Search",
- "icon": "$(search)"
- },
- {
- "command": "commandtree.generateSummaries",
- "title": "Generate AI Summaries"
- },
- {
- "command": "commandtree.selectModel",
- "title": "CommandTree: Select AI Model"
- },
{
"command": "commandtree.openPreview",
"title": "Open Preview",
@@ -142,11 +129,6 @@
"when": "view == commandtree && commandtree.hasFilter",
"group": "navigation@3"
},
- {
- "command": "commandtree.semanticSearch",
- "when": "view == commandtree && commandtree.aiSummariesEnabled",
- "group": "9_search"
- },
{
"command": "commandtree.refresh",
"when": "view == commandtree",
@@ -361,27 +343,12 @@
"Sort by command type, then alphabetically by name"
],
"description": "How to sort commands within categories"
- },
- "commandtree.enableAiSummaries": {
- "type": "boolean",
- "default": true,
- "description": "Use GitHub Copilot to generate plain-language summaries of scripts, enabling semantic search"
- },
- "commandtree.aiModel": {
- "type": "string",
- "default": "",
- "description": "Copilot model ID to use for summaries (e.g. 'gpt-4o-mini'). Leave empty to be prompted on first use."
}
}
},
"configurationDefaults": {
"workbench.tree.indent": 16
- },
- "languageModels": [
- {
- "vendor": "copilot"
- }
- ]
+ }
},
"scripts": {
"compile": "tsc -p ./",
diff --git a/src/CommandTreeProvider.ts b/src/CommandTreeProvider.ts
index 4a5555f..76999a4 100644
--- a/src/CommandTreeProvider.ts
+++ b/src/CommandTreeProvider.ts
@@ -6,8 +6,6 @@ import { discoverAllTasks, flattenTasks, getExcludePatterns } from './discovery'
import { TagConfig } from './config/TagConfig';
import { logger } from './utils/logger';
import { buildNestedFolderItems } from './tree/folderTree';
-import { getAllEmbeddingRows } from './semantic';
-import type { EmbeddingRow } from './semantic/db';
type SortOrder = 'folder' | 'name' | 'type';
@@ -49,107 +47,37 @@ export class CommandTreeProvider implements vscode.TreeDataProvider | null = null;
- private summaries: ReadonlyMap = new Map();
private readonly tagConfig: TagConfig;
private readonly workspaceRoot: string;
constructor(workspaceRoot: string) {
this.workspaceRoot = workspaceRoot;
- // SPEC.md **user-data-storage**: Tags stored in SQLite, not .vscode/commandtree.json
this.tagConfig = new TagConfig();
}
- /**
- * Refreshes all commands.
- */
async refresh(): Promise {
this.tagConfig.load();
const excludePatterns = getExcludePatterns();
this.discoveryResult = await discoverAllTasks(this.workspaceRoot, excludePatterns);
this.tasks = this.tagConfig.applyTags(flattenTasks(this.discoveryResult));
- this.loadSummaries();
- this.tasks = this.attachSummaries(this.tasks);
this._onDidChangeTreeData.fire(undefined);
}
- /**
- * Loads summaries from SQLite into memory.
- */
- private loadSummaries(): void {
- const result = getAllEmbeddingRows();
- if (!result.ok) {
- return;
- }
- const map = new Map();
- for (const row of result.value) {
- map.set(row.commandId, row);
- }
- this.summaries = map;
- }
-
- /**
- * Attaches loaded summaries to task items for tooltip display.
- */
- private attachSummaries(tasks: TaskItem[]): TaskItem[] {
- if (this.summaries.size === 0) {
- return tasks;
- }
- return tasks.map(task => {
- const record = this.summaries.get(task.id);
- if (record === undefined) {
- return task;
- }
- const warning = record.securityWarning;
- return {
- ...task,
- summary: record.summary,
- ...(warning !== null ? { securityWarning: warning } : {})
- };
- });
- }
-
- /**
- * Sets tag filter and refreshes tree.
- */
setTagFilter(tag: string | null): void {
logger.filter('setTagFilter', { tagFilter: tag });
this.tagFilter = tag;
this._onDidChangeTreeData.fire(undefined);
}
- /**
- * Sets semantic filter with command IDs and their similarity scores.
- * SPEC.md **ai-search-implementation**: Scores preserved for display.
- */
- setSemanticFilter(results: ReadonlyArray<{ readonly id: string; readonly score: number }>): void {
- const map = new Map();
- for (const r of results) {
- map.set(r.id, r.score);
- }
- this.semanticFilter = map;
- this._onDidChangeTreeData.fire(undefined);
- }
-
- /**
- * Clears all filters.
- */
clearFilters(): void {
this.tagFilter = null;
- this.semanticFilter = null;
this._onDidChangeTreeData.fire(undefined);
}
- /**
- * Returns whether any filter is active.
- */
hasFilter(): boolean {
- return this.tagFilter !== null || this.semanticFilter !== null;
+ return this.tagFilter !== null;
}
- /**
- * Gets all unique tags.
- */
getAllTags(): string[] {
const tags = new Set();
for (const task of this.tasks) {
@@ -157,16 +85,12 @@ export class CommandTreeProvider implements vscode.TreeDataProvider> {
const result = this.tagConfig.addTaskToTag(task, tagName);
if (result.ok) {
@@ -175,9 +99,6 @@ export class CommandTreeProvider implements vscode.TreeDataProvider> {
const result = this.tagConfig.removeTaskFromTag(task, tagName);
if (result.ok) {
@@ -186,9 +107,6 @@ export class CommandTreeProvider implements vscode.TreeDataProvider this.buildCategoryIfNonEmpty(filtered, def))
.filter((c): c is CommandTreeItem => c !== null);
}
- /**
- * Builds a single category node if tasks of that type exist.
- */
private buildCategoryIfNonEmpty(
tasks: readonly TaskItem[],
def: CategoryDef
@@ -235,71 +143,35 @@ export class CommandTreeProvider implements vscode.TreeDataProvider this.sortTasks(t),
- getScore: (id: string) => this.getSemanticScore(id)
+ sortTasks: (t) => this.sortTasks(t)
});
return new CommandTreeItem(null, `${name} (${tasks.length})`, children);
}
- /**
- * Builds a flat category without folder grouping.
- */
private buildFlatCategory(name: string, tasks: TaskItem[]): CommandTreeItem {
const sorted = this.sortTasks(tasks);
const categoryId = name;
- const children = sorted.map(t => new CommandTreeItem(
- t,
- null,
- [],
- categoryId,
- this.getSemanticScore(t.id)
- ));
+ const children = sorted.map(t => new CommandTreeItem(t, null, [], categoryId));
return new CommandTreeItem(null, `${name} (${tasks.length})`, children);
}
- /**
- * Gets similarity score for a task if semantic filtering is active.
- * SPEC.md **ai-search-implementation**: Scores displayed as percentages.
- */
- private getSemanticScore(taskId: string): number | undefined {
- return this.semanticFilter?.get(taskId);
- }
-
- /**
- * Gets the configured sort order.
- */
private getSortOrder(): SortOrder {
return vscode.workspace
.getConfiguration('commandtree')
.get('sortOrder', 'folder');
}
- /**
- * Sorts commands based on the configured sort order.
- */
private sortTasks(tasks: TaskItem[]): TaskItem[] {
const comparator = this.getComparator();
return [...tasks].sort(comparator);
}
private getComparator(): (a: TaskItem, b: TaskItem) => number {
- // SPEC.md **ai-search-implementation**: Sort by score when semantic filter is active
- if (this.semanticFilter !== null) {
- const scoreMap = this.semanticFilter;
- return (a, b) => {
- const scoreA = scoreMap.get(a.id) ?? 0;
- const scoreB = scoreMap.get(b.id) ?? 0;
- return scoreB - scoreA;
- };
- }
const order = this.getSortOrder();
if (order === 'folder') {
return (a, b) => a.category.localeCompare(b.category) || a.label.localeCompare(b.label);
@@ -310,27 +182,9 @@ export class CommandTreeProvider implements vscode.TreeDataProvider a.label.localeCompare(b.label);
}
- /**
- * Applies tag and semantic filters in sequence.
- */
- private applyFilters(tasks: TaskItem[]): TaskItem[] {
- logger.filter('applyFilters START', { inputCount: tasks.length });
- let result = tasks;
- result = this.applyTagFilter(result);
- result = this.applySemanticFilter(result);
- logger.filter('applyFilters END', { outputCount: result.length });
- return result;
- }
-
private applyTagFilter(tasks: TaskItem[]): TaskItem[] {
if (this.tagFilter === null || this.tagFilter === '') { return tasks; }
const tag = this.tagFilter;
return tasks.filter(t => t.tags.includes(tag));
}
-
- private applySemanticFilter(tasks: TaskItem[]): TaskItem[] {
- if (this.semanticFilter === null) { return tasks; }
- const scoreMap = this.semanticFilter;
- return tasks.filter(t => scoreMap.has(t.id));
- }
}
diff --git a/src/QuickTasksProvider.ts b/src/QuickTasksProvider.ts
index 229daa8..7981be0 100644
--- a/src/QuickTasksProvider.ts
+++ b/src/QuickTasksProvider.ts
@@ -9,8 +9,8 @@ import type { TaskItem, Result } from './models/TaskItem';
import { CommandTreeItem } from './models/TaskItem';
import { TagConfig } from './config/TagConfig';
import { logger } from './utils/logger';
-import { getDb } from './semantic/lifecycle';
-import { getCommandIdsByTag } from './semantic/db';
+import { getDb } from './db/lifecycle';
+import { getCommandIdsByTag } from './db/db';
const QUICK_TASK_MIME_TYPE = 'application/vnd.commandtree.quicktask';
const QUICK_TAG = 'quick';
diff --git a/src/config/TagConfig.ts b/src/config/TagConfig.ts
index b5e63f7..2a38826 100644
--- a/src/config/TagConfig.ts
+++ b/src/config/TagConfig.ts
@@ -6,14 +6,14 @@
import type { TaskItem, Result } from '../models/TaskItem';
import { err } from '../models/TaskItem';
-import { getDb } from '../semantic/lifecycle';
+import { getDb } from '../db/lifecycle';
import {
addTagToCommand,
removeTagFromCommand,
getCommandIdsByTag,
getAllTagNames,
reorderTagCommands
-} from '../semantic/db';
+} from '../db/db';
export class TagConfig {
private commandTagsMap = new Map();
diff --git a/src/db/db.ts b/src/db/db.ts
new file mode 100644
index 0000000..1b35894
--- /dev/null
+++ b/src/db/db.ts
@@ -0,0 +1,290 @@
+/**
+ * SPEC: database-schema, database-schema/tags-table, database-schema/command-tags-junction, database-schema/tag-operations
+ * Tag-only SQLite storage layer.
+ * Uses node-sqlite3-wasm for WASM-based SQLite.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import type { Result } from '../models/Result';
+import { ok, err } from '../models/Result';
+import { logger } from '../utils/logger';
+
+import sqlite3 from 'node-sqlite3-wasm';
+import type { Database as SqliteDatabase } from 'node-sqlite3-wasm';
+
+const COMMAND_TABLE = 'commands';
+const TAG_TABLE = 'tags';
+const COMMAND_TAGS_TABLE = 'command_tags';
+
+export interface DbHandle {
+ readonly db: SqliteDatabase;
+ readonly path: string;
+}
+
+/**
+ * Opens a SQLite database at the given path.
+ * Enables foreign key constraints on every connection.
+ */
+export function openDatabase(
+ dbPath: string,
+): Result {
+ try {
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
+ const db = new sqlite3.Database(dbPath);
+ db.exec('PRAGMA foreign_keys = ON');
+ return ok({ db, path: dbPath });
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Failed to open database';
+ logger.error('openDatabase FAILED', { dbPath, error: msg });
+ return err(msg);
+ }
+}
+
+/**
+ * Closes a database connection.
+ */
+export function closeDatabase(handle: DbHandle): Result {
+ try {
+ handle.db.close();
+ return ok(undefined);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Failed to close database';
+ return err(msg);
+ }
+}
+
+/**
+ * SPEC: database-schema, database-schema/tags-table, database-schema/command-tags-junction
+ * Creates the commands, tags, and command_tags tables if they do not exist.
+ */
+export function initSchema(handle: DbHandle): Result {
+ try {
+ handle.db.exec(`
+ CREATE TABLE IF NOT EXISTS ${COMMAND_TABLE} (
+ command_id TEXT PRIMARY KEY
+ )
+ `);
+ handle.db.exec(`
+ CREATE TABLE IF NOT EXISTS ${TAG_TABLE} (
+ tag_id TEXT PRIMARY KEY,
+ tag_name TEXT NOT NULL UNIQUE,
+ description TEXT
+ )
+ `);
+ handle.db.exec(`
+ CREATE TABLE IF NOT EXISTS ${COMMAND_TAGS_TABLE} (
+ command_id TEXT NOT NULL,
+ tag_id TEXT NOT NULL,
+ display_order INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (command_id, tag_id),
+ FOREIGN KEY (command_id) REFERENCES ${COMMAND_TABLE}(command_id) ON DELETE CASCADE,
+ FOREIGN KEY (tag_id) REFERENCES ${TAG_TABLE}(tag_id) ON DELETE CASCADE
+ )
+ `);
+ return ok(undefined);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Failed to init schema';
+ return err(msg);
+ }
+}
+
+type RawRow = Record;
+
+/**
+ * Ensures a command record exists for referential integrity.
+ */
+export function ensureCommandExists(params: {
+ readonly handle: DbHandle;
+ readonly commandId: string;
+}): Result {
+ try {
+ params.handle.db.run(
+ `INSERT OR IGNORE INTO ${COMMAND_TABLE} (command_id) VALUES (?)`,
+ [params.commandId],
+ );
+ return ok(undefined);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Failed to register command';
+ return err(msg);
+ }
+}
+
+/**
+ * SPEC: database-schema/tag-operations, tagging, tagging/management
+ * Adds a tag to a command with optional display order.
+ * Ensures both tag and command exist before creating junction record.
+ */
+export function addTagToCommand(params: {
+ readonly handle: DbHandle;
+ readonly commandId: string;
+ readonly tagName: string;
+ readonly displayOrder?: number;
+}): Result {
+ try {
+ const cmdResult = ensureCommandExists({
+ handle: params.handle,
+ commandId: params.commandId,
+ });
+ if (!cmdResult.ok) { return cmdResult; }
+ const existing = params.handle.db.get(
+ `SELECT tag_id FROM ${TAG_TABLE} WHERE tag_name = ?`,
+ [params.tagName],
+ );
+ const tagId = existing !== null
+ ? ((existing as RawRow)['tag_id'] as string)
+ : crypto.randomUUID();
+ if (existing === null) {
+ params.handle.db.run(
+ `INSERT INTO ${TAG_TABLE} (tag_id, tag_name, description) VALUES (?, ?, NULL)`,
+ [tagId, params.tagName],
+ );
+ }
+ const order = params.displayOrder ?? 0;
+ params.handle.db.run(
+ `INSERT OR IGNORE INTO ${COMMAND_TAGS_TABLE} (command_id, tag_id, display_order) VALUES (?, ?, ?)`,
+ [params.commandId, tagId, order],
+ );
+ return ok(undefined);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Failed to add tag to command';
+ return err(msg);
+ }
+}
+
+/**
+ * SPEC: database-schema/tag-operations, tagging, tagging/management
+ * Removes a tag from a command.
+ */
+export function removeTagFromCommand(params: {
+ readonly handle: DbHandle;
+ readonly commandId: string;
+ readonly tagName: string;
+}): Result {
+ try {
+ params.handle.db.run(
+ `DELETE FROM ${COMMAND_TAGS_TABLE}
+ WHERE command_id = ?
+ AND tag_id = (SELECT tag_id FROM ${TAG_TABLE} WHERE tag_name = ?)`,
+ [params.commandId, params.tagName],
+ );
+ return ok(undefined);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Failed to remove tag from command';
+ return err(msg);
+ }
+}
+
+/**
+ * SPEC: database-schema/tag-operations, tagging/filter
+ * Gets all command IDs for a given tag, ordered by display_order.
+ */
+export function getCommandIdsByTag(params: {
+ readonly handle: DbHandle;
+ readonly tagName: string;
+}): Result {
+ try {
+ const rows = params.handle.db.all(
+ `SELECT ct.command_id
+ FROM ${COMMAND_TAGS_TABLE} ct
+ JOIN ${TAG_TABLE} t ON ct.tag_id = t.tag_id
+ WHERE t.tag_name = ?
+ ORDER BY ct.display_order`,
+ [params.tagName],
+ );
+ return ok(rows.map((r) => (r as RawRow)['command_id'] as string));
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Failed to get command IDs by tag';
+ return err(msg);
+ }
+}
+
+/**
+ * SPEC: database-schema/tag-operations, tagging
+ * Gets all tags for a given command.
+ */
+export function getTagsForCommand(params: {
+ readonly handle: DbHandle;
+ readonly commandId: string;
+}): Result {
+ try {
+ const rows = params.handle.db.all(
+ `SELECT t.tag_name
+ FROM ${TAG_TABLE} t
+ JOIN ${COMMAND_TAGS_TABLE} ct ON t.tag_id = ct.tag_id
+ WHERE ct.command_id = ?`,
+ [params.commandId],
+ );
+ return ok(rows.map((r) => (r as RawRow)['tag_name'] as string));
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Failed to get tags for command';
+ return err(msg);
+ }
+}
+
+/**
+ * SPEC: database-schema/tag-operations, tagging/filter
+ * Gets all distinct tag names.
+ */
+export function getAllTagNames(handle: DbHandle): Result {
+ try {
+ const rows = handle.db.all(
+ `SELECT tag_name FROM ${TAG_TABLE} ORDER BY tag_name`,
+ );
+ return ok(rows.map((r) => (r as RawRow)['tag_name'] as string));
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Failed to get all tag names';
+ return err(msg);
+ }
+}
+
+/**
+ * SPEC: database-schema/tag-operations, quick-launch
+ * Updates the display order for a tag assignment.
+ */
+export function updateTagDisplayOrder(params: {
+ readonly handle: DbHandle;
+ readonly commandId: string;
+ readonly tagId: string;
+ readonly newOrder: number;
+}): Result {
+ try {
+ params.handle.db.run(
+ `UPDATE ${COMMAND_TAGS_TABLE} SET display_order = ? WHERE command_id = ? AND tag_id = ?`,
+ [params.newOrder, params.commandId, params.tagId],
+ );
+ return ok(undefined);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Failed to update tag display order';
+ return err(msg);
+ }
+}
+
+/**
+ * SPEC: quick-launch
+ * Reorders command IDs for a tag by updating display_order.
+ */
+export function reorderTagCommands(params: {
+ readonly handle: DbHandle;
+ readonly tagName: string;
+ readonly orderedCommandIds: readonly string[];
+}): Result {
+ try {
+ const tagRow = params.handle.db.get(
+ `SELECT tag_id FROM ${TAG_TABLE} WHERE tag_name = ?`,
+ [params.tagName],
+ );
+ if (tagRow === null) { return err(`Tag "${params.tagName}" not found`); }
+ const tagId = (tagRow as RawRow)['tag_id'] as string;
+ params.orderedCommandIds.forEach((commandId, index) => {
+ params.handle.db.run(
+ `UPDATE ${COMMAND_TAGS_TABLE} SET display_order = ? WHERE command_id = ? AND tag_id = ?`,
+ [index, commandId, tagId],
+ );
+ });
+ return ok(undefined);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Failed to reorder tag commands';
+ return err(msg);
+ }
+}
diff --git a/src/db/lifecycle.ts b/src/db/lifecycle.ts
new file mode 100644
index 0000000..cdd885c
--- /dev/null
+++ b/src/db/lifecycle.ts
@@ -0,0 +1,81 @@
+/**
+ * SPEC: database-schema
+ * Singleton lifecycle management for the database.
+ */
+
+import * as fs from 'fs';
+import * as path from 'path';
+import type { Result } from '../models/Result';
+import { ok, err } from '../models/Result';
+import { logger } from '../utils/logger';
+import type { DbHandle } from './db';
+import { openDatabase, initSchema, closeDatabase } from './db';
+
+const COMMANDTREE_DIR = '.commandtree';
+const DB_FILENAME = 'commandtree.sqlite3';
+
+let dbHandle: DbHandle | null = null;
+
+/**
+ * Initialises the SQLite database singleton.
+ * Re-creates if the DB file was deleted externally.
+ */
+export function initDb(workspaceRoot: string): Result {
+ if (dbHandle !== null && fs.existsSync(dbHandle.path)) {
+ return ok(dbHandle);
+ }
+ resetStaleHandle();
+
+ const dbDir = path.join(workspaceRoot, COMMANDTREE_DIR);
+ try {
+ fs.mkdirSync(dbDir, { recursive: true });
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Failed to create directory';
+ return err(msg);
+ }
+
+ const dbPath = path.join(dbDir, DB_FILENAME);
+ const openResult = openDatabase(dbPath);
+ if (!openResult.ok) { return openResult; }
+
+ const schemaResult = initSchema(openResult.value);
+ if (!schemaResult.ok) {
+ closeDatabase(openResult.value);
+ return err(schemaResult.error);
+ }
+
+ dbHandle = openResult.value;
+ logger.info('SQLite database initialised', { path: dbPath });
+ return ok(dbHandle);
+}
+
+/**
+ * Returns the current database handle.
+ * Invalidates a stale handle if the DB file was deleted.
+ */
+export function getDb(): Result {
+ if (dbHandle !== null && fs.existsSync(dbHandle.path)) {
+ return ok(dbHandle);
+ }
+ resetStaleHandle();
+ return err('Database not initialised. Call initDb first.');
+}
+
+function resetStaleHandle(): void {
+ if (dbHandle !== null) {
+ closeDatabase(dbHandle);
+ dbHandle = null;
+ }
+}
+
+/**
+ * Disposes the database connection.
+ */
+export function disposeDb(): void {
+ const currentDb = dbHandle;
+ dbHandle = null;
+ if (currentDb !== null) {
+ closeDatabase(currentDb);
+ }
+ logger.info('Database disposed');
+}
diff --git a/src/extension.ts b/src/extension.ts
index e22b8e3..1f65fc9 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -7,17 +7,8 @@ import type { TaskItem } from './models/TaskItem';
import { TaskRunner } from './runners/TaskRunner';
import { QuickTasksProvider } from './QuickTasksProvider';
import { logger } from './utils/logger';
-import {
- isAiEnabled,
- summariseAllTasks,
- registerAllCommands,
- initSemanticStore,
- disposeSemanticStore
-} from './semantic';
-import { createVSCodeFileSystem } from './semantic/vscodeAdapters';
-import { forceSelectModel } from './semantic/summariser';
-import { getDb } from './semantic/lifecycle';
-import { addTagToCommand, removeTagFromCommand, getCommandIdsByTag } from './semantic/db';
+import { initDb, getDb, disposeDb } from './db/lifecycle';
+import { addTagToCommand, removeTagFromCommand, getCommandIdsByTag } from './db/db';
let treeProvider: CommandTreeProvider;
let quickTasksProvider: QuickTasksProvider;
@@ -35,40 +26,22 @@ export async function activate(context: vscode.ExtensionContext): Promise {
- const tasks = treeProvider.getAllTasks();
- if (tasks.length === 0) { return; }
- const result = await registerAllCommands({
- tasks,
- workspaceRoot,
- fs: createVSCodeFileSystem(),
- });
+function initDatabase(workspaceRoot: string): void {
+ const result = initDb(workspaceRoot);
if (!result.ok) {
- logger.warn('Command registration failed', { error: result.error });
- } else {
- logger.info('Commands registered in DB', { count: result.value });
- }
-}
-
-async function initSemanticSubsystem(workspaceRoot: string): Promise {
- const storeResult = await initSemanticStore(workspaceRoot);
- if (!storeResult.ok) {
- logger.warn('SQLite init failed, semantic search unavailable', { error: storeResult.error });
+ logger.warn('SQLite init failed', { error: result.error });
}
}
@@ -86,9 +59,9 @@ function registerTreeViews(context: vscode.ExtensionContext): void {
);
}
-function registerCommands(context: vscode.ExtensionContext, workspaceRoot: string): void {
+function registerCommands(context: vscode.ExtensionContext): void {
registerCoreCommands(context);
- registerFilterCommands(context, workspaceRoot);
+ registerFilterCommands(context);
registerTagCommands(context);
registerQuickCommands(context);
}
@@ -118,23 +91,12 @@ function registerCoreCommands(context: vscode.ExtensionContext): void {
);
}
-function registerFilterCommands(context: vscode.ExtensionContext, workspaceRoot: string): void {
+function registerFilterCommands(context: vscode.ExtensionContext): void {
context.subscriptions.push(
vscode.commands.registerCommand('commandtree.filterByTag', handleFilterByTag),
vscode.commands.registerCommand('commandtree.clearFilter', () => {
treeProvider.clearFilters();
updateFilterContext();
- }),
- vscode.commands.registerCommand('commandtree.semanticSearch', async (q?: string) => { await handleSemanticSearch(q, workspaceRoot); }),
- vscode.commands.registerCommand('commandtree.generateSummaries', async () => { await runSummarisation(workspaceRoot); }),
- vscode.commands.registerCommand('commandtree.selectModel', async () => {
- const result = await forceSelectModel();
- if (result.ok) {
- vscode.window.showInformationMessage(`CommandTree: AI model set to ${result.value}`);
- await runSummarisation(workspaceRoot);
- } else {
- vscode.window.showWarningMessage(`CommandTree: ${result.error}`);
- }
})
);
}
@@ -218,10 +180,6 @@ async function handleRemoveTag(item: CommandTreeItem | TaskItem | undefined, tag
quickTasksProvider.updateTasks(treeProvider.getAllTasks());
}
-async function handleSemanticSearch(_queryArg: string | undefined, _workspaceRoot: string): Promise {
- await vscode.window.showInformationMessage('Semantic search is currently disabled');
-}
-
function setupFileWatcher(context: vscode.ExtensionContext, workspaceRoot: string): void {
const watcher = vscode.workspace.createFileSystemWatcher(
'**/{package.json,Makefile,makefile,tasks.json,launch.json,*.sh,*.py}'
@@ -232,7 +190,7 @@ function setupFileWatcher(context: vscode.ExtensionContext, workspaceRoot: strin
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
- syncAndSummarise(workspaceRoot).catch((e: unknown) => {
+ syncQuickTasks().catch((e: unknown) => {
logger.error('Sync failed', { error: e instanceof Error ? e.message : 'Unknown' });
});
}, 2000);
@@ -272,15 +230,6 @@ async function syncQuickTasks(): Promise {
logger.info('syncQuickTasks END');
}
-async function syncAndSummarise(workspaceRoot: string): Promise {
- await syncQuickTasks();
- await registerDiscoveredCommands(workspaceRoot);
- const aiEnabled = vscode.workspace.getConfiguration('commandtree').get('enableAiSummaries', true);
- if (isAiEnabled(aiEnabled)) {
- await runSummarisation(workspaceRoot);
- }
-}
-
interface TagPattern {
readonly id?: string;
readonly type?: string;
@@ -383,48 +332,6 @@ async function pickOrCreateTag(existingTags: string[], taskLabel: string): Promi
});
}
-function initAiSummaries(workspaceRoot: string): void {
- const aiEnabled = vscode.workspace.getConfiguration('commandtree').get('enableAiSummaries', true);
- if (!isAiEnabled(aiEnabled)) { return; }
- vscode.commands.executeCommand('setContext', 'commandtree.aiSummariesEnabled', true);
- runSummarisation(workspaceRoot).catch((e: unknown) => {
- logger.error('AI summarisation failed', { error: e instanceof Error ? e.message : 'Unknown' });
- });
-}
-
-async function runSummarisation(workspaceRoot: string): Promise {
- const tasks = treeProvider.getAllTasks();
- logger.info('[DIAG] runSummarisation called', { taskCount: tasks.length, workspaceRoot });
- if (tasks.length === 0) {
- logger.warn('[DIAG] No tasks to summarise, returning early');
- return;
- }
-
- const fileSystem = createVSCodeFileSystem();
-
- // Step 1: Generate summaries via Copilot (independent pipeline)
- const summaryResult = await summariseAllTasks({
- tasks,
- workspaceRoot,
- fs: fileSystem,
- onProgress: (done, total) => {
- logger.info('Summary progress', { done, total });
- }
- });
- if (!summaryResult.ok) {
- logger.error('Summary pipeline failed', { error: summaryResult.error });
- vscode.window.showErrorMessage(`CommandTree: Summary failed — ${summaryResult.error}`);
- return;
- }
-
- // Embedding pipeline disabled — summaries still work via Copilot
- if (summaryResult.value > 0) {
- await treeProvider.refresh();
- quickTasksProvider.updateTasks(treeProvider.getAllTasks());
- }
- vscode.window.showInformationMessage(`CommandTree: Summarised ${summaryResult.value} commands`);
-}
-
function updateFilterContext(): void {
vscode.commands.executeCommand(
'setContext',
@@ -433,6 +340,6 @@ function updateFilterContext(): void {
);
}
-export async function deactivate(): Promise {
- await disposeSemanticStore();
+export function deactivate(): void {
+ disposeDb();
}
diff --git a/src/models/TaskItem.ts b/src/models/TaskItem.ts
index 1af4a4f..8af70c7 100644
--- a/src/models/TaskItem.ts
+++ b/src/models/TaskItem.ts
@@ -74,8 +74,6 @@ export interface TaskItem {
readonly tags: readonly string[];
readonly params?: readonly ParamDef[];
readonly description?: string;
- readonly summary?: string;
- readonly securityWarning?: string;
}
/**
@@ -92,8 +90,6 @@ export interface MutableTaskItem {
tags: string[];
params?: ParamDef[];
description?: string;
- summary?: string;
- securityWarning?: string;
}
/**
@@ -104,18 +100,12 @@ export class CommandTreeItem extends vscode.TreeItem {
public readonly task: TaskItem | null,
public readonly categoryLabel: string | null,
public readonly children: CommandTreeItem[] = [],
- parentId?: string,
- similarityScore?: number
+ parentId?: string
) {
- const rawLabel = task?.label ?? categoryLabel ?? '';
- const hasWarning = task?.securityWarning !== undefined && task.securityWarning !== '';
- const baseLabel = hasWarning ? `\u26A0\uFE0F ${rawLabel}` : rawLabel;
- const labelWithScore = similarityScore !== undefined
- ? `${baseLabel} (${Math.round(similarityScore * 100)}%)`
- : baseLabel;
+ const label = task?.label ?? categoryLabel ?? '';
super(
- labelWithScore,
+ label,
children.length > 0
? vscode.TreeItemCollapsibleState.Collapsed
: vscode.TreeItemCollapsibleState.None
@@ -156,14 +146,6 @@ export class CommandTreeItem extends vscode.TreeItem {
private buildTooltip(task: TaskItem): vscode.MarkdownString {
const md = new vscode.MarkdownString();
md.appendMarkdown(`**${task.label}**\n\n`);
- if (task.securityWarning !== undefined && task.securityWarning !== '') {
- md.appendMarkdown(`\u26A0\uFE0F **Security Warning:** ${task.securityWarning}\n\n`);
- md.appendMarkdown(`---\n\n`);
- }
- if (task.summary !== undefined && task.summary !== '') {
- md.appendMarkdown(`> ${task.summary}\n\n`);
- md.appendMarkdown(`---\n\n`);
- }
md.appendMarkdown(`Type: \`${task.type}\`\n\n`);
md.appendMarkdown(`Command: \`${task.command}\`\n\n`);
if (task.cwd !== undefined && task.cwd !== '') {
diff --git a/src/semantic/adapters.ts b/src/semantic/adapters.ts
deleted file mode 100644
index 09674bf..0000000
--- a/src/semantic/adapters.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-/**
- * SPEC: ai-semantic-search
- *
- * Adapter interfaces for decoupling semantic providers from VS Code.
- * Allows unit testing without VS Code instance.
- */
-
-import * as fs from 'fs';
-import * as path from 'path';
-import type { Result } from '../models/Result.js';
-
-/**
- * File system operations abstraction.
- * Implementations: VSCodeFileSystem (production), NodeFileSystem (unit tests)
- */
-export interface FileSystemAdapter {
- readFile: (path: string) => Promise>;
- writeFile: (path: string, content: string) => Promise>;
- exists: (path: string) => Promise;
- delete: (path: string) => Promise>;
-}
-
-/**
- * Configuration reading abstraction.
- * Implementations: VSCodeConfig (production), MockConfig (unit tests)
- */
-export interface ConfigAdapter {
- get: (key: string, defaultValue: T) => T;
-}
-
-export interface SummaryAdapterResult {
- readonly summary: string;
- readonly securityWarning: string;
-}
-
-/**
- * Language Model API abstraction for summarisation.
- * Implementations: CopilotLM (production), MockLM (unit tests)
- */
-export interface LanguageModelAdapter {
- summarise: (params: {
- readonly label: string;
- readonly type: string;
- readonly command: string;
- readonly content: string;
- }) => Promise>;
-}
-
-/**
- * Creates a Node.js fs-based file system adapter (for unit tests).
- */
-export function createNodeFileSystem(): FileSystemAdapter {
- const fsPromises = fs.promises;
-
- return {
- readFile: async (filePath: string): Promise> => {
- try {
- const content = await fsPromises.readFile(filePath, 'utf-8');
- const { ok } = await import('../models/Result.js');
- return ok(content);
- } catch (e) {
- const { err } = await import('../models/Result.js');
- const msg = e instanceof Error ? e.message : 'Read failed';
- return err(msg);
- }
- },
-
- writeFile: async (filePath: string, content: string): Promise> => {
- try {
- const dir = path.dirname(filePath);
- await fsPromises.mkdir(dir, { recursive: true });
- await fsPromises.writeFile(filePath, content, 'utf-8');
- const { ok } = await import('../models/Result.js');
- return ok(undefined);
- } catch (e) {
- const { err } = await import('../models/Result.js');
- const msg = e instanceof Error ? e.message : 'Write failed';
- return err(msg);
- }
- },
-
- exists: async (filePath: string): Promise => {
- try {
- await fsPromises.access(filePath);
- return true;
- } catch {
- return false;
- }
- },
-
- delete: async (filePath: string): Promise> => {
- try {
- await fsPromises.unlink(filePath);
- const { ok } = await import('../models/Result.js');
- return ok(undefined);
- } catch (e) {
- const { err } = await import('../models/Result.js');
- const msg = e instanceof Error ? e.message : 'Delete failed';
- return err(msg);
- }
- }
- };
-}
diff --git a/src/semantic/db.ts b/src/semantic/db.ts
deleted file mode 100644
index 01e146d..0000000
--- a/src/semantic/db.ts
+++ /dev/null
@@ -1,580 +0,0 @@
-/**
- * SPEC: database-schema, database-schema/tags-table, database-schema/command-tags-junction, database-schema/tag-operations
- * Embedding serialization and SQLite storage layer.
- * Uses node-sqlite3-wasm for WASM-based SQLite with BLOB embedding storage.
- */
-
-import * as fs from "fs";
-import * as path from "path";
-import type { Result } from "../models/Result";
-import { ok, err } from "../models/Result";
-import type { SummaryStoreData } from "./store";
-
-import type { Database as SqliteDatabase } from "node-sqlite3-wasm";
-
-const COMMAND_TABLE = "commands";
-const TAG_TABLE = "tags";
-const COMMAND_TAGS_TABLE = "command_tags";
-
-export interface EmbeddingRow {
- readonly commandId: string;
- readonly contentHash: string;
- readonly summary: string;
- readonly securityWarning: string | null;
- readonly embedding: Float32Array | null;
- readonly lastUpdated: string;
-}
-
-export interface DbHandle {
- readonly db: SqliteDatabase;
- readonly path: string;
-}
-
-/**
- * Serializes a Float32Array embedding to a Uint8Array for storage.
- */
-export function embeddingToBytes(embedding: Float32Array): Uint8Array {
- const buffer = new ArrayBuffer(embedding.length * 4);
- const view = new Float32Array(buffer);
- view.set(embedding);
- return new Uint8Array(buffer);
-}
-
-/**
- * Deserializes a Uint8Array back to a Float32Array embedding.
- */
-export function bytesToEmbedding(bytes: Uint8Array): Float32Array {
- const buffer = new ArrayBuffer(bytes.length);
- const view = new Uint8Array(buffer);
- view.set(bytes);
- return new Float32Array(buffer);
-}
-
-/**
- * Opens a SQLite database at the given path.
- * CRITICAL: Enables foreign key constraints on EVERY connection.
- */
-export async function openDatabase(
- dbPath: string,
-): Promise> {
- try {
- fs.mkdirSync(path.dirname(dbPath), { recursive: true });
- const mod = await import("node-sqlite3-wasm");
- const db = new mod.default.Database(dbPath);
- db.exec("PRAGMA foreign_keys = ON");
- return ok({ db, path: dbPath });
- } catch (e) {
- const msg = e instanceof Error ? e.message : "Failed to open database";
- return err(msg);
- }
-}
-
-/**
- * Closes a database connection.
- */
-export function closeDatabase(handle: DbHandle): Result {
- try {
- handle.db.close();
- return ok(undefined);
- } catch (e) {
- const msg = e instanceof Error ? e.message : "Failed to close database";
- return err(msg);
- }
-}
-
-/**
- * SPEC: database-schema, database-schema/tags-table, database-schema/command-tags-junction
- * Creates the commands, tags, and command_tags tables if they do not exist.
- * STRICT referential integrity enforced with CASCADE DELETE.
- */
-export function initSchema(handle: DbHandle): Result {
- try {
- handle.db.exec(`
- CREATE TABLE IF NOT EXISTS ${COMMAND_TABLE} (
- command_id TEXT PRIMARY KEY,
- content_hash TEXT NOT NULL,
- summary TEXT NOT NULL,
- embedding BLOB,
- security_warning TEXT,
- last_updated TEXT NOT NULL
- )
- `);
-
- try {
- handle.db.exec(
- `ALTER TABLE ${COMMAND_TABLE} ADD COLUMN security_warning TEXT`,
- );
- } catch {
- // Column already exists — expected for existing databases
- }
-
- handle.db.exec(`
- CREATE TABLE IF NOT EXISTS ${TAG_TABLE} (
- tag_id TEXT PRIMARY KEY,
- tag_name TEXT NOT NULL UNIQUE,
- description TEXT
- )
- `);
-
- const existing = handle.db.get(
- `SELECT sql FROM sqlite_master WHERE type='table' AND name=?`,
- [COMMAND_TAGS_TABLE],
- ) as { sql: string } | null;
- if (existing !== null && !existing.sql.includes('FOREIGN KEY (command_id)')) {
- handle.db.exec(`DROP TABLE ${COMMAND_TAGS_TABLE}`);
- }
-
- handle.db.exec(`
- CREATE TABLE IF NOT EXISTS ${COMMAND_TAGS_TABLE} (
- command_id TEXT NOT NULL,
- tag_id TEXT NOT NULL,
- display_order INTEGER NOT NULL DEFAULT 0,
- PRIMARY KEY (command_id, tag_id),
- FOREIGN KEY (command_id) REFERENCES ${COMMAND_TABLE}(command_id) ON DELETE CASCADE,
- FOREIGN KEY (tag_id) REFERENCES ${TAG_TABLE}(tag_id) ON DELETE CASCADE
- )
- `);
- return ok(undefined);
- } catch (e) {
- const msg = e instanceof Error ? e.message : "Failed to init schema";
- return err(msg);
- }
-}
-
-/**
- * SPEC: database-schema/commands-table
- * Upserts a single embedding record (full row).
- */
-export function upsertRow(params: {
- readonly handle: DbHandle;
- readonly row: EmbeddingRow;
-}): Result {
- try {
- const blob =
- params.row.embedding !== null
- ? embeddingToBytes(params.row.embedding)
- : null;
- params.handle.db.run(
- `INSERT INTO ${COMMAND_TABLE}
- (command_id, content_hash, summary, embedding, security_warning, last_updated)
- VALUES (?, ?, ?, ?, ?, ?)
- ON CONFLICT(command_id) DO UPDATE SET
- content_hash = excluded.content_hash,
- summary = excluded.summary,
- embedding = excluded.embedding,
- security_warning = excluded.security_warning,
- last_updated = excluded.last_updated`,
- [
- params.row.commandId,
- params.row.contentHash,
- params.row.summary,
- blob,
- params.row.securityWarning,
- params.row.lastUpdated,
- ],
- );
- return ok(undefined);
- } catch (e) {
- const msg = e instanceof Error ? e.message : "Failed to upsert row";
- return err(msg);
- }
-}
-
-/**
- * Upserts ONLY the summary and content hash for a command.
- * Does NOT touch the embedding column. Used by the summary pipeline.
- */
-export function upsertSummary(params: {
- readonly handle: DbHandle;
- readonly commandId: string;
- readonly contentHash: string;
- readonly summary: string;
- readonly securityWarning: string | null;
-}): Result {
- try {
- const now = new Date().toISOString();
- params.handle.db.run(
- `INSERT INTO ${COMMAND_TABLE}
- (command_id, content_hash, summary, embedding, security_warning, last_updated)
- VALUES (?, ?, ?, NULL, ?, ?)
- ON CONFLICT(command_id) DO UPDATE SET
- content_hash = excluded.content_hash,
- summary = excluded.summary,
- security_warning = excluded.security_warning,
- last_updated = excluded.last_updated`,
- [params.commandId, params.contentHash, params.summary, params.securityWarning, now],
- );
- return ok(undefined);
- } catch (e) {
- const msg = e instanceof Error ? e.message : "Failed to upsert summary";
- return err(msg);
- }
-}
-
-/**
- * Updates ONLY the embedding for an existing command row.
- * Does NOT touch the summary column. Used by the embedding pipeline.
- */
-export function upsertEmbedding(params: {
- readonly handle: DbHandle;
- readonly commandId: string;
- readonly embedding: Float32Array;
-}): Result {
- try {
- const blob = embeddingToBytes(params.embedding);
- params.handle.db.run(
- `UPDATE ${COMMAND_TABLE}
- SET embedding = ?, last_updated = ?
- WHERE command_id = ?`,
- [blob, new Date().toISOString(), params.commandId],
- );
- return ok(undefined);
- } catch (e) {
- const msg = e instanceof Error ? e.message : "Failed to upsert embedding";
- return err(msg);
- }
-}
-
-/**
- * Gets all rows that have a summary but no embedding.
- * Used by the embedding pipeline to find work.
- */
-export function getRowsMissingEmbedding(
- handle: DbHandle,
-): Result {
- try {
- const rows = handle.db.all(
- `SELECT * FROM ${COMMAND_TABLE} WHERE summary != '' AND embedding IS NULL`,
- );
- return ok(rows.map((r) => rowToEmbeddingRow(r as RawRow)));
- } catch (e) {
- const msg = e instanceof Error ? e.message : "Failed to query rows";
- return err(msg);
- }
-}
-
-/**
- * SPEC: database-schema/commands-table
- * Gets a single record by command ID.
- */
-export function getRow(params: {
- readonly handle: DbHandle;
- readonly commandId: string;
-}): Result {
- try {
- const row = params.handle.db.get(
- `SELECT * FROM ${COMMAND_TABLE} WHERE command_id = ?`,
- [params.commandId],
- );
- if (row === null) {
- return ok(undefined);
- }
- return ok(rowToEmbeddingRow(row as RawRow));
- } catch (e) {
- const msg = e instanceof Error ? e.message : "Failed to get row";
- return err(msg);
- }
-}
-
-/**
- * SPEC: database-schema/commands-table
- * Gets all records from the database.
- */
-export function getAllRows(handle: DbHandle): Result {
- try {
- const rows = handle.db.all(`SELECT * FROM ${COMMAND_TABLE}`);
- return ok(rows.map((r) => rowToEmbeddingRow(r as RawRow)));
- } catch (e) {
- const msg = e instanceof Error ? e.message : "Failed to get all rows";
- return err(msg);
- }
-}
-
-type RawRow = Record;
-
-/**
- * Converts a raw SQLite row to a typed EmbeddingRow.
- */
-function rowToEmbeddingRow(row: RawRow): EmbeddingRow {
- const blob = row["embedding"];
- const embedding = blob instanceof Uint8Array ? bytesToEmbedding(blob) : null;
- const warning = row["security_warning"];
- return {
- commandId: row["command_id"] as string,
- contentHash: row["content_hash"] as string,
- summary: row["summary"] as string,
- securityWarning: typeof warning === "string" ? warning : null,
- embedding,
- lastUpdated: row["last_updated"] as string,
- };
-}
-
-/**
- * Imports records from the legacy JSON summary store into SQLite.
- * Embedding column is NULL for imported records.
- */
-export function importFromJsonStore(params: {
- readonly handle: DbHandle;
- readonly jsonData: SummaryStoreData;
-}): Result {
- try {
- const records = Object.values(params.jsonData.records);
- for (const record of records) {
- params.handle.db.run(
- `INSERT OR IGNORE INTO ${COMMAND_TABLE}
- (command_id, content_hash, summary, embedding, security_warning, last_updated)
- VALUES (?, ?, ?, ?, NULL, ?)`,
- [
- record.commandId,
- record.contentHash,
- record.summary,
- null,
- record.lastUpdated,
- ],
- );
- }
- return ok(records.length);
- } catch (e) {
- const msg = e instanceof Error ? e.message : "Failed to import from JSON";
- return err(msg);
- }
-}
-
-// ---------------------------------------------------------------------------
-// SPEC: tagging - Junction table operations
-// ---------------------------------------------------------------------------
-
-/**
- * Registers a discovered command in the DB with its content hash.
- * Inserts with empty summary if new; updates only content_hash if existing.
- * Does NOT touch summary, embedding, or security_warning on existing rows.
- */
-export function registerCommand(params: {
- readonly handle: DbHandle;
- readonly commandId: string;
- readonly contentHash: string;
-}): Result {
- try {
- const now = new Date().toISOString();
- params.handle.db.run(
- `INSERT INTO ${COMMAND_TABLE}
- (command_id, content_hash, summary, embedding, security_warning, last_updated)
- VALUES (?, ?, '', NULL, NULL, ?)
- ON CONFLICT(command_id) DO UPDATE SET
- content_hash = excluded.content_hash,
- last_updated = excluded.last_updated`,
- [params.commandId, params.contentHash, now],
- );
- return ok(undefined);
- } catch (e) {
- const msg =
- e instanceof Error ? e.message : "Failed to register command";
- return err(msg);
- }
-}
-
-/**
- * Ensures a command record exists before adding tags to it.
- * Inserts placeholder if needed to maintain referential integrity.
- */
-export function ensureCommandExists(params: {
- readonly handle: DbHandle;
- readonly commandId: string;
-}): Result {
- return registerCommand({
- handle: params.handle,
- commandId: params.commandId,
- contentHash: "",
- });
-}
-
-/**
- * SPEC: database-schema/tag-operations, tagging, tagging/management
- * Adds a tag to a command with optional display order.
- * Ensures BOTH tag and command exist before creating junction record.
- * STRICT referential integrity enforced.
- */
-export function addTagToCommand(params: {
- readonly handle: DbHandle;
- readonly commandId: string;
- readonly tagName: string;
- readonly displayOrder?: number;
-}): Result {
- try {
- const cmdResult = ensureCommandExists({
- handle: params.handle,
- commandId: params.commandId,
- });
- if (!cmdResult.ok) {
- return cmdResult;
- }
- const existing = params.handle.db.get(
- `SELECT tag_id FROM ${TAG_TABLE} WHERE tag_name = ?`,
- [params.tagName],
- );
- const tagId =
- existing !== null
- ? ((existing as RawRow)["tag_id"] as string)
- : crypto.randomUUID();
- if (existing === null) {
- params.handle.db.run(
- `INSERT INTO ${TAG_TABLE} (tag_id, tag_name, description) VALUES (?, ?, NULL)`,
- [tagId, params.tagName],
- );
- }
- const order = params.displayOrder ?? 0;
- params.handle.db.run(
- `INSERT OR IGNORE INTO ${COMMAND_TAGS_TABLE} (command_id, tag_id, display_order) VALUES (?, ?, ?)`,
- [params.commandId, tagId, order],
- );
- return ok(undefined);
- } catch (e) {
- const msg = e instanceof Error ? e.message : "Failed to add tag to command";
- return err(msg);
- }
-}
-
-/**
- * SPEC: database-schema/tag-operations, tagging, tagging/management
- * Removes a tag from a command.
- */
-export function removeTagFromCommand(params: {
- readonly handle: DbHandle;
- readonly commandId: string;
- readonly tagName: string;
-}): Result {
- try {
- params.handle.db.run(
- `DELETE FROM ${COMMAND_TAGS_TABLE}
- WHERE command_id = ?
- AND tag_id = (SELECT tag_id FROM ${TAG_TABLE} WHERE tag_name = ?)`,
- [params.commandId, params.tagName],
- );
- return ok(undefined);
- } catch (e) {
- const msg =
- e instanceof Error ? e.message : "Failed to remove tag from command";
- return err(msg);
- }
-}
-
-/**
- * SPEC: database-schema/tag-operations, tagging/filter
- * Gets all command IDs for a given tag, ordered by display_order.
- */
-export function getCommandIdsByTag(params: {
- readonly handle: DbHandle;
- readonly tagName: string;
-}): Result {
- try {
- const rows = params.handle.db.all(
- `SELECT ct.command_id
- FROM ${COMMAND_TAGS_TABLE} ct
- JOIN ${TAG_TABLE} t ON ct.tag_id = t.tag_id
- WHERE t.tag_name = ?
- ORDER BY ct.display_order`,
- [params.tagName],
- );
- return ok(rows.map((r) => (r as RawRow)["command_id"] as string));
- } catch (e) {
- const msg =
- e instanceof Error ? e.message : "Failed to get command IDs by tag";
- return err(msg);
- }
-}
-
-/**
- * SPEC: database-schema/tag-operations, tagging
- * Gets all tags for a given command.
- */
-export function getTagsForCommand(params: {
- readonly handle: DbHandle;
- readonly commandId: string;
-}): Result {
- try {
- const rows = params.handle.db.all(
- `SELECT t.tag_name
- FROM ${TAG_TABLE} t
- JOIN ${COMMAND_TAGS_TABLE} ct ON t.tag_id = ct.tag_id
- WHERE ct.command_id = ?`,
- [params.commandId],
- );
- return ok(rows.map((r) => (r as RawRow)["tag_name"] as string));
- } catch (e) {
- const msg =
- e instanceof Error ? e.message : "Failed to get tags for command";
- return err(msg);
- }
-}
-
-/**
- * SPEC: database-schema/tag-operations, tagging/filter
- * Gets all distinct tag names from tags table.
- */
-export function getAllTagNames(handle: DbHandle): Result {
- try {
- const rows = handle.db.all(
- `SELECT tag_name FROM ${TAG_TABLE} ORDER BY tag_name`,
- );
- return ok(rows.map((r) => (r as RawRow)["tag_name"] as string));
- } catch (e) {
- const msg = e instanceof Error ? e.message : "Failed to get all tag names";
- return err(msg);
- }
-}
-
-/**
- * SPEC: database-schema/tag-operations, quick-launch
- * Updates the display order for a tag assignment in the junction table.
- */
-export function updateTagDisplayOrder(params: {
- readonly handle: DbHandle;
- readonly commandId: string;
- readonly tagId: string;
- readonly newOrder: number;
-}): Result {
- try {
- params.handle.db.run(
- `UPDATE ${COMMAND_TAGS_TABLE} SET display_order = ? WHERE command_id = ? AND tag_id = ?`,
- [params.newOrder, params.commandId, params.tagId],
- );
- return ok(undefined);
- } catch (e) {
- const msg =
- e instanceof Error ? e.message : "Failed to update tag display order";
- return err(msg);
- }
-}
-
-/**
- * SPEC: quick-launch
- * Reorders command IDs for a tag by updating display_order for all junction records.
- * Used for drag-and-drop reordering in Quick Launch.
- */
-export function reorderTagCommands(params: {
- readonly handle: DbHandle;
- readonly tagName: string;
- readonly orderedCommandIds: readonly string[];
-}): Result {
- try {
- const tagRow = params.handle.db.get(
- `SELECT tag_id FROM ${TAG_TABLE} WHERE tag_name = ?`,
- [params.tagName],
- );
- if (tagRow === null) {
- return err(`Tag "${params.tagName}" not found`);
- }
- const tagId = (tagRow as RawRow)["tag_id"] as string;
- params.orderedCommandIds.forEach((commandId, index) => {
- params.handle.db.run(
- `UPDATE ${COMMAND_TAGS_TABLE} SET display_order = ? WHERE command_id = ? AND tag_id = ?`,
- [index, commandId, tagId],
- );
- });
- return ok(undefined);
- } catch (e) {
- const msg =
- e instanceof Error ? e.message : "Failed to reorder tag commands";
- return err(msg);
- }
-}
diff --git a/src/semantic/embedder.ts b/src/semantic/embedder.ts
deleted file mode 100644
index a8d529b..0000000
--- a/src/semantic/embedder.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-/**
- * Text embedding via @huggingface/transformers (all-MiniLM-L6-v2).
- * Uses WASM backend (onnxruntime-web) to avoid shipping 208MB native binaries.
- */
-
-import type { Result } from '../models/Result';
-import { ok, err } from '../models/Result';
-
-// const ORT_SYMBOL = Symbol.for('onnxruntime');
-
-interface Pipeline {
- (text: string, options: { pooling: string; normalize: boolean }): Promise<{ data: Float32Array }>;
- dispose: () => Promise;
-}
-
-export interface EmbedderHandle {
- readonly pipeline: Pipeline;
-}
-
-// --- Embedding disabled: injectWasmBackend and createEmbedder commented out ---
-// /** Injects WASM runtime so transformers.js skips the native onnxruntime-node binary. */
-// async function injectWasmBackend(): Promise {
-// if (ORT_SYMBOL in globalThis) { return; }
-// const ort = await import('onnxruntime-web');
-// (globalThis as Record)[ORT_SYMBOL] = ort;
-// }
-
-/**
- * Creates an embedder by loading the MiniLM model.
- * DISABLED — embedding functionality is turned off.
- */
-export async function createEmbedder(_params: {
- readonly modelCacheDir: string;
- readonly onProgress?: (progress: unknown) => void;
-}): Promise> {
- await Promise.resolve();
- return err('Embedding is disabled');
-}
-
-/**
- * Disposes the embedder and frees model memory.
- */
-export async function disposeEmbedder(handle: EmbedderHandle): Promise {
- try {
- await handle.pipeline.dispose();
- } catch {
- // Best-effort cleanup
- }
-}
-
-/**
- * Embeds a single text string into a 384-dim vector.
- */
-export async function embedText(params: {
- readonly handle: EmbedderHandle;
- readonly text: string;
-}): Promise> {
- try {
- const output = await params.handle.pipeline(
- params.text,
- { pooling: 'mean', normalize: true }
- );
- return ok(new Float32Array(output.data));
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Embedding failed';
- return err(msg);
- }
-}
-
-/**
- * Embeds multiple texts in sequence.
- */
-export async function embedBatch(params: {
- readonly handle: EmbedderHandle;
- readonly texts: readonly string[];
-}): Promise> {
- const results: Float32Array[] = [];
- for (const text of params.texts) {
- const result = await embedText({ handle: params.handle, text });
- if (!result.ok) {
- return result;
- }
- results.push(result.value);
- }
- return ok(results);
-}
diff --git a/src/semantic/embeddingPipeline.ts b/src/semantic/embeddingPipeline.ts
deleted file mode 100644
index ae3c9a8..0000000
--- a/src/semantic/embeddingPipeline.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-/**
- * SPEC: ai-semantic-search
- *
- * Embedding pipeline: generates embeddings for commands and stores them in SQLite.
- * COMPLETELY DECOUPLED from Copilot summarisation.
- * Does NOT import summariser, summaryPipeline, or vscode LM APIs.
- */
-
-import type { Result } from '../models/TaskItem';
-import { ok, err } from '../models/TaskItem';
-import { logger } from '../utils/logger';
-import { initDb } from './lifecycle';
-import { getOrCreateEmbedder } from './lifecycle';
-import { getRowsMissingEmbedding, upsertEmbedding } from './db';
-import type { EmbeddingRow } from './db';
-import { embedText } from './embedder';
-
-/**
- * Embeds text into a vector. Returns error on failure — NEVER null.
- */
-async function embedOrFail(params: {
- readonly text: string;
- readonly workspaceRoot: string;
-}): Promise> {
- const embedderResult = await getOrCreateEmbedder({
- workspaceRoot: params.workspaceRoot
- });
- if (!embedderResult.ok) { return err(embedderResult.error); }
-
- return await embedText({
- handle: embedderResult.value,
- text: params.text
- });
-}
-
-/**
- * Processes a single row: embeds its summary and stores the embedding.
- */
-async function processOneEmbedding(params: {
- readonly row: EmbeddingRow;
- readonly workspaceRoot: string;
-}): Promise> {
- const dbInit = await initDb(params.workspaceRoot);
- if (!dbInit.ok) { return err(dbInit.error); }
-
- const embedding = await embedOrFail({
- text: params.row.summary,
- workspaceRoot: params.workspaceRoot
- });
- if (!embedding.ok) { return err(embedding.error); }
-
- return upsertEmbedding({
- handle: dbInit.value,
- commandId: params.row.commandId,
- embedding: embedding.value
- });
-}
-
-/**
- * Generates embeddings for all commands that have a summary but no embedding.
- * Reads summaries from the DB — does NOT call Copilot.
- */
-export async function embedAllPending(params: {
- readonly workspaceRoot: string;
- readonly onProgress?: (done: number, total: number) => void;
-}): Promise> {
- logger.info('[EMBED] embedAllPending START');
-
- const dbInit = await initDb(params.workspaceRoot);
- if (!dbInit.ok) {
- logger.error('[EMBED] initDb failed', { error: dbInit.error });
- return err(dbInit.error);
- }
-
- const pendingResult = getRowsMissingEmbedding(dbInit.value);
- if (!pendingResult.ok) { return err(pendingResult.error); }
-
- const pending = pendingResult.value;
- logger.info('[EMBED] rows missing embeddings', { count: pending.length });
-
- if (pending.length === 0) {
- logger.info('[EMBED] All embeddings up to date');
- return ok(0);
- }
-
- let succeeded = 0;
- let failed = 0;
-
- for (const row of pending) {
- const result = await processOneEmbedding({
- row,
- workspaceRoot: params.workspaceRoot
- });
- if (result.ok) {
- succeeded++;
- } else {
- failed++;
- logger.error('[EMBED] Embedding failed', { id: row.commandId, error: result.error });
- }
- params.onProgress?.(succeeded + failed, pending.length);
- }
-
- logger.info('[EMBED] complete', { succeeded, failed });
-
- if (succeeded === 0 && failed > 0) {
- return err(`All ${failed} embeddings failed`);
- }
- return ok(succeeded);
-}
diff --git a/src/semantic/index.ts b/src/semantic/index.ts
deleted file mode 100644
index de8d312..0000000
--- a/src/semantic/index.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-/**
- * SPEC: ai-semantic-search
- *
- * Semantic search facade.
- * Re-exports the two INDEPENDENT pipelines and provides search.
- *
- * - Summary pipeline (summaryPipeline.ts) generates Copilot summaries.
- * - Embedding pipeline (embeddingPipeline.ts) generates vector embeddings.
- * - They share the SQLite DB but do NOT import each other.
- */
-
-import type { Result } from '../models/TaskItem';
-import { ok, err } from '../models/TaskItem';
-import { initDb, getDb, getOrCreateEmbedder, disposeSemantic } from './lifecycle';
-import { getAllRows } from './db';
-import type { EmbeddingRow } from './db';
-import { embedText } from './embedder';
-import { rankBySimilarity, type ScoredCandidate } from './similarity';
-
-export { summariseAllTasks, registerAllCommands } from './summaryPipeline';
-export { embedAllPending } from './embeddingPipeline';
-
-const SEARCH_TOP_K = 20;
-const SEARCH_SIMILARITY_THRESHOLD = 0.3;
-
-/**
- * Checks if the user has enabled AI summaries.
- */
-export function isAiEnabled(enabled: boolean): boolean {
- return enabled;
-}
-
-/**
- * Initialises the semantic search subsystem.
- */
-export async function initSemanticStore(workspaceRoot: string): Promise> {
- const result = await initDb(workspaceRoot);
- if (!result.ok) { return err(result.error); }
- return ok(undefined);
-}
-
-/**
- * Disposes all semantic search resources.
- */
-export async function disposeSemanticStore(): Promise {
- await disposeSemantic();
-}
-
-/**
- * Performs semantic search using cosine similarity on stored embeddings.
- * SPEC.md **ai-search-implementation**: Scores must be preserved and displayed.
- */
-export async function semanticSearch(params: {
- readonly query: string;
- readonly workspaceRoot: string;
-}): Promise> {
- const dbInit = await initDb(params.workspaceRoot);
- if (!dbInit.ok) { return err(dbInit.error); }
-
- const rowsResult = getAllRows(dbInit.value);
- if (!rowsResult.ok) { return err(rowsResult.error); }
-
- if (rowsResult.value.length === 0) { return ok([]); }
-
- const embedderResult = await getOrCreateEmbedder({
- workspaceRoot: params.workspaceRoot
- });
- if (!embedderResult.ok) { return err(embedderResult.error); }
-
- const embResult = await embedText({
- handle: embedderResult.value,
- text: params.query
- });
- if (!embResult.ok) { return err(embResult.error); }
-
- const candidates = rowsResult.value.map(r => ({
- id: r.commandId,
- embedding: r.embedding
- }));
-
- const ranked = rankBySimilarity({
- query: embResult.value,
- candidates,
- topK: SEARCH_TOP_K,
- threshold: SEARCH_SIMILARITY_THRESHOLD
- });
-
- return ok(ranked);
-}
-
-/**
- * Gets all embedding rows for the CommandTreeProvider to read summaries.
- */
-export function getAllEmbeddingRows(): Result {
- const dbResult = getDb();
- if (!dbResult.ok) { return err(dbResult.error); }
- return getAllRows(dbResult.value);
-}
diff --git a/src/semantic/lifecycle.ts b/src/semantic/lifecycle.ts
deleted file mode 100644
index 36168a2..0000000
--- a/src/semantic/lifecycle.ts
+++ /dev/null
@@ -1,144 +0,0 @@
-/**
- * SPEC: database-schema
- * Singleton lifecycle management for the semantic search subsystem.
- * Manages database and embedder handles via cached promises
- * to avoid race conditions on module-level state.
- */
-
-import * as fs from 'fs';
-import * as path from 'path';
-import type { Result } from '../models/TaskItem';
-import { ok, err } from '../models/TaskItem';
-import { logger } from '../utils/logger';
-import type { DbHandle } from './db';
-import { openDatabase, initSchema, closeDatabase } from './db';
-import type { EmbedderHandle } from './embedder';
-import { createEmbedder, disposeEmbedder } from './embedder';
-
-const COMMANDTREE_DIR = '.commandtree';
-const DB_FILENAME = 'commandtree.sqlite3';
-const MODEL_DIR = 'models';
-
-let dbPromise: Promise> | null = null;
-let dbHandle: DbHandle | null = null;
-let embedderPromise: Promise> | null = null;
-let embedderHandle: EmbedderHandle | null = null;
-
-function ensureDirectory(dir: string): Result {
- try {
- fs.mkdirSync(dir, { recursive: true });
- return ok(undefined);
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Failed to create directory';
- return err(msg);
- }
-}
-
-async function doInitDb(workspaceRoot: string): Promise> {
- const dbDir = path.join(workspaceRoot, COMMANDTREE_DIR);
- const dirResult = ensureDirectory(dbDir);
- if (!dirResult.ok) { return err(dirResult.error); }
- const dbPath = path.join(dbDir, DB_FILENAME);
- const openResult = await openDatabase(dbPath);
- if (!openResult.ok) { return openResult; }
-
- const opened = openResult.value;
- const schemaResult = initSchema(opened);
- if (!schemaResult.ok) {
- closeDatabase(opened);
- return err(schemaResult.error);
- }
-
- logger.info('SQLite database initialised', { path: dbPath });
- return ok(opened);
-}
-
-function applyDbResult(result: Result): Result {
- if (result.ok) { dbHandle = result.value; } else { dbPromise = null; }
- return result;
-}
-
-/**
- * Initialises the SQLite database singleton.
- * Re-creates if the DB file was deleted externally.
- */
-export async function initDb(workspaceRoot: string): Promise> {
- if (dbHandle !== null && fs.existsSync(dbHandle.path)) {
- return ok(dbHandle);
- }
- resetStaleHandle();
- dbPromise ??= doInitDb(workspaceRoot).then(applyDbResult);
- return await dbPromise;
-}
-
-/**
- * Returns the current database handle.
- * Invalidates a stale handle if the DB file was deleted.
- */
-export function getDb(): Result {
- if (dbHandle !== null && fs.existsSync(dbHandle.path)) {
- return ok(dbHandle);
- }
- resetStaleHandle();
- return err('Database not initialised. Call initDb first.');
-}
-
-function resetStaleHandle(): void {
- if (dbHandle !== null) {
- closeDatabase(dbHandle);
- dbHandle = null;
- dbPromise = null;
- }
-}
-
-async function doCreateEmbedder(params: {
- readonly workspaceRoot: string;
- readonly onProgress?: (progress: unknown) => void;
-}): Promise> {
- const modelDir = path.join(params.workspaceRoot, COMMANDTREE_DIR, MODEL_DIR);
- const dirResult = ensureDirectory(modelDir);
- if (!dirResult.ok) { return err(dirResult.error); }
- const embedderParams = params.onProgress !== undefined
- ? { modelCacheDir: modelDir, onProgress: params.onProgress }
- : { modelCacheDir: modelDir };
- return await createEmbedder(embedderParams);
-}
-
-function applyEmbedderResult(result: Result): Result {
- if (result.ok) { embedderHandle = result.value; } else { embedderPromise = null; }
- return result;
-}
-
-/**
- * Gets or creates the embedder singleton.
- */
-export async function getOrCreateEmbedder(params: {
- readonly workspaceRoot: string;
- readonly onProgress?: (progress: unknown) => void;
-}): Promise> {
- if (embedderHandle !== null) {
- return ok(embedderHandle);
- }
- embedderPromise ??= doCreateEmbedder(params).then(applyEmbedderResult);
- return await embedderPromise;
-}
-
-/**
- * Disposes all semantic search resources.
- */
-export async function disposeSemantic(): Promise {
- const currentEmbedder = embedderHandle;
- embedderHandle = null;
- embedderPromise = null;
- if (currentEmbedder !== null) {
- await disposeEmbedder(currentEmbedder);
- }
-
- const currentDb = dbHandle;
- dbHandle = null;
- dbPromise = null;
- if (currentDb !== null) {
- closeDatabase(currentDb);
- }
- logger.info('Semantic search resources disposed');
-}
diff --git a/src/semantic/modelSelection.ts b/src/semantic/modelSelection.ts
deleted file mode 100644
index 88125eb..0000000
--- a/src/semantic/modelSelection.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * Pure model selection logic — no vscode dependency.
- * Testable outside of the VS Code extension host.
- */
-
-/** Inline Result type to avoid importing TaskItem (which depends on vscode). */
-type Result = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: E };
-const ok = (value: T): Result => ({ ok: true, value });
-const err = (error: E): Result => ({ ok: false, error });
-
-/** The "Auto" virtual model ID — not a real endpoint. */
-export const AUTO_MODEL_ID = 'auto';
-
-/** Minimal model reference for selection logic. */
-export interface ModelRef {
- readonly id: string;
- readonly name: string;
-}
-
-/** Dependencies injected into model selection for testability. */
-export interface ModelSelectionDeps {
- readonly getSavedId: () => string;
- readonly fetchById: (id: string) => Promise;
- readonly fetchAll: () => Promise;
- readonly promptUser: (models: readonly ModelRef[]) => Promise;
- readonly saveId: (id: string) => Promise;
-}
-
-/**
- * Resolves a concrete (non-auto) model from a list.
- * When preferredId is "auto", picks the first non-auto model.
- * When preferredId is specific, finds that exact model.
- */
-export function pickConcreteModel(params: {
- readonly models: readonly ModelRef[];
- readonly preferredId: string;
-}): ModelRef | undefined {
- if (params.preferredId === AUTO_MODEL_ID) {
- return params.models.find(m => m.id !== AUTO_MODEL_ID)
- ?? params.models[0];
- }
- return params.models.find(m => m.id === params.preferredId);
-}
-
-/**
- * Pure model selection logic. Uses saved setting if available,
- * otherwise prompts user and persists the choice.
- */
-export async function resolveModel(
- deps: ModelSelectionDeps
-): Promise> {
- const savedId = deps.getSavedId();
-
- if (savedId !== '') {
- const exact = await deps.fetchById(savedId);
- const first = exact[0];
- if (first !== undefined) { return ok(first); }
- }
-
- const allModels = await deps.fetchAll();
- if (allModels.length === 0) { return err('No Copilot model available after retries'); }
-
- const picked = await deps.promptUser(allModels);
- if (picked === undefined) { return err('Model selection cancelled'); }
-
- await deps.saveId(picked.id);
- return ok(picked);
-}
diff --git a/src/semantic/similarity.ts b/src/semantic/similarity.ts
deleted file mode 100644
index 954735a..0000000
--- a/src/semantic/similarity.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-/**
- * Pure vector math for semantic similarity search.
- * No VS Code dependencies — testable in isolation.
- */
-
-export interface ScoredCandidate {
- readonly id: string;
- readonly score: number;
-}
-
-interface RankParams {
- readonly query: Float32Array;
- readonly candidates: ReadonlyArray<{ readonly id: string; readonly embedding: Float32Array | null }>;
- readonly topK: number;
- readonly threshold: number;
-}
-
-/**
- * Computes cosine similarity between two vectors.
- * Returns 0 for zero-magnitude vectors.
- */
-export function cosineSimilarity(a: Float32Array, b: Float32Array): number {
- let dot = 0;
- let magA = 0;
- let magB = 0;
- for (let i = 0; i < a.length; i++) {
- dot += (a[i] ?? 0) * (b[i] ?? 0);
- magA += (a[i] ?? 0) * (a[i] ?? 0);
- magB += (b[i] ?? 0) * (b[i] ?? 0);
- }
- const denom = Math.sqrt(magA) * Math.sqrt(magB);
- return denom === 0 ? 0 : dot / denom;
-}
-
-/**
- * Ranks candidates by cosine similarity to query, filtered and sorted.
- */
-export function rankBySimilarity(params: RankParams): ScoredCandidate[] {
- const scored: ScoredCandidate[] = [];
- for (const c of params.candidates) {
- if (c.embedding === null) { continue; }
- const score = cosineSimilarity(params.query, c.embedding);
- if (score >= params.threshold) {
- scored.push({ id: c.id, score });
- }
- }
- scored.sort((a, b) => b.score - a.score);
- return scored.slice(0, params.topK);
-}
diff --git a/src/semantic/store.ts b/src/semantic/store.ts
deleted file mode 100644
index c31a6da..0000000
--- a/src/semantic/store.ts
+++ /dev/null
@@ -1,171 +0,0 @@
-import * as fs from 'fs/promises';
-import * as path from 'path';
-import * as crypto from 'crypto';
-import type { Result } from '../models/Result.js';
-import { ok, err } from '../models/Result.js';
-
-/**
- * Summary record for a single discovered command.
- */
-export interface SummaryRecord {
- readonly commandId: string;
- readonly contentHash: string;
- readonly summary: string;
- readonly lastUpdated: string;
-}
-
-/**
- * Full summary store data structure.
- */
-export interface SummaryStoreData {
- readonly records: Readonly>;
-}
-
-const STORE_FILENAME = 'commandtree-summaries.json';
-
-/**
- * Computes a content hash for change detection.
- */
-export function computeContentHash(content: string): string {
- return crypto
- .createHash('sha256')
- .update(content)
- .digest('hex')
- .substring(0, 16);
-}
-
-/**
- * Checks whether a record needs re-summarisation.
- */
-export function needsUpdate(
- record: SummaryRecord | undefined,
- currentHash: string
-): boolean {
- return record?.contentHash !== currentHash;
-}
-
-/**
- * Reads the summary store from disk.
- * NO VS CODE DEPENDENCY - uses Node.js fs for unit testing.
- */
-export async function readSummaryStore(
- workspaceRoot: string
-): Promise> {
- const storePath = path.join(workspaceRoot, '.vscode', STORE_FILENAME);
-
- try {
- const content = await fs.readFile(storePath, 'utf-8');
- const parsed = JSON.parse(content) as SummaryStoreData;
- return ok(parsed);
- } catch {
- return ok({ records: {} });
- }
-}
-
-/**
- * Writes the summary store to disk.
- * NO VS CODE DEPENDENCY - uses Node.js fs for unit testing.
- */
-export async function writeSummaryStore(
- workspaceRoot: string,
- data: SummaryStoreData
-): Promise> {
- const storePath = path.join(workspaceRoot, '.vscode', STORE_FILENAME);
- const content = JSON.stringify(data, null, 2);
-
- try {
- const dir = path.dirname(storePath);
- await fs.mkdir(dir, { recursive: true });
- await fs.writeFile(storePath, content, 'utf-8');
- return ok(undefined);
- } catch (e) {
- const message = e instanceof Error ? e.message : 'Failed to write summary store';
- return err(message);
- }
-}
-
-/**
- * Creates a new store with an updated record.
- */
-export function upsertRecord(
- store: SummaryStoreData,
- record: SummaryRecord
-): SummaryStoreData {
- return {
- records: {
- ...store.records,
- [record.commandId]: record
- }
- };
-}
-
-/**
- * Looks up a record by command ID.
- */
-export function getRecord(
- store: SummaryStoreData,
- commandId: string
-): SummaryRecord | undefined {
- return store.records[commandId];
-}
-
-/**
- * Gets all records as an array.
- */
-export function getAllRecords(store: SummaryStoreData): SummaryRecord[] {
- return Object.values(store.records);
-}
-
-/**
- * Reads the legacy JSON store for migration to SQLite.
- * Returns empty array if the file does not exist.
- * NO VS CODE DEPENDENCY - uses Node.js fs for unit testing.
- */
-export async function readLegacyJsonStore(
- workspaceRoot: string
-): Promise {
- const storePath = path.join(workspaceRoot, '.vscode', STORE_FILENAME);
-
- try {
- const content = await fs.readFile(storePath, 'utf-8');
- const parsed = JSON.parse(content) as SummaryStoreData;
- return Object.values(parsed.records);
- } catch {
- return [];
- }
-}
-
-/**
- * Deletes the legacy JSON store after successful migration.
- * NO VS CODE DEPENDENCY - uses Node.js fs for unit testing.
- */
-export async function deleteLegacyJsonStore(
- workspaceRoot: string
-): Promise> {
- const storePath = path.join(workspaceRoot, '.vscode', STORE_FILENAME);
-
- try {
- await fs.unlink(storePath);
- return ok(undefined);
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Failed to delete legacy store';
- return err(msg);
- }
-}
-
-/**
- * Checks whether the legacy JSON store file exists.
- * NO VS CODE DEPENDENCY - uses Node.js fs for unit testing.
- */
-export async function legacyStoreExists(
- workspaceRoot: string
-): Promise {
- const storePath = path.join(workspaceRoot, '.vscode', STORE_FILENAME);
-
- try {
- await fs.access(storePath);
- return true;
- } catch {
- return false;
- }
-}
diff --git a/src/semantic/summariser.ts b/src/semantic/summariser.ts
deleted file mode 100644
index 5339360..0000000
--- a/src/semantic/summariser.ts
+++ /dev/null
@@ -1,256 +0,0 @@
-/**
- * SPEC: ai-summary-generation
- *
- * GitHub Copilot integration for generating command summaries.
- * Uses VS Code Language Model Tool API for structured output (summary + security warning).
- */
-import * as vscode from 'vscode';
-import type { Result } from '../models/TaskItem';
-import { ok, err } from '../models/TaskItem';
-import { logger } from '../utils/logger';
-import { resolveModel } from './modelSelection';
-import type { ModelSelectionDeps, ModelRef } from './modelSelection';
-export type { ModelRef, ModelSelectionDeps } from './modelSelection';
-export { resolveModel, AUTO_MODEL_ID } from './modelSelection';
-
-const MAX_CONTENT_LENGTH = 4000;
-const MODEL_RETRY_COUNT = 10;
-const MODEL_RETRY_DELAY_MS = 2000;
-
-const TOOL_NAME = 'report_command_analysis';
-
-export interface SummaryResult {
- readonly summary: string;
- readonly securityWarning: string;
-}
-
-const ANALYSIS_TOOL: vscode.LanguageModelChatTool = {
- name: TOOL_NAME,
- description: 'Report the analysis of a command including summary and any security warnings',
- inputSchema: {
- type: 'object',
- properties: {
- summary: {
- type: 'string',
- description: 'Plain-language summary of the command in 1-2 sentences'
- },
- securityWarning: {
- type: 'string',
- description: 'Security warning if the command has risks (deletes files, writes credentials, modifies system config, runs untrusted code). Empty string if no risks.'
- }
- },
- required: ['summary', 'securityWarning']
- }
-};
-
-/**
- * Waits for a delay (used for retry backoff).
- */
-async function delay(ms: number): Promise {
- await new Promise(resolve => { setTimeout(resolve, ms); });
-}
-
-/**
- * Fetches Copilot models with retry, optionally filtering by ID.
- */
-async function fetchModels(
- selector: vscode.LanguageModelChatSelector
-): Promise {
- for (let attempt = 0; attempt < MODEL_RETRY_COUNT; attempt++) {
- try {
- const models = await vscode.lm.selectChatModels(selector);
- if (models.length > 0) { return models; }
- logger.info('Copilot not ready, retrying', { attempt });
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Unknown';
- logger.warn('Model selection error', { attempt, error: msg });
- }
- if (attempt < MODEL_RETRY_COUNT - 1) { await delay(MODEL_RETRY_DELAY_MS); }
- }
- return [];
-}
-
-/**
- * Formats model metadata for the quickpick detail line.
- */
-function formatModelDetail(m: vscode.LanguageModelChat): string {
- const tokens = `${Math.round(m.maxInputTokens / 1000)}k tokens`;
- const parts = [m.family, m.version, tokens].filter(p => p !== '');
- return parts.join(' · ');
-}
-
-/**
- * Shows a quickpick of all available Copilot models with metadata.
- * Returns the chosen model ref, or undefined if cancelled.
- */
-async function promptModelPicker(
- models: readonly vscode.LanguageModelChat[]
-): Promise {
- const items = models.map(m => ({
- label: m.name,
- description: m.id,
- detail: formatModelDetail(m),
- model: m
- }));
- const picked = await vscode.window.showQuickPick(items, {
- placeHolder: 'Select a Copilot model for summarisation',
- title: 'CommandTree: Choose AI Model',
- ignoreFocusOut: true,
- matchOnDetail: true
- });
- return picked?.model;
-}
-
-/**
- * Builds the standard ModelSelectionDeps wired to VS Code APIs.
- */
-function buildVSCodeDeps(): ModelSelectionDeps {
- const config = vscode.workspace.getConfiguration('commandtree');
- return {
- getSavedId: (): string => config.get('aiModel', ''),
- fetchById: async (id: string): Promise => await fetchModels({ vendor: 'copilot', id }),
- fetchAll: async (): Promise => await fetchModels({ vendor: 'copilot' }),
- promptUser: async (): Promise => {
- const all = await fetchModels({ vendor: 'copilot' });
- const picked = await promptModelPicker(all);
- return picked !== undefined ? { id: picked.id, name: picked.name } : undefined;
- },
- saveId: async (id: string): Promise => { await config.update('aiModel', id, vscode.ConfigurationTarget.Global); }
- };
-}
-
-/**
- * Selects the configured model by ID, or prompts the user to pick one.
- * When "auto" is selected, uses the Copilot auto model directly.
- */
-export async function selectCopilotModel(): Promise> {
- const result = await resolveModel(buildVSCodeDeps());
- if (!result.ok) { return result; }
-
- const allModels = await fetchModels({ vendor: 'copilot' });
- if (allModels.length === 0) { return err('No Copilot models available'); }
-
- const model = allModels.find(m => m.id === result.value.id);
- if (!model) { return err('Selected model no longer available'); }
-
- logger.info('Resolved model for requests', { selected: result.value.id, resolved: model.id });
- return ok(model);
-}
-
-/**
- * Forces the model picker open (ignoring saved setting) and saves the choice.
- * Used by the commandtree.selectModel command.
- */
-export async function forceSelectModel(): Promise> {
- const all = await fetchModels({ vendor: 'copilot' });
- if (all.length === 0) { return err('No Copilot models available'); }
-
- const picked = await promptModelPicker(all);
- if (picked === undefined) { return err('Model selection cancelled'); }
-
- const config = vscode.workspace.getConfiguration('commandtree');
- await config.update('aiModel', picked.id, vscode.ConfigurationTarget.Global);
- logger.info('Model changed via command', { id: picked.id, name: picked.name });
- return ok(picked.name);
-}
-
-/**
- * Extracts the tool call result from the LLM response stream.
- */
-async function extractToolCall(
- response: vscode.LanguageModelChatResponse
-): Promise {
- for await (const part of response.stream) {
- if (part instanceof vscode.LanguageModelToolCallPart) {
- const input = part.input as Record;
- const summary = typeof input['summary'] === 'string' ? input['summary'] : '';
- const warning = typeof input['securityWarning'] === 'string' ? input['securityWarning'] : '';
- return { summary, securityWarning: warning };
- }
- }
- return null;
-}
-
-/**
- * Sends a chat request with tool calling to get structured output.
- */
-async function sendToolRequest(
- model: vscode.LanguageModelChat,
- prompt: string
-): Promise> {
- try {
- logger.info('sendRequest using model', { id: model.id, name: model.name });
- const messages = [vscode.LanguageModelChatMessage.User(prompt)];
- const options: vscode.LanguageModelChatRequestOptions = {
- tools: [ANALYSIS_TOOL],
- toolMode: vscode.LanguageModelChatToolMode.Required
- };
- const response = await model.sendRequest(messages, options, new vscode.CancellationTokenSource().token);
- const result = await extractToolCall(response);
- if (result === null) { return err('No tool call in LLM response'); }
- return ok(result);
- } catch (e) {
- const message = e instanceof Error ? e.message : 'LLM request failed';
- return err(message);
- }
-}
-
-/**
- * Builds the prompt for script summarisation.
- */
-function buildSummaryPrompt(params: {
- readonly type: string;
- readonly label: string;
- readonly command: string;
- readonly content: string;
-}): string {
- const truncated = params.content.length > MAX_CONTENT_LENGTH
- ? params.content.substring(0, MAX_CONTENT_LENGTH)
- : params.content;
-
- return [
- `Analyse this ${params.type} command. Provide a plain-language summary (1-2 sentences).`,
- `If the command has security risks (writes credentials, deletes files, modifies system config, runs untrusted code, etc.), describe the risk. Otherwise leave securityWarning empty.`,
- `Name: ${params.label}`,
- `Command: ${params.command}`,
- '',
- 'Script content:',
- truncated
- ].join('\n');
-}
-
-/**
- * Generates a structured summary for a script via Copilot tool calling.
- */
-export async function summariseScript(params: {
- readonly model: vscode.LanguageModelChat;
- readonly label: string;
- readonly type: string;
- readonly command: string;
- readonly content: string;
-}): Promise> {
- const prompt = buildSummaryPrompt(params);
- const result = await sendToolRequest(params.model, prompt);
-
- if (!result.ok) {
- logger.error('Summarisation failed', { label: params.label, error: result.error });
- return result;
- }
- if (result.value.summary === '') {
- return err('Empty summary returned');
- }
-
- logger.info('Generated summary', {
- label: params.label,
- summary: result.value.summary,
- hasWarning: result.value.securityWarning !== ''
- });
- return result;
-}
-
-/**
- * NO FALLBACK SUMMARIES.
- * Every summary MUST come from a real LLM (Copilot).
- * Fake metadata strings let tests pass without exercising the real pipeline.
- * If Copilot is unavailable, summarisation MUST fail — not silently degrade.
- */
diff --git a/src/semantic/summaryPipeline.ts b/src/semantic/summaryPipeline.ts
deleted file mode 100644
index 5421d0d..0000000
--- a/src/semantic/summaryPipeline.ts
+++ /dev/null
@@ -1,208 +0,0 @@
-/**
- * SPEC: ai-summary-generation
- *
- * Summary pipeline: generates Copilot summaries and stores them in SQLite.
- * COMPLETELY DECOUPLED from embedding generation.
- * Does NOT import embedder, similarity, or embeddingPipeline.
- */
-
-import type * as vscode from 'vscode';
-import type { TaskItem, Result } from '../models/TaskItem';
-import { ok, err } from '../models/TaskItem';
-import { logger } from '../utils/logger';
-import { computeContentHash } from './store';
-import type { FileSystemAdapter } from './adapters';
-import type { SummaryResult } from './summariser';
-import { selectCopilotModel, summariseScript } from './summariser';
-import { initDb } from './lifecycle';
-import { upsertSummary, getRow, registerCommand } from './db';
-import type { DbHandle } from './db';
-
-const MAX_CONSECUTIVE_FAILURES = 3;
-
-interface PendingItem {
- readonly task: TaskItem;
- readonly content: string;
- readonly hash: string;
-}
-
-/**
- * Reads script content for a task using the provided file system adapter.
- */
-async function readTaskContent(params: {
- readonly task: TaskItem;
- readonly fs: FileSystemAdapter;
-}): Promise {
- const result = await params.fs.readFile(params.task.filePath);
- return result.ok ? result.value : params.task.command;
-}
-
-/**
- * Finds tasks that need a new or updated summary.
- */
-async function findPendingSummaries(params: {
- readonly handle: DbHandle;
- readonly tasks: readonly TaskItem[];
- readonly fs: FileSystemAdapter;
-}): Promise {
- const pending: PendingItem[] = [];
- for (const task of params.tasks) {
- const content = await readTaskContent({ task, fs: params.fs });
- const hash = computeContentHash(content);
- const existing = getRow({ handle: params.handle, commandId: task.id });
- const needsSummary = !existing.ok
- || existing.value === undefined
- || existing.value.summary === ''
- || existing.value.contentHash !== hash;
- if (needsSummary) {
- pending.push({ task, content, hash });
- }
- }
- return pending;
-}
-
-/**
- * Gets a summary for a task via Copilot.
- * NO FALLBACK. If Copilot is unavailable, returns null.
- */
-async function getSummary(params: {
- readonly model: vscode.LanguageModelChat;
- readonly task: TaskItem;
- readonly content: string;
-}): Promise {
- const result = await summariseScript({
- model: params.model,
- label: params.task.label,
- type: params.task.type,
- command: params.task.command,
- content: params.content
- });
- return result.ok ? result.value : null;
-}
-
-/**
- * Summarises a single task and stores the summary in SQLite.
- * Does NOT generate embeddings.
- */
-async function processOneSummary(params: {
- readonly model: vscode.LanguageModelChat;
- readonly task: TaskItem;
- readonly content: string;
- readonly hash: string;
- readonly handle: DbHandle;
-}): Promise> {
- const result = await getSummary(params);
- if (result === null) { return err('Copilot summary failed'); }
-
- const warning = result.securityWarning === '' ? null : result.securityWarning;
- return upsertSummary({
- handle: params.handle,
- commandId: params.task.id,
- contentHash: params.hash,
- summary: result.summary,
- securityWarning: warning
- });
-}
-
-/**
- * Registers all discovered commands in SQLite with their content hashes.
- * Does NOT require Copilot. Preserves existing summaries.
- */
-export async function registerAllCommands(params: {
- readonly tasks: readonly TaskItem[];
- readonly workspaceRoot: string;
- readonly fs: FileSystemAdapter;
-}): Promise> {
- const dbInit = await initDb(params.workspaceRoot);
- if (!dbInit.ok) { return err(dbInit.error); }
-
- let registered = 0;
- for (const task of params.tasks) {
- const content = await readTaskContent({ task, fs: params.fs });
- const hash = computeContentHash(content);
- const result = registerCommand({
- handle: dbInit.value,
- commandId: task.id,
- contentHash: hash,
- });
- if (result.ok) { registered++; }
- }
- logger.info('[REGISTER] Commands registered in DB', { registered });
- return ok(registered);
-}
-
-/**
- * Summarises all tasks that are new or have changed content.
- * Stores summaries in SQLite. Does NOT touch embeddings.
- * Commands are registered in DB BEFORE Copilot is contacted.
- */
-export async function summariseAllTasks(params: {
- readonly tasks: readonly TaskItem[];
- readonly workspaceRoot: string;
- readonly fs: FileSystemAdapter;
- readonly onProgress?: (done: number, total: number) => void;
-}): Promise> {
- logger.info('[SUMMARY] summariseAllTasks START', {
- taskCount: params.tasks.length,
- });
-
- // Step 1: Always register commands in DB (independent of Copilot)
- const regResult = await registerAllCommands(params);
- if (!regResult.ok) {
- logger.error('[SUMMARY] registerAllCommands failed', { error: regResult.error });
- return err(regResult.error);
- }
-
- // Step 2: Try Copilot — if unavailable, commands are still in DB
- const modelResult = await selectCopilotModel();
- if (!modelResult.ok) {
- logger.error('[SUMMARY] Copilot model selection failed', { error: modelResult.error });
- return err(modelResult.error);
- }
-
- const dbInit = await initDb(params.workspaceRoot);
- if (!dbInit.ok) { return err(dbInit.error); }
-
- const pending = await findPendingSummaries({
- handle: dbInit.value,
- tasks: params.tasks,
- fs: params.fs
- });
- logger.info('[SUMMARY] findPendingSummaries complete', { pendingCount: pending.length });
-
- if (pending.length === 0) {
- logger.info('[SUMMARY] All summaries up to date');
- return ok(0);
- }
-
- let succeeded = 0;
- let failed = 0;
-
- for (const item of pending) {
- const result = await processOneSummary({
- model: modelResult.value,
- task: item.task,
- content: item.content,
- hash: item.hash,
- handle: dbInit.value
- });
- if (result.ok) {
- succeeded++;
- } else {
- failed++;
- logger.error('[SUMMARY] Task failed', { id: item.task.id, error: result.error });
- if (failed >= MAX_CONSECUTIVE_FAILURES) {
- logger.error('[SUMMARY] Too many failures, aborting', { failed });
- break;
- }
- }
- params.onProgress?.(succeeded + failed, pending.length);
- }
-
- logger.info('[SUMMARY] complete', { succeeded, failed });
-
- if (succeeded === 0 && failed > 0) {
- return err(`All ${failed} tasks failed to summarise`);
- }
- return ok(succeeded);
-}
diff --git a/src/semantic/types.ts b/src/semantic/types.ts
deleted file mode 100644
index 1b5afc6..0000000
--- a/src/semantic/types.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-/**
- * Re-exports the canonical types used across the semantic search feature.
- * Other modules in src/semantic/ define their own specific interfaces;
- * this file provides shared type aliases and any cross-cutting types.
- */
-
-export type { SummaryRecord, SummaryStoreData } from './store';
diff --git a/src/semantic/vscodeAdapters.ts b/src/semantic/vscodeAdapters.ts
deleted file mode 100644
index 54644a2..0000000
--- a/src/semantic/vscodeAdapters.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-/**
- * VS Code adapter implementations for production use.
- * These wrap VS Code APIs to match the adapter interfaces.
- */
-
-import * as vscode from 'vscode';
-import type { FileSystemAdapter, ConfigAdapter, LanguageModelAdapter, SummaryAdapterResult } from './adapters';
-import type { Result } from '../models/Result';
-import { ok, err } from '../models/Result';
-
-/**
- * Creates a VS Code-based file system adapter for production use.
- */
-export function createVSCodeFileSystem(): FileSystemAdapter {
- return {
- readFile: async (filePath: string): Promise> => {
- try {
- const uri = vscode.Uri.file(filePath);
- const bytes = await vscode.workspace.fs.readFile(uri);
- const content = new TextDecoder().decode(bytes);
- return ok(content);
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Read failed';
- return err(msg);
- }
- },
-
- writeFile: async (filePath: string, content: string): Promise> => {
- try {
- const uri = vscode.Uri.file(filePath);
- const bytes = new TextEncoder().encode(content);
- await vscode.workspace.fs.writeFile(uri, bytes);
- return ok(undefined);
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Write failed';
- return err(msg);
- }
- },
-
- exists: async (filePath: string): Promise => {
- try {
- const uri = vscode.Uri.file(filePath);
- await vscode.workspace.fs.stat(uri);
- return true;
- } catch {
- return false;
- }
- },
-
- delete: async (filePath: string): Promise> => {
- try {
- const uri = vscode.Uri.file(filePath);
- await vscode.workspace.fs.delete(uri);
- return ok(undefined);
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Delete failed';
- return err(msg);
- }
- }
- };
-}
-
-/**
- * Creates a VS Code configuration adapter for production use.
- */
-export function createVSCodeConfig(): ConfigAdapter {
- return {
- get: (key: string, defaultValue: T): T => {
- return vscode.workspace.getConfiguration().get(key, defaultValue);
- }
- };
-}
-
-/**
- * Creates a Copilot language model adapter for production use.
- * Wraps the VS Code Language Model API for summarisation.
- */
-export function createCopilotLM(): LanguageModelAdapter {
- return {
- summarise: async (params): Promise> => {
- try {
- // Import summariser functions
- const { selectCopilotModel, summariseScript } = await import('./summariser.js');
-
- // Select model
- const modelResult = await selectCopilotModel();
- if (!modelResult.ok) {
- return err(modelResult.error);
- }
-
- // Generate summary with structured tool output
- return await summariseScript({
- model: modelResult.value,
- label: params.label,
- type: params.type,
- command: params.command,
- content: params.content
- });
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Summarisation failed';
- return err(msg);
- }
- }
- };
-}
diff --git a/src/test/e2e/commands.e2e.test.ts b/src/test/e2e/commands.e2e.test.ts
index 6864e53..84a201c 100644
--- a/src/test/e2e/commands.e2e.test.ts
+++ b/src/test/e2e/commands.e2e.test.ts
@@ -134,7 +134,6 @@ suite("Commands and UI E2E Tests", () => {
"commandtree.run",
"commandtree.filterByTag",
"commandtree.clearFilter",
- "commandtree.semanticSearch",
];
for (const cmd of expectedCommands) {
@@ -204,17 +203,13 @@ suite("Commands and UI E2E Tests", () => {
(m) => m.when?.includes("view == commandtree") === true,
);
- assert.ok(taskTreeMenus.length >= 4, "Should have at least 4 menu items");
+ assert.ok(taskTreeMenus.length >= 3, "Should have at least 3 menu items");
const commands = taskTreeMenus.map((m) => m.command);
assert.ok(
commands.includes("commandtree.filterByTag"),
"Should have filterByTag in menu",
);
- assert.ok(
- commands.includes("commandtree.semanticSearch"),
- "Should have semanticSearch in menu",
- );
assert.ok(
commands.includes("commandtree.clearFilter"),
"Should have clearFilter in menu",
@@ -330,7 +325,7 @@ suite("Commands and UI E2E Tests", () => {
);
});
- test("commandtree view has exactly 4 title bar icons", function () {
+ test("commandtree view has exactly 3 title bar icons", function () {
this.timeout(10000);
const packageJson = readPackageJson();
@@ -344,14 +339,13 @@ suite("Commands and UI E2E Tests", () => {
assert.strictEqual(
taskTreeMenus.length,
- 4,
- `Expected exactly 4 view/title items for commandtree, got ${taskTreeMenus.length}: ${taskTreeMenus.map((m) => m.command).join(", ")}`,
+ 3,
+ `Expected exactly 3 view/title items for commandtree, got ${taskTreeMenus.length}: ${taskTreeMenus.map((m) => m.command).join(", ")}`,
);
const expectedCommands = [
"commandtree.filterByTag",
"commandtree.clearFilter",
- "commandtree.semanticSearch",
"commandtree.refresh",
];
for (const cmd of expectedCommands) {
@@ -410,12 +404,6 @@ suite("Commands and UI E2E Tests", () => {
const runCmd = commands.find((c) => c.command === "commandtree.run");
assert.ok(runCmd?.icon === "$(play)", "Run should have play icon");
- const semanticSearchCmd = commands.find((c) => c.command === "commandtree.semanticSearch");
- assert.ok(
- semanticSearchCmd?.icon === "$(search)",
- "SemanticSearch should have search icon",
- );
-
const tagFilterCmd = commands.find(
(c) => c.command === "commandtree.filterByTag",
);
diff --git a/src/test/e2e/copilot.e2e.test.ts b/src/test/e2e/copilot.e2e.test.ts
deleted file mode 100644
index 2e72c5a..0000000
--- a/src/test/e2e/copilot.e2e.test.ts
+++ /dev/null
@@ -1,200 +0,0 @@
-/**
- * SPEC: ai-summary-generation
- *
- * COPILOT LANGUAGE MODEL API — REAL E2E TEST
- *
- * This test ACTUALLY hits the VS Code Language Model API.
- * It selects a Copilot model, sends a real prompt, and verifies
- * a real streamed response comes back.
- *
- * These tests require GitHub Copilot to be authenticated and available.
- * In CI/automated environments without Copilot, the suite is skipped.
- * To run manually: authenticate Copilot, accept consent dialog when prompted.
- */
-
-import * as assert from "assert";
-import * as vscode from "vscode";
-import { activateExtension, sleep } from "../helpers/helpers";
-
-const MODEL_WAIT_MS = 2000;
-const MODEL_MAX_ATTEMPTS = 30;
-const COPILOT_VENDOR = "copilot";
-
-// Copilot tests disabled — skip until re-enabled
-suite.skip("Copilot Language Model API E2E", () => {
- let copilotAvailable = false;
-
- suiteSetup(async function () {
- this.timeout(120000);
- await activateExtension();
- await sleep(3000);
-
- // Check if Copilot is available (authenticated + consent granted)
- for (let i = 0; i < MODEL_MAX_ATTEMPTS; i++) {
- const models = await vscode.lm.selectChatModels({ vendor: COPILOT_VENDOR });
- if (models.length > 0) {
- // Try to actually use the model to confirm we have permission
- try {
- const testModel = models[0];
- if (testModel === undefined) { continue; }
- const testResponse = await testModel.sendRequest(
- [vscode.LanguageModelChatMessage.User("test")],
- {},
- new vscode.CancellationTokenSource().token
- );
- // Consume response to verify it's actually usable
- const chunks: string[] = [];
- for await (const chunk of testResponse.text) {
- chunks.push(chunk);
- }
- if (chunks.length === 0) { continue; }
- copilotAvailable = true;
- break;
- } catch (e) {
- // Permission denied or authentication failed
- if (e instanceof vscode.LanguageModelError && e.message.includes("cannot be used")) {
- break; // No point retrying permission errors
- }
- }
- }
- await sleep(MODEL_WAIT_MS);
- }
-
- if (!copilotAvailable) {
- this.skip();
- }
- });
-
- test("selectChatModels returns at least one Copilot model", async function () {
- this.timeout(120000);
-
- let model: vscode.LanguageModelChat | null = null;
- for (let i = 0; i < MODEL_MAX_ATTEMPTS; i++) {
- const models = await vscode.lm.selectChatModels({
- vendor: COPILOT_VENDOR,
- });
- if (models.length > 0) {
- model = models[0] ?? null;
- break;
- }
- await sleep(MODEL_WAIT_MS);
- }
-
- assert.ok(
- model !== null,
- "selectChatModels must return a Copilot model — accept the consent dialog!",
- );
- assert.ok(typeof model.id === "string" && model.id.length > 0, "Model must have an id");
- assert.ok(typeof model.name === "string" && model.name.length > 0, "Model must have a name");
- assert.ok(model.maxInputTokens > 0, "Model must report maxInputTokens > 0");
- });
-
- test("sendRequest returns a streamed response from Copilot", async function () {
- this.timeout(120000);
-
- // Get all available models
- const allModels = await vscode.lm.selectChatModels({ vendor: COPILOT_VENDOR });
- assert.ok(allModels.length > 0, "No Copilot models available");
-
- // Try each model until we find one that works
- let lastError: Error | undefined;
- let successfulResponse: vscode.LanguageModelChatResponse | undefined;
-
- for (const model of allModels) {
- const messages = [
- vscode.LanguageModelChatMessage.User("Reply with exactly: HELLO_COMMANDTREE"),
- ];
- const tokenSource = new vscode.CancellationTokenSource();
-
- try {
- const response = await model.sendRequest(messages, {}, tokenSource.token);
- successfulResponse = response;
- tokenSource.dispose();
- break;
- } catch (e) {
- lastError = e as Error;
- tokenSource.dispose();
- continue;
- }
- }
-
- assert.ok(
- successfulResponse !== undefined,
- `No usable model found. Last error: ${lastError?.message}`,
- );
-
- assert.ok(
- typeof successfulResponse.text[Symbol.asyncIterator] === "function",
- "Response.text must be async iterable",
- );
-
- // Collect the streamed text
- const chunks: string[] = [];
- for await (const chunk of successfulResponse.text) {
- assert.ok(typeof chunk === "string", `Each chunk must be a string, got ${typeof chunk}`);
- chunks.push(chunk);
- }
- const fullResponse = chunks.join("").trim();
-
- assert.ok(chunks.length > 0, "Must receive at least one chunk from stream");
-
- assert.ok(fullResponse.length > 0, "Response must not be empty");
- assert.ok(
- fullResponse.includes("HELLO_COMMANDTREE"),
- `Response should contain HELLO_COMMANDTREE, got: "${fullResponse}"`,
- );
- });
-
- test("LanguageModelError is thrown for invalid requests", async function () {
- this.timeout(120000);
-
- // Get all available models and find one that works
- const allModels = await vscode.lm.selectChatModels({ vendor: COPILOT_VENDOR });
- assert.ok(allModels.length > 0, "No Copilot models available");
-
- let usableModel: vscode.LanguageModelChat | undefined;
- for (const model of allModels) {
- const testToken = new vscode.CancellationTokenSource();
- try {
- await model.sendRequest(
- [vscode.LanguageModelChatMessage.User("test")],
- {},
- testToken.token,
- );
- usableModel = model;
- testToken.dispose();
- break;
- } catch (e) {
- testToken.dispose();
- if (e instanceof vscode.LanguageModelError && e.message.includes("cannot be used")) {
- continue;
- }
- usableModel = model;
- break;
- }
- }
-
- assert.ok(usableModel !== undefined, "No usable Copilot model found");
-
- // Send with an already-cancelled token to trigger an error
- const tokenSource = new vscode.CancellationTokenSource();
- tokenSource.cancel();
-
- try {
- await usableModel.sendRequest(
- [vscode.LanguageModelChatMessage.User("test")],
- {},
- tokenSource.token,
- );
- // If we get here, cancellation didn't throw — that's also valid behaviour
- } catch (e) {
- // Verify it's the correct error type from the API
- assert.ok(
- e instanceof vscode.LanguageModelError || e instanceof vscode.CancellationError,
- `Expected LanguageModelError or CancellationError, got: ${String(e)}`,
- );
- }
-
- tokenSource.dispose();
- });
-});
diff --git a/src/test/e2e/markdown.e2e.test.ts b/src/test/e2e/markdown.e2e.test.ts
index 08740f8..bbe32d1 100644
--- a/src/test/e2e/markdown.e2e.test.ts
+++ b/src/test/e2e/markdown.e2e.test.ts
@@ -198,6 +198,16 @@ suite("Markdown Discovery and Preview E2E Tests", () => {
finalEditorCount >= initialEditorCount,
"Running markdown item should open preview"
);
+
+ // Verify markdown uses preview, not terminal (exercises TaskRunner.runMarkdownPreview routing)
+ const markdownTerminals = vscode.window.terminals.filter(t =>
+ t.name.includes("guide.md")
+ );
+ assert.strictEqual(
+ markdownTerminals.length,
+ 0,
+ "Markdown preview should NOT create a terminal"
+ );
});
});
diff --git a/src/test/e2e/quicktasks.e2e.test.ts b/src/test/e2e/quicktasks.e2e.test.ts
index bddfe6b..e644c24 100644
--- a/src/test/e2e/quicktasks.e2e.test.ts
+++ b/src/test/e2e/quicktasks.e2e.test.ts
@@ -12,10 +12,12 @@ import {
activateExtension,
sleep,
getCommandTreeProvider,
+ getQuickTasksProvider,
+ getLabelString,
} from "../helpers/helpers";
-import type { CommandTreeProvider } from "../helpers/helpers";
-import { getDb } from "../../semantic/lifecycle";
-import { getCommandIdsByTag, getTagsForCommand } from "../../semantic/db";
+import type { CommandTreeProvider, QuickTasksProvider } from "../helpers/helpers";
+import { getDb } from "../../db/lifecycle";
+import { getCommandIdsByTag, getTagsForCommand } from "../../db/db";
import { CommandTreeItem } from "../../models/TaskItem";
const QUICK_TAG = "quick";
@@ -23,11 +25,13 @@ const QUICK_TAG = "quick";
// SPEC: quick-launch
suite("Quick Launch E2E Tests (SQLite Junction Table)", () => {
let treeProvider: CommandTreeProvider;
+ let quickProvider: QuickTasksProvider;
suiteSetup(async function () {
this.timeout(30000);
await activateExtension();
treeProvider = getCommandTreeProvider();
+ quickProvider = getQuickTasksProvider();
await sleep(2000);
});
@@ -90,6 +94,16 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => {
`Task ${task.id} should have 'quick' tag in database`
);
+ // Verify the Quick Launch tree view shows the task
+ const quickItems = quickProvider.getChildren();
+ assert.ok(quickItems.length > 0, "Quick tasks view should have items after add");
+ const hasTask = quickItems.some(qi => qi.task?.id === task.id);
+ assert.ok(hasTask, "Quick tasks view should include the added task");
+ const firstItem = quickItems[0];
+ assert.ok(firstItem !== undefined, "First quick item must exist");
+ const treeItem = quickProvider.getTreeItem(firstItem);
+ assert.ok(treeItem.label !== undefined, "getTreeItem should return a TreeItem with a label");
+
// Clean up
const removeItem = new CommandTreeItem(task, null, []);
await vscode.commands.executeCommand("commandtree.removeFromQuick", removeItem);
@@ -136,6 +150,11 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => {
!tagsResult.value.includes(QUICK_TAG),
`Task ${task.id} should NOT have 'quick' tag after removal`
);
+
+ // Verify tree view no longer shows the task
+ const quickItemsAfterRemoval = quickProvider.getChildren();
+ const hasRemovedTask = quickItemsAfterRemoval.some(item => item.task?.id === task.id);
+ assert.ok(!hasRemovedTask, "Quick tasks view should NOT include removed task");
});
test("E2E: Quick commands ordered by display_order", async function () {
@@ -186,6 +205,16 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => {
"Tasks should be ordered by insertion order via display_order column"
);
+ // Verify tree view reflects correct ordering
+ const quickItems = quickProvider.getChildren();
+ const taskItems = quickItems.filter(item => item.task !== null);
+ assert.ok(taskItems.length >= 3, "Should show at least 3 quick tasks in tree");
+ const viewItem0 = taskItems[0];
+ const viewItem1 = taskItems[1];
+ assert.ok(viewItem0 !== undefined && viewItem1 !== undefined, "View items must exist");
+ assert.strictEqual(viewItem0.task?.id, task1.id, "First view item should match first added task");
+ assert.strictEqual(viewItem1.task?.id, task2.id, "Second view item should match second added task");
+
// Clean up
const removeItem1 = new CommandTreeItem(task1, null, []);
const removeItem2 = new CommandTreeItem(task2, null, []);
@@ -286,6 +315,21 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => {
}
}
+ // Verify TagConfig.getOrderedCommandIds and reorderCommands
+ const { TagConfig } = await import("../../config/TagConfig.js");
+ const tagConfig = new TagConfig();
+ tagConfig.load();
+ const configOrderedIds = tagConfig.getOrderedCommandIds(QUICK_TAG);
+ assert.ok(configOrderedIds.length >= 3, "getOrderedCommandIds should return at least 3 IDs");
+ const reversed = [...configOrderedIds].reverse();
+ const reorderResult = tagConfig.reorderCommands(QUICK_TAG, reversed);
+ assert.ok(reorderResult.ok, "reorderCommands should succeed");
+ const newOrderedIds = tagConfig.getOrderedCommandIds(QUICK_TAG);
+ const firstReversed = reversed[0];
+ const lastReversed = reversed[reversed.length - 1];
+ assert.ok(firstReversed !== undefined && lastReversed !== undefined, "Reversed IDs must exist");
+ assert.strictEqual(newOrderedIds[0], firstReversed, "First ID should match reversed order");
+
// Clean up
for (const task of tasks) {
const removeItem = new CommandTreeItem(task, null, []);
@@ -294,4 +338,22 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => {
await sleep(500);
});
});
+
+ // SPEC: quick-launch
+ suite("Quick Launch Tree View", () => {
+ test("Quick tasks view shows placeholder when empty", function () {
+ this.timeout(10000);
+ const items = quickProvider.getChildren();
+ if (items.length === 1 && items[0]?.task === null) {
+ const label = getLabelString(items[0].label);
+ assert.ok(
+ label.includes("No quick commands"),
+ "Placeholder should mention no quick commands"
+ );
+ }
+ for (const item of items) {
+ assert.ok(item.label !== undefined, "All items should have a label");
+ }
+ });
+ });
});
diff --git a/src/test/e2e/semantic.e2e.test.ts b/src/test/e2e/semantic.e2e.test.ts
deleted file mode 100644
index f8363ee..0000000
--- a/src/test/e2e/semantic.e2e.test.ts
+++ /dev/null
@@ -1,605 +0,0 @@
-/* eslint-disable no-console */
-/**
- * SPEC: ai-semantic-search, ai-embedding-generation, ai-search-implementation, database-schema
- *
- * VECTOR EMBEDDING SEARCH — E2E TESTS
- * Pipeline: Copilot summary → MiniLM embedding → SQLite BLOB → cosine similarity
- * These tests FAIL without Copilot + HuggingFace — that is correct.
- */
-
-import * as assert from "assert";
-import * as vscode from "vscode";
-import * as fs from "fs";
-import * as path from "path";
-import {
- activateExtension,
- sleep,
- getFixturePath,
- getCommandTreeProvider,
- collectLeafItems,
- collectLeafTasks,
- getLabelString,
-} from "../helpers/helpers";
-import type { CommandTreeProvider } from "../helpers/helpers";
-
-const COMMANDTREE_DIR = ".commandtree";
-const DB_FILENAME = "commandtree.sqlite3";
-const MINILM_EMBEDDING_DIM = 384;
-const EMBEDDING_BLOB_BYTES = MINILM_EMBEDDING_DIM * 4;
-const SEARCH_SETTLE_MS = 2000;
-const SHORT_SETTLE_MS = 1000;
-const INPUT_BOX_RENDER_MS = 1000;
-const COPILOT_VENDOR = "copilot";
-const COPILOT_WAIT_MS = 2000;
-const COPILOT_MAX_ATTEMPTS = 30;
-
-type SqlRow = Record;
-
-/**
- * Opens the SQLite DB artifact directly and checks for REAL embedding BLOBs.
- * This is black-box: we inspect the file the extension wrote, not internal APIs.
- *
- * CRITICAL: This exists to catch fraud. If embeddings are null or wrong-size,
- * the "search" was just dumb text matching — not vector proximity.
- */
-async function queryEmbeddingStats(dbPath: string): Promise<{
- readonly rowCount: number;
- readonly embeddedCount: number;
- readonly nullCount: number;
- readonly wrongSizeCount: number;
- readonly sampleBlobLength: number;
-}> {
- const mod = await import("node-sqlite3-wasm");
- const db = new mod.default.Database(dbPath);
- try {
- const total = db.get(
- "SELECT COUNT(*) as cnt FROM commands",
- ) as SqlRow | null;
- const embedded = db.get(
- "SELECT COUNT(*) as cnt FROM commands WHERE embedding IS NOT NULL",
- ) as SqlRow | null;
- const nulls = db.get(
- "SELECT COUNT(*) as cnt FROM commands WHERE embedding IS NULL",
- ) as SqlRow | null;
- const wrongSize = db.get(
- "SELECT COUNT(*) as cnt FROM commands WHERE embedding IS NOT NULL AND LENGTH(embedding) != ?",
- [EMBEDDING_BLOB_BYTES],
- ) as SqlRow | null;
- const sample = db.get(
- "SELECT embedding FROM commands WHERE embedding IS NOT NULL LIMIT 1",
- ) as SqlRow | null;
- return {
- rowCount: Number(total?.["cnt"] ?? 0),
- embeddedCount: Number(embedded?.["cnt"] ?? 0),
- nullCount: Number(nulls?.["cnt"] ?? 0),
- wrongSizeCount: Number(wrongSize?.["cnt"] ?? 0),
- sampleBlobLength:
- (sample?.["embedding"] as Uint8Array | undefined)?.length ?? 0,
- };
- } finally {
- db.close();
- }
-}
-
-// Embedding functionality disabled — skip until re-enabled
-suite.skip("Vector Embedding Search E2E", () => {
- let provider: CommandTreeProvider;
- let totalTaskCount: number;
-
- // SPEC.md **ai-summary-generation** (Copilot requirement), **ai-embedding-generation** (model download)
- suiteSetup(async function () {
- this.timeout(300000); // 5 min — Copilot + model download
-
- // CLEAN SLATE: delete stale DB from previous run BEFORE activation
- const staleDir = getFixturePath(COMMANDTREE_DIR);
- if (fs.existsSync(staleDir)) {
- fs.rmSync(staleDir, { recursive: true, force: true });
- }
-
- await activateExtension();
- provider = getCommandTreeProvider();
- await sleep(3000);
-
- console.log(`[DEBUG] Workspace root: ${vscode.workspace.workspaceFolders?.[0]?.uri.fsPath}`);
-
- totalTaskCount = (await collectLeafTasks(provider)).length;
- assert.ok(
- totalTaskCount > 0,
- "Fixture workspace must have discovered tasks",
- );
-
- // GATE: Wait for Copilot LM API to initialize
- let copilotModels: vscode.LanguageModelChat[] = [];
- for (let i = 0; i < COPILOT_MAX_ATTEMPTS; i++) {
- copilotModels = await vscode.lm.selectChatModels({
- vendor: COPILOT_VENDOR,
- });
- if (copilotModels.length > 0) {
- break;
- }
- if (i === COPILOT_MAX_ATTEMPTS - 1) {
- const allModels = await vscode.lm.selectChatModels();
- const info = allModels.map((m) => `${m.vendor}/${m.name}/${m.id}`);
- assert.fail(
- `GATE FAILED: No Copilot models after ${COPILOT_MAX_ATTEMPTS} attempts (${(COPILOT_MAX_ATTEMPTS * COPILOT_WAIT_MS) / 1000}s). ` +
- `All available models: [${info.join(", ")}].`,
- );
- }
- await sleep(COPILOT_WAIT_MS);
- }
-
- await vscode.workspace
- .getConfiguration("commandtree")
- .update("enableAiSummaries", true, vscode.ConfigurationTarget.Workspace);
- await sleep(SHORT_SETTLE_MS);
-
- console.log(`[DEBUG] Tasks before generateSummaries: ${(await collectLeafTasks(provider)).length}`);
-
- await vscode.commands.executeCommand("commandtree.generateSummaries");
- await sleep(5000);
-
- console.log(`[DEBUG] Tasks after generateSummaries: ${(await collectLeafTasks(provider)).length}`);
-
- // GATE: Verify the pipeline actually produced real embeddings.
- const dbPath = getFixturePath(path.join(COMMANDTREE_DIR, DB_FILENAME));
- console.log(`[DEBUG] Database path: ${dbPath}`);
- console.log(`[DEBUG] Database exists: ${fs.existsSync(dbPath)}`);
-
- assert.ok(
- fs.existsSync(dbPath),
- "GATE FAILED: SQLite DB does not exist after generateSummaries. Pipeline did not fire.",
- );
- const gateStats = await queryEmbeddingStats(dbPath);
- console.log(`[DEBUG] Gate stats: rowCount=${gateStats.rowCount}, embeddedCount=${gateStats.embeddedCount}, nullCount=${gateStats.nullCount}`);
-
- assert.ok(
- gateStats.embeddedCount > 0,
- `GATE FAILED: ${gateStats.embeddedCount}/${gateStats.rowCount} rows have real embedding BLOBs.`,
- );
- });
-
- suiteTeardown(async function () {
- this.timeout(15000);
- await vscode.commands.executeCommand("commandtree.clearFilter");
- await vscode.workspace
- .getConfiguration("commandtree")
- .update("enableAiSummaries", false, vscode.ConfigurationTarget.Workspace);
-
- const dir = getFixturePath(COMMANDTREE_DIR);
- if (fs.existsSync(dir)) {
- fs.rmSync(dir, { recursive: true, force: true });
- }
- });
-
- // SPEC.md **ai-search-implementation**: "User invokes semantic search through magnifying glass icon in the UI"
- test("semanticSearch command is registered and invokable", async function () {
- this.timeout(10000);
-
- const commands = await vscode.commands.getCommands(true);
- assert.ok(
- commands.includes("commandtree.semanticSearch"),
- "semanticSearch command must be registered for UI icon to work"
- );
- });
-
- // SPEC.md **ai-embedding-generation**, **database-schema**
- test("embedding pipeline fires and writes REAL 384-dim vectors to SQLite", async function () {
- this.timeout(15000);
-
- const dbPath = getFixturePath(path.join(COMMANDTREE_DIR, DB_FILENAME));
- assert.ok(
- fs.existsSync(dbPath),
- "DB file must exist — pipeline did not fire",
- );
-
- const stats = await queryEmbeddingStats(dbPath);
-
- assert.ok(
- stats.rowCount > 0,
- `DB has ${stats.rowCount} rows — pipeline produced nothing`,
- );
- assert.strictEqual(
- stats.nullCount,
- 0,
- `${stats.nullCount}/${stats.rowCount} rows have NULL embeddings — embedder failed`,
- );
- assert.strictEqual(
- stats.embeddedCount,
- stats.rowCount,
- `Only ${stats.embeddedCount}/${stats.rowCount} rows have embeddings`,
- );
- assert.strictEqual(
- stats.wrongSizeCount,
- 0,
- `${stats.wrongSizeCount} BLOBs have wrong size (need ${EMBEDDING_BLOB_BYTES} bytes)`,
- );
- assert.strictEqual(
- stats.sampleBlobLength,
- EMBEDDING_BLOB_BYTES,
- `Sample BLOB is ${stats.sampleBlobLength} bytes, need ${EMBEDDING_BLOB_BYTES}`,
- );
-
- const mod = await import("node-sqlite3-wasm");
- const db = new mod.default.Database(dbPath);
- try {
- const row = db.get(
- "SELECT embedding FROM commands WHERE embedding IS NOT NULL LIMIT 1",
- ) as SqlRow | null;
- const blob = row?.["embedding"] as Uint8Array | undefined;
- assert.ok(blob !== undefined, "Could not read sample BLOB");
- const floats = new Float32Array(
- blob.buffer,
- blob.byteOffset,
- MINILM_EMBEDDING_DIM,
- );
- const nonZero = floats.filter((v) => v !== 0).length;
- assert.ok(
- nonZero > MINILM_EMBEDDING_DIM / 2,
- `Embedding has ${nonZero}/${MINILM_EMBEDDING_DIM} non-zero values — likely garbage`,
- );
- } finally {
- db.close();
- }
- });
-
- // SPEC.md **ai-search-implementation**
- test("semantic search filters tree to relevant results", async function () {
- this.timeout(120000);
-
- await vscode.commands.executeCommand(
- "commandtree.semanticSearch",
- "run tests",
- );
- await sleep(SEARCH_SETTLE_MS);
-
- assert.ok(provider.hasFilter(), "Semantic filter should be active");
-
- const visible = await collectLeafTasks(provider);
- assert.ok(visible.length > 0, "Search should return at least one result");
- assert.ok(
- visible.length < totalTaskCount,
- `Filter should reduce tasks (${visible.length} visible < ${totalTaskCount} total)`,
- );
-
- await vscode.commands.executeCommand("commandtree.clearFilter");
- });
-
- // SPEC.md **ai-search-implementation**
- test("deploy query surfaces deploy-related tasks", async function () {
- this.timeout(120000);
-
- await vscode.commands.executeCommand(
- "commandtree.semanticSearch",
- "deploy application to production server",
- );
- await sleep(SEARCH_SETTLE_MS);
-
- const results = await collectLeafTasks(provider);
- assert.ok(results.length > 0, '"deploy" query must return results');
- assert.ok(
- results.length < totalTaskCount,
- `"deploy" query should not return all tasks (${results.length} < ${totalTaskCount})`,
- );
-
- const labels = results.map((t) => t.label.toLowerCase());
- const hasDeployResult = labels.some((l) => l.includes("deploy"));
- assert.ok(
- hasDeployResult,
- `"deploy" query should include deploy tasks, got: [${labels.join(", ")}]`,
- );
-
- await vscode.commands.executeCommand("commandtree.clearFilter");
- });
-
- // SPEC.md **ai-search-implementation**
- test("build query surfaces build-related tasks", async function () {
- this.timeout(120000);
-
- await vscode.commands.executeCommand(
- "commandtree.semanticSearch",
- "compile and build the project",
- );
- await sleep(SEARCH_SETTLE_MS);
-
- const results = await collectLeafTasks(provider);
- assert.ok(results.length > 0, '"build" query must return results');
-
- const labels = results.map((t) => t.label.toLowerCase());
- const hasBuildResult = labels.some((l) => l.includes("build"));
- assert.ok(
- hasBuildResult,
- `"build" query should include build tasks, got: [${labels.join(", ")}]`,
- );
-
- await vscode.commands.executeCommand("commandtree.clearFilter");
- });
-
- // SPEC.md **ai-search-implementation**
- test("different queries produce different result sets", async function () {
- this.timeout(120000);
-
- await vscode.commands.executeCommand(
- "commandtree.semanticSearch",
- "build project",
- );
- await sleep(SEARCH_SETTLE_MS);
- const buildResults = await collectLeafTasks(provider);
- const buildIds = new Set(buildResults.map((t) => t.id));
- assert.ok(buildIds.size > 0, "Build search should have results");
-
- await vscode.commands.executeCommand("commandtree.clearFilter");
- await sleep(500);
- await vscode.commands.executeCommand(
- "commandtree.semanticSearch",
- "deploy to production",
- );
- await sleep(SEARCH_SETTLE_MS);
- const deployResults = await collectLeafTasks(provider);
- const deployIds = new Set(deployResults.map((t) => t.id));
- assert.ok(deployIds.size > 0, "Deploy search should have results");
-
- const identical =
- buildIds.size === deployIds.size &&
- [...buildIds].every((id) => deployIds.has(id));
- assert.ok(
- !identical,
- "Different queries should produce different result sets",
- );
-
- await vscode.commands.executeCommand("commandtree.clearFilter");
- });
-
- // SPEC.md **ai-search-implementation**
- test("empty query does not activate filter", async function () {
- this.timeout(15000);
-
- await vscode.commands.executeCommand("commandtree.semanticSearch", "");
- await sleep(SHORT_SETTLE_MS);
-
- assert.ok(!provider.hasFilter(), "Empty query should not activate filter");
- const tasks = await collectLeafTasks(provider);
- assert.strictEqual(
- tasks.length,
- totalTaskCount,
- "All tasks should remain visible after empty query",
- );
- });
-
- // SPEC.md **ai-search-implementation**
- test("test query surfaces test-related tasks", async function () {
- this.timeout(120000);
-
- await vscode.commands.executeCommand(
- "commandtree.semanticSearch",
- "run the test suite",
- );
- await sleep(SEARCH_SETTLE_MS);
-
- const results = await collectLeafTasks(provider);
- assert.ok(results.length > 0, '"test" query must return results');
-
- const labels = results.map((t) => t.label.toLowerCase());
- const hasTestResult = labels.some(
- (l) => l.includes("test") || l.includes("spec") || l.includes("check"),
- );
- assert.ok(
- hasTestResult,
- `"test" query should include test tasks, got: [${labels.join(", ")}]`,
- );
-
- await vscode.commands.executeCommand("commandtree.clearFilter");
- });
-
- // SPEC.md **ai-search-implementation**
- test("clear filter restores all tasks after search", async function () {
- this.timeout(30000);
-
- await vscode.commands.executeCommand("commandtree.semanticSearch", "build");
- await sleep(SEARCH_SETTLE_MS);
- assert.ok(provider.hasFilter(), "Filter should be active before clearing");
-
- await vscode.commands.executeCommand("commandtree.clearFilter");
- await sleep(SHORT_SETTLE_MS);
-
- assert.ok(!provider.hasFilter(), "Filter should be cleared");
- const restored = await collectLeafTasks(provider);
- assert.strictEqual(
- restored.length,
- totalTaskCount,
- "All tasks should be visible after clearing filter",
- );
- });
-
- // SPEC.md **ai-search-implementation**
- test("query-specific searches surface relevant tasks", async function () {
- this.timeout(120000);
- const cases = [
- {
- query: "deploy application to production server",
- keywords: ["deploy"],
- },
- { query: "compile and build the project", keywords: ["build"] },
- { query: "run the test suite", keywords: ["test", "spec", "check"] },
- ];
- const resultSets: Array> = [];
- for (const tc of cases) {
- await vscode.commands.executeCommand(
- "commandtree.semanticSearch",
- tc.query,
- );
- await sleep(SEARCH_SETTLE_MS);
- const results = await collectLeafTasks(provider);
- assert.ok(
- results.length > 0,
- `"${tc.keywords[0]}" query must return results`,
- );
- assert.ok(
- results.length < totalTaskCount,
- `"${tc.keywords[0]}" should not return all (${results.length} < ${totalTaskCount})`,
- );
- const labels = results.map((t) => t.label.toLowerCase());
- const hasMatch = labels.some((l) =>
- tc.keywords.some((k) => l.includes(k)),
- );
- assert.ok(
- hasMatch,
- `"${tc.keywords[0]}" query should match, got: [${labels.join(", ")}]`,
- );
- resultSets.push(new Set(results.map((t) => t.id)));
- await vscode.commands.executeCommand("commandtree.clearFilter");
- await sleep(500);
- }
- const first = resultSets[0];
- const second = resultSets[1];
- if (first !== undefined && second !== undefined) {
- const identical =
- first.size === second.size && [...first].every((id) => second.has(id));
- assert.ok(
- !identical,
- "Different queries should produce different result sets",
- );
- }
- });
-
- // SPEC.md **ai-search-implementation**
- test("search command without args opens input box and cancellation is clean", async function () {
- this.timeout(30000);
-
- const searchPromise = vscode.commands.executeCommand(
- "commandtree.semanticSearch",
- );
- await sleep(INPUT_BOX_RENDER_MS);
-
- await vscode.commands.executeCommand("workbench.action.closeQuickOpen");
- await searchPromise;
- await sleep(SHORT_SETTLE_MS);
-
- assert.ok(
- !provider.hasFilter(),
- "Cancelling input box should not activate semantic filter",
- );
-
- const tasks = await collectLeafTasks(provider);
- assert.strictEqual(
- tasks.length,
- totalTaskCount,
- "All tasks should remain visible after cancelling search input",
- );
- });
-
- // SPEC.md **ai-search-implementation** (Cosine similarity, threshold 0.3)
- test("cosine similarity discriminates: related query filters, unrelated does not", async function () {
- this.timeout(120000);
-
- await vscode.commands.executeCommand(
- "commandtree.semanticSearch",
- "compile and build the project",
- );
- await sleep(SEARCH_SETTLE_MS);
- const relatedFiltered = provider.hasFilter();
- const relatedCount = (await collectLeafTasks(provider)).length;
- await vscode.commands.executeCommand("commandtree.clearFilter");
- await sleep(500);
-
- await vscode.commands.executeCommand(
- "commandtree.semanticSearch",
- "quantum entanglement photon wavelength",
- );
- await sleep(SEARCH_SETTLE_MS);
- const unrelatedFiltered = provider.hasFilter();
- const unrelatedCount = (await collectLeafTasks(provider)).length;
- await vscode.commands.executeCommand("commandtree.clearFilter");
-
- assert.ok(
- relatedFiltered,
- "Related query must activate filter via cosine similarity",
- );
- assert.ok(
- relatedCount > 0 && relatedCount < totalTaskCount,
- "Related must find subset",
- );
-
- if (!unrelatedFiltered) {
- assert.strictEqual(
- unrelatedCount,
- totalTaskCount,
- "No filter = all tasks visible",
- );
- } else {
- assert.ok(
- unrelatedCount < relatedCount,
- `Unrelated should find fewer (${unrelatedCount}) than related (${relatedCount})`,
- );
- }
- });
-
- // SPEC.md **ai-search-implementation**
- test("filtered tree items retain correct UI properties", async function () {
- this.timeout(120000);
-
- await vscode.commands.executeCommand("commandtree.semanticSearch", "build");
- await sleep(SEARCH_SETTLE_MS);
-
- const items = await collectLeafItems(provider);
- assert.ok(items.length > 0, "Filtered tree should have items");
-
- for (const item of items) {
- assert.ok(item.task !== null, "Leaf items should have a task");
- assert.ok(
- typeof item.label === "string" || typeof item.label === "object",
- "Tree item should have a label",
- );
- assert.ok(
- item.tooltip !== undefined,
- `Tree item "${item.task.label}" should have a tooltip`,
- );
- assert.ok(
- item.iconPath !== undefined,
- `Tree item "${item.task.label}" should have an icon`,
- );
- assert.ok(
- item.contextValue === "task" || item.contextValue === "task-quick",
- `Leaf item should have task context value, got: "${item.contextValue}"`,
- );
- }
-
- await vscode.commands.executeCommand("commandtree.clearFilter");
- });
-
- // SPEC.md line 271: Match percentage displayed next to each command (e.g., "build (87%)")
- test("tree labels display similarity scores as percentages after semantic search", async function () {
- this.timeout(120000);
-
- await vscode.commands.executeCommand(
- "commandtree.semanticSearch",
- "build the project"
- );
- await sleep(SEARCH_SETTLE_MS);
-
- const items = await collectLeafItems(provider);
- assert.ok(items.length > 0, "Search should return results");
-
- const labelsWithScores = items.filter(item => {
- const label = getLabelString(item.label);
- return /\(\d+%\)/.test(label);
- });
-
- assert.ok(
- labelsWithScores.length > 0,
- `At least one result should show similarity score in label like "task (87%)", got labels: [${items.map(i => getLabelString(i.label)).join(", ")}]`
- );
-
- for (const item of labelsWithScores) {
- const label = getLabelString(item.label);
- const match = /\((\d+)%\)/.exec(label);
- assert.ok(match !== null, `Label should have percentage format: "${label}"`);
- const percentage = parseInt(match[1] ?? "0", 10);
- assert.ok(
- percentage >= 0 && percentage <= 100,
- `Percentage should be 0-100, got ${percentage} in "${label}"`
- );
- }
-
- await vscode.commands.executeCommand("commandtree.clearFilter");
- });
-});
diff --git a/src/test/e2e/summaries.e2e.test.ts b/src/test/e2e/summaries.e2e.test.ts
deleted file mode 100644
index 42cb1f9..0000000
--- a/src/test/e2e/summaries.e2e.test.ts
+++ /dev/null
@@ -1,234 +0,0 @@
-/**
- * SPEC: ai-summary-generation
- *
- * AI SUMMARY GENERATION — E2E TESTS
- * Pipeline: Copilot summary → SQLite storage → tooltip display
- * Tests security warnings, summary display, and debounce behaviour.
- */
-
-import * as assert from "assert";
-import * as vscode from "vscode";
-import * as fs from "fs";
-import {
- activateExtension,
- sleep,
- getFixturePath,
- getCommandTreeProvider,
- collectLeafItems,
- collectLeafTasks,
- getTooltipText,
-} from "../helpers/helpers";
-import type { CommandTreeProvider } from "../helpers/helpers";
-
-const SHORT_SETTLE_MS = 1000;
-const COPILOT_VENDOR = "copilot";
-const COPILOT_WAIT_MS = 2000;
-const COPILOT_MAX_ATTEMPTS = 30;
-
-// Summary tests disabled — skip until re-enabled
-suite.skip("AI Summary Generation E2E", () => {
- let provider: CommandTreeProvider;
-
- suiteSetup(async function () {
- this.timeout(300000);
-
- await activateExtension();
- provider = getCommandTreeProvider();
- await sleep(3000);
-
- const totalTasks = (await collectLeafTasks(provider)).length;
- assert.ok(totalTasks > 0, "Fixture workspace must have discovered tasks");
-
- let copilotModels: vscode.LanguageModelChat[] = [];
- for (let i = 0; i < COPILOT_MAX_ATTEMPTS; i++) {
- copilotModels = await vscode.lm.selectChatModels({
- vendor: COPILOT_VENDOR,
- });
- if (copilotModels.length > 0) {
- break;
- }
- if (i === COPILOT_MAX_ATTEMPTS - 1) {
- const allModels = await vscode.lm.selectChatModels();
- const info = allModels.map((m) => `${m.vendor}/${m.name}/${m.id}`);
- assert.fail(
- `GATE FAILED: No Copilot models after ${COPILOT_MAX_ATTEMPTS} attempts. ` +
- `All available models: [${info.join(", ")}].`,
- );
- }
- await sleep(COPILOT_WAIT_MS);
- }
-
- await vscode.workspace
- .getConfiguration("commandtree")
- .update("enableAiSummaries", true, vscode.ConfigurationTarget.Workspace);
- await sleep(SHORT_SETTLE_MS);
-
- await vscode.commands.executeCommand("commandtree.generateSummaries");
- await sleep(5000);
- });
-
- suiteTeardown(async function () {
- this.timeout(15000);
- await vscode.workspace
- .getConfiguration("commandtree")
- .update("enableAiSummaries", false, vscode.ConfigurationTarget.Workspace);
- });
-
- // SPEC.md **ai-summary-generation**
- test("tasks have AI-generated summaries after pipeline", async function () {
- this.timeout(15000);
-
- const tasks = await collectLeafTasks(provider);
- const withSummary = tasks.filter(
- (t) => t.summary !== undefined && t.summary !== "",
- );
-
- assert.ok(
- withSummary.length > 0,
- `At least one task should have an AI summary, got 0 out of ${tasks.length}`,
- );
- for (const task of withSummary) {
- assert.ok(
- typeof task.summary === "string" && task.summary.length > 5,
- `Summary for "${task.label}" should be a meaningful string, got: "${task.summary}"`,
- );
- const fakePattern = `${task.type} command "${task.label}": ${task.command}`;
- assert.notStrictEqual(
- task.summary,
- fakePattern,
- `FRAUD: Summary for "${task.label}" matches fake metadata pattern`,
- );
- }
- });
-
- // SPEC.md **ai-summary-generation** (Display: Tooltip on hover)
- test("tree items show summaries in tooltips as markdown blockquotes", async function () {
- this.timeout(15000);
-
- const items = await collectLeafItems(provider);
- const withSummaryTooltip = items.filter((item) => {
- const tip = getTooltipText(item);
- return tip.includes("> ");
- });
-
- assert.ok(
- withSummaryTooltip.length > 0,
- "At least one tree item should show summary as markdown blockquote in tooltip",
- );
-
- for (const item of withSummaryTooltip) {
- const tip = getTooltipText(item);
- assert.ok(
- tip.includes(`**${item.task?.label}**`),
- `Tooltip should contain the task label "${item.task?.label}"`,
- );
- assert.ok(
- item.tooltip instanceof vscode.MarkdownString,
- "Tooltip should be a MarkdownString for rich display",
- );
- }
- });
-
- // SPEC.md line 211: Security warning in tooltip
- test("tooltips display security warning icon when summary contains security keywords", async function () {
- this.timeout(15000);
-
- const items = await collectLeafItems(provider);
- const allTooltips = items
- .map(i => ({ item: i, tooltip: getTooltipText(i) }))
- .filter(x => x.tooltip.includes("> "));
-
- const withWarning = allTooltips.filter(x => x.tooltip.includes("\u26A0\uFE0F"));
- const withKeywords = allTooltips.filter(x => {
- const lower = x.tooltip.toLowerCase();
- return ['danger', 'unsafe', 'caution', 'warning', 'security', 'risk', 'vulnerability']
- .some(k => lower.includes(k));
- });
-
- assert.ok(
- withKeywords.length >= 0,
- "Checking for security keywords in summaries"
- );
-
- if (withKeywords.length > 0) {
- assert.ok(
- withWarning.length > 0,
- `Found ${withKeywords.length} summaries with security keywords, but 0 have \u26A0\uFE0F icon`
- );
- }
- });
-
- // SPEC.md **ai-summary-generation** (Display: security warnings shown as ⚠️ prefix on label + tooltip section)
- test("security warnings appear in label and tooltips when Copilot flags risky commands", async function () {
- this.timeout(15000);
-
- const tasks = await collectLeafTasks(provider);
- const items = await collectLeafItems(provider);
-
- const securityWarnings = tasks.filter(
- (t) => t.securityWarning !== undefined && t.securityWarning !== '',
- );
-
- if (securityWarnings.length === 0) {
- return;
- }
-
- assert.ok(
- securityWarnings.length > 0,
- "Found commands with security warnings from Copilot",
- );
-
- for (const task of securityWarnings) {
- const item = items.find((i) => i.task?.id === task.id);
- assert.ok(
- item !== undefined,
- `Tree item should exist for flagged command "${task.label}"`,
- );
-
- const tip = getTooltipText(item);
- assert.ok(
- tip.includes("\u26A0\uFE0F"),
- `Tooltip for "${task.label}" should contain security warning emoji`,
- );
- assert.ok(
- tip.includes(task.securityWarning ?? ""),
- `Tooltip for "${task.label}" should include security warning text`,
- );
-
- const label = typeof item.label === 'string' ? item.label : '';
- assert.ok(
- label.includes("\u26A0\uFE0F"),
- `Label for "${task.label}" should be prefixed with \u26A0\uFE0F`,
- );
- }
- });
-
- // SPEC.md line 209: File watch with debounce
- test("rapid file changes are debounced to prevent excessive re-summarization", async function () {
- this.timeout(60000);
-
- const testFilePath = getFixturePath("test-debounce.sh");
- const testContent = "#!/bin/bash\necho 'test'\n";
-
- fs.writeFileSync(testFilePath, testContent);
- await sleep(SHORT_SETTLE_MS);
-
- const startCount = (await collectLeafTasks(provider)).length;
-
- fs.writeFileSync(testFilePath, "#!/bin/bash\necho 'change1'\n");
- await sleep(500);
- fs.writeFileSync(testFilePath, "#!/bin/bash\necho 'change2'\n");
- await sleep(500);
- fs.writeFileSync(testFilePath, "#!/bin/bash\necho 'change3'\n");
- await sleep(3000);
-
- const endCount = (await collectLeafTasks(provider)).length;
- assert.ok(
- endCount >= startCount,
- `Task count should not decrease after rapid changes (${endCount} >= ${startCount})`
- );
-
- fs.unlinkSync(testFilePath);
- await sleep(SHORT_SETTLE_MS);
- });
-});
diff --git a/src/test/e2e/tagconfig.e2e.test.ts b/src/test/e2e/tagconfig.e2e.test.ts
index 75e16cb..301033d 100644
--- a/src/test/e2e/tagconfig.e2e.test.ts
+++ b/src/test/e2e/tagconfig.e2e.test.ts
@@ -14,8 +14,8 @@ import {
getCommandTreeProvider,
} from '../helpers/helpers';
import type { CommandTreeProvider } from '../helpers/helpers';
-import { getDb } from '../../semantic/lifecycle';
-import { getCommandIdsByTag, getTagsForCommand } from '../../semantic/db';
+import { getDb } from '../../db/lifecycle';
+import { getCommandIdsByTag, getTagsForCommand } from '../../db/db';
// SPEC: tagging
suite('Junction Table Tagging E2E Tests', () => {
@@ -55,6 +55,10 @@ suite('Junction Table Tagging E2E Tests', () => {
assert.ok(tagsResult.value.length > 0, 'Task should have at least one tag');
assert.ok(tagsResult.value.includes(testTag), `Task should have tag "${testTag}"`);
+ // Verify getAllTags includes the new tag (exercises CommandTreeProvider.getAllTags + TagConfig.getTagNames)
+ const allTags = treeProvider.getAllTags();
+ assert.ok(allTags.includes(testTag), `getAllTags should include "${testTag}"`);
+
// Clean up
await vscode.commands.executeCommand('commandtree.removeTag', task, testTag);
await sleep(500);
@@ -187,4 +191,33 @@ suite('Junction Table Tagging E2E Tests', () => {
await vscode.commands.executeCommand('commandtree.removeTag', task1, testTag);
await sleep(500);
});
+
+ // SPEC: tagging/config-file
+ test('E2E: Tags from commandtree.json are synced at activation', function () {
+ this.timeout(15000);
+
+ // The fixture workspace has .vscode/commandtree.json with tags: build, test, deploy, debug, scripts, ci
+ // syncTagsFromJson runs at activation, so tags should already be in DB
+ const allTags = treeProvider.getAllTags();
+
+ const expectedTags = ['build', 'test', 'deploy', 'debug', 'scripts', 'ci'];
+ for (const tag of expectedTags) {
+ assert.ok(
+ allTags.includes(tag),
+ `Tag "${tag}" from commandtree.json should be synced. Found: [${allTags.join(', ')}]`
+ );
+ }
+
+ // Verify pattern matching: "scripts" tag applies to shell tasks (type: "shell" pattern)
+ const dbResult = getDb();
+ assert.ok(dbResult.ok, 'Database must be available');
+ const scriptsResult = getCommandIdsByTag({ handle: dbResult.value, tagName: 'scripts' });
+ assert.ok(scriptsResult.ok, 'Should get command IDs for scripts tag');
+ assert.ok(scriptsResult.value.length > 0, 'scripts tag should match shell commands');
+
+ // Verify "debug" tag applies to launch configs (type: "launch" pattern)
+ const debugResult = getCommandIdsByTag({ handle: dbResult.value, tagName: 'debug' });
+ assert.ok(debugResult.ok, 'Should get command IDs for debug tag');
+ assert.ok(debugResult.value.length > 0, 'debug tag should match launch configs');
+ });
});
diff --git a/src/test/unit/command-registration.unit.test.ts b/src/test/unit/command-registration.unit.test.ts
deleted file mode 100644
index 741016a..0000000
--- a/src/test/unit/command-registration.unit.test.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import * as assert from 'assert';
-import * as path from 'path';
-import * as fs from 'fs';
-import * as os from 'os';
-import { openDatabase, initSchema, getAllRows, registerCommand, getRow, upsertSummary } from '../../semantic/db';
-import type { DbHandle } from '../../semantic/db';
-import { computeContentHash } from '../../semantic/store';
-
-/**
- * SPEC: database-schema
- *
- * UNIT TESTS for command registration in SQLite.
- * Proves that discovered commands are ALWAYS stored in the DB,
- * regardless of whether Copilot summarisation succeeds.
- */
-
-function makeTmpDir(): string {
- return fs.mkdtempSync(path.join(os.tmpdir(), 'ct-reg-'));
-}
-
-suite('Command Registration Unit Tests', function () {
- this.timeout(10000);
- let tmpDir: string;
- let handle: DbHandle;
-
- setup(async () => {
- tmpDir = makeTmpDir();
- const dbPath = path.join(tmpDir, 'test.sqlite3');
- const openResult = await openDatabase(dbPath);
- assert.ok(openResult.ok, 'DB should open');
- handle = openResult.value;
- const schemaResult = initSchema(handle);
- assert.ok(schemaResult.ok, 'Schema should init');
- });
-
- teardown(() => {
- try { handle.db.close(); } catch { /* already closed */ }
- fs.rmSync(tmpDir, { recursive: true, force: true });
- });
-
- test('registerCommand inserts new command with empty summary', () => {
- const result = registerCommand({
- handle,
- commandId: 'npm:build',
- contentHash: 'abc123',
- });
- assert.ok(result.ok, 'registerCommand should succeed');
-
- const row = getRow({ handle, commandId: 'npm:build' });
- assert.ok(row.ok, 'getRow should succeed');
- assert.ok(row.value !== undefined, 'Row must exist in DB after registration');
- assert.strictEqual(row.value.commandId, 'npm:build');
- assert.strictEqual(row.value.contentHash, 'abc123');
- assert.strictEqual(row.value.summary, '', 'Summary should be empty for unsummarised command');
- assert.strictEqual(row.value.embedding, null, 'Embedding should be null');
- assert.strictEqual(row.value.securityWarning, null, 'Security warning should be null');
- });
-
- test('registerCommand preserves existing summary when content hash changes', () => {
- // Simulate: Copilot already summarised this command
- upsertSummary({
- handle,
- commandId: 'npm:test',
- contentHash: 'old-hash',
- summary: 'Runs unit tests',
- securityWarning: null,
- });
-
- // Now re-register with new hash (script content changed)
- const result = registerCommand({
- handle,
- commandId: 'npm:test',
- contentHash: 'new-hash',
- });
- assert.ok(result.ok);
-
- const row = getRow({ handle, commandId: 'npm:test' });
- assert.ok(row.ok && row.value !== undefined);
- assert.strictEqual(row.value.contentHash, 'new-hash', 'Hash should be updated');
- assert.strictEqual(row.value.summary, 'Runs unit tests', 'Existing summary MUST be preserved');
- });
-
- test('registerCommand is idempotent — calling twice does not duplicate', () => {
- registerCommand({ handle, commandId: 'npm:lint', contentHash: 'h1' });
- registerCommand({ handle, commandId: 'npm:lint', contentHash: 'h2' });
-
- const rows = getAllRows(handle);
- assert.ok(rows.ok);
- const lintRows = rows.value.filter(r => r.commandId === 'npm:lint');
- assert.strictEqual(lintRows.length, 1, 'Must be exactly one row, not duplicated');
- const lintRow = lintRows[0];
- assert.ok(lintRow !== undefined, 'Lint row must exist');
- assert.strictEqual(lintRow.contentHash, 'h2', 'Hash should reflect latest registration');
- });
-
- test('registered command with empty summary needs summarisation even when hash matches', () => {
- // registerCommand writes empty summary + correct hash
- const hash = computeContentHash('tsc && node dist/index.js');
- registerCommand({ handle, commandId: 'npm:build', contentHash: hash });
-
- const row = getRow({ handle, commandId: 'npm:build' });
- assert.ok(row.ok && row.value !== undefined);
- // Hash matches but summary is empty — summary pipeline MUST detect this
- assert.strictEqual(row.value.contentHash, hash);
- assert.strictEqual(row.value.summary, '', 'Summary is empty');
-
- // Summary is empty (asserted above), so this command MUST be queued for summarisation
- assert.strictEqual(row.value.summary.length, 0, 'Command with empty summary MUST be queued for summarisation');
- });
-
- test('all discovered commands land in DB with correct content hashes', () => {
- const commands = [
- { id: 'npm:build', content: 'tsc && node dist/index.js' },
- { id: 'npm:test', content: 'jest --coverage' },
- { id: 'npm:lint', content: 'eslint src/' },
- { id: 'shell:deploy.sh', content: '#!/bin/bash\nrsync -avz dist/ server:/' },
- { id: 'make:clean', content: 'rm -rf dist/' },
- ];
-
- for (const cmd of commands) {
- const hash = computeContentHash(cmd.content);
- const result = registerCommand({ handle, commandId: cmd.id, contentHash: hash });
- assert.ok(result.ok, `registerCommand should succeed for ${cmd.id}`);
- }
-
- const rows = getAllRows(handle);
- assert.ok(rows.ok);
- assert.strictEqual(rows.value.length, 5, 'All 5 commands must be in DB');
-
- for (const cmd of commands) {
- const row = getRow({ handle, commandId: cmd.id });
- assert.ok(row.ok && row.value !== undefined, `${cmd.id} must exist in DB`);
- assert.strictEqual(row.value.contentHash, computeContentHash(cmd.content), `${cmd.id} hash must match`);
- assert.strictEqual(row.value.summary, '', `${cmd.id} summary should be empty (no Copilot)`);
- }
- });
-});
diff --git a/src/test/unit/embedding-provider.unit.test.ts b/src/test/unit/embedding-provider.unit.test.ts
deleted file mode 100644
index 14eee4b..0000000
--- a/src/test/unit/embedding-provider.unit.test.ts
+++ /dev/null
@@ -1,192 +0,0 @@
-import * as assert from 'assert';
-import * as fs from 'fs';
-import * as os from 'os';
-import * as path from 'path';
-import { createEmbedder, embedText, disposeEmbedder } from '../../semantic/embedder.js';
-import { openDatabase, closeDatabase, initSchema, upsertRow, getAllRows } from '../../semantic/db.js';
-import { rankBySimilarity, cosineSimilarity } from '../../semantic/similarity.js';
-
-/**
- * SPEC: ai-embedding-generation, database-schema, ai-search-implementation
- *
- * EMBEDDING PROVIDER TESTS — NO MOCKS, REAL MODEL ONLY
- * Tests the REAL HuggingFace all-MiniLM-L6-v2 model + SQLite storage + cosine similarity search.
- * No VS Code dependencies — pure embedding provider testing.
- *
- * This test proves:
- * 1. The embedding model produces real 384-dim vectors
- * 2. Vectors are correctly serialized to SQLite BLOBs
- * 3. Vector search finds semantically similar commands
- * 4. The search code works end-to-end
- */
-// Embedding functionality disabled — skip until re-enabled
-suite.skip('Embedding Provider Tests (REAL MODEL)', function () {
- this.timeout(60000); // HuggingFace model download can be slow on first run
-
- const testDbPath = path.join(os.tmpdir(), `commandtree-test-${Date.now()}.sqlite3`);
- const modelCacheDir = path.join(os.tmpdir(), 'commandtree-test-models');
-
- suiteTeardown(() => {
- // Clean up test database
- if (fs.existsSync(testDbPath)) {
- fs.unlinkSync(testDbPath);
- }
- });
-
- test('REAL embedding pipeline: embed → store → search → find semantically similar', async () => {
- // Step 1: Create REAL embedder with HuggingFace model
- const embedderResult = await createEmbedder({ modelCacheDir });
- assert.ok(embedderResult.ok, `Failed to create embedder: ${embedderResult.ok ? '' : embedderResult.error}`);
- const embedder = embedderResult.value;
-
- // Step 2: Open database and initialize schema
- const dbResult = await openDatabase(testDbPath);
- assert.ok(dbResult.ok, `Failed to open database: ${dbResult.ok ? '' : dbResult.error}`);
- const db = dbResult.value;
-
- const schemaResult = initSchema(db);
- assert.ok(schemaResult.ok, `Failed to init schema: ${schemaResult.ok ? '' : schemaResult.error}`);
-
- // Step 3: Create REAL embeddings for test commands
- const testCommands = [
- { id: 'build', summary: 'Build and compile the TypeScript project' },
- { id: 'test', summary: 'Run the test suite with Mocha' },
- { id: 'install', summary: 'Install NPM dependencies from package.json' },
- { id: 'clean', summary: 'Delete build artifacts and generated files' },
- { id: 'watch', summary: 'Watch files and rebuild on changes' },
- ];
-
- const embeddings: Array<{ id: string; embedding: Float32Array }> = [];
-
- for (const cmd of testCommands) {
- const embeddingResult = await embedText({ handle: embedder, text: cmd.summary });
- assert.ok(embeddingResult.ok, `Failed to embed "${cmd.summary}": ${embeddingResult.ok ? '' : embeddingResult.error}`);
-
- const embedding = embeddingResult.value;
- assert.strictEqual(embedding.length, 384, `Expected 384 dimensions, got ${embedding.length}`);
-
- // Verify embedding is normalized (unit vector)
- let magnitude = 0;
- for (const value of embedding) {
- magnitude += value * value;
- }
- const norm = Math.sqrt(magnitude);
- assert.ok(Math.abs(norm - 1.0) < 0.01, `Embedding should be normalized, got magnitude ${norm}`);
-
- embeddings.push({ id: cmd.id, embedding });
-
- // Step 4: Store in SQLite
- const row = {
- commandId: cmd.id,
- contentHash: `hash-${cmd.id}`,
- summary: cmd.summary,
- securityWarning: null,
- embedding,
- lastUpdated: new Date().toISOString(),
- };
- const upsertResult = upsertRow({ handle: db, row });
- assert.ok(upsertResult.ok, `Failed to upsert row: ${upsertResult.ok ? '' : upsertResult.error}`);
- }
-
- // Step 5: Verify data was written to database
- const allRowsResult = getAllRows(db);
- assert.ok(allRowsResult.ok, `Failed to get all rows: ${allRowsResult.ok ? '' : allRowsResult.error}`);
- const allRows = allRowsResult.value;
- assert.strictEqual(allRows.length, testCommands.length, `Expected ${testCommands.length} rows, got ${allRows.length}`);
-
- // Verify all embeddings are non-null and correct size
- for (const row of allRows) {
- assert.ok(row.embedding !== null, `Row ${row.commandId} has null embedding`);
- assert.strictEqual(row.embedding.length, 384, `Row ${row.commandId} embedding has wrong size: ${row.embedding.length}`);
- }
-
- // Step 6: Create query embedding for "compile code"
- const queryResult = await embedText({ handle: embedder, text: 'compile code' });
- assert.ok(queryResult.ok, `Failed to embed query: ${queryResult.ok ? '' : queryResult.error}`);
- const queryEmbedding = queryResult.value;
-
- // Step 7: Use REAL search code to find semantically similar commands
- const candidates = allRows.map(row => ({
- id: row.commandId,
- embedding: row.embedding,
- }));
-
- const results = rankBySimilarity({
- query: queryEmbedding,
- candidates,
- topK: 3,
- threshold: 0.0,
- });
-
- // Step 8: Verify semantic search works correctly
- assert.ok(results.length > 0, 'Search should return results');
-
- // "compile code" should be most similar to "build" (compile and build are semantically similar)
- const topResult = results[0];
- assert.ok(topResult !== undefined, 'Should have at least one result');
- assert.strictEqual(topResult.id, 'build', `Expected "build" to be most similar to "compile code", got "${topResult.id}"`);
-
- // Score should be reasonably high (>0.4 for semantically related terms with all-MiniLM-L6-v2)
- assert.ok(topResult.score > 0.4, `Expected similarity score > 0.4, got ${topResult.score}`);
-
- // "test" and "install" should be less similar than "build"
- const buildScore = topResult.score;
- const otherResults = results.slice(1);
- for (const result of otherResults) {
- assert.ok(result.score < buildScore, `"${result.id}" should have lower score than "build"`);
- }
-
- // Step 9: Clean up
- await disposeEmbedder(embedder);
- const closeResult = closeDatabase(db);
- assert.ok(closeResult.ok, `Failed to close database: ${closeResult.ok ? '' : closeResult.error}`);
- });
-
- test('embedding proximity: semantically similar texts have high similarity', async () => {
- const embedderResult = await createEmbedder({ modelCacheDir });
- assert.ok(embedderResult.ok);
- const embedder = embedderResult.value;
-
- // Embed two semantically similar texts
- const text1Result = await embedText({ handle: embedder, text: 'run unit tests' });
- const text2Result = await embedText({ handle: embedder, text: 'execute test suite' });
-
- assert.ok(text1Result.ok);
- assert.ok(text2Result.ok);
-
- const embedding1 = text1Result.value;
- const embedding2 = text2Result.value;
-
- // Use the REAL similarity function
- const similarity = cosineSimilarity(embedding1, embedding2);
-
- // Semantically similar texts should have high similarity (> 0.6 for all-MiniLM-L6-v2)
- assert.ok(similarity > 0.6, `Expected similarity > 0.6 for similar texts, got ${similarity}`);
-
- // Clean up
- await disposeEmbedder(embedder);
- });
-
- test('embedding proximity: semantically different texts have low similarity', async () => {
- const embedderResult = await createEmbedder({ modelCacheDir });
- assert.ok(embedderResult.ok);
- const embedder = embedderResult.value;
-
- // Embed two completely unrelated texts
- const text1Result = await embedText({ handle: embedder, text: 'compile TypeScript source code' });
- const text2Result = await embedText({ handle: embedder, text: 'clean up temporary files' });
-
- assert.ok(text1Result.ok);
- assert.ok(text2Result.ok);
-
- const embedding1 = text1Result.value;
- const embedding2 = text2Result.value;
-
- const similarity = cosineSimilarity(embedding1, embedding2);
-
- // Semantically different texts should have lower similarity (< 0.6)
- assert.ok(similarity < 0.6, `Expected similarity < 0.6 for different texts, got ${similarity}`);
-
- await disposeEmbedder(embedder);
- });
-});
diff --git a/src/test/unit/embedding-storage.unit.test.ts b/src/test/unit/embedding-storage.unit.test.ts
deleted file mode 100644
index 43d1b8c..0000000
--- a/src/test/unit/embedding-storage.unit.test.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import * as assert from 'assert';
-import { embeddingToBytes, bytesToEmbedding } from '../../semantic/db';
-
-/**
- * SPEC: database-schema
- *
- * UNIT TESTS for embedding serialization and storage.
- * Proves embeddings survive the Float32Array -> bytes -> Float32Array roundtrip
- * and that the SQLite storage layer correctly persists vector data.
- * Pure logic - no VS Code.
- */
-suite('Embedding Storage Unit Tests', function () {
- this.timeout(5000);
-
- suite('Serialization Roundtrip', () => {
- test('384-dim embedding survives bytes roundtrip exactly', () => {
- const original = new Float32Array(384);
- for (let i = 0; i < 384; i++) {
- original[i] = Math.sin(i * 0.1) * 0.5;
- }
-
- const bytes = embeddingToBytes(original);
- const restored = bytesToEmbedding(bytes);
-
- assert.strictEqual(
- restored.length,
- 384,
- `Restored embedding should have 384 dims, got ${restored.length}`
- );
-
- for (let i = 0; i < 384; i++) {
- assert.strictEqual(
- restored[i],
- original[i],
- `Dim ${i}: expected ${original[i]}, got ${restored[i]}`
- );
- }
- });
-
- test('bytes size is 4x embedding length (Float32 = 4 bytes)', () => {
- const embedding = new Float32Array(384);
- const bytes = embeddingToBytes(embedding);
- assert.strictEqual(
- bytes.length,
- 384 * 4,
- `384 floats should produce ${384 * 4} bytes, got ${bytes.length}`
- );
- });
-
- test('preserves negative values', () => {
- const original = new Float32Array([-0.5, -1.0, -0.001, 0.0, 0.5, 1.0]);
- const bytes = embeddingToBytes(original);
- const restored = bytesToEmbedding(bytes);
-
- for (let i = 0; i < original.length; i++) {
- assert.strictEqual(
- restored[i],
- original[i],
- `Index ${i}: expected ${original[i]}, got ${restored[i]}`
- );
- }
- });
-
- test('preserves very small values (near zero)', () => {
- const original = new Float32Array([1e-7, -1e-7, 1e-10, 0.0]);
- const bytes = embeddingToBytes(original);
- const restored = bytesToEmbedding(bytes);
-
- for (let i = 0; i < original.length; i++) {
- assert.strictEqual(
- restored[i],
- original[i],
- `Index ${i}: expected ${original[i]}, got ${restored[i]}`
- );
- }
- });
-
- test('empty embedding produces empty bytes', () => {
- const original = new Float32Array(0);
- const bytes = embeddingToBytes(original);
- const restored = bytesToEmbedding(bytes);
-
- assert.strictEqual(bytes.length, 0);
- assert.strictEqual(restored.length, 0);
- });
-
- test('different embeddings produce different bytes', () => {
- const a = new Float32Array([1, 0, 0]);
- const b = new Float32Array([0, 1, 0]);
- const bytesA = embeddingToBytes(a);
- const bytesB = embeddingToBytes(b);
-
- let differ = false;
- for (let i = 0; i < bytesA.length; i++) {
- if (bytesA[i] !== bytesB[i]) {
- differ = true;
- break;
- }
- }
- assert.ok(differ, 'Different embeddings must produce different bytes');
- });
- });
-});
diff --git a/src/test/unit/model-selection.unit.test.ts b/src/test/unit/model-selection.unit.test.ts
deleted file mode 100644
index 2b1715f..0000000
--- a/src/test/unit/model-selection.unit.test.ts
+++ /dev/null
@@ -1,212 +0,0 @@
-/**
- * Unit tests for model selection logic (resolveModel).
- * Proves that:
- * 1. When a saved model ID exists, that exact model is returned
- * 2. When user picks from quickpick, the ID is saved to settings
- * 3. When no models available, returns error
- * 4. When user cancels quickpick, returns error
- */
-import * as assert from 'assert';
-import { resolveModel, pickConcreteModel, AUTO_MODEL_ID } from '../../semantic/modelSelection';
-import type { ModelSelectionDeps, ModelRef } from '../../semantic/modelSelection';
-
-const AUTO: ModelRef = { id: AUTO_MODEL_ID, name: 'Auto' };
-const HAIKU: ModelRef = { id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' };
-const OPUS: ModelRef = { id: 'claude-opus-4.6', name: 'Claude Opus 4.6' };
-const ALL_MODELS: readonly ModelRef[] = [OPUS, HAIKU];
-const ALL_WITH_AUTO: readonly ModelRef[] = [AUTO, OPUS, HAIKU];
-
-function makeDeps(overrides: Partial): ModelSelectionDeps {
- return {
- getSavedId: (): string => '',
- fetchById: async (): Promise => { await Promise.resolve(); return []; },
- fetchAll: async (): Promise => { await Promise.resolve(); return ALL_MODELS; },
- promptUser: async (): Promise => { await Promise.resolve(); return undefined; },
- saveId: async (): Promise => { await Promise.resolve(); },
- ...overrides
- };
-}
-
-suite('Model Selection (resolveModel)', () => {
-
- test('returns saved model when setting matches', async () => {
- const deps = makeDeps({
- getSavedId: (): string => HAIKU.id,
- fetchById: async (id: string): Promise => { await Promise.resolve(); return id === HAIKU.id ? [HAIKU] : []; }
- });
-
- const result = await resolveModel(deps);
-
- assert.ok(result.ok, 'Expected ok result');
- assert.strictEqual(result.value.id, HAIKU.id);
- assert.strictEqual(result.value.name, HAIKU.name);
- });
-
- test('does NOT call fetchAll when saved model found', async () => {
- let fetchAllCalled = false;
- const deps = makeDeps({
- getSavedId: (): string => HAIKU.id,
- fetchById: async (): Promise => { await Promise.resolve(); return [HAIKU]; },
- fetchAll: async (): Promise => { await Promise.resolve(); fetchAllCalled = true; return ALL_MODELS; }
- });
-
- await resolveModel(deps);
-
- assert.strictEqual(fetchAllCalled, false, 'fetchAll should not be called when saved model exists');
- });
-
- test('does NOT call promptUser when saved model found', async () => {
- let promptCalled = false;
- const deps = makeDeps({
- getSavedId: (): string => HAIKU.id,
- fetchById: async (): Promise => { await Promise.resolve(); return [HAIKU]; },
- promptUser: async (): Promise => { await Promise.resolve(); promptCalled = true; return HAIKU; }
- });
-
- await resolveModel(deps);
-
- assert.strictEqual(promptCalled, false, 'promptUser should not be called when saved model exists');
- });
-
- test('prompts user when no saved setting', async () => {
- let promptedModels: readonly ModelRef[] = [];
- const deps = makeDeps({
- getSavedId: (): string => '',
- fetchAll: async (): Promise => { await Promise.resolve(); return ALL_MODELS; },
- promptUser: async (models: readonly ModelRef[]): Promise => { await Promise.resolve(); promptedModels = models; return HAIKU; },
- saveId: async (): Promise => { await Promise.resolve(); }
- });
-
- const result = await resolveModel(deps);
-
- assert.ok(result.ok, 'Expected ok result');
- assert.strictEqual(result.value.id, HAIKU.id);
- assert.strictEqual(promptedModels.length, ALL_MODELS.length);
- });
-
- test('saves picked model ID to settings', async () => {
- let savedId = '';
- const deps = makeDeps({
- getSavedId: (): string => '',
- fetchAll: async (): Promise => { await Promise.resolve(); return ALL_MODELS; },
- promptUser: async (): Promise => { await Promise.resolve(); return HAIKU; },
- saveId: async (id: string): Promise => { await Promise.resolve(); savedId = id; }
- });
-
- await resolveModel(deps);
-
- assert.strictEqual(savedId, HAIKU.id, 'Must save the picked model ID');
- });
-
- test('returns error when no models available', async () => {
- const deps = makeDeps({
- getSavedId: (): string => '',
- fetchAll: async (): Promise => { await Promise.resolve(); return []; }
- });
-
- const result = await resolveModel(deps);
-
- assert.ok(!result.ok, 'Expected error result');
- });
-
- test('returns error when user cancels quickpick', async () => {
- const deps = makeDeps({
- getSavedId: (): string => '',
- fetchAll: async (): Promise => { await Promise.resolve(); return ALL_MODELS; },
- promptUser: async (): Promise => { await Promise.resolve(); return undefined; }
- });
-
- const result = await resolveModel(deps);
-
- assert.ok(!result.ok, 'Expected error result');
- });
-
- test('falls back to prompt when saved model ID not found', async () => {
- let promptCalled = false;
- const deps = makeDeps({
- getSavedId: (): string => 'nonexistent-model',
- fetchById: async (): Promise => { await Promise.resolve(); return []; },
- fetchAll: async (): Promise => { await Promise.resolve(); return ALL_MODELS; },
- promptUser: async (): Promise => { await Promise.resolve(); promptCalled = true; return HAIKU; },
- saveId: async (): Promise => { await Promise.resolve(); }
- });
-
- const result = await resolveModel(deps);
-
- assert.ok(result.ok, 'Expected ok result');
- assert.strictEqual(promptCalled, true, 'Should prompt when saved model not found');
- assert.strictEqual(result.value.id, HAIKU.id);
- });
-});
-
-suite('pickConcreteModel (legacy — no longer used in main flow)', () => {
-
- test('returns specific model when preferredId is not auto', () => {
- const result = pickConcreteModel({ models: ALL_MODELS, preferredId: HAIKU.id });
- assert.ok(result, 'Expected a model to be returned');
- assert.strictEqual(result.id, HAIKU.id);
- assert.strictEqual(result.name, HAIKU.name);
- });
-
- test('skips auto and returns first concrete model', () => {
- const result = pickConcreteModel({ models: ALL_WITH_AUTO, preferredId: AUTO_MODEL_ID });
- assert.ok(result, 'Expected a concrete model');
- assert.strictEqual(result.id, OPUS.id, 'Must skip auto and pick first concrete model');
- assert.notStrictEqual(result.id, AUTO_MODEL_ID, 'Must NOT return auto model');
- });
-
- test('returns undefined when specific model not in list', () => {
- const result = pickConcreteModel({ models: ALL_MODELS, preferredId: 'nonexistent' });
- assert.strictEqual(result, undefined);
- });
-
- test('returns undefined for empty model list', () => {
- const result = pickConcreteModel({ models: [], preferredId: HAIKU.id });
- assert.strictEqual(result, undefined);
- });
-
- test('returns undefined for empty list with auto preferred', () => {
- const result = pickConcreteModel({ models: [], preferredId: AUTO_MODEL_ID });
- assert.strictEqual(result, undefined);
- });
-
- test('auto with only concrete models picks first', () => {
- const result = pickConcreteModel({ models: ALL_MODELS, preferredId: AUTO_MODEL_ID });
- assert.ok(result, 'Expected a model');
- assert.strictEqual(result.id, OPUS.id, 'Should pick first model when no auto in list');
- });
-});
-
-suite('Direct model lookup (selectCopilotModel fix)', () => {
-
- test('auto resolved ID selects auto model — NOT premium', () => {
- const models = ALL_WITH_AUTO;
- const resolvedId = AUTO_MODEL_ID;
-
- const selected = models.find(m => m.id === resolvedId);
-
- assert.ok(selected, 'Auto model must exist in list');
- assert.strictEqual(selected.id, AUTO_MODEL_ID, 'Must use auto model directly');
- assert.notStrictEqual(selected.id, OPUS.id, 'Must NOT resolve to premium opus model');
- });
-
- test('specific model ID selects that exact model', () => {
- const models = ALL_WITH_AUTO;
- const resolvedId = HAIKU.id;
-
- const selected = models.find(m => m.id === resolvedId);
-
- assert.ok(selected, 'Haiku model must be found');
- assert.strictEqual(selected.id, HAIKU.id);
- assert.strictEqual(selected.name, HAIKU.name);
- });
-
- test('nonexistent model ID returns undefined', () => {
- const models = ALL_WITH_AUTO;
- const resolvedId = 'nonexistent';
-
- const selected = models.find(m => m.id === resolvedId);
-
- assert.strictEqual(selected, undefined, 'Nonexistent model must not match');
- });
-});
diff --git a/src/test/unit/similarity.unit.test.ts b/src/test/unit/similarity.unit.test.ts
deleted file mode 100644
index 0b64d6d..0000000
--- a/src/test/unit/similarity.unit.test.ts
+++ /dev/null
@@ -1,201 +0,0 @@
-import * as assert from 'assert';
-import { cosineSimilarity, rankBySimilarity } from '../../semantic/similarity';
-
-/**
- * SPEC: ai-search-implementation
- *
- * UNIT TESTS for cosine similarity vector math.
- * Proves that vector proximity search actually works correctly.
- * Pure math - no VS Code, no I/O.
- */
-suite('Cosine Similarity Unit Tests', function () {
- this.timeout(5000);
-
- suite('cosineSimilarity', () => {
- test('identical vectors have similarity 1.0', () => {
- const a = new Float32Array([1, 2, 3, 4, 5]);
- const b = new Float32Array([1, 2, 3, 4, 5]);
- const sim = cosineSimilarity(a, b);
- assert.ok(
- Math.abs(sim - 1.0) < 0.0001,
- `Identical vectors should have similarity ~1.0, got ${sim}`
- );
- });
-
- test('orthogonal vectors have similarity 0.0', () => {
- const a = new Float32Array([1, 0, 0]);
- const b = new Float32Array([0, 1, 0]);
- const sim = cosineSimilarity(a, b);
- assert.ok(
- Math.abs(sim) < 0.0001,
- `Orthogonal vectors should have similarity ~0.0, got ${sim}`
- );
- });
-
- test('opposite vectors have similarity -1.0', () => {
- const a = new Float32Array([1, 2, 3]);
- const b = new Float32Array([-1, -2, -3]);
- const sim = cosineSimilarity(a, b);
- assert.ok(
- Math.abs(sim - (-1.0)) < 0.0001,
- `Opposite vectors should have similarity ~-1.0, got ${sim}`
- );
- });
-
- test('similar vectors have high positive similarity', () => {
- const a = new Float32Array([1, 2, 3, 4, 5]);
- const b = new Float32Array([1.1, 2.1, 3.1, 4.1, 5.1]);
- const sim = cosineSimilarity(a, b);
- assert.ok(
- sim > 0.99,
- `Similar vectors should have high similarity, got ${sim}`
- );
- });
-
- test('dissimilar vectors have low similarity', () => {
- const a = new Float32Array([1, 0, 0, 0, 0]);
- const b = new Float32Array([0, 0, 0, 0, 1]);
- const sim = cosineSimilarity(a, b);
- assert.ok(
- Math.abs(sim) < 0.01,
- `Dissimilar vectors should have low similarity, got ${sim}`
- );
- });
-
- test('works with 384-dim vectors (MiniLM embedding size)', () => {
- const a = new Float32Array(384);
- const b = new Float32Array(384);
- for (let i = 0; i < 384; i++) {
- a[i] = Math.sin(i * 0.1);
- b[i] = Math.sin(i * 0.1 + 0.01);
- }
- const sim = cosineSimilarity(a, b);
- assert.ok(
- sim > 0.99,
- `Slightly shifted 384-dim vectors should be very similar, got ${sim}`
- );
- });
-
- test('zero vector returns 0.0', () => {
- const a = new Float32Array([0, 0, 0]);
- const b = new Float32Array([1, 2, 3]);
- const sim = cosineSimilarity(a, b);
- assert.strictEqual(sim, 0, 'Zero vector should return 0.0');
- });
-
- test('is commutative: sim(a,b) === sim(b,a)', () => {
- const a = new Float32Array([3, 7, 2, 9, 1]);
- const b = new Float32Array([5, 1, 8, 3, 6]);
- const simAB = cosineSimilarity(a, b);
- const simBA = cosineSimilarity(b, a);
- assert.ok(
- Math.abs(simAB - simBA) < 0.0001,
- `sim(a,b)=${simAB} should equal sim(b,a)=${simBA}`
- );
- });
-
- test('magnitude does not affect similarity', () => {
- const a = new Float32Array([1, 2, 3]);
- const b = new Float32Array([2, 4, 6]);
- const sim = cosineSimilarity(a, b);
- assert.ok(
- Math.abs(sim - 1.0) < 0.0001,
- `Scaled vectors should have similarity 1.0, got ${sim}`
- );
- });
- });
-
- suite('rankBySimilarity', () => {
- test('returns candidates ranked by descending similarity', () => {
- const query = new Float32Array([1, 0, 0]);
- const candidates = [
- { id: 'far', embedding: new Float32Array([0, 1, 0]) },
- { id: 'close', embedding: new Float32Array([0.9, 0.1, 0]) },
- { id: 'medium', embedding: new Float32Array([0.5, 0.5, 0]) },
- ];
-
- const results = rankBySimilarity({ query, candidates, topK: 3, threshold: 0 });
-
- assert.strictEqual(results.length, 3, 'Should return all 3 candidates');
- assert.strictEqual(results[0]?.id, 'close', 'Most similar should be first');
- assert.strictEqual(results[1]?.id, 'medium', 'Medium similar should be second');
- assert.strictEqual(results[2]?.id, 'far', 'Least similar should be last');
- });
-
- test('respects topK limit', () => {
- const query = new Float32Array([1, 0, 0]);
- const candidates = [
- { id: 'a', embedding: new Float32Array([1, 0, 0]) },
- { id: 'b', embedding: new Float32Array([0.9, 0.1, 0]) },
- { id: 'c', embedding: new Float32Array([0.5, 0.5, 0]) },
- { id: 'd', embedding: new Float32Array([0, 1, 0]) },
- ];
-
- const results = rankBySimilarity({ query, candidates, topK: 2, threshold: 0 });
- assert.strictEqual(results.length, 2, 'Should return only topK candidates');
- assert.strictEqual(results[0]?.id, 'a');
- assert.strictEqual(results[1]?.id, 'b');
- });
-
- test('respects similarity threshold', () => {
- const query = new Float32Array([1, 0, 0]);
- const candidates = [
- { id: 'high', embedding: new Float32Array([0.95, 0.05, 0]) },
- { id: 'low', embedding: new Float32Array([0, 1, 0]) },
- ];
-
- const results = rankBySimilarity({ query, candidates, topK: 10, threshold: 0.5 });
- assert.strictEqual(results.length, 1, 'Should filter out below-threshold candidates');
- assert.strictEqual(results[0]?.id, 'high');
- });
-
- test('returns empty array when no candidates meet threshold', () => {
- const query = new Float32Array([1, 0, 0]);
- const candidates = [
- { id: 'a', embedding: new Float32Array([0, 1, 0]) },
- { id: 'b', embedding: new Float32Array([0, 0, 1]) },
- ];
-
- const results = rankBySimilarity({ query, candidates, topK: 10, threshold: 0.9 });
- assert.strictEqual(results.length, 0, 'No candidates should meet high threshold');
- });
-
- test('returns empty array for empty candidates', () => {
- const query = new Float32Array([1, 0, 0]);
- const results = rankBySimilarity({ query, candidates: [], topK: 10, threshold: 0 });
- assert.strictEqual(results.length, 0);
- });
-
- test('result scores are in descending order', () => {
- const query = new Float32Array([1, 0, 0, 0]);
- const candidates = [
- { id: 'a', embedding: new Float32Array([0.1, 0.9, 0, 0]) },
- { id: 'b', embedding: new Float32Array([0.8, 0.2, 0, 0]) },
- { id: 'c', embedding: new Float32Array([0.5, 0.5, 0, 0]) },
- ];
-
- const results = rankBySimilarity({ query, candidates, topK: 10, threshold: 0 });
-
- for (let i = 1; i < results.length; i++) {
- const prev = results[i - 1];
- const curr = results[i];
- assert.ok(
- prev !== undefined && curr !== undefined && prev.score >= curr.score,
- `Score ${prev?.score} should be >= ${curr?.score}`
- );
- }
- });
-
- test('skips candidates with null embeddings', () => {
- const query = new Float32Array([1, 0, 0]);
- const candidates = [
- { id: 'has-embed', embedding: new Float32Array([0.9, 0.1, 0]) },
- { id: 'no-embed', embedding: null as unknown as Float32Array },
- ];
-
- const results = rankBySimilarity({ query, candidates, topK: 10, threshold: 0 });
- assert.strictEqual(results.length, 1, 'Should skip null embeddings');
- assert.strictEqual(results[0]?.id, 'has-embed');
- });
- });
-});
diff --git a/src/tree/folderTree.ts b/src/tree/folderTree.ts
index 9b5051a..52f60e2 100644
--- a/src/tree/folderTree.ts
+++ b/src/tree/folderTree.ts
@@ -15,14 +15,12 @@ function renderFolder({
node,
parentDir,
parentTreeId,
- sortTasks,
- getScore
+ sortTasks
}: {
node: DirNode;
parentDir: string;
parentTreeId: string;
sortTasks: (tasks: TaskItem[]) => TaskItem[];
- getScore: (id: string) => number | undefined;
}): CommandTreeItem {
const label = getFolderLabel(node.dir, parentDir);
const folderId = `${parentTreeId}/${label}`;
@@ -30,35 +28,30 @@ function renderFolder({
t,
null,
[],
- folderId,
- getScore(t.id)
+ folderId
));
const subItems = node.subdirs.map(sub => renderFolder({
node: sub,
parentDir: node.dir,
parentTreeId: folderId,
- sortTasks,
- getScore
+ sortTasks
}));
return new CommandTreeItem(null, label, [...taskItems, ...subItems], parentTreeId);
}
/**
* Builds nested folder tree items from a flat list of tasks.
- * SPEC.md **ai-search-implementation**: Displays similarity scores as percentages.
*/
export function buildNestedFolderItems({
tasks,
workspaceRoot,
categoryId,
- sortTasks,
- getScore
+ sortTasks
}: {
tasks: TaskItem[];
workspaceRoot: string;
categoryId: string;
sortTasks: (tasks: TaskItem[]) => TaskItem[];
- getScore: (id: string) => number | undefined;
}): CommandTreeItem[] {
const groups = groupByFullDir(tasks, workspaceRoot);
const rootNodes = buildDirTree(groups);
@@ -70,16 +63,14 @@ export function buildNestedFolderItems({
node,
parentDir: '',
parentTreeId: categoryId,
- sortTasks,
- getScore
+ sortTasks
}));
} else {
const items = sortTasks(node.tasks).map(t => new CommandTreeItem(
t,
null,
[],
- categoryId,
- getScore(t.id)
+ categoryId
));
result.push(...items);
}
From 52f8092b7873bc96c663c9db0603b72ca7b3b4a5 Mon Sep 17 00:00:00 2001
From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com>
Date: Tue, 24 Feb 2026 06:20:50 +1100
Subject: [PATCH 05/30] Fix tree hierarchy
---
.claude/skills/fix-bug/SKILL.md | 66 +++++++++++++++++++
Agents.md | 45 ++++++++++---
package-lock.json | 4 +-
package.json | 4 +-
src/test/e2e/treeview.e2e.test.ts | 41 ++++++++++++
.../workspace/scripts/subdir/nested.sh | 3 +
6 files changed, 151 insertions(+), 12 deletions(-)
create mode 100644 .claude/skills/fix-bug/SKILL.md
create mode 100644 src/test/fixtures/workspace/scripts/subdir/nested.sh
diff --git a/.claude/skills/fix-bug/SKILL.md b/.claude/skills/fix-bug/SKILL.md
new file mode 100644
index 0000000..0bb15ce
--- /dev/null
+++ b/.claude/skills/fix-bug/SKILL.md
@@ -0,0 +1,66 @@
+---
+name: fix-bug
+description: Fix a bug using test-driven development. Use when the user reports a bug, describes unexpected behavior, wants to fix a defect, or says something is broken. Enforces a strict test-first workflow where a failing test must be written and verified before any fix is attempted.
+argument-hint: "[bug description]"
+allowed-tools: Read, Grep, Glob, Edit, Write, Bash
+---
+
+# Bug Fix Skill — Test-First Workflow
+
+You MUST follow this exact workflow. Do NOT skip steps. Do NOT fix the bug before writing a failing test.
+
+## Step 1: Understand the Bug
+
+- Read the bug description: $ARGUMENTS
+- Investigate the codebase to understand the relevant code
+- Identify the root cause (or narrow down candidates)
+- Summarize your understanding of the bug to the user before proceeding
+
+## Step 2: Write a Failing Test
+
+- Write a test that **directly exercises the buggy behavior**
+- The test must assert the **correct/expected** behavior — so it FAILS against the current broken code
+- The test name should clearly describe the bug (e.g., `test_orange_color_not_applied_to_head`)
+- Use the project's existing test framework and conventions
+
+## Step 3: Run the Test — Confirm It FAILS
+
+- Run ONLY the new test (not the full suite)
+- **Verify the test FAILS** and that it fails **because of the bug**, not for some other reason (typo, import error, wrong selector, etc.)
+- If the test passes: your test does not capture the bug. Go back to Step 2
+- If the test fails for the wrong reason: fix the test, not the code. Go back to Step 2
+- **Repeat until the test fails specifically because of the bug**
+
+## Step 4: Show Failure to User
+
+- Show the user the test code and the failure output
+- Explicitly ask: "This test fails because of the bug. Can you confirm this captures the issue before I fix it?"
+- **STOP and WAIT for user acknowledgment before proceeding**
+- Do NOT continue to Step 5 until the user confirms
+
+## Step 5: Fix the Bug
+
+- Make the **minimum change** needed to fix the bug
+- Do not refactor, clean up, or "improve" surrounding code
+- Do not change the test
+
+## Step 6: Run the Test — Confirm It PASSES
+
+- Run the new test again
+- **Verify it PASSES**
+- If it fails: go back to Step 5 and adjust the fix
+- **Repeat until the test passes**
+
+## Step 7: Run the Full Test Suite
+
+- Run ALL tests to make sure nothing else broke
+- If other tests fail: fix the regression without breaking the new test
+- Report the final result to the user
+
+## Rules
+
+- NEVER fix the bug before the failing test is written and confirmed
+- NEVER skip asking the user to acknowledge the test failure
+- NEVER modify the test to make it pass — modify the source code
+- If you cannot write a test for the bug, explain why and ask the user how to proceed
+- Keep the fix minimal — one bug, one fix, one test
diff --git a/Agents.md b/Agents.md
index e12ed08..e07e22a 100644
--- a/Agents.md
+++ b/Agents.md
@@ -11,9 +11,10 @@ You are working with many other agents. Make sure there is effective cooperation
- **Zero duplication - TOP PRIORITY** - Always search for existing code before adding. Move; don't copy files. Add assertions to tests rather than duplicating tests. AIM FOR LESS CODE!
- **No string literals** - Named constants only, and it ONE location
+- DO NOT USE GIT
- **Functional style** - Prefer pure functions, avoid classes where possible
- **No suppressing warnings** - Fix them properly
-- **No REGEX** It is absolutely ⛔️ illegal
+- **No REGEX** It is absolutely ⛔️ illegal, and no text matching in general
- **Don't run long runnings tasks** like docker builds, tests. Ask the user to do it!!
- **Expressions over assignments** - Prefer const and immutable patterns
- **Named parameters** - Use object params for functions with 3+ args
@@ -23,6 +24,8 @@ You are working with many other agents. Make sure there is effective cooperation
### Typescript
- **TypeScript strict mode** - No `any`, no implicit types, turn all lints up to error
+- **Regularly run the linter** - Fix lint errors IMMEDIATELY
+- **Decouple providers from the VSCODE SDK** - No vscode sdk use within the providers
- **Ignoring lints = ⛔️ illegal** - Fix violations immediately
- **No throwing** - Only return `Result`
@@ -36,7 +39,6 @@ You are working with many other agents. Make sure there is effective cooperation
#### Rules
- **Prefer e2e tests over unit tests** - only unit tests for isolating bugs
-- DO NOT USE GIT
- Separate e2e tests from unit tests by file. They should not be in the same file together.
- Prefer adding assertions to existing tests rather than adding new tests
- Test files in `src/test/suite/*.test.ts`
@@ -96,8 +98,21 @@ assert.ok(true, 'Command ran');
## Critical Docs
+### Vscode SDK
[VSCode Extension API](https://code.visualstudio.com/api/)
-[SCode Extension Testing API](https://code.visualstudio.com/api/extension-guides/testing)
+[VSCode Extension Testing API](https://code.visualstudio.com/api/extension-guides/testing)
+[VSCODE Language Model API](https://code.visualstudio.com/api/extension-guides/ai/language-model)
+[Language Model Tool API](https://code.visualstudio.com/api/extension-guides/ai/tools)
+[AI extensibility in VS Cod](https://code.visualstudio.com/api/extension-guides/ai/ai-extensibility-overview)
+[AI language models in VS Code](https://code.visualstudio.com/docs/copilot/customization/language-models)
+
+### Website
+
+https://developers.google.com/search/blog/2025/05/succeeding-in-ai-search
+https://developers.google.com/search/docs/fundamentals/seo-starter-guide
+
+https://studiohawk.com.au/blog/how-to-optimise-ai-overviews/
+https://about.ads.microsoft.com/en/blog/post/october-2025/optimizing-your-content-for-inclusion-in-ai-search-answers
## Project Structure
@@ -110,11 +125,25 @@ CommandTree/
│ │ └── TagConfig.ts # Tag configuration from commandtree.json
│ ├── discovery/
│ │ ├── index.ts # Discovery orchestration
-│ │ ├── shell.ts # Shell script discovery
-│ │ ├── npm.ts # NPM script discovery
-│ │ ├── make.ts # Makefile target discovery
-│ │ ├── launch.ts # launch.json discovery
-│ │ └── tasks.ts # tasks.json discovery
+│ │ ├── shell.ts # Shell scripts (.sh, .bash, .zsh)
+│ │ ├── npm.ts # NPM scripts (package.json)
+│ │ ├── make.ts # Makefile targets
+│ │ ├── launch.ts # VS Code launch configs
+│ │ ├── tasks.ts # VS Code tasks
+│ │ ├── python.ts # Python scripts (.py)
+│ │ ├── powershell.ts # PowerShell scripts (.ps1)
+│ │ ├── gradle.ts # Gradle tasks
+│ │ ├── cargo.ts # Cargo (Rust) tasks
+│ │ ├── maven.ts # Maven goals (pom.xml)
+│ │ ├── ant.ts # Ant targets (build.xml)
+│ │ ├── just.ts # Just recipes (justfile)
+│ │ ├── taskfile.ts # Taskfile tasks (Taskfile.yml)
+│ │ ├── deno.ts # Deno tasks (deno.json)
+│ │ ├── rake.ts # Rake tasks (Rakefile)
+│ │ ├── composer.ts # Composer scripts (composer.json)
+│ │ ├── docker.ts # Docker Compose services
+│ │ ├── dotnet.ts # .NET projects (.csproj)
+│ │ └── markdown.ts # Markdown files (.md)
│ ├── models/
│ │ └── TaskItem.ts # Task data model and TreeItem
│ ├── runners/
diff --git a/package-lock.json b/package-lock.json
index 6d30c2c..99a407c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "commandtree",
- "version": "0.5.0",
+ "version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "commandtree",
- "version": "0.5.0",
+ "version": "0.6.0",
"license": "MIT",
"dependencies": {
"node-sqlite3-wasm": "^0.8.53"
diff --git a/package.json b/package.json
index 9cc0474..5b61f73 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "commandtree",
"displayName": "CommandTree",
"description": "Unified command runner: discover shell scripts, npm scripts, Makefiles, launch configs, VS Code tasks and more in one filterable tree",
- "version": "0.5.0",
+ "version": "0.6.0",
"author": "Christian Findlay",
"license": "MIT",
"publisher": "nimblesite",
@@ -364,7 +364,7 @@
"clean": "rm -rf node_modules out *.vsix coverage || true",
"package": "npm run compile && vsce package",
"uninstall": "code --uninstall-extension nimblesite.commandtree || true",
- "install-ext": "code --install-extension commandtree-*.vsix",
+ "install-ext": "ls commandtree-*.vsix | xargs code --install-extension",
"build-and-install": "npm run clean && npm install && npm run uninstall && npm run package && npm run install-ext"
},
"devDependencies": {
diff --git a/src/test/e2e/treeview.e2e.test.ts b/src/test/e2e/treeview.e2e.test.ts
index f190fee..117bb13 100644
--- a/src/test/e2e/treeview.e2e.test.ts
+++ b/src/test/e2e/treeview.e2e.test.ts
@@ -11,6 +11,7 @@ import {
activateExtension,
sleep,
getCommandTreeProvider,
+ getLabelString,
} from "../helpers/helpers";
import type { CommandTreeItem } from "../../models/TaskItem";
@@ -99,4 +100,44 @@ suite("TreeView E2E Tests", () => {
);
});
});
+
+ suite("Folder Hierarchy", () => {
+ test("folders must come before files in tree — normal file/folder rules", async function () {
+ this.timeout(15000);
+ const provider = getCommandTreeProvider();
+ const categories = await provider.getChildren();
+ const shellCategory = categories.find(
+ (c) => getLabelString(c.label).includes("Shell Scripts"),
+ );
+ assert.ok(
+ shellCategory !== undefined,
+ "Should find Shell Scripts category",
+ );
+
+ const topChildren = await provider.getChildren(shellCategory);
+ const mixedFolder = topChildren.find(
+ (c) =>
+ c.task === null &&
+ c.children.some((gc) => gc.task !== null) &&
+ c.children.some((gc) => gc.task === null),
+ );
+ assert.ok(
+ mixedFolder !== undefined,
+ "Should find a folder containing both files and subfolders",
+ );
+
+ const kids = mixedFolder.children;
+ let seenTask = false;
+ for (const child of kids) {
+ if (child.task !== null) {
+ seenTask = true;
+ } else {
+ assert.ok(
+ !seenTask,
+ "Folder node must not appear after a file node — folders come first",
+ );
+ }
+ }
+ });
+ });
});
diff --git a/src/test/fixtures/workspace/scripts/subdir/nested.sh b/src/test/fixtures/workspace/scripts/subdir/nested.sh
new file mode 100644
index 0000000..1ffbef6
--- /dev/null
+++ b/src/test/fixtures/workspace/scripts/subdir/nested.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+# Nested script for testing folder hierarchy
+echo "nested"
From 6cc85f4e2c88cd8e4d7e2035a9ea02d70ab04af7 Mon Sep 17 00:00:00 2001
From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com>
Date: Tue, 24 Feb 2026 06:38:34 +1100
Subject: [PATCH 06/30] Fixed?
---
src/tree/folderTree.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/tree/folderTree.ts b/src/tree/folderTree.ts
index 52f60e2..db982e5 100644
--- a/src/tree/folderTree.ts
+++ b/src/tree/folderTree.ts
@@ -36,7 +36,7 @@ function renderFolder({
parentTreeId: folderId,
sortTasks
}));
- return new CommandTreeItem(null, label, [...taskItems, ...subItems], parentTreeId);
+ return new CommandTreeItem(null, label, [...subItems, ...taskItems], parentTreeId);
}
/**
From d5a9dd66c15ad8dd8e3c912434a972d4f66683bb Mon Sep 17 00:00:00 2001
From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com>
Date: Tue, 24 Feb 2026 07:04:20 +1100
Subject: [PATCH 07/30] Refactor tree nodes
---
src/CommandTreeProvider.ts | 32 ++--
src/QuickTasksProvider.ts | 7 +-
src/discovery/ant.ts | 4 +-
src/discovery/cargo.ts | 4 +-
src/discovery/composer.ts | 4 +-
src/discovery/deno.ts | 4 +-
src/discovery/docker.ts | 4 +-
src/discovery/dotnet.ts | 4 +-
src/discovery/gradle.ts | 4 +-
src/discovery/index.ts | 62 +++++---
src/discovery/just.ts | 4 +-
src/discovery/launch.ts | 4 +-
src/discovery/make.ts | 4 +-
src/discovery/markdown.ts | 4 +-
src/discovery/maven.ts | 4 +-
src/discovery/npm.ts | 4 +-
src/discovery/powershell.ts | 4 +-
src/discovery/python.ts | 4 +-
src/discovery/rake.ts | 4 +-
src/discovery/shell.ts | 4 +-
src/discovery/taskfile.ts | 4 +-
src/discovery/tasks.ts | 4 +-
src/models/TaskItem.ts | 223 +++++-----------------------
src/test/e2e/quicktasks.e2e.test.ts | 32 ++--
src/tree/folderTree.ts | 24 ++-
src/tree/nodeFactory.ts | 92 ++++++++++++
26 files changed, 279 insertions(+), 269 deletions(-)
create mode 100644 src/tree/nodeFactory.ts
diff --git a/src/CommandTreeProvider.ts b/src/CommandTreeProvider.ts
index 76999a4..0e006e7 100644
--- a/src/CommandTreeProvider.ts
+++ b/src/CommandTreeProvider.ts
@@ -1,16 +1,17 @@
import * as vscode from 'vscode';
-import type { TaskItem, Result } from './models/TaskItem';
-import { CommandTreeItem } from './models/TaskItem';
+import type { TaskItem, TaskType, Result } from './models/TaskItem';
+import type { CommandTreeItem } from './models/TaskItem';
import type { DiscoveryResult } from './discovery';
import { discoverAllTasks, flattenTasks, getExcludePatterns } from './discovery';
import { TagConfig } from './config/TagConfig';
import { logger } from './utils/logger';
import { buildNestedFolderItems } from './tree/folderTree';
+import { createTaskNode, createCategoryNode } from './tree/nodeFactory';
type SortOrder = 'folder' | 'name' | 'type';
interface CategoryDef {
- readonly type: string;
+ readonly type: TaskType;
readonly label: string;
readonly flat?: boolean;
}
@@ -139,25 +140,32 @@ export class CommandTreeProvider implements vscode.TreeDataProvider t.type === def.type);
if (matched.length === 0) { return null; }
return def.flat === true
- ? this.buildFlatCategory(def.label, matched)
- : this.buildCategoryWithFolders(def.label, matched);
+ ? this.buildFlatCategory(def, matched)
+ : this.buildCategoryWithFolders(def, matched);
}
- private buildCategoryWithFolders(name: string, tasks: TaskItem[]): CommandTreeItem {
+ private buildCategoryWithFolders(def: CategoryDef, tasks: TaskItem[]): CommandTreeItem {
const children = buildNestedFolderItems({
tasks,
workspaceRoot: this.workspaceRoot,
- categoryId: name,
+ categoryId: def.label,
sortTasks: (t) => this.sortTasks(t)
});
- return new CommandTreeItem(null, `${name} (${tasks.length})`, children);
+ return createCategoryNode({
+ label: `${def.label} (${tasks.length})`,
+ children,
+ type: def.type,
+ });
}
- private buildFlatCategory(name: string, tasks: TaskItem[]): CommandTreeItem {
+ private buildFlatCategory(def: CategoryDef, tasks: TaskItem[]): CommandTreeItem {
const sorted = this.sortTasks(tasks);
- const categoryId = name;
- const children = sorted.map(t => new CommandTreeItem(t, null, [], categoryId));
- return new CommandTreeItem(null, `${name} (${tasks.length})`, children);
+ const children = sorted.map(t => createTaskNode(t));
+ return createCategoryNode({
+ label: `${def.label} (${tasks.length})`,
+ children,
+ type: def.type,
+ });
}
private getSortOrder(): SortOrder {
diff --git a/src/QuickTasksProvider.ts b/src/QuickTasksProvider.ts
index 7981be0..5e87bab 100644
--- a/src/QuickTasksProvider.ts
+++ b/src/QuickTasksProvider.ts
@@ -6,11 +6,12 @@
import * as vscode from 'vscode';
import type { TaskItem, Result } from './models/TaskItem';
-import { CommandTreeItem } from './models/TaskItem';
+import type { CommandTreeItem } from './models/TaskItem';
import { TagConfig } from './config/TagConfig';
import { logger } from './utils/logger';
import { getDb } from './db/lifecycle';
import { getCommandIdsByTag } from './db/db';
+import { createTaskNode, createPlaceholderNode } from './tree/nodeFactory';
const QUICK_TASK_MIME_TYPE = 'application/vnd.commandtree.quicktask';
const QUICK_TAG = 'quick';
@@ -109,10 +110,10 @@ export class QuickTasksProvider implements vscode.TreeDataProvider task.tags.includes(QUICK_TAG));
logger.quick('Filtered quick tasks', { count: quickTasks.length });
if (quickTasks.length === 0) {
- return [new CommandTreeItem(null, 'No quick commands - star commands to add them here', [])];
+ return [createPlaceholderNode('No quick commands - star commands to add them here')];
}
const sorted = this.sortByDisplayOrder(quickTasks);
- return sorted.map(task => new CommandTreeItem(task, null, []));
+ return sorted.map(task => createTaskNode(task));
}
/**
diff --git a/src/discovery/ant.ts b/src/discovery/ant.ts
index 8658737..59a65b1 100644
--- a/src/discovery/ant.ts
+++ b/src/discovery/ant.ts
@@ -1,9 +1,11 @@
import * as vscode from 'vscode';
import * as path from 'path';
-import type { TaskItem } from '../models/TaskItem';
+import type { TaskItem, IconDef } from '../models/TaskItem';
import { generateTaskId, simplifyPath } from '../models/TaskItem';
import { readFile } from '../utils/fileUtils';
+export const ICON_DEF: IconDef = { icon: 'symbol-constructor', color: 'terminal.ansiYellow' };
+
/**
* Discovers Ant targets from build.xml files.
* Only returns tasks if Java source files (.java) exist in the workspace.
diff --git a/src/discovery/cargo.ts b/src/discovery/cargo.ts
index aedd7d6..0263222 100644
--- a/src/discovery/cargo.ts
+++ b/src/discovery/cargo.ts
@@ -1,9 +1,11 @@
import * as vscode from 'vscode';
import * as path from 'path';
-import type { TaskItem } from '../models/TaskItem';
+import type { TaskItem, IconDef } from '../models/TaskItem';
import { generateTaskId, simplifyPath } from '../models/TaskItem';
import { readFile } from '../utils/fileUtils';
+export const ICON_DEF: IconDef = { icon: 'package', color: 'terminal.ansiRed' };
+
/**
* Standard Cargo commands that are always available.
*/
diff --git a/src/discovery/composer.ts b/src/discovery/composer.ts
index d97f822..5d6b50b 100644
--- a/src/discovery/composer.ts
+++ b/src/discovery/composer.ts
@@ -1,9 +1,11 @@
import * as vscode from 'vscode';
import * as path from 'path';
-import type { TaskItem, MutableTaskItem } from '../models/TaskItem';
+import type { TaskItem, MutableTaskItem, IconDef } from '../models/TaskItem';
import { generateTaskId, simplifyPath } from '../models/TaskItem';
import { readFile, parseJson } from '../utils/fileUtils';
+export const ICON_DEF: IconDef = { icon: 'symbol-interface', color: 'terminal.ansiYellow' };
+
interface ComposerJson {
scripts?: Record;
'scripts-descriptions'?: Record;
diff --git a/src/discovery/deno.ts b/src/discovery/deno.ts
index a282cd3..2826f6c 100644
--- a/src/discovery/deno.ts
+++ b/src/discovery/deno.ts
@@ -1,9 +1,11 @@
import * as vscode from 'vscode';
import * as path from 'path';
-import type { TaskItem, MutableTaskItem } from '../models/TaskItem';
+import type { TaskItem, MutableTaskItem, IconDef } from '../models/TaskItem';
import { generateTaskId, simplifyPath } from '../models/TaskItem';
import { readFile, parseJson } from '../utils/fileUtils';
+export const ICON_DEF: IconDef = { icon: 'symbol-namespace', color: 'terminal.ansiWhite' };
+
interface DenoJson {
tasks?: Record;
}
diff --git a/src/discovery/docker.ts b/src/discovery/docker.ts
index cda0154..69cae79 100644
--- a/src/discovery/docker.ts
+++ b/src/discovery/docker.ts
@@ -1,9 +1,11 @@
import * as vscode from 'vscode';
import * as path from 'path';
-import type { TaskItem, MutableTaskItem } from '../models/TaskItem';
+import type { TaskItem, MutableTaskItem, IconDef } from '../models/TaskItem';
import { generateTaskId, simplifyPath } from '../models/TaskItem';
import { readFile } from '../utils/fileUtils';
+export const ICON_DEF: IconDef = { icon: 'server-environment', color: 'terminal.ansiBlue' };
+
/**
* Discovers Docker Compose services from docker-compose.yml files.
*/
diff --git a/src/discovery/dotnet.ts b/src/discovery/dotnet.ts
index e4fa55e..5d4f56c 100644
--- a/src/discovery/dotnet.ts
+++ b/src/discovery/dotnet.ts
@@ -1,9 +1,11 @@
import * as vscode from 'vscode';
import * as path from 'path';
-import type { TaskItem, ParamDef, MutableTaskItem } from '../models/TaskItem';
+import type { TaskItem, ParamDef, MutableTaskItem, IconDef } from '../models/TaskItem';
import { generateTaskId, simplifyPath } from '../models/TaskItem';
import { readFile } from '../utils/fileUtils';
+export const ICON_DEF: IconDef = { icon: 'circuit-board', color: 'terminal.ansiMagenta' };
+
interface ProjectInfo {
isTestProject: boolean;
isExecutable: boolean;
diff --git a/src/discovery/gradle.ts b/src/discovery/gradle.ts
index 5de8a40..ae9c5f5 100644
--- a/src/discovery/gradle.ts
+++ b/src/discovery/gradle.ts
@@ -1,9 +1,11 @@
import * as vscode from 'vscode';
import * as path from 'path';
-import type { TaskItem } from '../models/TaskItem';
+import type { TaskItem, IconDef } from '../models/TaskItem';
import { generateTaskId, simplifyPath } from '../models/TaskItem';
import { readFile } from '../utils/fileUtils';
+export const ICON_DEF: IconDef = { icon: 'symbol-property', color: 'terminal.ansiGreen' };
+
/**
* Discovers Gradle tasks from build.gradle and build.gradle.kts files.
* Only returns tasks if Java, Kotlin, or Groovy source files exist in the workspace.
diff --git a/src/discovery/index.ts b/src/discovery/index.ts
index fe36e88..404ff82 100644
--- a/src/discovery/index.ts
+++ b/src/discovery/index.ts
@@ -1,26 +1,48 @@
import * as vscode from 'vscode';
-import type { TaskItem } from '../models/TaskItem';
-import { discoverShellScripts } from './shell';
-import { discoverNpmScripts } from './npm';
-import { discoverMakeTargets } from './make';
-import { discoverLaunchConfigs } from './launch';
-import { discoverVsCodeTasks } from './tasks';
-import { discoverPythonScripts } from './python';
-import { discoverPowerShellScripts } from './powershell';
-import { discoverGradleTasks } from './gradle';
-import { discoverCargoTasks } from './cargo';
-import { discoverMavenGoals } from './maven';
-import { discoverAntTargets } from './ant';
-import { discoverJustRecipes } from './just';
-import { discoverTaskfileTasks } from './taskfile';
-import { discoverDenoTasks } from './deno';
-import { discoverRakeTasks } from './rake';
-import { discoverComposerScripts } from './composer';
-import { discoverDockerComposeServices } from './docker';
-import { discoverDotnetProjects } from './dotnet';
-import { discoverMarkdownFiles } from './markdown';
+import type { TaskItem, TaskType, IconDef } from '../models/TaskItem';
+import { discoverShellScripts, ICON_DEF as SHELL_ICON } from './shell';
+import { discoverNpmScripts, ICON_DEF as NPM_ICON } from './npm';
+import { discoverMakeTargets, ICON_DEF as MAKE_ICON } from './make';
+import { discoverLaunchConfigs, ICON_DEF as LAUNCH_ICON } from './launch';
+import { discoverVsCodeTasks, ICON_DEF as VSCODE_ICON } from './tasks';
+import { discoverPythonScripts, ICON_DEF as PYTHON_ICON } from './python';
+import { discoverPowerShellScripts, ICON_DEF as POWERSHELL_ICON } from './powershell';
+import { discoverGradleTasks, ICON_DEF as GRADLE_ICON } from './gradle';
+import { discoverCargoTasks, ICON_DEF as CARGO_ICON } from './cargo';
+import { discoverMavenGoals, ICON_DEF as MAVEN_ICON } from './maven';
+import { discoverAntTargets, ICON_DEF as ANT_ICON } from './ant';
+import { discoverJustRecipes, ICON_DEF as JUST_ICON } from './just';
+import { discoverTaskfileTasks, ICON_DEF as TASKFILE_ICON } from './taskfile';
+import { discoverDenoTasks, ICON_DEF as DENO_ICON } from './deno';
+import { discoverRakeTasks, ICON_DEF as RAKE_ICON } from './rake';
+import { discoverComposerScripts, ICON_DEF as COMPOSER_ICON } from './composer';
+import { discoverDockerComposeServices, ICON_DEF as DOCKER_ICON } from './docker';
+import { discoverDotnetProjects, ICON_DEF as DOTNET_ICON } from './dotnet';
+import { discoverMarkdownFiles, ICON_DEF as MARKDOWN_ICON } from './markdown';
import { logger } from '../utils/logger';
+export const ICON_REGISTRY: Record = {
+ shell: SHELL_ICON,
+ npm: NPM_ICON,
+ make: MAKE_ICON,
+ launch: LAUNCH_ICON,
+ vscode: VSCODE_ICON,
+ python: PYTHON_ICON,
+ powershell: POWERSHELL_ICON,
+ gradle: GRADLE_ICON,
+ cargo: CARGO_ICON,
+ maven: MAVEN_ICON,
+ ant: ANT_ICON,
+ just: JUST_ICON,
+ taskfile: TASKFILE_ICON,
+ deno: DENO_ICON,
+ rake: RAKE_ICON,
+ composer: COMPOSER_ICON,
+ docker: DOCKER_ICON,
+ dotnet: DOTNET_ICON,
+ markdown: MARKDOWN_ICON,
+};
+
export interface DiscoveryResult {
shell: TaskItem[];
npm: TaskItem[];
diff --git a/src/discovery/just.ts b/src/discovery/just.ts
index 29d98d8..5851d6e 100644
--- a/src/discovery/just.ts
+++ b/src/discovery/just.ts
@@ -1,9 +1,11 @@
import * as vscode from 'vscode';
import * as path from 'path';
-import type { TaskItem, ParamDef, MutableTaskItem } from '../models/TaskItem';
+import type { TaskItem, ParamDef, MutableTaskItem, IconDef } from '../models/TaskItem';
import { generateTaskId, simplifyPath } from '../models/TaskItem';
import { readFile } from '../utils/fileUtils';
+export const ICON_DEF: IconDef = { icon: 'checklist', color: 'terminal.ansiMagenta' };
+
/**
* Discovers Just recipes from justfile.
*/
diff --git a/src/discovery/launch.ts b/src/discovery/launch.ts
index 3821436..1e4d172 100644
--- a/src/discovery/launch.ts
+++ b/src/discovery/launch.ts
@@ -1,8 +1,10 @@
import * as vscode from 'vscode';
-import type { TaskItem, MutableTaskItem } from '../models/TaskItem';
+import type { TaskItem, MutableTaskItem, IconDef } from '../models/TaskItem';
import { generateTaskId } from '../models/TaskItem';
import { readJsonFile } from '../utils/fileUtils';
+export const ICON_DEF: IconDef = { icon: 'debug-alt', color: 'debugIcon.startForeground' };
+
interface LaunchConfig {
name?: string;
type?: string;
diff --git a/src/discovery/make.ts b/src/discovery/make.ts
index 113a07f..a69bdd4 100644
--- a/src/discovery/make.ts
+++ b/src/discovery/make.ts
@@ -1,9 +1,11 @@
import * as vscode from 'vscode';
import * as path from 'path';
-import type { TaskItem } from '../models/TaskItem';
+import type { TaskItem, IconDef } from '../models/TaskItem';
import { generateTaskId, simplifyPath } from '../models/TaskItem';
import { readFile } from '../utils/fileUtils';
+export const ICON_DEF: IconDef = { icon: 'tools', color: 'terminal.ansiYellow' };
+
/**
* SPEC: command-discovery/makefile-targets
*
diff --git a/src/discovery/markdown.ts b/src/discovery/markdown.ts
index 41957ca..7599192 100644
--- a/src/discovery/markdown.ts
+++ b/src/discovery/markdown.ts
@@ -1,9 +1,11 @@
import * as vscode from 'vscode';
import * as path from 'path';
-import type { TaskItem, MutableTaskItem } from '../models/TaskItem';
+import type { TaskItem, MutableTaskItem, IconDef } from '../models/TaskItem';
import { generateTaskId, simplifyPath } from '../models/TaskItem';
import { readFile } from '../utils/fileUtils';
+export const ICON_DEF: IconDef = { icon: 'markdown', color: 'terminal.ansiCyan' };
+
const MAX_DESCRIPTION_LENGTH = 150;
/**
diff --git a/src/discovery/maven.ts b/src/discovery/maven.ts
index 70941fa..41ce2fe 100644
--- a/src/discovery/maven.ts
+++ b/src/discovery/maven.ts
@@ -1,8 +1,10 @@
import * as vscode from 'vscode';
import * as path from 'path';
-import type { TaskItem } from '../models/TaskItem';
+import type { TaskItem, IconDef } from '../models/TaskItem';
import { generateTaskId, simplifyPath } from '../models/TaskItem';
+export const ICON_DEF: IconDef = { icon: 'library', color: 'terminal.ansiRed' };
+
/**
* Standard Maven goals/phases.
*/
diff --git a/src/discovery/npm.ts b/src/discovery/npm.ts
index 723bbb0..7b3d315 100644
--- a/src/discovery/npm.ts
+++ b/src/discovery/npm.ts
@@ -1,9 +1,11 @@
import * as vscode from 'vscode';
import * as path from 'path';
-import type { TaskItem } from '../models/TaskItem';
+import type { TaskItem, IconDef } from '../models/TaskItem';
import { generateTaskId, simplifyPath } from '../models/TaskItem';
import { readFile, parseJson } from '../utils/fileUtils';
+export const ICON_DEF: IconDef = { icon: 'package', color: 'terminal.ansiMagenta' };
+
interface PackageJson {
scripts?: Record;
}
diff --git a/src/discovery/powershell.ts b/src/discovery/powershell.ts
index 894dc29..0807ebb 100644
--- a/src/discovery/powershell.ts
+++ b/src/discovery/powershell.ts
@@ -1,9 +1,11 @@
import * as vscode from 'vscode';
import * as path from 'path';
-import type { TaskItem, ParamDef, MutableTaskItem } from '../models/TaskItem';
+import type { TaskItem, ParamDef, MutableTaskItem, IconDef } from '../models/TaskItem';
import { generateTaskId, simplifyPath } from '../models/TaskItem';
import { readFile } from '../utils/fileUtils';
+export const ICON_DEF: IconDef = { icon: 'terminal-powershell', color: 'terminal.ansiBlue' };
+
/**
* Discovers PowerShell and Batch scripts (.ps1, .bat, .cmd files) in the workspace.
*/
diff --git a/src/discovery/python.ts b/src/discovery/python.ts
index 13a14d3..ae203ca 100644
--- a/src/discovery/python.ts
+++ b/src/discovery/python.ts
@@ -1,9 +1,11 @@
import * as vscode from 'vscode';
import * as path from 'path';
-import type { TaskItem, ParamDef, MutableTaskItem } from '../models/TaskItem';
+import type { TaskItem, ParamDef, MutableTaskItem, IconDef } from '../models/TaskItem';
import { generateTaskId, simplifyPath } from '../models/TaskItem';
import { readFile } from '../utils/fileUtils';
+export const ICON_DEF: IconDef = { icon: 'symbol-misc', color: 'terminal.ansiCyan' };
+
/**
* SPEC: command-discovery/python-scripts
*
diff --git a/src/discovery/rake.ts b/src/discovery/rake.ts
index 7600ac5..293acb7 100644
--- a/src/discovery/rake.ts
+++ b/src/discovery/rake.ts
@@ -1,9 +1,11 @@
import * as vscode from 'vscode';
import * as path from 'path';
-import type { TaskItem, MutableTaskItem } from '../models/TaskItem';
+import type { TaskItem, MutableTaskItem, IconDef } from '../models/TaskItem';
import { generateTaskId, simplifyPath } from '../models/TaskItem';
import { readFile } from '../utils/fileUtils';
+export const ICON_DEF: IconDef = { icon: 'ruby', color: 'terminal.ansiRed' };
+
/**
* Discovers Rake tasks from Rakefile.
* Only returns tasks if Ruby source files (.rb) exist in the workspace.
diff --git a/src/discovery/shell.ts b/src/discovery/shell.ts
index d5e51c7..1f6a2fd 100644
--- a/src/discovery/shell.ts
+++ b/src/discovery/shell.ts
@@ -1,9 +1,11 @@
import * as vscode from 'vscode';
import * as path from 'path';
-import type { TaskItem, ParamDef, MutableTaskItem } from '../models/TaskItem';
+import type { TaskItem, ParamDef, MutableTaskItem, IconDef } from '../models/TaskItem';
import { generateTaskId, simplifyPath } from '../models/TaskItem';
import { readFile } from '../utils/fileUtils';
+export const ICON_DEF: IconDef = { icon: 'terminal', color: 'terminal.ansiGreen' };
+
/**
* SPEC: command-discovery/shell-scripts
*
diff --git a/src/discovery/taskfile.ts b/src/discovery/taskfile.ts
index 516b302..eecaf0d 100644
--- a/src/discovery/taskfile.ts
+++ b/src/discovery/taskfile.ts
@@ -1,9 +1,11 @@
import * as vscode from 'vscode';
import * as path from 'path';
-import type { TaskItem, MutableTaskItem } from '../models/TaskItem';
+import type { TaskItem, MutableTaskItem, IconDef } from '../models/TaskItem';
import { generateTaskId, simplifyPath } from '../models/TaskItem';
import { readFile } from '../utils/fileUtils';
+export const ICON_DEF: IconDef = { icon: 'tasklist', color: 'terminal.ansiCyan' };
+
/**
* Discovers tasks from Taskfile.yml (go-task).
*/
diff --git a/src/discovery/tasks.ts b/src/discovery/tasks.ts
index 494925b..d8c9dd7 100644
--- a/src/discovery/tasks.ts
+++ b/src/discovery/tasks.ts
@@ -1,8 +1,10 @@
import * as vscode from 'vscode';
-import type { TaskItem, ParamDef, MutableTaskItem } from '../models/TaskItem';
+import type { TaskItem, ParamDef, MutableTaskItem, IconDef } from '../models/TaskItem';
import { generateTaskId } from '../models/TaskItem';
import { readJsonFile } from '../utils/fileUtils';
+export const ICON_DEF: IconDef = { icon: 'gear', color: 'terminal.ansiBlue' };
+
interface TaskInput {
id: string;
description?: string;
diff --git a/src/models/TaskItem.ts b/src/models/TaskItem.ts
index 8af70c7..d409dba 100644
--- a/src/models/TaskItem.ts
+++ b/src/models/TaskItem.ts
@@ -3,6 +3,14 @@ import * as path from 'path';
export type { Result, Ok, Err } from './Result';
export { ok, err } from './Result';
+/**
+ * Icon definition for a task type. Plain data — no VS Code dependency.
+ */
+export interface IconDef {
+ readonly icon: string;
+ readonly color: string;
+}
+
/**
* Command type identifiers.
*/
@@ -93,197 +101,44 @@ export interface MutableTaskItem {
}
/**
- * Tree node for the CommandTree view.
+ * Pre-computed display properties for a CommandTreeItem.
+ */
+export interface CommandTreeItemProps {
+ readonly task: TaskItem | null;
+ readonly categoryLabel: string | null;
+ readonly children: CommandTreeItem[];
+ readonly id: string;
+ readonly contextValue: string;
+ readonly iconPath?: vscode.ThemeIcon;
+ readonly tooltip?: vscode.MarkdownString;
+ readonly description?: string;
+ readonly command?: vscode.Command;
+}
+
+/**
+ * Tree node for the CommandTree view. Dumb data container — no logic.
*/
export class CommandTreeItem extends vscode.TreeItem {
- constructor(
- public readonly task: TaskItem | null,
- public readonly categoryLabel: string | null,
- public readonly children: CommandTreeItem[] = [],
- parentId?: string
- ) {
- const label = task?.label ?? categoryLabel ?? '';
+ public readonly task: TaskItem | null;
+ public readonly categoryLabel: string | null;
+ public readonly children: CommandTreeItem[];
+ constructor(props: CommandTreeItemProps) {
super(
- label,
- children.length > 0
+ props.task?.label ?? props.categoryLabel ?? '',
+ props.children.length > 0
? vscode.TreeItemCollapsibleState.Collapsed
: vscode.TreeItemCollapsibleState.None
);
-
- // Set unique id for proper tree rendering and indentation
- if (task !== null) {
- this.id = task.id;
- const isQuick = task.tags.includes('quick');
- const isMarkdown = task.type === 'markdown';
-
- if (isMarkdown && isQuick) {
- this.contextValue = 'task-markdown-quick';
- } else if (isMarkdown) {
- this.contextValue = 'task-markdown';
- } else if (isQuick) {
- this.contextValue = 'task-quick';
- } else {
- this.contextValue = 'task';
- }
-
- this.tooltip = this.buildTooltip(task);
- this.iconPath = this.getIcon(task.type);
- const tagStr = task.tags.length > 0 ? ` [${task.tags.join(', ')}]` : '';
- this.description = `${task.category}${tagStr}`;
- this.command = {
- command: 'vscode.open',
- title: 'Open File',
- arguments: [vscode.Uri.file(task.filePath)]
- };
- } else if (categoryLabel !== null && categoryLabel !== '') {
- this.id = parentId !== undefined ? `${parentId}/${categoryLabel}` : categoryLabel;
- this.contextValue = 'category';
- this.iconPath = this.getCategoryIcon(categoryLabel);
- }
- }
-
- private buildTooltip(task: TaskItem): vscode.MarkdownString {
- const md = new vscode.MarkdownString();
- md.appendMarkdown(`**${task.label}**\n\n`);
- md.appendMarkdown(`Type: \`${task.type}\`\n\n`);
- md.appendMarkdown(`Command: \`${task.command}\`\n\n`);
- if (task.cwd !== undefined && task.cwd !== '') {
- md.appendMarkdown(`Working Dir: \`${task.cwd}\`\n\n`);
- }
- if (task.tags.length > 0) {
- md.appendMarkdown(`Tags: ${task.tags.map(t => `\`${t}\``).join(', ')}\n\n`);
- }
- md.appendMarkdown(`Source: \`${task.filePath}\``);
- return md;
- }
-
- private getIcon(type: TaskType): vscode.ThemeIcon {
- switch (type) {
- case 'shell': {
- return new vscode.ThemeIcon('terminal', new vscode.ThemeColor('terminal.ansiGreen'));
- }
- case 'npm': {
- return new vscode.ThemeIcon('package', new vscode.ThemeColor('terminal.ansiMagenta'));
- }
- case 'make': {
- return new vscode.ThemeIcon('tools', new vscode.ThemeColor('terminal.ansiYellow'));
- }
- case 'launch': {
- return new vscode.ThemeIcon('debug-alt', new vscode.ThemeColor('debugIcon.startForeground'));
- }
- case 'vscode': {
- return new vscode.ThemeIcon('gear', new vscode.ThemeColor('terminal.ansiBlue'));
- }
- case 'python': {
- return new vscode.ThemeIcon('symbol-misc', new vscode.ThemeColor('terminal.ansiCyan'));
- }
- case 'powershell': {
- return new vscode.ThemeIcon('terminal-powershell', new vscode.ThemeColor('terminal.ansiBlue'));
- }
- case 'gradle': {
- return new vscode.ThemeIcon('symbol-property', new vscode.ThemeColor('terminal.ansiGreen'));
- }
- case 'cargo': {
- return new vscode.ThemeIcon('package', new vscode.ThemeColor('terminal.ansiRed'));
- }
- case 'maven': {
- return new vscode.ThemeIcon('library', new vscode.ThemeColor('terminal.ansiRed'));
- }
- case 'ant': {
- return new vscode.ThemeIcon('symbol-constructor', new vscode.ThemeColor('terminal.ansiYellow'));
- }
- case 'just': {
- return new vscode.ThemeIcon('checklist', new vscode.ThemeColor('terminal.ansiMagenta'));
- }
- case 'taskfile': {
- return new vscode.ThemeIcon('tasklist', new vscode.ThemeColor('terminal.ansiCyan'));
- }
- case 'deno': {
- return new vscode.ThemeIcon('symbol-namespace', new vscode.ThemeColor('terminal.ansiWhite'));
- }
- case 'rake': {
- return new vscode.ThemeIcon('ruby', new vscode.ThemeColor('terminal.ansiRed'));
- }
- case 'composer': {
- return new vscode.ThemeIcon('symbol-interface', new vscode.ThemeColor('terminal.ansiYellow'));
- }
- case 'docker': {
- return new vscode.ThemeIcon('server-environment', new vscode.ThemeColor('terminal.ansiBlue'));
- }
- case 'dotnet': {
- return new vscode.ThemeIcon('circuit-board', new vscode.ThemeColor('terminal.ansiMagenta'));
- }
- case 'markdown': {
- return new vscode.ThemeIcon('markdown', new vscode.ThemeColor('terminal.ansiCyan'));
- }
- default: {
- const exhaustiveCheck: never = type;
- return exhaustiveCheck;
- }
- }
- }
-
- private getCategoryIcon(category: string): vscode.ThemeIcon {
- const lower = category.toLowerCase();
- if (lower.includes('shell')) {
- return new vscode.ThemeIcon('terminal', new vscode.ThemeColor('terminal.ansiGreen'));
- }
- if (lower.includes('npm')) {
- return new vscode.ThemeIcon('package', new vscode.ThemeColor('terminal.ansiMagenta'));
- }
- if (lower.includes('make')) {
- return new vscode.ThemeIcon('tools', new vscode.ThemeColor('terminal.ansiYellow'));
- }
- if (lower.includes('launch')) {
- return new vscode.ThemeIcon('debug-alt', new vscode.ThemeColor('debugIcon.startForeground'));
- }
- if (lower.includes('task')) {
- return new vscode.ThemeIcon('gear', new vscode.ThemeColor('terminal.ansiBlue'));
- }
- if (lower.includes('python')) {
- return new vscode.ThemeIcon('symbol-misc', new vscode.ThemeColor('terminal.ansiCyan'));
- }
- if (lower.includes('powershell') || lower.includes('batch')) {
- return new vscode.ThemeIcon('terminal-powershell', new vscode.ThemeColor('terminal.ansiBlue'));
- }
- if (lower.includes('gradle')) {
- return new vscode.ThemeIcon('symbol-property', new vscode.ThemeColor('terminal.ansiGreen'));
- }
- if (lower.includes('cargo') || lower.includes('rust')) {
- return new vscode.ThemeIcon('package', new vscode.ThemeColor('terminal.ansiRed'));
- }
- if (lower.includes('maven')) {
- return new vscode.ThemeIcon('library', new vscode.ThemeColor('terminal.ansiRed'));
- }
- if (lower.includes('ant')) {
- return new vscode.ThemeIcon('symbol-constructor', new vscode.ThemeColor('terminal.ansiYellow'));
- }
- if (lower.includes('just')) {
- return new vscode.ThemeIcon('checklist', new vscode.ThemeColor('terminal.ansiMagenta'));
- }
- if (lower.includes('taskfile')) {
- return new vscode.ThemeIcon('tasklist', new vscode.ThemeColor('terminal.ansiCyan'));
- }
- if (lower.includes('deno')) {
- return new vscode.ThemeIcon('symbol-namespace', new vscode.ThemeColor('terminal.ansiWhite'));
- }
- if (lower.includes('rake') || lower.includes('ruby')) {
- return new vscode.ThemeIcon('ruby', new vscode.ThemeColor('terminal.ansiRed'));
- }
- if (lower.includes('composer') || lower.includes('php')) {
- return new vscode.ThemeIcon('symbol-interface', new vscode.ThemeColor('terminal.ansiYellow'));
- }
- if (lower.includes('docker')) {
- return new vscode.ThemeIcon('server-environment', new vscode.ThemeColor('terminal.ansiBlue'));
- }
- if (lower.includes('dotnet') || lower.includes('.net') || lower.includes('csharp') || lower.includes('fsharp')) {
- return new vscode.ThemeIcon('circuit-board', new vscode.ThemeColor('terminal.ansiMagenta'));
- }
- if (lower.includes('markdown') || lower.includes('docs')) {
- return new vscode.ThemeIcon('markdown', new vscode.ThemeColor('terminal.ansiCyan'));
- }
- return new vscode.ThemeIcon('folder');
+ this.task = props.task;
+ this.categoryLabel = props.categoryLabel;
+ this.children = props.children;
+ this.id = props.id;
+ this.contextValue = props.contextValue;
+ if (props.iconPath !== undefined) { this.iconPath = props.iconPath; }
+ if (props.tooltip !== undefined) { this.tooltip = props.tooltip; }
+ if (props.description !== undefined) { this.description = props.description; }
+ if (props.command !== undefined) { this.command = props.command; }
}
}
diff --git a/src/test/e2e/quicktasks.e2e.test.ts b/src/test/e2e/quicktasks.e2e.test.ts
index e644c24..655659e 100644
--- a/src/test/e2e/quicktasks.e2e.test.ts
+++ b/src/test/e2e/quicktasks.e2e.test.ts
@@ -18,7 +18,7 @@ import {
import type { CommandTreeProvider, QuickTasksProvider } from "../helpers/helpers";
import { getDb } from "../../db/lifecycle";
import { getCommandIdsByTag, getTagsForCommand } from "../../db/db";
-import { CommandTreeItem } from "../../models/TaskItem";
+import { createTaskNode } from "../../tree/nodeFactory";
const QUICK_TAG = "quick";
@@ -76,7 +76,7 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => {
assert.ok(task !== undefined, "First task must exist");
// Add to quick via UI command
- const item = new CommandTreeItem(task, null, []);
+ const item = createTaskNode(task);
await vscode.commands.executeCommand("commandtree.addToQuick", item);
await sleep(1000);
@@ -105,7 +105,7 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => {
assert.ok(treeItem.label !== undefined, "getTreeItem should return a TreeItem with a label");
// Clean up
- const removeItem = new CommandTreeItem(task, null, []);
+ const removeItem = createTaskNode(task);
await vscode.commands.executeCommand("commandtree.removeFromQuick", removeItem);
await sleep(500);
});
@@ -118,7 +118,7 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => {
assert.ok(task !== undefined, "First task must exist");
// Add to quick first
- const addItem = new CommandTreeItem(task, null, []);
+ const addItem = createTaskNode(task);
await vscode.commands.executeCommand("commandtree.addToQuick", addItem);
await sleep(1000);
@@ -136,7 +136,7 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => {
);
// Remove from quick via UI
- const removeItem = new CommandTreeItem(task, null, []);
+ const removeItem = createTaskNode(task);
await vscode.commands.executeCommand("commandtree.removeFromQuick", removeItem);
await sleep(1000);
@@ -172,13 +172,13 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => {
);
// Add tasks in specific order
- const item1 = new CommandTreeItem(task1, null, []);
+ const item1 = createTaskNode(task1);
await vscode.commands.executeCommand("commandtree.addToQuick", item1);
await sleep(500);
- const item2 = new CommandTreeItem(task2, null, []);
+ const item2 = createTaskNode(task2);
await vscode.commands.executeCommand("commandtree.addToQuick", item2);
await sleep(500);
- const item3 = new CommandTreeItem(task3, null, []);
+ const item3 = createTaskNode(task3);
await vscode.commands.executeCommand("commandtree.addToQuick", item3);
await sleep(1000);
@@ -216,9 +216,9 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => {
assert.strictEqual(viewItem1.task?.id, task2.id, "Second view item should match second added task");
// Clean up
- const removeItem1 = new CommandTreeItem(task1, null, []);
- const removeItem2 = new CommandTreeItem(task2, null, []);
- const removeItem3 = new CommandTreeItem(task3, null, []);
+ const removeItem1 = createTaskNode(task1);
+ const removeItem2 = createTaskNode(task2);
+ const removeItem3 = createTaskNode(task3);
await vscode.commands.executeCommand("commandtree.removeFromQuick", removeItem1);
await vscode.commands.executeCommand("commandtree.removeFromQuick", removeItem2);
await vscode.commands.executeCommand("commandtree.removeFromQuick", removeItem3);
@@ -233,7 +233,7 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => {
assert.ok(task !== undefined, "First task must exist");
// Add to quick once
- const item = new CommandTreeItem(task, null, []);
+ const item = createTaskNode(task);
await vscode.commands.executeCommand("commandtree.addToQuick", item);
await sleep(1000);
@@ -249,7 +249,7 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => {
assert.strictEqual(initialCount, 1, "Should have exactly one instance of task");
// Try to add again (should be ignored by INSERT OR IGNORE)
- const item2 = new CommandTreeItem(task, null, []);
+ const item2 = createTaskNode(task);
await vscode.commands.executeCommand("commandtree.addToQuick", item2);
await sleep(1000);
@@ -266,7 +266,7 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => {
);
// Clean up
- const removeItem = new CommandTreeItem(task, null, []);
+ const removeItem = createTaskNode(task);
await vscode.commands.executeCommand("commandtree.removeFromQuick", removeItem);
await sleep(500);
});
@@ -285,7 +285,7 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => {
// Add in specific order
for (const task of tasks) {
- const item = new CommandTreeItem(task, null, []);
+ const item = createTaskNode(task);
await vscode.commands.executeCommand("commandtree.addToQuick", item);
await sleep(500);
}
@@ -332,7 +332,7 @@ suite("Quick Launch E2E Tests (SQLite Junction Table)", () => {
// Clean up
for (const task of tasks) {
- const removeItem = new CommandTreeItem(task, null, []);
+ const removeItem = createTaskNode(task);
await vscode.commands.executeCommand("commandtree.removeFromQuick", removeItem);
}
await sleep(500);
diff --git a/src/tree/folderTree.ts b/src/tree/folderTree.ts
index db982e5..267897b 100644
--- a/src/tree/folderTree.ts
+++ b/src/tree/folderTree.ts
@@ -1,5 +1,5 @@
import type { TaskItem } from '../models/TaskItem';
-import { CommandTreeItem } from '../models/TaskItem';
+import type { CommandTreeItem } from '../models/TaskItem';
import type { DirNode } from './dirTree';
import {
groupByFullDir,
@@ -7,6 +7,7 @@ import {
needsFolderWrapper,
getFolderLabel
} from './dirTree';
+import { createTaskNode, createCategoryNode } from './nodeFactory';
/**
* Renders a DirNode as a folder CommandTreeItem.
@@ -24,19 +25,18 @@ function renderFolder({
}): CommandTreeItem {
const label = getFolderLabel(node.dir, parentDir);
const folderId = `${parentTreeId}/${label}`;
- const taskItems = sortTasks(node.tasks).map(t => new CommandTreeItem(
- t,
- null,
- [],
- folderId
- ));
+ const taskItems = sortTasks(node.tasks).map(t => createTaskNode(t));
const subItems = node.subdirs.map(sub => renderFolder({
node: sub,
parentDir: node.dir,
parentTreeId: folderId,
sortTasks
}));
- return new CommandTreeItem(null, label, [...subItems, ...taskItems], parentTreeId);
+ return createCategoryNode({
+ label,
+ children: [...subItems, ...taskItems],
+ parentId: parentTreeId,
+ });
}
/**
@@ -66,13 +66,7 @@ export function buildNestedFolderItems({
sortTasks
}));
} else {
- const items = sortTasks(node.tasks).map(t => new CommandTreeItem(
- t,
- null,
- [],
- categoryId
- ));
- result.push(...items);
+ result.push(...sortTasks(node.tasks).map(t => createTaskNode(t)));
}
}
diff --git a/src/tree/nodeFactory.ts b/src/tree/nodeFactory.ts
new file mode 100644
index 0000000..10016ab
--- /dev/null
+++ b/src/tree/nodeFactory.ts
@@ -0,0 +1,92 @@
+import * as vscode from 'vscode';
+import type { TaskItem, TaskType, IconDef } from '../models/TaskItem';
+import { CommandTreeItem } from '../models/TaskItem';
+import { ICON_REGISTRY } from '../discovery';
+
+const DEFAULT_FOLDER_ICON = new vscode.ThemeIcon('folder');
+
+function toThemeIcon(def: IconDef): vscode.ThemeIcon {
+ return new vscode.ThemeIcon(def.icon, new vscode.ThemeColor(def.color));
+}
+
+function resolveContextValue(task: TaskItem): string {
+ const isQuick = task.tags.includes('quick');
+ const isMarkdown = task.type === 'markdown';
+ if (isMarkdown && isQuick) { return 'task-markdown-quick'; }
+ if (isMarkdown) { return 'task-markdown'; }
+ if (isQuick) { return 'task-quick'; }
+ return 'task';
+}
+
+function buildTooltip(task: TaskItem): vscode.MarkdownString {
+ const md = new vscode.MarkdownString();
+ md.appendMarkdown(`**${task.label}**\n\n`);
+ md.appendMarkdown(`Type: \`${task.type}\`\n\n`);
+ md.appendMarkdown(`Command: \`${task.command}\`\n\n`);
+ if (task.cwd !== undefined && task.cwd !== '') {
+ md.appendMarkdown(`Working Dir: \`${task.cwd}\`\n\n`);
+ }
+ if (task.tags.length > 0) {
+ md.appendMarkdown(`Tags: ${task.tags.map(t => `\`${t}\``).join(', ')}\n\n`);
+ }
+ md.appendMarkdown(`Source: \`${task.filePath}\``);
+ return md;
+}
+
+function buildDescription(task: TaskItem): string {
+ const tagStr = task.tags.length > 0 ? ` [${task.tags.join(', ')}]` : '';
+ return `${task.category}${tagStr}`;
+}
+
+export function createTaskNode(task: TaskItem): CommandTreeItem {
+ return new CommandTreeItem({
+ task,
+ categoryLabel: null,
+ children: [],
+ id: task.id,
+ contextValue: resolveContextValue(task),
+ tooltip: buildTooltip(task),
+ iconPath: toThemeIcon(ICON_REGISTRY[task.type]),
+ description: buildDescription(task),
+ command: {
+ command: 'vscode.open',
+ title: 'Open File',
+ arguments: [vscode.Uri.file(task.filePath)],
+ },
+ });
+}
+
+export function createCategoryNode({
+ label,
+ children,
+ parentId,
+ type,
+}: {
+ label: string;
+ children: CommandTreeItem[];
+ parentId?: string;
+ type?: TaskType;
+}): CommandTreeItem {
+ const id = parentId !== undefined ? `${parentId}/${label}` : label;
+ const iconPath = type !== undefined
+ ? toThemeIcon(ICON_REGISTRY[type])
+ : DEFAULT_FOLDER_ICON;
+ return new CommandTreeItem({
+ task: null,
+ categoryLabel: label,
+ children,
+ id,
+ contextValue: 'category',
+ iconPath,
+ });
+}
+
+export function createPlaceholderNode(message: string): CommandTreeItem {
+ return new CommandTreeItem({
+ task: null,
+ categoryLabel: message,
+ children: [],
+ id: message,
+ contextValue: 'placeholder',
+ });
+}
From 884bb2da8986a0412f95510f12a3ba7c65cfc2a1 Mon Sep 17 00:00:00 2001
From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com>
Date: Tue, 24 Feb 2026 07:14:31 +1100
Subject: [PATCH 08/30] Remove the root folder
---
src/test/e2e/treeview.e2e.test.ts | 18 ++++++++++++++++++
src/tree/folderTree.ts | 12 +++++++++++-
2 files changed, 29 insertions(+), 1 deletion(-)
diff --git a/src/test/e2e/treeview.e2e.test.ts b/src/test/e2e/treeview.e2e.test.ts
index 117bb13..5afbf64 100644
--- a/src/test/e2e/treeview.e2e.test.ts
+++ b/src/test/e2e/treeview.e2e.test.ts
@@ -102,6 +102,24 @@ suite("TreeView E2E Tests", () => {
});
suite("Folder Hierarchy", () => {
+ test("root-level items appear directly under category — no Root folder node", async function () {
+ this.timeout(15000);
+ const provider = getCommandTreeProvider();
+ const categories = await provider.getChildren();
+
+ for (const category of categories) {
+ const topChildren = await provider.getChildren(category);
+ for (const child of topChildren) {
+ const label = getLabelString(child.label);
+ assert.notStrictEqual(
+ label,
+ "Root",
+ `Category "${getLabelString(category.label)}" must NOT have a "Root" folder — root items should appear directly under the category`,
+ );
+ }
+ }
+ });
+
test("folders must come before files in tree — normal file/folder rules", async function () {
this.timeout(15000);
const provider = getCommandTreeProvider();
diff --git a/src/tree/folderTree.ts b/src/tree/folderTree.ts
index 267897b..ffb3b20 100644
--- a/src/tree/folderTree.ts
+++ b/src/tree/folderTree.ts
@@ -58,7 +58,17 @@ export function buildNestedFolderItems({
const result: CommandTreeItem[] = [];
for (const node of rootNodes) {
- if (needsFolderWrapper(node, rootNodes.length)) {
+ if (node.dir === '') {
+ for (const sub of node.subdirs) {
+ result.push(renderFolder({
+ node: sub,
+ parentDir: '',
+ parentTreeId: categoryId,
+ sortTasks
+ }));
+ }
+ result.push(...sortTasks(node.tasks).map(t => createTaskNode(t)));
+ } else if (needsFolderWrapper(node, rootNodes.length)) {
result.push(renderFolder({
node,
parentDir: '',
From fee1e3a6064df7696b3d81ca2dc3d3d324e1b67f Mon Sep 17 00:00:00 2001
From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com>
Date: Tue, 24 Feb 2026 08:05:06 +1100
Subject: [PATCH 09/30] Further refactoring
---
src/CommandTreeProvider.ts | 384 ++++++-------
src/QuickTasksProvider.ts | 413 ++++++++------
src/config/TagConfig.ts | 271 ++++-----
src/discovery/ant.ts | 154 +++---
src/discovery/cargo.ts | 220 ++++----
src/discovery/composer.ts | 141 ++---
src/discovery/deno.ts | 139 ++---
src/discovery/docker.ts | 256 +++++----
src/discovery/dotnet.ts | 273 +++++-----
src/discovery/gradle.ts | 152 +++---
src/discovery/index.ts | 450 +++++++++------
src/discovery/just.ts | 263 ++++-----
src/discovery/launch.ts | 105 ++--
src/discovery/make.ts | 126 ++---
src/discovery/markdown.ts | 144 ++---
src/discovery/maven.ts | 96 ++--
src/discovery/npm.ts | 96 ++--
src/discovery/powershell.ts | 353 ++++++------
src/discovery/python.ts | 343 ++++++------
src/discovery/rake.ts | 164 +++---
src/discovery/shell.ts | 166 +++---
src/discovery/taskfile.ts | 246 +++++----
src/discovery/tasks.ts | 222 ++++----
src/extension.ts | 667 +++++++++++++----------
src/models/TaskItem.ts | 262 +++++----
src/runners/TaskRunner.ts | 452 ++++++++-------
src/test/e2e/markdown.e2e.test.ts | 143 +++--
src/test/e2e/quicktasks.e2e.test.ts | 190 +++++--
src/test/e2e/runner.e2e.test.ts | 38 +-
src/test/e2e/treeview.e2e.test.ts | 24 +-
src/test/helpers/helpers.ts | 372 +++++++------
src/test/unit/treehierarchy.unit.test.ts | 419 ++++++++------
src/tree/folderTree.ts | 134 ++---
src/tree/nodeFactory.ts | 161 +++---
34 files changed, 4471 insertions(+), 3568 deletions(-)
diff --git a/src/CommandTreeProvider.ts b/src/CommandTreeProvider.ts
index 0e006e7..03b5fd2 100644
--- a/src/CommandTreeProvider.ts
+++ b/src/CommandTreeProvider.ts
@@ -1,198 +1,198 @@
-import * as vscode from 'vscode';
-import type { TaskItem, TaskType, Result } from './models/TaskItem';
-import type { CommandTreeItem } from './models/TaskItem';
-import type { DiscoveryResult } from './discovery';
-import { discoverAllTasks, flattenTasks, getExcludePatterns } from './discovery';
-import { TagConfig } from './config/TagConfig';
-import { logger } from './utils/logger';
-import { buildNestedFolderItems } from './tree/folderTree';
-import { createTaskNode, createCategoryNode } from './tree/nodeFactory';
-
-type SortOrder = 'folder' | 'name' | 'type';
-
-interface CategoryDef {
- readonly type: TaskType;
- readonly label: string;
- readonly flat?: boolean;
-}
-
-const CATEGORY_DEFS: readonly CategoryDef[] = [
- { type: 'shell', label: 'Shell Scripts' },
- { type: 'npm', label: 'NPM Scripts' },
- { type: 'make', label: 'Make Targets' },
- { type: 'launch', label: 'VS Code Launch', flat: true },
- { type: 'vscode', label: 'VS Code Tasks', flat: true },
- { type: 'python', label: 'Python Scripts' },
- { type: 'powershell', label: 'PowerShell/Batch' },
- { type: 'gradle', label: 'Gradle Tasks' },
- { type: 'cargo', label: 'Cargo (Rust)' },
- { type: 'maven', label: 'Maven Goals' },
- { type: 'ant', label: 'Ant Targets' },
- { type: 'just', label: 'Just Recipes' },
- { type: 'taskfile', label: 'Taskfile' },
- { type: 'deno', label: 'Deno Tasks' },
- { type: 'rake', label: 'Rake Tasks' },
- { type: 'composer', label: 'Composer Scripts' },
- { type: 'docker', label: 'Docker Compose' },
- { type: 'dotnet', label: '.NET Projects' },
- { type: 'markdown', label: 'Markdown Files' },
-];
+import * as vscode from "vscode";
+import type { CommandItem, Result, CategoryDef } from "./models/TaskItem";
+import type { CommandTreeItem } from "./models/TaskItem";
+import type { DiscoveryResult } from "./discovery";
+import {
+ discoverAllTasks,
+ flattenTasks,
+ getExcludePatterns,
+ CATEGORY_DEFS,
+} from "./discovery";
+import { TagConfig } from "./config/TagConfig";
+import { logger } from "./utils/logger";
+import { buildNestedFolderItems } from "./tree/folderTree";
+import { createCommandNode, createCategoryNode } from "./tree/nodeFactory";
+
+type SortOrder = "folder" | "name" | "type";
/**
* Tree data provider for CommandTree view.
*/
export class CommandTreeProvider implements vscode.TreeDataProvider {
- private readonly _onDidChangeTreeData = new vscode.EventEmitter();
- readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
-
- private tasks: TaskItem[] = [];
- private discoveryResult: DiscoveryResult | null = null;
- private tagFilter: string | null = null;
- private readonly tagConfig: TagConfig;
- private readonly workspaceRoot: string;
-
- constructor(workspaceRoot: string) {
- this.workspaceRoot = workspaceRoot;
- this.tagConfig = new TagConfig();
- }
-
- async refresh(): Promise {
- this.tagConfig.load();
- const excludePatterns = getExcludePatterns();
- this.discoveryResult = await discoverAllTasks(this.workspaceRoot, excludePatterns);
- this.tasks = this.tagConfig.applyTags(flattenTasks(this.discoveryResult));
- this._onDidChangeTreeData.fire(undefined);
- }
-
- setTagFilter(tag: string | null): void {
- logger.filter('setTagFilter', { tagFilter: tag });
- this.tagFilter = tag;
- this._onDidChangeTreeData.fire(undefined);
- }
-
- clearFilters(): void {
- this.tagFilter = null;
- this._onDidChangeTreeData.fire(undefined);
- }
-
- hasFilter(): boolean {
- return this.tagFilter !== null;
- }
-
- getAllTags(): string[] {
- const tags = new Set();
- for (const task of this.tasks) {
- for (const tag of task.tags) {
- tags.add(tag);
- }
- }
- for (const tag of this.tagConfig.getTagNames()) {
- tags.add(tag);
- }
- return Array.from(tags).sort();
- }
-
- async addTaskToTag(task: TaskItem, tagName: string): Promise> {
- const result = this.tagConfig.addTaskToTag(task, tagName);
- if (result.ok) {
- await this.refresh();
- }
- return result;
- }
-
- async removeTaskFromTag(task: TaskItem, tagName: string): Promise> {
- const result = this.tagConfig.removeTaskFromTag(task, tagName);
- if (result.ok) {
- await this.refresh();
- }
- return result;
- }
-
- getAllTasks(): TaskItem[] {
- return this.tasks;
- }
-
- getTreeItem(element: CommandTreeItem): vscode.TreeItem {
- return element;
- }
-
- async getChildren(element?: CommandTreeItem): Promise {
- if (!this.discoveryResult) {
- await this.refresh();
- }
- if (!element) {
- return this.buildRootCategories();
- }
- return element.children;
- }
-
- private buildRootCategories(): CommandTreeItem[] {
- const filtered = this.applyTagFilter(this.tasks);
- return CATEGORY_DEFS
- .map(def => this.buildCategoryIfNonEmpty(filtered, def))
- .filter((c): c is CommandTreeItem => c !== null);
- }
-
- private buildCategoryIfNonEmpty(
- tasks: readonly TaskItem[],
- def: CategoryDef
- ): CommandTreeItem | null {
- const matched = tasks.filter(t => t.type === def.type);
- if (matched.length === 0) { return null; }
- return def.flat === true
- ? this.buildFlatCategory(def, matched)
- : this.buildCategoryWithFolders(def, matched);
- }
-
- private buildCategoryWithFolders(def: CategoryDef, tasks: TaskItem[]): CommandTreeItem {
- const children = buildNestedFolderItems({
- tasks,
- workspaceRoot: this.workspaceRoot,
- categoryId: def.label,
- sortTasks: (t) => this.sortTasks(t)
- });
- return createCategoryNode({
- label: `${def.label} (${tasks.length})`,
- children,
- type: def.type,
- });
- }
-
- private buildFlatCategory(def: CategoryDef, tasks: TaskItem[]): CommandTreeItem {
- const sorted = this.sortTasks(tasks);
- const children = sorted.map(t => createTaskNode(t));
- return createCategoryNode({
- label: `${def.label} (${tasks.length})`,
- children,
- type: def.type,
- });
- }
-
- private getSortOrder(): SortOrder {
- return vscode.workspace
- .getConfiguration('commandtree')
- .get('sortOrder', 'folder');
- }
-
- private sortTasks(tasks: TaskItem[]): TaskItem[] {
- const comparator = this.getComparator();
- return [...tasks].sort(comparator);
- }
-
- private getComparator(): (a: TaskItem, b: TaskItem) => number {
- const order = this.getSortOrder();
- if (order === 'folder') {
- return (a, b) => a.category.localeCompare(b.category) || a.label.localeCompare(b.label);
- }
- if (order === 'type') {
- return (a, b) => a.type.localeCompare(b.type) || a.label.localeCompare(b.label);
- }
- return (a, b) => a.label.localeCompare(b.label);
- }
-
- private applyTagFilter(tasks: TaskItem[]): TaskItem[] {
- if (this.tagFilter === null || this.tagFilter === '') { return tasks; }
- const tag = this.tagFilter;
- return tasks.filter(t => t.tags.includes(tag));
- }
+ private readonly _onDidChangeTreeData = new vscode.EventEmitter<
+ CommandTreeItem | undefined
+ >();
+ readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
+
+ private commands: CommandItem[] = [];
+ private discoveryResult: DiscoveryResult | null = null;
+ private tagFilter: string | null = null;
+ private readonly tagConfig: TagConfig;
+ private readonly workspaceRoot: string;
+
+ constructor(workspaceRoot: string) {
+ this.workspaceRoot = workspaceRoot;
+ this.tagConfig = new TagConfig();
+ }
+
+ async refresh(): Promise {
+ this.tagConfig.load();
+ const excludePatterns = getExcludePatterns();
+ this.discoveryResult = await discoverAllTasks(
+ this.workspaceRoot,
+ excludePatterns,
+ );
+ this.commands = this.tagConfig.applyTags(flattenTasks(this.discoveryResult));
+ this._onDidChangeTreeData.fire(undefined);
+ }
+
+ setTagFilter(tag: string | null): void {
+ logger.filter("setTagFilter", { tagFilter: tag });
+ this.tagFilter = tag;
+ this._onDidChangeTreeData.fire(undefined);
+ }
+
+ clearFilters(): void {
+ this.tagFilter = null;
+ this._onDidChangeTreeData.fire(undefined);
+ }
+
+ hasFilter(): boolean {
+ return this.tagFilter !== null;
+ }
+
+ getAllTags(): string[] {
+ const tags = new Set();
+ for (const task of this.commands) {
+ for (const tag of task.tags) {
+ tags.add(tag);
+ }
+ }
+ for (const tag of this.tagConfig.getTagNames()) {
+ tags.add(tag);
+ }
+ return Array.from(tags).sort();
+ }
+
+ async addTaskToTag(
+ task: CommandItem,
+ tagName: string,
+ ): Promise> {
+ const result = this.tagConfig.addTaskToTag(task, tagName);
+ if (result.ok) {
+ await this.refresh();
+ }
+ return result;
+ }
+
+ async removeTaskFromTag(
+ task: CommandItem,
+ tagName: string,
+ ): Promise> {
+ const result = this.tagConfig.removeTaskFromTag(task, tagName);
+ if (result.ok) {
+ await this.refresh();
+ }
+ return result;
+ }
+
+ getAllTasks(): CommandItem[] {
+ return this.commands;
+ }
+
+ getTreeItem(element: CommandTreeItem): vscode.TreeItem {
+ return element;
+ }
+
+ async getChildren(element?: CommandTreeItem): Promise {
+ if (!this.discoveryResult) {
+ await this.refresh();
+ }
+ if (!element) {
+ return this.buildRootCategories();
+ }
+ return element.children;
+ }
+
+ private buildRootCategories(): CommandTreeItem[] {
+ const filtered = this.applyTagFilter(this.commands);
+ return CATEGORY_DEFS.map((def) =>
+ this.buildCategoryIfNonEmpty(filtered, def),
+ ).filter((c): c is CommandTreeItem => c !== null);
+ }
+
+ private buildCategoryIfNonEmpty(
+ tasks: readonly CommandItem[],
+ def: CategoryDef,
+ ): CommandTreeItem | null {
+ const matched = tasks.filter((t) => t.type === def.type);
+ if (matched.length === 0) {
+ return null;
+ }
+ return def.flat === true
+ ? this.buildFlatCategory(def, matched)
+ : this.buildCategoryWithFolders(def, matched);
+ }
+
+ private buildCategoryWithFolders(
+ def: CategoryDef,
+ tasks: CommandItem[],
+ ): CommandTreeItem {
+ const children = buildNestedFolderItems({
+ tasks,
+ workspaceRoot: this.workspaceRoot,
+ categoryId: def.label,
+ sortTasks: (t) => this.sortTasks(t),
+ });
+ return createCategoryNode({
+ label: `${def.label} (${tasks.length})`,
+ children,
+ type: def.type,
+ });
+ }
+
+ private buildFlatCategory(
+ def: CategoryDef,
+ tasks: CommandItem[],
+ ): CommandTreeItem {
+ const sorted = this.sortTasks(tasks);
+ const children = sorted.map((t) => createCommandNode(t));
+ return createCategoryNode({
+ label: `${def.label} (${tasks.length})`,
+ children,
+ type: def.type,
+ });
+ }
+
+ private getSortOrder(): SortOrder {
+ return vscode.workspace
+ .getConfiguration("commandtree")
+ .get("sortOrder", "folder");
+ }
+
+ private sortTasks(tasks: CommandItem[]): CommandItem[] {
+ const comparator = this.getComparator();
+ return [...tasks].sort(comparator);
+ }
+
+ private getComparator(): (a: CommandItem, b: CommandItem) => number {
+ const order = this.getSortOrder();
+ if (order === "folder") {
+ return (a, b) =>
+ a.category.localeCompare(b.category) || a.label.localeCompare(b.label);
+ }
+ if (order === "type") {
+ return (a, b) =>
+ a.type.localeCompare(b.type) || a.label.localeCompare(b.label);
+ }
+ return (a, b) => a.label.localeCompare(b.label);
+ }
+
+ private applyTagFilter(tasks: CommandItem[]): CommandItem[] {
+ if (this.tagFilter === null || this.tagFilter === "") {
+ return tasks;
+ }
+ const tag = this.tagFilter;
+ return tasks.filter((t) => t.tags.includes(tag));
+ }
}
diff --git a/src/QuickTasksProvider.ts b/src/QuickTasksProvider.ts
index 5e87bab..85033af 100644
--- a/src/QuickTasksProvider.ts
+++ b/src/QuickTasksProvider.ts
@@ -4,216 +4,275 @@
* Uses junction table for ordering (display_order column).
*/
-import * as vscode from 'vscode';
-import type { TaskItem, Result } from './models/TaskItem';
-import type { CommandTreeItem } from './models/TaskItem';
-import { TagConfig } from './config/TagConfig';
-import { logger } from './utils/logger';
-import { getDb } from './db/lifecycle';
-import { getCommandIdsByTag } from './db/db';
-import { createTaskNode, createPlaceholderNode } from './tree/nodeFactory';
-
-const QUICK_TASK_MIME_TYPE = 'application/vnd.commandtree.quicktask';
-const QUICK_TAG = 'quick';
+import * as vscode from "vscode";
+import type { CommandItem, Result, CommandTreeItem } from "./models/TaskItem";
+import { isCommandItem } from "./models/TaskItem";
+import { TagConfig } from "./config/TagConfig";
+import { logger } from "./utils/logger";
+import { getDb } from "./db/lifecycle";
+import { getCommandIdsByTag } from "./db/db";
+import { createCommandNode, createPlaceholderNode } from "./tree/nodeFactory";
+
+const QUICK_TASK_MIME_TYPE = "application/vnd.commandtree.quicktask";
+const QUICK_TAG = "quick";
/**
* SPEC: quick-launch
* Provider for the Quick Launch view - shows commands tagged as "quick".
* Supports drag-and-drop reordering via display_order column.
*/
-export class QuickTasksProvider implements vscode.TreeDataProvider, vscode.TreeDragAndDropController {
- private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter();
- readonly onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event;
+export class QuickTasksProvider
+ implements
+ vscode.TreeDataProvider,
+ vscode.TreeDragAndDropController
+{
+ private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter<
+ CommandTreeItem | undefined
+ >();
+ readonly onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event;
+
+ readonly dropMimeTypes = [QUICK_TASK_MIME_TYPE];
+ readonly dragMimeTypes = [QUICK_TASK_MIME_TYPE];
+
+ private readonly tagConfig: TagConfig;
+ private allTasks: CommandItem[] = [];
+
+ constructor() {
+ this.tagConfig = new TagConfig();
+ }
+
+ /**
+ * SPEC: quick-launch
+ * Updates the list of all tasks and refreshes the view.
+ */
+ updateTasks(tasks: CommandItem[]): void {
+ logger.quick("updateTasks called", { taskCount: tasks.length });
+ this.tagConfig.load();
+ this.allTasks = this.tagConfig.applyTags(tasks);
+ const quickCount = this.allTasks.filter((t) =>
+ t.tags.includes(QUICK_TAG),
+ ).length;
+ logger.quick("updateTasks complete", {
+ taskCount: this.allTasks.length,
+ quickTaskCount: quickCount,
+ quickTasks: this.allTasks
+ .filter((t) => t.tags.includes(QUICK_TAG))
+ .map((t) => t.id),
+ });
+ this.onDidChangeTreeDataEmitter.fire(undefined);
+ }
+
+ /**
+ * SPEC: quick-launch
+ * Adds a command to the quick list.
+ */
+ addToQuick(task: CommandItem): Result {
+ const result = this.tagConfig.addTaskToTag(task, QUICK_TAG);
+ if (result.ok) {
+ this.tagConfig.load();
+ this.allTasks = this.tagConfig.applyTags(this.allTasks);
+ this.onDidChangeTreeDataEmitter.fire(undefined);
+ }
+ return result;
+ }
- readonly dropMimeTypes = [QUICK_TASK_MIME_TYPE];
- readonly dragMimeTypes = [QUICK_TASK_MIME_TYPE];
+ /**
+ * SPEC: quick-launch
+ * Removes a command from the quick list.
+ */
+ removeFromQuick(task: CommandItem): Result {
+ const result = this.tagConfig.removeTaskFromTag(task, QUICK_TAG);
+ if (result.ok) {
+ this.tagConfig.load();
+ this.allTasks = this.tagConfig.applyTags(this.allTasks);
+ this.onDidChangeTreeDataEmitter.fire(undefined);
+ }
+ return result;
+ }
+
+ /**
+ * Refreshes the view.
+ */
+ refresh(): void {
+ this.onDidChangeTreeDataEmitter.fire(undefined);
+ }
- private readonly tagConfig: TagConfig;
- private allTasks: TaskItem[] = [];
+ getTreeItem(element: CommandTreeItem): vscode.TreeItem {
+ return element;
+ }
- constructor() {
- this.tagConfig = new TagConfig();
+ getChildren(element?: CommandTreeItem): CommandTreeItem[] {
+ if (element !== undefined) {
+ return element.children;
}
+ logger.quick("getChildren called", {
+ allTasksCount: this.allTasks.length,
+ allTasksWithTags: this.allTasks.map((t) => ({
+ id: t.id,
+ label: t.label,
+ tags: t.tags,
+ })),
+ });
+ const items = this.buildQuickItems();
+ logger.quick("Returning quick tasks", { count: items.length });
+ return items;
+ }
- /**
- * SPEC: quick-launch
- * Updates the list of all tasks and refreshes the view.
- */
- updateTasks(tasks: TaskItem[]): void {
- logger.quick('updateTasks called', { taskCount: tasks.length });
- this.tagConfig.load();
- this.allTasks = this.tagConfig.applyTags(tasks);
- const quickCount = this.allTasks.filter(t => t.tags.includes(QUICK_TAG)).length;
- logger.quick('updateTasks complete', {
- taskCount: this.allTasks.length,
- quickTaskCount: quickCount,
- quickTasks: this.allTasks.filter(t => t.tags.includes(QUICK_TAG)).map(t => t.id)
- });
- this.onDidChangeTreeDataEmitter.fire(undefined);
+ /**
+ * SPEC: quick-launch
+ * Builds quick task tree items ordered by display_order from junction table.
+ */
+ private buildQuickItems(): CommandTreeItem[] {
+ const quickTasks = this.allTasks.filter((task) =>
+ task.tags.includes(QUICK_TAG),
+ );
+ logger.quick("Filtered quick tasks", { count: quickTasks.length });
+ if (quickTasks.length === 0) {
+ return [
+ createPlaceholderNode(
+ "No quick commands - star commands to add them here",
+ ),
+ ];
}
+ const sorted = this.sortByDisplayOrder(quickTasks);
+ return sorted.map((task) => createCommandNode(task));
+ }
- /**
- * SPEC: quick-launch
- * Adds a command to the quick list.
- */
- addToQuick(task: TaskItem): Result {
- const result = this.tagConfig.addTaskToTag(task, QUICK_TAG);
- if (result.ok) {
- this.tagConfig.load();
- this.allTasks = this.tagConfig.applyTags(this.allTasks);
- this.onDidChangeTreeDataEmitter.fire(undefined);
- }
- return result;
+ /**
+ * SPEC: quick-launch, tagging
+ * Sorts tasks by display_order from junction table.
+ */
+ private sortByDisplayOrder(tasks: CommandItem[]): CommandItem[] {
+ const dbResult = getDb();
+ if (!dbResult.ok) {
+ return tasks.sort((a, b) => a.label.localeCompare(b.label));
}
- /**
- * SPEC: quick-launch
- * Removes a command from the quick list.
- */
- removeFromQuick(task: TaskItem): Result {
- const result = this.tagConfig.removeTaskFromTag(task, QUICK_TAG);
- if (result.ok) {
- this.tagConfig.load();
- this.allTasks = this.tagConfig.applyTags(this.allTasks);
- this.onDidChangeTreeDataEmitter.fire(undefined);
- }
- return result;
+ const orderedIdsResult = getCommandIdsByTag({
+ handle: dbResult.value,
+ tagName: QUICK_TAG,
+ });
+ if (!orderedIdsResult.ok) {
+ return tasks.sort((a, b) => a.label.localeCompare(b.label));
}
- /**
- * Refreshes the view.
- */
- refresh(): void {
- this.onDidChangeTreeDataEmitter.fire(undefined);
+ const orderedIds = orderedIdsResult.value;
+ return [...tasks].sort((a, b) => {
+ const indexA = orderedIds.indexOf(a.id);
+ const indexB = orderedIds.indexOf(b.id);
+ if (indexA === -1 && indexB === -1) {
+ return a.label.localeCompare(b.label);
+ }
+ if (indexA === -1) {
+ return 1;
+ }
+ if (indexB === -1) {
+ return -1;
+ }
+ return indexA - indexB;
+ });
+ }
+
+ /**
+ * Called when dragging starts.
+ */
+ handleDrag(
+ source: readonly CommandTreeItem[],
+ dataTransfer: vscode.DataTransfer,
+ ): void {
+ const taskItem = source[0];
+ if (taskItem === undefined || !isCommandItem(taskItem.data)) {
+ return;
}
+ dataTransfer.set(
+ QUICK_TASK_MIME_TYPE,
+ new vscode.DataTransferItem(taskItem.data.id),
+ );
+ }
- getTreeItem(element: CommandTreeItem): vscode.TreeItem {
- return element;
+ /**
+ * SPEC: quick-launch
+ * Called when dropping - reorders tasks in junction table.
+ */
+ handleDrop(
+ target: CommandTreeItem | undefined,
+ dataTransfer: vscode.DataTransfer,
+ ): void {
+ const draggedTask = this.extractDraggedTask(dataTransfer);
+ if (draggedTask === undefined) {
+ return;
}
- getChildren(element?: CommandTreeItem): CommandTreeItem[] {
- if (element !== undefined) { return element.children; }
- logger.quick('getChildren called', {
- allTasksCount: this.allTasks.length,
- allTasksWithTags: this.allTasks.map(t => ({ id: t.id, label: t.label, tags: t.tags }))
- });
- const items = this.buildQuickItems();
- logger.quick('Returning quick tasks', { count: items.length });
- return items;
+ const dbResult = getDb();
+ if (!dbResult.ok) {
+ return;
}
- /**
- * SPEC: quick-launch
- * Builds quick task tree items ordered by display_order from junction table.
- */
- private buildQuickItems(): CommandTreeItem[] {
- const quickTasks = this.allTasks.filter(task => task.tags.includes(QUICK_TAG));
- logger.quick('Filtered quick tasks', { count: quickTasks.length });
- if (quickTasks.length === 0) {
- return [createPlaceholderNode('No quick commands - star commands to add them here')];
- }
- const sorted = this.sortByDisplayOrder(quickTasks);
- return sorted.map(task => createTaskNode(task));
+ const orderedIdsResult = getCommandIdsByTag({
+ handle: dbResult.value,
+ tagName: QUICK_TAG,
+ });
+ if (!orderedIdsResult.ok) {
+ return;
}
- /**
- * SPEC: quick-launch, tagging
- * Sorts tasks by display_order from junction table.
- */
- private sortByDisplayOrder(tasks: TaskItem[]): TaskItem[] {
- const dbResult = getDb();
- if (!dbResult.ok) {
- return tasks.sort((a, b) => a.label.localeCompare(b.label));
- }
-
- const orderedIdsResult = getCommandIdsByTag({
- handle: dbResult.value,
- tagName: QUICK_TAG
- });
- if (!orderedIdsResult.ok) {
- return tasks.sort((a, b) => a.label.localeCompare(b.label));
- }
-
- const orderedIds = orderedIdsResult.value;
- return [...tasks].sort((a, b) => {
- const indexA = orderedIds.indexOf(a.id);
- const indexB = orderedIds.indexOf(b.id);
- if (indexA === -1 && indexB === -1) { return a.label.localeCompare(b.label); }
- if (indexA === -1) { return 1; }
- if (indexB === -1) { return -1; }
- return indexA - indexB;
- });
+ const orderedIds = orderedIdsResult.value;
+ const currentIndex = orderedIds.indexOf(draggedTask.id);
+ if (currentIndex === -1) {
+ return;
}
- /**
- * Called when dragging starts.
- */
- handleDrag(source: readonly CommandTreeItem[], dataTransfer: vscode.DataTransfer): void {
- const taskItem = source[0];
- if (taskItem?.task === null) {
- return;
- }
- dataTransfer.set(QUICK_TASK_MIME_TYPE, new vscode.DataTransferItem(taskItem?.task?.id ?? ''));
+ const targetData =
+ target !== undefined && isCommandItem(target.data)
+ ? target.data
+ : undefined;
+ const targetIndex =
+ targetData !== undefined
+ ? orderedIds.indexOf(targetData.id)
+ : orderedIds.length - 1;
+
+ if (targetIndex === -1 || currentIndex === targetIndex) {
+ return;
}
- /**
- * SPEC: quick-launch
- * Called when dropping - reorders tasks in junction table.
- */
- handleDrop(target: CommandTreeItem | undefined, dataTransfer: vscode.DataTransfer): void {
- const draggedTask = this.extractDraggedTask(dataTransfer);
- if (draggedTask === undefined) { return; }
-
- const dbResult = getDb();
- if (!dbResult.ok) { return; }
-
- const orderedIdsResult = getCommandIdsByTag({
- handle: dbResult.value,
- tagName: QUICK_TAG
- });
- if (!orderedIdsResult.ok) { return; }
-
- const orderedIds = orderedIdsResult.value;
- const currentIndex = orderedIds.indexOf(draggedTask.id);
- if (currentIndex === -1) { return; }
-
- const targetTask = target?.task;
- const targetIndex = targetTask !== null && targetTask !== undefined
- ? orderedIds.indexOf(targetTask.id)
- : orderedIds.length - 1;
-
- if (targetIndex === -1 || currentIndex === targetIndex) { return; }
-
- const reordered = [...orderedIds];
- reordered.splice(currentIndex, 1);
- reordered.splice(targetIndex, 0, draggedTask.id);
-
- for (let i = 0; i < reordered.length; i++) {
- const commandId = reordered[i];
- if (commandId !== undefined) {
- dbResult.value.db.run(
- `UPDATE command_tags
+ const reordered = [...orderedIds];
+ reordered.splice(currentIndex, 1);
+ reordered.splice(targetIndex, 0, draggedTask.id);
+
+ for (let i = 0; i < reordered.length; i++) {
+ const commandId = reordered[i];
+ if (commandId !== undefined) {
+ dbResult.value.db.run(
+ `UPDATE command_tags
SET display_order = ?
WHERE command_id = ?
AND tag_id = (SELECT tag_id FROM tags WHERE tag_name = ?)`,
- [i, commandId, QUICK_TAG]
- );
- }
- }
-
- this.tagConfig.load();
- this.allTasks = this.tagConfig.applyTags(this.allTasks);
- this.onDidChangeTreeDataEmitter.fire(undefined);
+ [i, commandId, QUICK_TAG],
+ );
+ }
}
- /**
- * Extracts the dragged task from a data transfer.
- */
- private extractDraggedTask(dataTransfer: vscode.DataTransfer): TaskItem | undefined {
- const transferItem = dataTransfer.get(QUICK_TASK_MIME_TYPE);
- if (transferItem === undefined) { return undefined; }
- const draggedId = transferItem.value as string;
- if (draggedId === '') { return undefined; }
- return this.allTasks.find(t => t.id === draggedId && t.tags.includes(QUICK_TAG));
+ this.tagConfig.load();
+ this.allTasks = this.tagConfig.applyTags(this.allTasks);
+ this.onDidChangeTreeDataEmitter.fire(undefined);
+ }
+
+ /**
+ * Extracts the dragged task from a data transfer.
+ */
+ private extractDraggedTask(
+ dataTransfer: vscode.DataTransfer,
+ ): CommandItem | undefined {
+ const transferItem = dataTransfer.get(QUICK_TASK_MIME_TYPE);
+ if (transferItem === undefined) {
+ return undefined;
+ }
+ const draggedId = transferItem.value as string;
+ if (draggedId === "") {
+ return undefined;
}
+ return this.allTasks.find(
+ (t) => t.id === draggedId && t.tags.includes(QUICK_TAG),
+ );
+ }
}
diff --git a/src/config/TagConfig.ts b/src/config/TagConfig.ts
index 2a38826..6255045 100644
--- a/src/config/TagConfig.ts
+++ b/src/config/TagConfig.ts
@@ -4,157 +4,160 @@
* All tag data stored in SQLite tags table (junction table design).
*/
-import type { TaskItem, Result } from '../models/TaskItem';
-import { err } from '../models/TaskItem';
-import { getDb } from '../db/lifecycle';
+import type { CommandItem, Result } from "../models/TaskItem";
+import { err } from "../models/TaskItem";
+import { getDb } from "../db/lifecycle";
import {
- addTagToCommand,
- removeTagFromCommand,
- getCommandIdsByTag,
- getAllTagNames,
- reorderTagCommands
-} from '../db/db';
+ addTagToCommand,
+ removeTagFromCommand,
+ getCommandIdsByTag,
+ getAllTagNames,
+ reorderTagCommands,
+} from "../db/db";
export class TagConfig {
- private commandTagsMap = new Map();
-
- /**
- * SPEC: tagging
- * Loads all tag assignments from SQLite junction table.
- */
- load(): void {
- const dbResult = getDb();
- if (!dbResult.ok) {
- this.commandTagsMap = new Map();
- return;
- }
-
- const tagNamesResult = getAllTagNames(dbResult.value);
- if (!tagNamesResult.ok) {
- this.commandTagsMap = new Map();
- return;
- }
-
- const map = new Map();
- for (const tagName of tagNamesResult.value) {
- const commandIdsResult = getCommandIdsByTag({
- handle: dbResult.value,
- tagName
- });
- if (commandIdsResult.ok) {
- for (const commandId of commandIdsResult.value) {
- const tags = map.get(commandId) ?? [];
- tags.push(tagName);
- map.set(commandId, tags);
- }
- }
- }
- this.commandTagsMap = map;
+ private commandTagsMap = new Map();
+
+ /**
+ * SPEC: tagging
+ * Loads all tag assignments from SQLite junction table.
+ */
+ load(): void {
+ const dbResult = getDb();
+ if (!dbResult.ok) {
+ this.commandTagsMap = new Map();
+ return;
}
- /**
- * SPEC: tagging
- * Applies tags to tasks using exact command ID matching (no patterns).
- */
- applyTags(tasks: TaskItem[]): TaskItem[] {
- return tasks.map(task => {
- const tags = this.commandTagsMap.get(task.id) ?? [];
- return { ...task, tags };
- });
+ const tagNamesResult = getAllTagNames(dbResult.value);
+ if (!tagNamesResult.ok) {
+ this.commandTagsMap = new Map();
+ return;
}
- /**
- * SPEC: tagging
- * Gets all tag names.
- */
- getTagNames(): string[] {
- const dbResult = getDb();
- if (!dbResult.ok) {
- return [];
+ const map = new Map();
+ for (const tagName of tagNamesResult.value) {
+ const commandIdsResult = getCommandIdsByTag({
+ handle: dbResult.value,
+ tagName,
+ });
+ if (commandIdsResult.ok) {
+ for (const commandId of commandIdsResult.value) {
+ const tags = map.get(commandId) ?? [];
+ tags.push(tagName);
+ map.set(commandId, tags);
}
- const result = getAllTagNames(dbResult.value);
- return result.ok ? result.value : [];
+ }
+ }
+ this.commandTagsMap = map;
+ }
+
+ /**
+ * SPEC: tagging
+ * Applies tags to tasks using exact command ID matching (no patterns).
+ */
+ applyTags(tasks: CommandItem[]): CommandItem[] {
+ return tasks.map((task) => {
+ const tags = this.commandTagsMap.get(task.id) ?? [];
+ return { ...task, tags };
+ });
+ }
+
+ /**
+ * SPEC: tagging
+ * Gets all tag names.
+ */
+ getTagNames(): string[] {
+ const dbResult = getDb();
+ if (!dbResult.ok) {
+ return [];
+ }
+ const result = getAllTagNames(dbResult.value);
+ return result.ok ? result.value : [];
+ }
+
+ /**
+ * SPEC: tagging/management
+ * Adds a task to a tag by creating junction record with exact command ID.
+ */
+ addTaskToTag(task: CommandItem, tagName: string): Result {
+ const dbResult = getDb();
+ if (!dbResult.ok) {
+ return err(dbResult.error);
}
- /**
- * SPEC: tagging/management
- * Adds a task to a tag by creating junction record with exact command ID.
- */
- addTaskToTag(task: TaskItem, tagName: string): Result {
- const dbResult = getDb();
- if (!dbResult.ok) {
- return err(dbResult.error);
- }
-
- const result = addTagToCommand({
- handle: dbResult.value,
- commandId: task.id,
- tagName
- });
+ const result = addTagToCommand({
+ handle: dbResult.value,
+ commandId: task.id,
+ tagName,
+ });
- if (result.ok) {
- this.load();
- }
- return result;
+ if (result.ok) {
+ this.load();
+ }
+ return result;
+ }
+
+ /**
+ * SPEC: tagging/management
+ * Removes a task from a tag by deleting junction record.
+ */
+ removeTaskFromTag(task: CommandItem, tagName: string): Result {
+ const dbResult = getDb();
+ if (!dbResult.ok) {
+ return err(dbResult.error);
}
- /**
- * SPEC: tagging/management
- * Removes a task from a tag by deleting junction record.
- */
- removeTaskFromTag(task: TaskItem, tagName: string): Result {
- const dbResult = getDb();
- if (!dbResult.ok) {
- return err(dbResult.error);
- }
-
- const result = removeTagFromCommand({
- handle: dbResult.value,
- commandId: task.id,
- tagName
- });
+ const result = removeTagFromCommand({
+ handle: dbResult.value,
+ commandId: task.id,
+ tagName,
+ });
- if (result.ok) {
- this.load();
- }
- return result;
+ if (result.ok) {
+ this.load();
}
-
- /**
- * SPEC: quick-launch
- * Gets ordered command IDs for a tag (ordered by display_order).
- */
- getOrderedCommandIds(tagName: string): string[] {
- const dbResult = getDb();
- if (!dbResult.ok) {
- return [];
- }
- const result = getCommandIdsByTag({
- handle: dbResult.value,
- tagName
- });
- return result.ok ? result.value : [];
+ return result;
+ }
+
+ /**
+ * SPEC: quick-launch
+ * Gets ordered command IDs for a tag (ordered by display_order).
+ */
+ getOrderedCommandIds(tagName: string): string[] {
+ const dbResult = getDb();
+ if (!dbResult.ok) {
+ return [];
+ }
+ const result = getCommandIdsByTag({
+ handle: dbResult.value,
+ tagName,
+ });
+ return result.ok ? result.value : [];
+ }
+
+ /**
+ * SPEC: quick-launch
+ * Reorders commands for a tag by updating display_order in junction table.
+ */
+ reorderCommands(
+ tagName: string,
+ orderedCommandIds: string[],
+ ): Result {
+ const dbResult = getDb();
+ if (!dbResult.ok) {
+ return err(dbResult.error);
}
- /**
- * SPEC: quick-launch
- * Reorders commands for a tag by updating display_order in junction table.
- */
- reorderCommands(tagName: string, orderedCommandIds: string[]): Result {
- const dbResult = getDb();
- if (!dbResult.ok) {
- return err(dbResult.error);
- }
-
- const result = reorderTagCommands({
- handle: dbResult.value,
- tagName,
- orderedCommandIds
- });
+ const result = reorderTagCommands({
+ handle: dbResult.value,
+ tagName,
+ orderedCommandIds,
+ });
- if (result.ok) {
- this.load();
- }
- return result;
+ if (result.ok) {
+ this.load();
}
+ return result;
+ }
}
diff --git a/src/discovery/ant.ts b/src/discovery/ant.ts
index 59a65b1..ee83656 100644
--- a/src/discovery/ant.ts
+++ b/src/discovery/ant.ts
@@ -1,96 +1,116 @@
-import * as vscode from 'vscode';
-import * as path from 'path';
-import type { TaskItem, IconDef } from '../models/TaskItem';
-import { generateTaskId, simplifyPath } from '../models/TaskItem';
-import { readFile } from '../utils/fileUtils';
+import * as vscode from "vscode";
+import * as path from "path";
+import type { CommandItem, IconDef, CategoryDef } from "../models/TaskItem";
+import { generateCommandId, simplifyPath } from "../models/TaskItem";
+import { readFile } from "../utils/fileUtils";
-export const ICON_DEF: IconDef = { icon: 'symbol-constructor', color: 'terminal.ansiYellow' };
+export const ICON_DEF: IconDef = {
+ icon: "symbol-constructor",
+ color: "terminal.ansiYellow",
+};
+export const CATEGORY_DEF: CategoryDef = { type: "ant", label: "Ant Targets" };
/**
* Discovers Ant targets from build.xml files.
* Only returns tasks if Java source files (.java) exist in the workspace.
*/
export async function discoverAntTargets(
- workspaceRoot: string,
- excludePatterns: string[]
-): Promise {
- const exclude = `{${excludePatterns.join(',')}}`;
+ workspaceRoot: string,
+ excludePatterns: string[],
+): Promise {
+ const exclude = `{${excludePatterns.join(",")}}`;
- // Check if any Java source files exist before processing
- const javaFiles = await vscode.workspace.findFiles('**/*.java', exclude);
- if (javaFiles.length === 0) {
- return []; // No Java source code, skip Ant targets
- }
+ // Check if any Java source files exist before processing
+ const javaFiles = await vscode.workspace.findFiles("**/*.java", exclude);
+ if (javaFiles.length === 0) {
+ return []; // No Java source code, skip Ant targets
+ }
- const files = await vscode.workspace.findFiles('**/build.xml', exclude);
- const tasks: TaskItem[] = [];
+ const files = await vscode.workspace.findFiles("**/build.xml", exclude);
+ const commands: CommandItem[] = [];
- for (const file of files) {
- const result = await readFile(file);
- if (!result.ok) {
- continue; // Skip files we can't read
- }
+ for (const file of files) {
+ const result = await readFile(file);
+ if (!result.ok) {
+ continue; // Skip files we can't read
+ }
- const content = result.value;
- const antDir = path.dirname(file.fsPath);
- const category = simplifyPath(file.fsPath, workspaceRoot);
- const targets = parseAntTargets(content);
+ const content = result.value;
+ const antDir = path.dirname(file.fsPath);
+ const category = simplifyPath(file.fsPath, workspaceRoot);
+ const targets = parseAntTargets(content);
- for (const target of targets) {
- tasks.push({
- id: generateTaskId('ant', file.fsPath, target.name),
- label: target.name,
- type: 'ant',
- category,
- command: `ant ${target.name}`,
- cwd: antDir,
- filePath: file.fsPath,
- tags: [],
- ...(target.description !== undefined ? { description: target.description } : {})
- });
- }
+ for (const target of targets) {
+ commands.push({
+ id: generateCommandId("ant", file.fsPath, target.name),
+ label: target.name,
+ type: "ant",
+ category,
+ command: `ant ${target.name}`,
+ cwd: antDir,
+ filePath: file.fsPath,
+ tags: [],
+ ...(target.description !== undefined
+ ? { description: target.description }
+ : {}),
+ });
}
+ }
- return tasks;
+ return commands;
}
interface AntTarget {
- name: string;
- description?: string;
+ name: string;
+ description?: string;
}
/**
* Parses build.xml to extract target names and descriptions.
*/
function parseAntTargets(content: string): AntTarget[] {
- const targets: AntTarget[] = [];
+ const targets: AntTarget[] = [];
- // Match patterns
- const targetRegex = /]*name\s*=\s*["']([^"']+)["'][^>]*(?:description\s*=\s*["']([^"']+)["'])?[^>]*>/g;
- let match;
- while ((match = targetRegex.exec(content)) !== null) {
- const name = match[1];
- const description = match[2];
- if (name !== undefined && name !== '' && !targets.some(t => t.name === name)) {
- targets.push({
- name,
- ...(description !== undefined && description !== '' ? { description } : {})
- });
- }
+ // Match patterns
+ const targetRegex =
+ /]*name\s*=\s*["']([^"']+)["'][^>]*(?:description\s*=\s*["']([^"']+)["'])?[^>]*>/g;
+ let match;
+ while ((match = targetRegex.exec(content)) !== null) {
+ const name = match[1];
+ const description = match[2];
+ if (
+ name !== undefined &&
+ name !== "" &&
+ !targets.some((t) => t.name === name)
+ ) {
+ targets.push({
+ name,
+ ...(description !== undefined && description !== ""
+ ? { description }
+ : {}),
+ });
}
+ }
- // Also match targets where description comes before name
- const altRegex = /]*description\s*=\s*["']([^"']+)["'][^>]*name\s*=\s*["']([^"']+)["'][^>]*>/g;
- while ((match = altRegex.exec(content)) !== null) {
- const description = match[1];
- const name = match[2];
- if (name !== undefined && name !== '' && !targets.some(t => t.name === name)) {
- targets.push({
- name,
- ...(description !== undefined && description !== '' ? { description } : {})
- });
- }
+ // Also match targets where description comes before name
+ const altRegex =
+ /]*description\s*=\s*["']([^"']+)["'][^>]*name\s*=\s*["']([^"']+)["'][^>]*>/g;
+ while ((match = altRegex.exec(content)) !== null) {
+ const description = match[1];
+ const name = match[2];
+ if (
+ name !== undefined &&
+ name !== "" &&
+ !targets.some((t) => t.name === name)
+ ) {
+ targets.push({
+ name,
+ ...(description !== undefined && description !== ""
+ ? { description }
+ : {}),
+ });
}
+ }
- return targets;
+ return targets;
}
diff --git a/src/discovery/cargo.ts b/src/discovery/cargo.ts
index 0263222..e2f80aa 100644
--- a/src/discovery/cargo.ts
+++ b/src/discovery/cargo.ts
@@ -1,23 +1,27 @@
-import * as vscode from 'vscode';
-import * as path from 'path';
-import type { TaskItem, IconDef } from '../models/TaskItem';
-import { generateTaskId, simplifyPath } from '../models/TaskItem';
-import { readFile } from '../utils/fileUtils';
-
-export const ICON_DEF: IconDef = { icon: 'package', color: 'terminal.ansiRed' };
+import * as vscode from "vscode";
+import * as path from "path";
+import type { CommandItem, IconDef, CategoryDef } from "../models/TaskItem";
+import { generateCommandId, simplifyPath } from "../models/TaskItem";
+import { readFile } from "../utils/fileUtils";
+
+export const ICON_DEF: IconDef = { icon: "package", color: "terminal.ansiRed" };
+export const CATEGORY_DEF: CategoryDef = {
+ type: "cargo",
+ label: "Cargo (Rust)",
+};
/**
* Standard Cargo commands that are always available.
*/
const STANDARD_CARGO_COMMANDS = [
- { name: 'build', description: 'Compile the current package' },
- { name: 'run', description: 'Run the main binary' },
- { name: 'test', description: 'Run tests' },
- { name: 'check', description: 'Check code without building' },
- { name: 'clean', description: 'Remove build artifacts' },
- { name: 'clippy', description: 'Run Clippy lints' },
- { name: 'fmt', description: 'Format code with rustfmt' },
- { name: 'doc', description: 'Build documentation' }
+ { name: "build", description: "Compile the current package" },
+ { name: "run", description: "Run the main binary" },
+ { name: "test", description: "Run tests" },
+ { name: "check", description: "Check code without building" },
+ { name: "clean", description: "Remove build artifacts" },
+ { name: "clippy", description: "Run Clippy lints" },
+ { name: "fmt", description: "Format code with rustfmt" },
+ { name: "doc", description: "Build documentation" },
];
/**
@@ -25,117 +29,117 @@ const STANDARD_CARGO_COMMANDS = [
* Only returns tasks if Rust source files (.rs) exist in the workspace.
*/
export async function discoverCargoTasks(
- workspaceRoot: string,
- excludePatterns: string[]
-): Promise {
- const exclude = `{${excludePatterns.join(',')}}`;
-
- // Check if any Rust source files exist before processing
- const rustFiles = await vscode.workspace.findFiles('**/*.rs', exclude);
- if (rustFiles.length === 0) {
- return []; // No Rust source code, skip Cargo tasks
+ workspaceRoot: string,
+ excludePatterns: string[],
+): Promise {
+ const exclude = `{${excludePatterns.join(",")}}`;
+
+ // Check if any Rust source files exist before processing
+ const rustFiles = await vscode.workspace.findFiles("**/*.rs", exclude);
+ if (rustFiles.length === 0) {
+ return []; // No Rust source code, skip Cargo tasks
+ }
+
+ const files = await vscode.workspace.findFiles("**/Cargo.toml", exclude);
+ const commands: CommandItem[] = [];
+
+ for (const file of files) {
+ const result = await readFile(file);
+ if (!result.ok) {
+ continue; // Skip files we can't read
+ }
+
+ const content = result.value;
+ const cargoDir = path.dirname(file.fsPath);
+ const category = simplifyPath(file.fsPath, workspaceRoot);
+
+ // Add standard Cargo commands
+ for (const cmd of STANDARD_CARGO_COMMANDS) {
+ commands.push({
+ id: generateCommandId("cargo", file.fsPath, cmd.name),
+ label: cmd.name,
+ type: "cargo",
+ category,
+ command: `cargo ${cmd.name}`,
+ cwd: cargoDir,
+ filePath: file.fsPath,
+ tags: [],
+ description: cmd.description,
+ });
+ }
+
+ // Parse for binary targets
+ const binaries = parseCargoBinaries(content);
+ for (const bin of binaries) {
+ if (!commands.some((t) => t.label === `run --bin ${bin}`)) {
+ commands.push({
+ id: generateCommandId("cargo", file.fsPath, `run-${bin}`),
+ label: `run --bin ${bin}`,
+ type: "cargo",
+ category,
+ command: `cargo run --bin ${bin}`,
+ cwd: cargoDir,
+ filePath: file.fsPath,
+ tags: [],
+ description: `Run ${bin} binary`,
+ });
+ }
}
- const files = await vscode.workspace.findFiles('**/Cargo.toml', exclude);
- const tasks: TaskItem[] = [];
-
- for (const file of files) {
- const result = await readFile(file);
- if (!result.ok) {
- continue; // Skip files we can't read
- }
-
- const content = result.value;
- const cargoDir = path.dirname(file.fsPath);
- const category = simplifyPath(file.fsPath, workspaceRoot);
-
- // Add standard Cargo commands
- for (const cmd of STANDARD_CARGO_COMMANDS) {
- tasks.push({
- id: generateTaskId('cargo', file.fsPath, cmd.name),
- label: cmd.name,
- type: 'cargo',
- category,
- command: `cargo ${cmd.name}`,
- cwd: cargoDir,
- filePath: file.fsPath,
- tags: [],
- description: cmd.description
- });
- }
-
- // Parse for binary targets
- const binaries = parseCargoBinaries(content);
- for (const bin of binaries) {
- if (!tasks.some(t => t.label === `run --bin ${bin}`)) {
- tasks.push({
- id: generateTaskId('cargo', file.fsPath, `run-${bin}`),
- label: `run --bin ${bin}`,
- type: 'cargo',
- category,
- command: `cargo run --bin ${bin}`,
- cwd: cargoDir,
- filePath: file.fsPath,
- tags: [],
- description: `Run ${bin} binary`
- });
- }
- }
-
- // Parse for examples
- const examples = parseCargoExamples(content);
- for (const example of examples) {
- tasks.push({
- id: generateTaskId('cargo', file.fsPath, `example-${example}`),
- label: `run --example ${example}`,
- type: 'cargo',
- category,
- command: `cargo run --example ${example}`,
- cwd: cargoDir,
- filePath: file.fsPath,
- tags: [],
- description: `Run ${example} example`
- });
- }
+ // Parse for examples
+ const examples = parseCargoExamples(content);
+ for (const example of examples) {
+ commands.push({
+ id: generateCommandId("cargo", file.fsPath, `example-${example}`),
+ label: `run --example ${example}`,
+ type: "cargo",
+ category,
+ command: `cargo run --example ${example}`,
+ cwd: cargoDir,
+ filePath: file.fsPath,
+ tags: [],
+ description: `Run ${example} example`,
+ });
}
+ }
- return tasks;
+ return commands;
}
/**
* Parses Cargo.toml for binary targets.
*/
function parseCargoBinaries(content: string): string[] {
- const binaries: string[] = [];
-
- // Match [[bin]] sections with name = "..."
- const binRegex = /\[\[bin\]\][^[]*name\s*=\s*["'](\w+)["']/g;
- let match;
- while ((match = binRegex.exec(content)) !== null) {
- const name = match[1];
- if (name !== undefined && name !== '' && !binaries.includes(name)) {
- binaries.push(name);
- }
+ const binaries: string[] = [];
+
+ // Match [[bin]] sections with name = "..."
+ const binRegex = /\[\[bin\]\][^[]*name\s*=\s*["'](\w+)["']/g;
+ let match;
+ while ((match = binRegex.exec(content)) !== null) {
+ const name = match[1];
+ if (name !== undefined && name !== "" && !binaries.includes(name)) {
+ binaries.push(name);
}
+ }
- return binaries;
+ return binaries;
}
/**
* Parses Cargo.toml for example targets.
*/
function parseCargoExamples(content: string): string[] {
- const examples: string[] = [];
-
- // Match [[example]] sections with name = "..."
- const exampleRegex = /\[\[example\]\][^[]*name\s*=\s*["'](\w+)["']/g;
- let match;
- while ((match = exampleRegex.exec(content)) !== null) {
- const name = match[1];
- if (name !== undefined && name !== '' && !examples.includes(name)) {
- examples.push(name);
- }
+ const examples: string[] = [];
+
+ // Match [[example]] sections with name = "..."
+ const exampleRegex = /\[\[example\]\][^[]*name\s*=\s*["'](\w+)["']/g;
+ let match;
+ while ((match = exampleRegex.exec(content)) !== null) {
+ const name = match[1];
+ if (name !== undefined && name !== "" && !examples.includes(name)) {
+ examples.push(name);
}
+ }
- return examples;
+ return examples;
}
diff --git a/src/discovery/composer.ts b/src/discovery/composer.ts
index 5d6b50b..ace0f8b 100644
--- a/src/discovery/composer.ts
+++ b/src/discovery/composer.ts
@@ -1,14 +1,26 @@
-import * as vscode from 'vscode';
-import * as path from 'path';
-import type { TaskItem, MutableTaskItem, IconDef } from '../models/TaskItem';
-import { generateTaskId, simplifyPath } from '../models/TaskItem';
-import { readFile, parseJson } from '../utils/fileUtils';
+import * as vscode from "vscode";
+import * as path from "path";
+import type {
+ CommandItem,
+ MutableCommandItem,
+ IconDef,
+ CategoryDef,
+} from "../models/TaskItem";
+import { generateCommandId, simplifyPath } from "../models/TaskItem";
+import { readFile, parseJson } from "../utils/fileUtils";
-export const ICON_DEF: IconDef = { icon: 'symbol-interface', color: 'terminal.ansiYellow' };
+export const ICON_DEF: IconDef = {
+ icon: "symbol-interface",
+ color: "terminal.ansiYellow",
+};
+export const CATEGORY_DEF: CategoryDef = {
+ type: "composer",
+ label: "Composer Scripts",
+};
interface ComposerJson {
- scripts?: Record;
- 'scripts-descriptions'?: Record;
+ scripts?: Record;
+ "scripts-descriptions"?: Record;
}
/**
@@ -16,82 +28,85 @@ interface ComposerJson {
* Only returns tasks if PHP source files (.php) exist in the workspace.
*/
export async function discoverComposerScripts(
- workspaceRoot: string,
- excludePatterns: string[]
-): Promise {
- const exclude = `{${excludePatterns.join(',')}}`;
+ workspaceRoot: string,
+ excludePatterns: string[],
+): Promise {
+ const exclude = `{${excludePatterns.join(",")}}`;
- // Check if any PHP source files exist before processing
- const phpFiles = await vscode.workspace.findFiles('**/*.php', exclude);
- if (phpFiles.length === 0) {
- return []; // No PHP source code, skip Composer scripts
- }
+ // Check if any PHP source files exist before processing
+ const phpFiles = await vscode.workspace.findFiles("**/*.php", exclude);
+ if (phpFiles.length === 0) {
+ return []; // No PHP source code, skip Composer scripts
+ }
- const files = await vscode.workspace.findFiles('**/composer.json', exclude);
- const tasks: TaskItem[] = [];
+ const files = await vscode.workspace.findFiles("**/composer.json", exclude);
+ const commands: CommandItem[] = [];
- for (const file of files) {
- const contentResult = await readFile(file);
- if (!contentResult.ok) {
- continue; // Skip unreadable composer.json
- }
+ for (const file of files) {
+ const contentResult = await readFile(file);
+ if (!contentResult.ok) {
+ continue; // Skip unreadable composer.json
+ }
- const composerResult = parseJson