feat(v0.3): add Projects filter to ListWorkspaces; fix last/delete

ListOpts gains a Projects bool that filters by EffectiveType.
Default behavior (Projects: false) now returns tasks only --
this is a deliberate semantic change that supports the new
'ctask list' (tasks) vs 'ctask list --projects' (projects)
spec.

The change silently regresses two cmd-level callers that scan
for "the most recently updated workspace": cmd/last.go (used by
'ctask last') and cmd/delete.go (used to print the "this was
your most recent workspace" note). Both are fixed by unioning a
tasks-scan with a projects-scan, so 'last' and 'delete' continue
to consider both types.

Test helper createTestWorkspaceTyped allows setting an explicit
type (or "" to simulate a v0.2 workspace with no type field).
This commit is contained in:
2026-04-10 14:43:28 -04:00
parent 84ca6a8d1c
commit bfe89d830c
5 changed files with 98 additions and 5 deletions
+13
View File
@@ -10,6 +10,11 @@ type ListOpts struct {
IncludeArchived bool
Category string
Limit int
// Projects, when true, returns project workspaces only. When false (default),
// only task workspaces are returned. v0.2 workspaces with no Type field are
// treated as tasks via EffectiveType.
Projects bool
}
// ListWorkspaces returns workspaces in reverse-chronological order.
@@ -19,6 +24,11 @@ func ListWorkspaces(root string, opts ListOpts) ([]QueryResult, error) {
return nil, err
}
wantType := "task"
if opts.Projects {
wantType = "project"
}
var filtered []QueryResult
for _, ws := range all {
if !opts.IncludeArchived && ws.Meta.Status == "archived" {
@@ -27,6 +37,9 @@ func ListWorkspaces(root string, opts ListOpts) ([]QueryResult, error) {
if opts.Category != "" && ws.Meta.Category != opts.Category {
continue
}
if EffectiveType(ws.Meta) != wantType {
continue
}
filtered = append(filtered, ws)
}
+54
View File
@@ -47,6 +47,60 @@ func TestListWorkspaces(t *testing.T) {
}
}
func TestListProjectsFilter(t *testing.T) {
root := t.TempDir()
createTestWorkspaceTyped(t, root, "general", "2026-04-05_task-a", "active", "task")
createTestWorkspaceTyped(t, root, "general", "2026-04-04_task-b", "active", "") // legacy: no type -> task
createTestWorkspaceTyped(t, root, "projects", "2026-04-03_proj-a", "active", "project")
createTestWorkspaceTyped(t, root, "projects", "2026-04-02_proj-b", "archived", "project")
// Default: tasks only, active
results, err := ListWorkspaces(root, ListOpts{Limit: 20})
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
if len(results) != 2 {
t.Fatalf("expected 2 active tasks, got %d", len(results))
}
for _, r := range results {
if EffectiveType(r.Meta) != "task" {
t.Errorf("non-task in default list: %s (type %q)", r.Meta.Slug, r.Meta.Type)
}
}
// --projects: projects only, active
results, err = ListWorkspaces(root, ListOpts{Projects: true, Limit: 20})
if err != nil {
t.Fatalf("ListWorkspaces --projects: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 active project, got %d", len(results))
}
if results[0].Meta.Slug != "proj-a" {
t.Errorf("expected proj-a, got %s", results[0].Meta.Slug)
}
// --projects --all
results, err = ListWorkspaces(root, ListOpts{Projects: true, IncludeArchived: true, Limit: 20})
if err != nil {
t.Fatalf("ListWorkspaces --projects --all: %v", err)
}
if len(results) != 2 {
t.Fatalf("expected 2 projects with --all, got %d", len(results))
}
// --all alone: tasks only with archived
results, err = ListWorkspaces(root, ListOpts{IncludeArchived: true, Limit: 20})
if err != nil {
t.Fatalf("ListWorkspaces --all: %v", err)
}
for _, r := range results {
if EffectiveType(r.Meta) != "task" {
t.Errorf("--all alone returned non-task: %s", r.Meta.Slug)
}
}
}
func TestListReverseChronological(t *testing.T) {
root := t.TempDir()
createTestWorkspace(t, root, "general", "2026-04-01_first", "active")
+9 -1
View File
@@ -7,8 +7,15 @@ import (
"time"
)
// helper to create a minimal workspace for query testing
// createTestWorkspace creates a minimal task workspace (Type defaults to "task")
// for query/list testing.
func createTestWorkspace(t *testing.T, root, category, dirName string, status string) {
createTestWorkspaceTyped(t, root, category, dirName, status, "task")
}
// createTestWorkspaceTyped creates a minimal workspace of an explicit type
// (use "" to simulate a v0.2 workspace with no type field).
func createTestWorkspaceTyped(t *testing.T, root, category, dirName, status, taskType string) {
t.Helper()
dir := filepath.Join(root, category, dirName)
os.MkdirAll(dir, 0755)
@@ -23,6 +30,7 @@ func createTestWorkspace(t *testing.T, root, category, dirName string, status st
UpdatedAt: now,
Status: status,
Category: category,
Type: taskType,
Mode: "local",
Agent: "claude",
WorkspacePath: dir,