diff --git a/README.md b/README.md index 503ea1a4..ee5d3a6f 100644 --- a/README.md +++ b/README.md @@ -162,8 +162,8 @@ release artifact. Current release versions are: | Package | Version | Release role | | --- | ---: | --- | -| `@openspec-ui/core` | 0.16.0 | Shared behavior and persistence contract | -| `openspec-ui-vscode` | 0.10.0 | VS Code delivery | +| `@openspec-ui/core` | 0.17.0 | Shared behavior and persistence contract | +| `openspec-ui-vscode` | 0.11.0 | VS Code delivery | | `@openspec-ui/server` | 1.7.0 | Standalone server delivery | | `@openspec-ui/webui` | 1.8.0 | Shared browser UI | | `@openspec-ui/cli` | 0.1.0 | CI merge-gate delivery | diff --git a/openspec/changes/tasks-tree-expand/.openspec.yaml b/openspec/changes/tasks-tree-expand/.openspec.yaml new file mode 100644 index 00000000..14963146 --- /dev/null +++ b/openspec/changes/tasks-tree-expand/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-17 diff --git a/openspec/changes/tasks-tree-expand/design.md b/openspec/changes/tasks-tree-expand/design.md new file mode 100644 index 00000000..c101a50e --- /dev/null +++ b/openspec/changes/tasks-tree-expand/design.md @@ -0,0 +1,125 @@ +## Context + +`ChangeTreeItem` (`changes-tree.ts`) already carries a real, discovery- +sourced `changeDir` (from `discoverOpenSpecWorkspace`, not reconstructed +from a raw user-typed name) and an `archived` flag. Both +`ChangesTreeProvider.getChildren`/`ArchiveTreeProvider.getChildren` +already duplicate near-identical artifact-expansion logic for a +`ChangeTreeItem` element (`archive-tree.ts` literally mirrors +`changes-tree.ts`'s artifact-mapping block). + +`task-templates.ts`'s own comment explains the existing security pattern +this change follows: "`changeName` is never used to build a path +directly... a crafted name cannot escape [the workspace]" — paths always +come from `discoverOpenSpecWorkspace`'s own allowlisted `artifacts[]`, +never from joining a caller-supplied string onto a directory. +`change-state.ts` already has a `TASK_CHECKBOX_RE` regex for *counting* +checked/unchecked tasks, but nothing in core extracts individual tasks +with a line number. + +No existing command in this codebase does "reveal a specific line in an +open/to-be-opened editor" (`TextEditorRevealType`, `editor.selection`) — +the closest precedent, `ArtifactTreeItem`'s `.command = { command: +"vscode.open", ... }`, only opens a file, it does not jump to a line. + +## Goals / Non-Goals + +**Goals:** +- Both trees stay one shared `getChangeChildren` implementation, not two + independently-maintained copies — closes an existing duplication gap + (artifacts) while adding tasks, rather than duplicating the new logic a + third time. +- Delete never removes the wrong line, even if the tree is stale relative + to a file edited outside the tree (another editor, a CLI agent run, git + pull) since the tree was last refreshed. + +**Non-Goals:** +- No task *editing* (checking/unchecking, renaming) from the tree — only + reveal (both trees) and delete (active only). Marking a task done is + already possible by editing `tasks.md` directly (the checkbox syntax is + the same convention already used everywhere in this repository); adding + a second, tree-driven way to toggle it is a separate, unrequested + feature. +- No conflict resolution if `tasks.md` is open with unsaved edits when a + delete happens elsewhere (CLI, another window). This is an existing, + already-accepted risk class in this codebase — `deleteChange` already + deletes files that could be open in an editor with no special handling + — not a new gap introduced here. +- No standalone/webui equivalent. See proposal.md's Impact — deferred as + a clean follow-up, not out of scope by principle. + +## Decisions + +### `deleteTaskLine` re-verifies the exact line's text before deleting, not just its index + +The tree's `TaskTreeItem` line numbers are a snapshot from the last +`getChildren()` call; the real file can change before the user clicks +Delete (their own edit, a CLI agent run, another window). Rejected +trusting the stored line number blindly: an off-by-N delete after the +file shifted would silently remove the *wrong* task — a correctness bug +users would not immediately notice (task text just gone, no error). +Instead, `deleteTaskLine` re-reads the file fresh at delete time, checks +that the target line still parses as a checkbox item whose description +text exactly matches what the tree captured, and only then removes it; +otherwise it throws `TaskListChangedError` (shown as a warning, "refresh +and try again") — fails closed, matching this repository's own +fail-closed philosophy (`docs/adr/0006-fail-closed-journal-compatibility.md`). + +Rejected content-only matching (search the whole file for the first line +with matching text, ignore the stored line number entirely): silently +ambiguous when two tasks share identical text (e.g. copy-pasted +sub-tasks) — could delete a different occurrence than the one the user +actually clicked. Line-number-plus-text-verification is unambiguous and +still self-healing (the tree refreshes and the next click gets a fresh, +correct line number) without guessing across duplicates. + +### Reveal degrades gracefully instead of failing closed + +Unlike delete, revealing a task is non-destructive — if the stored line +number no longer matches (file changed since last refresh), falling back +to a whole-file search for the first line with matching text (and, if +that also fails, just opening the file at the top) is the better +trade-off: showing *something* useful beats blocking on a warning dialog +for a read-only navigation action. + +### Shared `getChangeChildren(workspaceRoot, element)` exported from `changes-tree.ts`, reused by `archive-tree.ts` + +`archive-tree.ts` already imports `ArtifactTreeItem`/`ChangeTreeItem`/ +`EmptyTreeItem` from `changes-tree.js` — extending that existing reuse +pattern to the children-computation logic itself (which was, before this +change, independently duplicated in both files) removes an existing +drift risk instead of adding a third copy of increasingly complex +children logic. + +### `openspec-ui.revealTask` is not exposed via `contributes.commands` + +It only makes sense with a specific `TaskTreeItem` argument (which +Command Palette invocation cannot supply); `deleteTask` reads `item ?? +return` too but is deliberately still exposed in `contributes.commands` +because the context-menu `when`-clause needs a Palette-registered command +id to bind to — `revealTask` is wired only via each `TaskTreeItem`'s own +`.command` property, matching the (also `contributes.commands`-absent) +generic `vscode.open` command already used the same way by +`ArtifactTreeItem`. + +## Risks / Trade-offs + +- **[Risk]** Parsing `tasks.md` client-side (regex over raw file content) + instead of via the `openspec` CLI's own `status --json` command could + drift from how the CLI itself defines "a task" if its checklist + convention ever changes. → **Mitigation**: accepted; `change-state.ts` + already has this exact same class of direct-regex dependency + (`TASK_CHECKBOX_RE`) for counting, so this isn't a new coupling, just a + second, consistent use of it. +- **[Risk]** Large `tasks.md` files (hundreds of tasks) render every task + as a tree node with no pagination/grouping. → **Mitigation**: accepted + for now — no repository's `tasks.md` in this project's own history has + approached that scale; revisit only if it becomes a real problem. + +## Migration Plan + +- No data migration; purely additive (new tree children, two new + commands, one new core module). +- Version bump (minor) for `@openspec-ui/core`, `openspec-ui-vscode`. +- Rollback: revert the package changes together; no persisted state + beyond `tasks.md` edits a user explicitly made via "Delete Task." diff --git a/openspec/changes/tasks-tree-expand/proposal.md b/openspec/changes/tasks-tree-expand/proposal.md new file mode 100644 index 00000000..3310cd4b --- /dev/null +++ b/openspec/changes/tasks-tree-expand/proposal.md @@ -0,0 +1,67 @@ +## Why + +A specific gap raised in review: the "Changes" and "Archive" tree views +(`openspec/specs/vscode-extension/spec.md`) already expand a change node +to show its artifacts (`proposal.md`/`design.md`/`tasks.md`/delta specs) +via `ArtifactTreeItem`, but not the *individual tasks* inside +`tasks.md` — a user who wants to see or manage progress on one specific +task has to open `tasks.md` and read/edit it manually, with no tree-level +view of task status, no per-task delete, and no quick way to jump from +"I clicked this task in the tree" to "here it is in the open markdown +file." This is the same class of gap `archive-tasks-as-template`/ +`template-catalog` already closed for other change artifacts — tree-level +interaction was extended to those, but never to tasks.md's own checklist +items. + +## What Changes + +- Expand each `ChangeTreeItem` (in both the "Changes" and "Archive" tree + views) to also list its individual checklist tasks from `tasks.md` as + child tree items, alongside the existing artifact children. +- Clicking a task tree item opens (or reveals, if already open) + `tasks.md` with the cursor moved to and the line selected/centered at + that exact task — in both trees, since this is a non-destructive, + purely informational action. +- Add "Delete Task" to the task context menu, **active changes only** + (never on archived tasks — the Archive tree's task children are + read-only/informational, matching how the Archive tree already has no + mutating actions beyond "Copy Tasks as Template" and "Unarchive" on the + change itself). Deleting a task removes that exact checklist line from + the change's `tasks.md`, with a confirmation prompt. +- Core: new `readTaskChecklist`/`deleteTaskLine` functions + (`packages/core/src/task-checklist.ts`) — `deleteTaskLine` re-verifies + the target line still contains the expected task text at delete time + (re-reading the file fresh, not trusting a potentially-stale line + number from when the tree was last refreshed) and fails closed with a + clear error if the file changed underneath, rather than risking + deleting the wrong line. + +## Capabilities + +### New Capabilities + +(none) + +### Modified Capabilities + +- `vscode-extension`: the Changes and Archive tree views now expose + individual tasks as child nodes, with reveal-in-editor (both trees) and + delete (active changes only). + +## Impact + +- `packages/core/src/task-checklist.ts` (new), `index.ts` (export). +- `packages/extension/src/tree/changes-tree.ts` (`TaskTreeItem`, shared + `getChangeChildren` helper reused by both tree providers), + `src/tree/archive-tree.ts` (calls the shared helper instead of + duplicating the artifact-only logic it has today). +- `packages/extension/src/commands.ts` (`openspec-ui.revealTask`, + `openspec-ui.deleteTask`), `package.json` (command registration + + context-menu entry scoped to active-task items only). +- No REST endpoint / standalone UI — this is a VS Code tree/editor + interaction, not a data operation standalone's browser shell has an + equivalent surface for today (no per-task tree view exists there); core + still owns the actual mutation logic, so standalone parity remains a + clean, non-breaking follow-up if ever wanted, per ADR-0001's + single-source-of-truth-in-core invariant. +- No change to the command/event protocol. diff --git a/openspec/changes/tasks-tree-expand/smoke-test-notes.md b/openspec/changes/tasks-tree-expand/smoke-test-notes.md new file mode 100644 index 00000000..0d7370ad --- /dev/null +++ b/openspec/changes/tasks-tree-expand/smoke-test-notes.md @@ -0,0 +1,42 @@ +## Core: real, not just mocked + +Ran `readTaskChecklist`/`deleteTaskLine` directly via `tsx` (temporary +scratch scripts, deleted afterward — `git status` confirmed no trace +left): + +- **`readTaskChecklist` against this repository's own live file**: + pointed it at this very change's own `tasks-tree-expand/tasks.md` + (self-referential, genuinely real data) — correctly found 14 tasks + with accurate line numbers and `done` state matching the actual + checked/unchecked boxes in the file. +- **`deleteTaskLine` against a real temp `tasks.md`**: created a 3-task + file, deleted the middle one — confirmed via `readTaskChecklist` + afterward (2 tasks left, correct ones) and by reading the raw file + content directly (`- [ ] 1.2 Delete me` line genuinely gone, the other + two untouched, headings/blank lines preserved). +- **Stale-delete rejection, for real**: immediately retried the same + delete call (now referring to a line that no longer holds that text) — + correctly threw `TaskListChangedError` without further modifying the + file, exactly as designed. + +## Extension: real VS Code Extension Host + +`npm run test:integration --workspace openspec-ui-vscode` — a genuine +`@vscode/test-electron` run, not a mock — 6/6 passing, including +"activates and registers all contributed commands," confirming the +extension activates cleanly with `openspec-ui.revealTask`/ +`openspec-ui.deleteTask` registered and the tree providers wired, with +no runtime errors in a real VS Code instance. + +## Not driven live: actual tree expansion / click-to-reveal / delete-via-UI + +As with the two prior VS Code-UI-only changes this session +(`template-catalog-v2`'s delete, `extension-release-tagging`'s +customize-manifest fix), there is no desktop-app UI automation tool +available in this environment — can't literally click a task tree node +and watch the editor jump to it. Coverage for that interaction rests on: +33 passing `commands.test.ts` cases (including the line-fallback logic +for `revealTask` and the confirmation/archived-guard logic for +`deleteTask`, using a `vscode-mock.ts` extended specifically to model +`TextEditorRevealType`/`Selection`/a real `lineAt().text` for this +change) plus the real Extension Host activation check above. diff --git a/openspec/changes/tasks-tree-expand/specs/vscode-extension/spec.md b/openspec/changes/tasks-tree-expand/specs/vscode-extension/spec.md new file mode 100644 index 00000000..0beafc4b --- /dev/null +++ b/openspec/changes/tasks-tree-expand/specs/vscode-extension/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: Changes and Archive trees expand to individual tasks, with reveal and scoped delete + +Expanding a change node in either the "Changes" or "Archive" tree view +SHALL also list that change's individual `tasks.md` checklist items as +child tree items, alongside its existing artifact children. Selecting a +task item SHALL open (or reveal, if already open) `tasks.md` with the +cursor moved to that task's line, in both trees. A "Delete Task" action +SHALL be available only on task items belonging to an active (non- +archived) change; selecting it, after confirmation, SHALL remove exactly +that task's checklist line from the change's `tasks.md`. Task items +belonging to archived changes SHALL NOT offer a delete action. + +#### Scenario: Expanding an active change shows its tasks + +- **WHEN** the user expands a change node in the "Changes" tree +- **THEN** its individual `tasks.md` checklist items appear as child + items alongside the existing artifact children + +#### Scenario: Selecting a task reveals it in the editor + +- **WHEN** the user selects a task tree item (in either tree) +- **THEN** `tasks.md` opens (or is revealed, if already open) with the + cursor at that task's line + +#### Scenario: Deleting a task from an active change + +- **WHEN** the user confirms "Delete Task" on a task belonging to an + active change +- **THEN** that exact line is removed from the change's `tasks.md` + +#### Scenario: Archived tasks offer no delete action + +- **WHEN** the user views a task item under the "Archive" tree +- **THEN** no delete action is available for it + +#### Scenario: The underlying file changed since the tree was last refreshed + +- **WHEN** the user attempts to delete a task whose stored position no + longer matches the current content of `tasks.md` +- **THEN** the system reports that the task list has changed and makes no + filesystem change, rather than risking deletion of a different line diff --git a/openspec/changes/tasks-tree-expand/tasks.md b/openspec/changes/tasks-tree-expand/tasks.md new file mode 100644 index 00000000..e12d9f3e --- /dev/null +++ b/openspec/changes/tasks-tree-expand/tasks.md @@ -0,0 +1,86 @@ +## 1. Core: task checklist read/delete + +- [x] 1.1 `packages/core/src/task-checklist.ts`: `TaskChecklistItem { lineNumber: + number; text: string; done: boolean }`, `TASK_CHECKBOX_LINE_RE` (shared + regex), `TaskListChangedError`, `readTaskChecklist(workspaceRoot, + changeName, archived)` (via `discoverOpenSpecWorkspace`'s allowlisted + artifact path, never a reconstructed path — same pattern as + `readArchivedChangeTasksTemplate`), `deleteTaskLine(workspaceRoot, + changeName, archived, lineNumber, expectedText)` (re-verifies the line's + text before removing it; preserves the file's original line-ending + style). +- [x] 1.2 `task-checklist.test.ts`: reads real checklist items with correct + line numbers/done state from a fixture `tasks.md`; deletes an existing + task (line gone, rest of file unchanged); throws `TaskListChangedError` + for a stale line number/mismatched text without modifying the file; + returns `[]` for a change with no `tasks.md`. 8/8 tests passing + (also covers archived-change path and CRLF preservation, beyond the + originally listed cases). +- [x] 1.3 Export from `packages/core/src/index.ts`. + +## 2. Extension: tree expansion + +- [x] 2.1 `changes-tree.ts`: `TaskTreeItem` (contextValue + `openspec-ui.activeTask`/`openspec-ui.archivedTask`, `.command` set to + `openspec-ui.revealTask` with itself as the argument); export + `getChangeChildren(workspaceRoot, element)` combining the existing + artifact-mapping logic with `readTaskChecklist`-derived task items; + `WorkbenchTreeItem` union extended with `TaskTreeItem`. +- [x] 2.2 `ChangesTreeProvider.getChildren`/`ArchiveTreeProvider.getChildren`: + both delegate to the shared `getChangeChildren` for a `ChangeTreeItem` + element instead of each having their own artifact-only mapping. +- [x] 2.3 `changes-tree.test.ts`/`archive-tree.test.ts`: expanding a change + now also returns task children with correct label/description/ + contextValue; archived task items get `openspec-ui.archivedTask`, + active get `openspec-ui.activeTask`. 5 + 3 = 8/8 tests passing. + +## 3. Extension: commands + +- [x] 3.1 `commands.ts`: `openspec-ui.revealTask` — opens/reveals + `tasks.md`, selects and centers the task's line; re-verifies the stored + line number against current file content first, falls back to a + whole-file text search, then to line 0, rather than failing. + Extended `test-utils/vscode-mock.ts` with `TextEditorRevealType`, + `Selection`, a real `lineAt().text`, and a `showTextDocument` that + returns a usable fake editor (`selection`/`revealRange`) — none of + these existed in the mock before this change. +- [x] 3.2 `commands.ts`: `openspec-ui.deleteTask` — no-ops for an archived + item; modal confirmation (`showWarningMessage(..., {modal:true}, + "Delete")`, same pattern as `deleteChange`); calls `deleteTaskLine`; + refreshes trees; `TaskListChangedError` surfaces as a warning, not + `showCommandError`. +- [x] 3.3 `package.json`: registered `openspec-ui.deleteTask` (title, + icon); context-menu entry `"when": "viewItem == + openspec-ui.activeTask"` only. `revealTask` is not added to + `contributes.commands` (see design.md). +- [x] 3.4 `commands.test.ts`: `revealTask` opens the right document and + sets the right selection for a fresh line number, and falls back + correctly for a stale one; `deleteTask` respects the confirmation gate, + no-ops for an archived item (without even prompting), calls core + + refreshes trees on success, surfaces `TaskListChangedError` as a + warning. 6 new tests, 33/33 extension command tests passing. + +## 4. Verification, versioning, and smoke test + +- [ ] 4.1 `npm run typecheck && npm run lint && npm run test` passes for + `packages/core`, `packages/extension`. Re-run `npm run verify` after + `git add`/commit of all new files. +- [x] 4.2 Bump `package.json` versions (minor) for `@openspec-ui/core`, + `openspec-ui-vscode`. core 0.16.0 → 0.17.0, extension 0.10.1 → 0.11.0. + Also added a `packages/extension/CHANGELOG.md` 0.11.0 entry, a README + Features bullet, and caught/fixed `README.md`'s version table having + been stale since the 0.10.1 fix (never updated then). +- [x] 4.3 Manual smoke test: real `readTaskChecklist`/`deleteTaskLine` + runs against this repository's own `tasks.md` files where practical + (core is Node-only, testable without VS Code); VS Code tree/editor + interaction verified via `npm run test:integration` (real Extension + Host) to the extent that harness can drive it, with any remaining gap + noted explicitly in `smoke-test-notes.md` (no desktop-UI automation + tool available in this environment, per the prior session's own + precedent for this exact limitation). + `readTaskChecklist` run against this very change's own live + `tasks.md` (14 real tasks, correct); `deleteTaskLine` run against a + real temp file (correct deletion + correct stale-rejection); real + `@vscode/test-electron` run, 6/6 passing. Full detail in + `smoke-test-notes.md`. +- [x] 4.4 `openspec change validate --strict tasks-tree-expand` passes. diff --git a/packages/core/package.json b/packages/core/package.json index 11127fc9..42d6fc3c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@openspec-ui/core", "private": true, - "version": "0.16.0", + "version": "0.17.0", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0ec0bff1..b3921605 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,6 +11,7 @@ export * from "./openspec.js"; export * from "./git.js"; export * from "./workbench.js"; export * from "./task-templates.js"; +export * from "./task-checklist.js"; export * from "./template-catalog.js"; export * from "./process-scheduler.js"; export * from "./checkpoint.js"; diff --git a/packages/core/src/task-checklist.test.ts b/packages/core/src/task-checklist.test.ts new file mode 100644 index 00000000..a007ab18 --- /dev/null +++ b/packages/core/src/task-checklist.test.ts @@ -0,0 +1,107 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { TaskListChangedError, deleteTaskLine, readTaskChecklist } from "./task-checklist.js"; + +const temporaryRoots: string[] = []; + +async function temporaryRoot(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), "openspec-task-checklist-")); + temporaryRoots.push(root); + return root; +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("readTaskChecklist", () => { + it("returns each checklist item with its line number and done state", async () => { + const root = await temporaryRoot(); + const changeDir = path.join(root, "openspec", "changes", "active-change"); + await mkdir(changeDir, { recursive: true }); + await writeFile( + path.join(changeDir, "tasks.md"), + "## 1. Setup\n\n- [x] 1.1 First task\n- [ ] 1.2 Second task\n", + ); + + const items = await readTaskChecklist(root, "active-change", false); + + expect(items).toEqual([ + { lineNumber: 2, text: "1.1 First task", done: true }, + { lineNumber: 3, text: "1.2 Second task", done: false }, + ]); + }); + + it("reads archived changes from openspec/changes/archive//", async () => { + const root = await temporaryRoot(); + const changeDir = path.join(root, "openspec", "changes", "archive", "old-change"); + await mkdir(changeDir, { recursive: true }); + await writeFile(path.join(changeDir, "tasks.md"), "- [ ] Only task\n"); + + const items = await readTaskChecklist(root, "old-change", true); + + expect(items).toEqual([{ lineNumber: 0, text: "Only task", done: false }]); + }); + + it("returns an empty list when the change has no tasks.md", async () => { + const root = await temporaryRoot(); + const changeDir = path.join(root, "openspec", "changes", "active-change"); + await mkdir(changeDir, { recursive: true }); + await writeFile(path.join(changeDir, "proposal.md"), "# Proposal\n"); + + expect(await readTaskChecklist(root, "active-change", false)).toEqual([]); + }); + + it("returns an empty list for an unknown change name", async () => { + const root = await temporaryRoot(); + expect(await readTaskChecklist(root, "does-not-exist", false)).toEqual([]); + }); +}); + +describe("deleteTaskLine", () => { + it("removes exactly the matching line and preserves the rest of the file", async () => { + const root = await temporaryRoot(); + const changeDir = path.join(root, "openspec", "changes", "active-change"); + await mkdir(changeDir, { recursive: true }); + const tasksPath = path.join(changeDir, "tasks.md"); + await writeFile(tasksPath, "## 1. Setup\n\n- [ ] 1.1 First task\n- [ ] 1.2 Second task\n"); + + await deleteTaskLine(root, "active-change", false, 2, "1.1 First task"); + + expect(await readFile(tasksPath, "utf8")).toBe("## 1. Setup\n\n- [ ] 1.2 Second task\n"); + }); + + it("preserves CRLF line endings when the original file used them", async () => { + const root = await temporaryRoot(); + const changeDir = path.join(root, "openspec", "changes", "active-change"); + await mkdir(changeDir, { recursive: true }); + const tasksPath = path.join(changeDir, "tasks.md"); + await writeFile(tasksPath, "- [ ] First\r\n- [ ] Second\r\n"); + + await deleteTaskLine(root, "active-change", false, 0, "First"); + + expect(await readFile(tasksPath, "utf8")).toBe("- [ ] Second\r\n"); + }); + + it("throws TaskListChangedError and makes no change when the line number is stale", async () => { + const root = await temporaryRoot(); + const changeDir = path.join(root, "openspec", "changes", "active-change"); + await mkdir(changeDir, { recursive: true }); + const tasksPath = path.join(changeDir, "tasks.md"); + const original = "- [ ] First\n- [ ] Second\n"; + await writeFile(tasksPath, original); + + await expect(deleteTaskLine(root, "active-change", false, 0, "A task that no longer exists")).rejects.toThrow( + TaskListChangedError, + ); + expect(await readFile(tasksPath, "utf8")).toBe(original); + }); + + it("throws TaskListChangedError for an unknown change without creating any file", async () => { + const root = await temporaryRoot(); + + await expect(deleteTaskLine(root, "does-not-exist", false, 0, "text")).rejects.toThrow(TaskListChangedError); + }); +}); diff --git a/packages/core/src/task-checklist.ts b/packages/core/src/task-checklist.ts new file mode 100644 index 00000000..8d207959 --- /dev/null +++ b/packages/core/src/task-checklist.ts @@ -0,0 +1,93 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { discoverOpenSpecWorkspace } from "./workbench.js"; + +// See openspec/changes/tasks-tree-expand/design.md. Paths always come +// from `discoverOpenSpecWorkspace`'s own allowlisted `artifacts[]`, never +// from joining a caller-supplied `changeName` onto a directory — same +// pattern `task-templates.ts`'s `readArchivedChangeTasksTemplate` already +// establishes. + +export interface TaskChecklistItem { + /** 0-indexed line number within tasks.md. */ + lineNumber: number; + text: string; + done: boolean; +} + +export class TaskListChangedError extends Error { + constructor(changeName: string) { + super(`Task list for ${changeName} has changed since it was last loaded — refresh and try again.`); + this.name = "TaskListChangedError"; + } +} + +/** Matches a checklist line (`- [ ] text` / `- [x] text`); group 1 is the + * check state, group 2 is the task text. Exported so callers needing the + * exact same parsing convention (e.g. the VS Code reveal command) don't + * duplicate a second, potentially-drifting copy of this regex. */ +export const TASK_CHECKBOX_LINE_RE = /^[ \t]*-\s\[([ xX])\]\s*(.*)$/; + +async function findTasksArtifactPath( + workspaceRoot: string, + changeName: string, + archived: boolean, +): Promise { + const workspace = await discoverOpenSpecWorkspace(workspaceRoot); + const list = archived ? workspace.archivedChanges : workspace.changes; + const change = list.find((c) => c.name === changeName); + const tasksArtifact = change?.artifacts.find((artifact) => artifact.id === "tasks"); + return tasksArtifact?.exists ? tasksArtifact.path : undefined; +} + +function parseChecklist(content: string): TaskChecklistItem[] { + const items: TaskChecklistItem[] = []; + content.split(/\r?\n/).forEach((line, lineNumber) => { + const match = line.match(TASK_CHECKBOX_LINE_RE); + if (!match) return; + items.push({ lineNumber, text: (match[2] ?? "").trim(), done: (match[1] ?? "").toLowerCase() === "x" }); + }); + return items; +} + +/** Returns `[]` if the change or its tasks.md does not exist — reading a + * task list is not an error-worthy condition the way deleting from one + * that changed underneath the caller is (see `deleteTaskLine`). */ +export async function readTaskChecklist( + workspaceRoot: string, + changeName: string, + archived: boolean, +): Promise { + const tasksPath = await findTasksArtifactPath(workspaceRoot, changeName, archived); + if (!tasksPath) return []; + const content = await readFile(tasksPath, "utf8"); + return parseChecklist(content); +} + +/** Re-reads tasks.md fresh and verifies `lineNumber` still holds a + * checklist item whose text exactly matches `expectedText` before + * removing it — never trusts a caller-supplied line number blindly (see + * design.md, "deleteTaskLine re-verifies..."). Throws + * `TaskListChangedError` (not a generic error) if the change, its + * tasks.md, or that specific line no longer matches what was expected. */ +export async function deleteTaskLine( + workspaceRoot: string, + changeName: string, + archived: boolean, + lineNumber: number, + expectedText: string, +): Promise { + const tasksPath = await findTasksArtifactPath(workspaceRoot, changeName, archived); + if (!tasksPath) throw new TaskListChangedError(changeName); + + const content = await readFile(tasksPath, "utf8"); + const eol = content.includes("\r\n") ? "\r\n" : "\n"; + const lines = content.split(/\r?\n/); + const line = lines[lineNumber]; + const match = line?.match(TASK_CHECKBOX_LINE_RE); + if (!match || (match[2] ?? "").trim() !== expectedText) { + throw new TaskListChangedError(changeName); + } + + lines.splice(lineNumber, 1); + await writeFile(tasksPath, lines.join(eol), "utf8"); +} diff --git a/packages/extension/CHANGELOG.md b/packages/extension/CHANGELOG.md index f157b657..514bd95d 100644 --- a/packages/extension/CHANGELOG.md +++ b/packages/extension/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.11.0 + +- The Changes and Archive trees now expand each change to also show its + individual `tasks.md` checklist items, not just its artifacts. + Selecting a task opens (or reveals, if already open) `tasks.md` at + that exact line, in both trees. "Delete Task" removes a single + checklist line from an active change's `tasks.md`, with confirmation — + archived tasks offer no delete action. + ## 0.10.1 - "Customize Template" now opens the created `template.json` after diff --git a/packages/extension/README.md b/packages/extension/README.md index 721e8525..1214ce1f 100644 --- a/packages/extension/README.md +++ b/packages/extension/README.md @@ -10,7 +10,9 @@ notifications while keeping lifecycle and security behavior in - Navigate `config.yaml`, active changes, archived changes, canonical specs, and every proposal, design, task list, and delta spec. - Create, validate, archive, unarchive, and delete changes from contextual - actions with confirmation for destructive operations. + actions with confirmation for destructive operations. Expand a change to + see its individual `tasks.md` checklist items; select one to jump to it + in the editor, or delete it (active changes only) with confirmation. - See queued, running, completed, failed, cancelled, interrupted, and rolled-back operations in the Processes view. Read-only work can run in parallel; workspace mutations are serialized to keep checkpoints isolated. diff --git a/packages/extension/package.json b/packages/extension/package.json index 14391a49..def1b2ec 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -3,7 +3,7 @@ "displayName": "OpenSpec Workbench", "description": "Native OpenSpec workflow with lifecycle controls, VS Code Chat, persistent processes, checkpoints, and conflict-safe rollback.", "publisher": "openspec-ui", - "version": "0.10.1", + "version": "0.11.0", "icon": "media/icon.png", "license": "MIT", "repository": { @@ -80,6 +80,11 @@ "title": "OpenSpec UI: Delete Change", "icon": "$(trash)" }, + { + "command": "openspec-ui.deleteTask", + "title": "OpenSpec UI: Delete Task", + "icon": "$(trash)" + }, { "command": "openspec-ui.openspecView", "title": "OpenSpec UI: OpenSpec View (CLI)" @@ -265,6 +270,11 @@ "when": "viewItem == openspec-ui.activeChange || viewItem == openspec-ui.archivedChange", "group": "openspec@9" }, + { + "command": "openspec-ui.deleteTask", + "when": "viewItem == openspec-ui.activeTask", + "group": "openspec@9" + }, { "command": "openspec-ui.cancelProcess", "when": "viewItem == openspec-ui.cancellableProcess || viewItem == openspec-ui.implementationProcess", diff --git a/packages/extension/src/commands.test.ts b/packages/extension/src/commands.test.ts index 3a177cfe..e7bbff50 100644 --- a/packages/extension/src/commands.test.ts +++ b/packages/extension/src/commands.test.ts @@ -17,21 +17,27 @@ const initOpenSpecMock = vi.fn(); const readArchivedChangeTasksTemplateMock = vi.fn(); const customizeTemplateMock = vi.fn(); const deleteProjectTemplateMock = vi.fn(); +const deleteTaskLineMock = vi.fn(); const renderTemplateMock = vi.fn(); class TemplateAlreadyExistsError extends Error {} class UnknownProjectTemplateError extends Error {} +class TaskListChangedError extends Error {} +const TASK_CHECKBOX_LINE_RE = /^[ \t]*-\s\[([ xX])\]\s*(.*)$/; vi.mock("@openspec-ui/core", () => ({ archiveChange: (...args: unknown[]) => archiveChangeMock(...args), createChange: (...args: unknown[]) => createChangeMock(...args), customizeTemplate: (...args: unknown[]) => customizeTemplateMock(...args), deleteChange: (...args: unknown[]) => deleteChangeMock(...args), deleteProjectTemplate: (...args: unknown[]) => deleteProjectTemplateMock(...args), + deleteTaskLine: (...args: unknown[]) => deleteTaskLineMock(...args), initOpenSpec: (...args: unknown[]) => initOpenSpecMock(...args), listChanges: (...args: unknown[]) => listChangesMock(...args), listSpecs: (...args: unknown[]) => listSpecsMock(...args), readArchivedChangeTasksTemplate: (...args: unknown[]) => readArchivedChangeTasksTemplateMock(...args), renderTemplate: (...args: unknown[]) => renderTemplateMock(...args), showChange: (...args: unknown[]) => showChangeMock(...args), + TASK_CHECKBOX_LINE_RE, + TaskListChangedError, TemplateAlreadyExistsError, UnknownProjectTemplateError, unarchiveChange: (...args: unknown[]) => unarchiveChangeMock(...args), @@ -110,6 +116,8 @@ describe("registerCommands", () => { "openspec-ui.archiveChange", "openspec-ui.unarchiveChange", "openspec-ui.deleteChange", + "openspec-ui.revealTask", + "openspec-ui.deleteTask", "openspec-ui.openspecView", "openspec-ui.showChangeDetails", "openspec-ui.validateChangeStrict", @@ -468,6 +476,103 @@ describe("registerCommands", () => { }); }); + describe("openspec-ui.revealTask", () => { + const changeDir = path.join("/workspace/repo", "openspec", "changes", "active-change"); + const tasksPath = path.join(changeDir, "tasks.md"); + const taskItem = { + changeName: "active-change", + changeDir, + archived: false, + lineNumber: 2, + text: "1.1 First task", + done: false, + }; + + it("opens tasks.md and reveals the exact line when the stored line number is still correct", async () => { + vscodeMock._documentContents.set(tasksPath, "## 1. Setup\n\n- [ ] 1.1 First task\n- [ ] 1.2 Second task\n"); + registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, makeDeps()); + + await vscodeMock._registeredCommands.get("openspec-ui.revealTask")?.(taskItem); + + expect(vscodeMock.window.showTextDocument).toHaveBeenCalledWith( + expect.objectContaining({ uri: expect.objectContaining({ fsPath: tasksPath }) }), + { preview: false }, + ); + const editor = await vscodeMock.window.showTextDocument.mock.results[0]?.value; + expect(editor.revealRange).toHaveBeenCalledWith( + expect.objectContaining({ start: { line: 2, character: 0 } }), + vscodeMock.TextEditorRevealType.InCenter, + ); + expect(editor.selection).toBeInstanceOf(vscodeMock.Selection); + }); + + it("falls back to searching the whole file when the stored line number is stale", async () => { + // Text is on line 3 now, not the stored lineNumber 2 (e.g. a line was + // inserted above it since the tree was last refreshed). + vscodeMock._documentContents.set(tasksPath, "## 1. Setup\n\n- [ ] Unrelated\n- [ ] 1.1 First task\n"); + registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, makeDeps()); + + await vscodeMock._registeredCommands.get("openspec-ui.revealTask")?.(taskItem); + + const editor = await vscodeMock.window.showTextDocument.mock.results[0]?.value; + expect(editor.revealRange).toHaveBeenCalledWith( + expect.objectContaining({ start: { line: 3, character: 0 } }), + vscodeMock.TextEditorRevealType.InCenter, + ); + }); + }); + + describe("openspec-ui.deleteTask", () => { + const activeItem = { + changeName: "active-change", + changeDir: path.join("/workspace/repo", "openspec", "changes", "active-change"), + archived: false, + lineNumber: 2, + text: "1.1 First task", + done: false, + }; + + it("deletes a task after confirmation and refreshes trees", async () => { + vscodeMock.window.showWarningMessage.mockResolvedValue("Delete"); + const deps = makeDeps(); + registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, deps); + + await vscodeMock._registeredCommands.get("openspec-ui.deleteTask")?.(activeItem); + + expect(deleteTaskLineMock).toHaveBeenCalledWith("/workspace/repo", "active-change", false, 2, "1.1 First task"); + expect(deps.refreshTrees).toHaveBeenCalled(); + }); + + it("does not delete when the confirmation is declined", async () => { + vscodeMock.window.showWarningMessage.mockResolvedValue(undefined); + registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, makeDeps()); + + await vscodeMock._registeredCommands.get("openspec-ui.deleteTask")?.(activeItem); + + expect(deleteTaskLineMock).not.toHaveBeenCalled(); + }); + + it("does nothing for an archived task, without even prompting", async () => { + registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, makeDeps()); + + await vscodeMock._registeredCommands.get("openspec-ui.deleteTask")?.({ ...activeItem, archived: true }); + + expect(vscodeMock.window.showWarningMessage).not.toHaveBeenCalled(); + expect(deleteTaskLineMock).not.toHaveBeenCalled(); + }); + + it("reports a stale task list as a warning, not an error", async () => { + vscodeMock.window.showWarningMessage.mockResolvedValueOnce("Delete"); + deleteTaskLineMock.mockRejectedValue(new TaskListChangedError("task list changed")); + registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, makeDeps()); + + await vscodeMock._registeredCommands.get("openspec-ui.deleteTask")?.(activeItem); + + expect(vscodeMock.window.showWarningMessage).toHaveBeenCalledTimes(2); + expect(vscodeMock.window.showErrorMessage).not.toHaveBeenCalled(); + }); + }); + describe("openspec-ui.insertTemplateIntoChange", () => { const templateItem = { template: { diff --git a/packages/extension/src/commands.ts b/packages/extension/src/commands.ts index a1650f40..07dc8ed5 100644 --- a/packages/extension/src/commands.ts +++ b/packages/extension/src/commands.ts @@ -5,6 +5,8 @@ import path from "node:path"; import * as vscode from "vscode"; import { + TASK_CHECKBOX_LINE_RE, + TaskListChangedError, TemplateAlreadyExistsError, UnknownProjectTemplateError, archiveChange, @@ -12,6 +14,7 @@ import { customizeTemplate, deleteChange, deleteProjectTemplate, + deleteTaskLine, initOpenSpec, listChanges, listSpecs, @@ -29,7 +32,7 @@ import { import type { RunController } from "./run-controller.js"; import { describeEvent } from "./describe-event.js"; import { openDiffAgainstHead } from "./native/diff.js"; -import type { ChangeTreeItem } from "./tree/changes-tree.js"; +import type { ChangeTreeItem, TaskTreeItem } from "./tree/changes-tree.js"; import type { TemplateTreeItem } from "./tree/templates-tree.js"; import type { ImplementationSessionManager } from "./implementation-sessions.js"; import type { AiPanelContext } from "./webview/ai-panel.js"; @@ -169,6 +172,23 @@ function formatOpenSpecViewSummaryMarkdown( return lines.join("\n"); } +/** Best-effort: if `tasks.md` changed since the tree was last refreshed, + * the stored line number may no longer point at the right task — falls + * back to a whole-document search by text, then to line 0, rather than + * failing (reveal is read-only, unlike delete — see + * openspec/changes/tasks-tree-expand/design.md). */ +function resolveTaskLine(document: vscode.TextDocument, lineNumber: number, expectedText: string): number { + if (lineNumber < document.lineCount) { + const match = document.lineAt(lineNumber).text.match(TASK_CHECKBOX_LINE_RE); + if (match && (match[2] ?? "").trim() === expectedText) return lineNumber; + } + for (let i = 0; i < document.lineCount; i += 1) { + const match = document.lineAt(i).text.match(TASK_CHECKBOX_LINE_RE); + if (match && (match[2] ?? "").trim() === expectedText) return i; + } + return 0; +} + async function openMarkdownDocument(title: string, markdown: string): Promise { const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: markdown }); await vscode.window.showTextDocument(doc, { preview: false }); @@ -453,6 +473,40 @@ export function registerCommands(context: vscode.ExtensionContext, deps: Command await showCommandError("delete change", error); } }), + vscode.commands.registerCommand("openspec-ui.revealTask", async (item?: TaskTreeItem) => { + if (!item) return; + try { + const tasksUri = vscode.Uri.file(path.join(item.changeDir, "tasks.md")); + const document = await vscode.workspace.openTextDocument(tasksUri); + const editor = await vscode.window.showTextDocument(document, { preview: false }); + const range = document.lineAt(resolveTaskLine(document, item.lineNumber, item.text)).range; + editor.selection = new vscode.Selection(range.start, range.start); + editor.revealRange(range, vscode.TextEditorRevealType.InCenter); + } catch (error) { + await showCommandError("reveal task", error); + } + }), + vscode.commands.registerCommand("openspec-ui.deleteTask", async (item?: TaskTreeItem) => { + const workspaceRoot = deps.getWorkspaceRoot(); + if (!workspaceRoot || !item || item.archived) return; + const answer = await vscode.window.showWarningMessage( + `Permanently delete task "${item.text}" from ${item.changeName}'s tasks.md?`, + { modal: true }, + "Delete", + ); + if (answer !== "Delete") return; + try { + await deleteTaskLine(workspaceRoot, item.changeName, item.archived, item.lineNumber, item.text); + deps.refreshTrees(); + void vscode.window.showInformationMessage(`OpenSpec UI: deleted task from ${item.changeName}.`); + } catch (error) { + if (error instanceof TaskListChangedError) { + void vscode.window.showWarningMessage(`OpenSpec UI: ${error.message}`); + return; + } + await showCommandError("delete task", error); + } + }), ); context.subscriptions.push( diff --git a/packages/extension/src/test-utils/vscode-mock.ts b/packages/extension/src/test-utils/vscode-mock.ts index 82b229be..b4e80d03 100644 --- a/packages/extension/src/test-utils/vscode-mock.ts +++ b/packages/extension/src/test-utils/vscode-mock.ts @@ -18,6 +18,17 @@ export enum ViewColumn { Beside = -2, } +export enum TextEditorRevealType { + Default = 0, + InCenter = 1, + InCenterIfOutsideViewport = 2, + AtTop = 3, +} + +export class Selection { + constructor(public readonly anchor: unknown, public readonly active: unknown) { } +} + export class EventEmitter { private listeners: Array<(e: T) => void> = []; readonly event = (listener: (e: T) => void) => { @@ -77,6 +88,8 @@ export function createVscodeMock() { TreeItemCollapsibleState, ProgressLocation, ViewColumn, + TextEditorRevealType, + Selection, EventEmitter, ThemeIcon, TreeItem, @@ -94,7 +107,11 @@ export function createVscodeMock() { showWarningMessage: vi.fn(), showErrorMessage: vi.fn(), showInformationMessage: vi.fn(), - showTextDocument: vi.fn(), + showTextDocument: vi.fn(async (document: unknown) => ({ + document, + selection: undefined as unknown, + revealRange: vi.fn(), + })), withProgress: vi.fn(async (_options: unknown, task: (progress: unknown, token: unknown) => unknown) => { const token = { onCancellationRequested: vi.fn(() => ({ dispose: vi.fn() })) }; return task({ report: vi.fn() }, token); @@ -116,7 +133,13 @@ export function createVscodeMock() { get lineCount() { return Math.max(1, (documentContents.get(uri.fsPath) ?? "").split("\n").length); }, - lineAt: (line: number) => ({ range: { end: { line, character: 0 } } }), + lineAt: (line: number) => { + const text = (documentContents.get(uri.fsPath) ?? "").split(/\r?\n/)[line] ?? ""; + return { + text, + range: { start: { line, character: 0 }, end: { line, character: text.length } }, + }; + }, }; } const { language, content } = arg; diff --git a/packages/extension/src/tree/archive-tree.test.ts b/packages/extension/src/tree/archive-tree.test.ts index 93684f72..1381e042 100644 --- a/packages/extension/src/tree/archive-tree.test.ts +++ b/packages/extension/src/tree/archive-tree.test.ts @@ -1,12 +1,14 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createVscodeMock } from "../test-utils/vscode-mock.js"; const vscodeMock = createVscodeMock(); vi.mock("vscode", () => vscodeMock); const discoverOpenSpecWorkspaceMock = vi.fn(); +const readTaskChecklistMock = vi.fn(); vi.mock("@openspec-ui/core", () => ({ discoverOpenSpecWorkspace: (...args: unknown[]) => discoverOpenSpecWorkspaceMock(...args), + readTaskChecklist: (...args: unknown[]) => readTaskChecklistMock(...args), })); const { ArchiveTreeProvider } = await import("./archive-tree.js"); @@ -15,6 +17,10 @@ afterEach(() => { vi.clearAllMocks(); }); +beforeEach(() => { + readTaskChecklistMock.mockResolvedValue([]); +}); + describe("ArchiveTreeProvider", () => { it("lists archived changes as collapsible nodes", async () => { discoverOpenSpecWorkspaceMock.mockResolvedValue({ @@ -33,6 +39,22 @@ describe("ArchiveTreeProvider", () => { expect(items[0]?.description).toBe("archived"); }); + it("shows archived tasks as read-only (openspec-ui.archivedTask) children", async () => { + discoverOpenSpecWorkspaceMock.mockResolvedValue({ + archiveExists: true, + archivedChanges: [{ name: "old-change-1", path: "/archive/old-change-1", state: "archived", artifacts: [] }], + }); + readTaskChecklistMock.mockResolvedValue([{ lineNumber: 0, text: "Only task", done: false }]); + + const provider = new ArchiveTreeProvider("/workspace/repo"); + const roots = await provider.getChildren(); + const children = await provider.getChildren(roots[0]); + + expect(readTaskChecklistMock).toHaveBeenCalledWith("/workspace/repo", "old-change-1", true); + expect(children.map((item) => item.label)).toEqual(["Only task"]); + expect(children[0]?.contextValue).toBe("openspec-ui.archivedTask"); + }); + it("explains when the archive directory does not exist", async () => { discoverOpenSpecWorkspaceMock.mockResolvedValue({ archiveExists: false, archivedChanges: [] }); diff --git a/packages/extension/src/tree/archive-tree.ts b/packages/extension/src/tree/archive-tree.ts index 6b89105f..4c57d50a 100644 --- a/packages/extension/src/tree/archive-tree.ts +++ b/packages/extension/src/tree/archive-tree.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; import { discoverOpenSpecWorkspace } from "@openspec-ui/core"; -import { ArtifactTreeItem, ChangeTreeItem, EmptyTreeItem, type WorkbenchTreeItem } from "./changes-tree.js"; +import { ChangeTreeItem, EmptyTreeItem, getChangeChildren, type WorkbenchTreeItem } from "./changes-tree.js"; export class ArchiveTreeProvider implements vscode.TreeDataProvider { private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter(); @@ -18,13 +18,7 @@ export class ArchiveTreeProvider implements vscode.TreeDataProvider { if (element instanceof ChangeTreeItem) { - return element.artifacts.map( - (artifact) => new ArtifactTreeItem( - artifact.kind === "delta-spec" ? `Spec: ${artifact.label}` : artifact.label, - artifact.path, - artifact.exists, - ), - ); + return getChangeChildren(this.workspaceRoot, element); } if (element) return []; const workspace = await discoverOpenSpecWorkspace(this.workspaceRoot); diff --git a/packages/extension/src/tree/changes-tree.test.ts b/packages/extension/src/tree/changes-tree.test.ts index 24025407..39f7be7d 100644 --- a/packages/extension/src/tree/changes-tree.test.ts +++ b/packages/extension/src/tree/changes-tree.test.ts @@ -1,12 +1,14 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createVscodeMock } from "../test-utils/vscode-mock.js"; const vscodeMock = createVscodeMock(); vi.mock("vscode", () => vscodeMock); const discoverOpenSpecWorkspaceMock = vi.fn(); +const readTaskChecklistMock = vi.fn(); vi.mock("@openspec-ui/core", () => ({ discoverOpenSpecWorkspace: (...args: unknown[]) => discoverOpenSpecWorkspaceMock(...args), + readTaskChecklist: (...args: unknown[]) => readTaskChecklistMock(...args), })); const { ChangesTreeProvider } = await import("./changes-tree.js"); @@ -15,6 +17,10 @@ afterEach(() => { vi.clearAllMocks(); }); +beforeEach(() => { + readTaskChecklistMock.mockResolvedValue([]); +}); + describe("ChangesTreeProvider", () => { it("lists config and collapsible changes with their derived state", async () => { discoverOpenSpecWorkspaceMock.mockResolvedValue({ @@ -62,6 +68,29 @@ describe("ChangesTreeProvider", () => { expect(artifacts[0]?.command?.command).toBe("vscode.open"); }); + it("shows tasks.md's individual checklist items after the artifacts, as active tasks", async () => { + discoverOpenSpecWorkspaceMock.mockResolvedValue({ + configPath: "/workspace/repo/openspec/config.yaml", + configExists: true, + changes: [{ name: "shared-ui", path: "/changes/shared-ui", state: "in-progress", artifacts: [] }], + }); + readTaskChecklistMock.mockResolvedValue([ + { lineNumber: 2, text: "1.1 First task", done: true }, + { lineNumber: 3, text: "1.2 Second task", done: false }, + ]); + + const provider = new ChangesTreeProvider("/workspace/repo"); + const roots = await provider.getChildren(); + const children = await provider.getChildren(roots[1]); + + expect(readTaskChecklistMock).toHaveBeenCalledWith("/workspace/repo", "shared-ui", false); + expect(children.map((item) => item.label)).toEqual(["1.1 First task", "1.2 Second task"]); + expect(children[0]?.description).toBe("done"); + expect(children[1]?.description).toBeUndefined(); + expect(children[0]?.contextValue).toBe("openspec-ui.activeTask"); + expect(children[0]?.command?.command).toBe("openspec-ui.revealTask"); + }); + it("offers initialization when the workspace has no OpenSpec artifacts", async () => { discoverOpenSpecWorkspaceMock.mockResolvedValue({ initialized: false, diff --git a/packages/extension/src/tree/changes-tree.ts b/packages/extension/src/tree/changes-tree.ts index 267d5dc9..a4d770f0 100644 --- a/packages/extension/src/tree/changes-tree.ts +++ b/packages/extension/src/tree/changes-tree.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import { discoverOpenSpecWorkspace, + readTaskChecklist, type ChangeState, type WorkbenchArtifact, } from "@openspec-ui/core"; @@ -62,7 +63,48 @@ export class EmptyTreeItem extends vscode.TreeItem { } } -export type WorkbenchTreeItem = ChangeTreeItem | ArtifactTreeItem | EmptyTreeItem; +export class TaskTreeItem extends vscode.TreeItem { + constructor( + public readonly changeName: string, + public readonly changeDir: string, + public readonly archived: boolean, + public readonly lineNumber: number, + public readonly text: string, + public readonly done: boolean, + ) { + super(text, vscode.TreeItemCollapsibleState.None); + this.description = done ? "done" : undefined; + this.contextValue = archived ? "openspec-ui.archivedTask" : "openspec-ui.activeTask"; + this.iconPath = new vscode.ThemeIcon(done ? "check" : "circle-large-outline"); + this.command = { command: "openspec-ui.revealTask", title: "Reveal Task", arguments: [this] }; + } +} + +export type WorkbenchTreeItem = ChangeTreeItem | ArtifactTreeItem | EmptyTreeItem | TaskTreeItem; + +/** Shared by `ChangesTreeProvider` and `ArchiveTreeProvider` — both trees + * expand a `ChangeTreeItem` the same way (artifacts, then tasks.md's + * individual checklist items); see + * openspec/changes/tasks-tree-expand/design.md, "Shared getChangeChildren". */ +export async function getChangeChildren( + workspaceRoot: string, + element: ChangeTreeItem, +): Promise { + const artifactItems = element.artifacts.map( + (artifact) => + new ArtifactTreeItem( + artifact.kind === "delta-spec" ? `Spec: ${artifact.label}` : artifact.label, + artifact.path, + artifact.exists, + ), + ); + const tasks = await readTaskChecklist(workspaceRoot, element.changeName, element.archived); + const taskItems = tasks.map( + (task) => + new TaskTreeItem(element.changeName, element.changeDir, element.archived, task.lineNumber, task.text, task.done), + ); + return [...artifactItems, ...taskItems]; +} export class ChangesTreeProvider implements vscode.TreeDataProvider { private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter(); @@ -80,14 +122,7 @@ export class ChangesTreeProvider implements vscode.TreeDataProvider { if (element instanceof ChangeTreeItem) { - return element.artifacts.map( - (artifact) => - new ArtifactTreeItem( - artifact.kind === "delta-spec" ? `Spec: ${artifact.label}` : artifact.label, - artifact.path, - artifact.exists, - ), - ); + return getChangeChildren(this.workspaceRoot, element); } if (element) return [];