From bfe89d830c72affc6c0c432279778b200f7ca118 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Fri, 10 Apr 2026 14:43:28 -0400 Subject: [PATCH] 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). --- cmd/delete.go | 11 +++++-- cmd/last.go | 15 +++++++-- internal/workspace/list.go | 13 ++++++++ internal/workspace/list_test.go | 54 ++++++++++++++++++++++++++++++++ internal/workspace/query_test.go | 10 +++++- 5 files changed, 98 insertions(+), 5 deletions(-) diff --git a/cmd/delete.go b/cmd/delete.go index c9a0d90..6a24bb1 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -70,11 +70,18 @@ func runDelete(cmd *cobra.Command, args []string) error { fmt.Printf(" Workspace: %s\n", relPath) fmt.Printf(" Files: %d (%s)\n", fileCount, formatSize(totalSize)) - // Check if this is the most recently updated workspace - results, _ := workspace.ListWorkspaces(root, workspace.ListOpts{ + // Check if this is the most recently updated workspace. + // v0.3: union tasks and projects so the "most recent" check spans both types. + tasks, _ := workspace.ListWorkspaces(root, workspace.ListOpts{ IncludeArchived: false, Limit: 0, }) + projects, _ := workspace.ListWorkspaces(root, workspace.ListOpts{ + IncludeArchived: false, + Projects: true, + Limit: 0, + }) + results := append(tasks, projects...) if len(results) > 0 { best := results[0] for _, r := range results[1:] { diff --git a/cmd/last.go b/cmd/last.go index 506cb86..8e418a5 100644 --- a/cmd/last.go +++ b/cmd/last.go @@ -31,14 +31,25 @@ func init() { func runLast(cmd *cobra.Command, args []string) error { root := config.ResolveRoot() - // Scan all non-archived workspaces and find the most recently updated - results, err := workspace.ListWorkspaces(root, workspace.ListOpts{ + // Scan all non-archived workspaces (tasks AND projects) and find the most + // recently updated. v0.3: ListWorkspaces filters by type, so we union the + // two type buckets here so `last` keeps working for both. + tasks, err := workspace.ListWorkspaces(root, workspace.ListOpts{ IncludeArchived: false, Limit: 0, }) if err != nil { return err } + projects, err := workspace.ListWorkspaces(root, workspace.ListOpts{ + IncludeArchived: false, + Projects: true, + Limit: 0, + }) + if err != nil { + return err + } + results := append(tasks, projects...) if len(results) == 0 { fmt.Fprintln(os.Stderr, "No active workspaces found.") diff --git a/internal/workspace/list.go b/internal/workspace/list.go index e96d943..3688637 100644 --- a/internal/workspace/list.go +++ b/internal/workspace/list.go @@ -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) } diff --git a/internal/workspace/list_test.go b/internal/workspace/list_test.go index 3517496..da5f538 100644 --- a/internal/workspace/list_test.go +++ b/internal/workspace/list_test.go @@ -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") diff --git a/internal/workspace/query_test.go b/internal/workspace/query_test.go index 45ed1a7..cb7bec7 100644 --- a/internal/workspace/query_test.go +++ b/internal/workspace/query_test.go @@ -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,