diff --git a/internal/api/app.go b/internal/api/app.go index 328b415..e5338a9 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -188,14 +188,20 @@ func (a *App) ensureBrowserInboxSubscriptions() { if a.browserInboxEvents == nil { a.browserInboxEvents = make(map[string]bool) } - for _, eventName := range []string{browserInboxMutationEvent} { + for _, eventName := range []string{browserInboxMutationEvent, workspaceRenamedEventName, workspaceTrashedEventName, workspaceRestoredEventName, workspacePurgedEventName} { if a.browserInboxEvents[eventName] { continue } a.browserInboxEvents[eventName] = true a.eventBus.Subscribe(eventName, func(event events.Event) { - if err := a.mutateBrowserInboxCapture(event); err != nil { - log.Printf("[api] browser inbox mutation failed: %v", err) + if event.Name == browserInboxMutationEvent { + if err := a.mutateBrowserInboxCapture(event); err != nil { + log.Printf("[api] browser inbox mutation failed: %v", err) + } + return + } + if err := a.updateBrowserInboxWorkspaceLifecycle(event); err != nil { + log.Printf("[api] browser inbox workspace lifecycle failed: %v", err) } }) } @@ -295,6 +301,10 @@ func (a *App) recordBrowserCapture(event events.Event) error { if firstPayloadText(capture, "capturedAt") == "" { capture["capturedAt"] = event.Timestamp } + if firstPayloadText(capture, "globalState") == "" { + capture["globalState"] = "inbox" + } + a.annotateBrowserCaptureWorkspace(capture) capture["receivedAt"] = time.Now().UTC().Format(time.RFC3339Nano) return a.updateBrowserInboxCaptures(func(captures []map[string]interface{}) []map[string]interface{} { for _, stored := range captures { @@ -315,7 +325,7 @@ func (a *App) mutateBrowserInboxCapture(event events.Event) error { } action := firstPayloadText(payload, "action") switch action { - case "migrate", "assign", "delete", "processed": + case "migrate", "assign", "archive", "restore", "delete", "processed": default: return fmt.Errorf("unsupported browser inbox mutation %q", action) } @@ -345,10 +355,17 @@ func (a *App) mutateBrowserInboxCapture(event events.Event) error { switch action { case "delete": continue + case "archive": + capture["globalState"] = "archived" + case "restore": + capture["globalState"] = "inbox" case "assign": workspaceRoot := firstPayloadText(payload, "workspaceRootPath") capture["workspaceRootPath"] = workspaceRoot capture["workspaceName"] = workspaceRoot + delete(capture, "workspaceId") + delete(capture, "workspaceTrashId") + a.annotateBrowserCaptureWorkspace(capture) case "processed": capture["processed"], _ = payload["processed"].(bool) } @@ -358,6 +375,67 @@ func (a *App) mutateBrowserInboxCapture(event events.Event) error { }) } +func (a *App) annotateBrowserCaptureWorkspace(capture map[string]interface{}) { + if capture == nil { + return + } + workspaceRoot := firstPayloadText(capture, "workspaceRootPath") + if workspaceRoot == "" { + capture["workspaceState"] = "unassigned" + delete(capture, "workspaceId") + delete(capture, "workspaceTrashId") + return + } + if firstPayloadText(capture, "workspaceId") != "" { + if firstPayloadText(capture, "workspaceState") == "" { + capture["workspaceState"] = "active" + } + return + } + if a.workspace == nil { + capture["workspaceState"] = "unavailable" + return + } + identity, err := a.workspace.GetWorkspaceIdentity(workspaceRoot) + if err != nil { + capture["workspaceState"] = "unavailable" + return + } + capture["workspaceId"] = identity.WorkspaceID + capture["workspaceRootPath"] = identity.RootPath + capture["workspaceName"] = identity.RootPath + capture["workspaceState"] = identity.State +} + +func (a *App) updateBrowserInboxWorkspaceLifecycle(event events.Event) error { + payload := eventPayloadMap(event.Payload) + workspaceID := firstPayloadText(payload, "workspaceId") + if workspaceID == "" { + return nil + } + return a.updateBrowserInboxCaptures(func(captures []map[string]interface{}) []map[string]interface{} { + for _, capture := range captures { + if firstPayloadText(capture, "workspaceId") != workspaceID { + continue + } + switch event.Name { + case workspaceRenamedEventName, workspaceRestoredEventName: + capture["workspaceRootPath"] = firstPayloadText(payload, "workspaceRootPath") + capture["workspaceName"] = firstPayloadText(payload, "workspaceName", "workspaceRootPath") + capture["workspaceState"] = "active" + delete(capture, "workspaceTrashId") + case workspaceTrashedEventName: + capture["workspaceState"] = "trashed" + capture["workspaceTrashId"] = firstPayloadText(payload, "trashId") + case workspacePurgedEventName: + capture["workspaceState"] = "orphaned" + delete(capture, "workspaceTrashId") + } + } + return captures + }) +} + func (a *App) updateBrowserInboxCaptures(update func([]map[string]interface{}) []map[string]interface{}) error { if !a.browserInboxAvailable() { return fmt.Errorf("browser inbox unavailable") @@ -411,6 +489,9 @@ func browserInboxCaptures(settings map[string]interface{}) ([]map[string]interfa continue } seen[captureID] = true + if firstPayloadText(capture, "globalState") == "" { + capture["globalState"] = "inbox" + } if firstPayloadText(capture, "workspaceRootPath") == "" && workspaceRoot != "" { capture["workspaceRootPath"] = workspaceRoot capture["workspaceName"] = workspaceRoot diff --git a/internal/api/app_test.go b/internal/api/app_test.go index 80fe32b..3a55859 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -699,6 +699,9 @@ func TestBrowserInboxRecordsCaptureWithoutMountedView(t *testing.T) { bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{ "pluginId": browserInboxPluginID, "action": "processed", "captureId": "capture-background", "processed": true, }}) + bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{ + "pluginId": browserInboxPluginID, "action": "archive", "captureId": "capture-background", + }}) if err := app.recordBrowserCapture(events.Event{ Name: "browser.capture.page", Payload: map[string]interface{}{ @@ -721,11 +724,105 @@ func TestBrowserInboxRecordsCaptureWithoutMountedView(t *testing.T) { if !ok { t.Fatalf("capture = %#v, want map[string]interface{}", stored[0]) } - if capture["captureId"] != "capture-background" || capture["title"] != "Background capture" || capture["workspaceRootPath"] != "Project" || capture["processed"] != true { + if capture["captureId"] != "capture-background" || capture["title"] != "Background capture" || capture["workspaceRootPath"] != "Project" || capture["processed"] != true || capture["globalState"] != "archived" { t.Fatalf("stored capture = %#v", capture) } } +func TestBrowserInboxArchiveKeepsAssignmentUntilExplicitPermanentDelete(t *testing.T) { + v := vault.NewVault(nil) + if err := v.CreateVault(t.TempDir()); err != nil { + t.Fatalf("CreateVault: %v", err) + } + bus := events.NewBus() + app := &App{ + eventBus: bus, + storage: storage.New(v), + vault: v, + plugins: []plugin.Plugin{{ + Manifest: plugin.Manifest{ID: browserInboxPluginID, Permissions: []string{"storage.namespace"}}, + Status: plugin.StatusLoaded, + Enabled: true, + }}, + } + app.ensureBrowserInboxSubscriptions() + if err := app.recordBrowserCapture(events.Event{Name: "browser.capture.page", Payload: map[string]interface{}{ + "captureId": "archive-1", "capturedAt": "2026-07-12T10:00:00Z", "url": "https://example.com", "workspaceRootPath": "Project", + }}); err != nil { + t.Fatalf("recordBrowserCapture: %v", err) + } + bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{ + "pluginId": browserInboxPluginID, "action": "archive", "captureId": "archive-1", + }}) + settings, err := app.storage.ReadPluginSettings(browserInboxPluginID) + if err != nil { + t.Fatalf("ReadPluginSettings: %v", err) + } + captures, _ := settings[browserInboxGlobalKey].([]interface{}) + if len(captures) != 1 { + t.Fatalf("captures after archive = %#v, want retained capture", captures) + } + archived := captures[0].(map[string]interface{}) + if archived["workspaceRootPath"] != "Project" || archived["globalState"] != "archived" { + t.Fatalf("archived capture = %#v, want preserved assignment", archived) + } + bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{ + "pluginId": browserInboxPluginID, "action": "delete", "captureId": "archive-1", "permanent": true, + }}) + settings, err = app.storage.ReadPluginSettings(browserInboxPluginID) + if err != nil { + t.Fatalf("ReadPluginSettings after delete: %v", err) + } + captures, _ = settings[browserInboxGlobalKey].([]interface{}) + if len(captures) != 0 { + t.Fatalf("captures after permanent delete = %#v, want none", captures) + } +} + +func TestBrowserInboxWorkspaceReferenceSurvivesRenameAndTrash(t *testing.T) { + v := vault.NewVault(nil) + if err := v.CreateVault(t.TempDir()); err != nil { + t.Fatalf("CreateVault: %v", err) + } + manager := workspace.NewManager(v.GetVaultPath()) + created, err := manager.CreateWorkspace("Project", "minimal") + if err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + bus := events.NewBus() + app := &App{ + eventBus: bus, + storage: storage.New(v), + vault: v, + workspace: manager, + plugins: []plugin.Plugin{{ + Manifest: plugin.Manifest{ID: browserInboxPluginID, Permissions: []string{"storage.namespace"}}, + Status: plugin.StatusLoaded, + Enabled: true, + }}, + } + app.ensureBrowserInboxSubscriptions() + if err := app.recordBrowserCapture(events.Event{Name: "browser.capture.page", Payload: map[string]interface{}{ + "captureId": "workspace-ref", "capturedAt": "2026-07-12T10:00:00Z", "url": "https://example.com", "workspaceRootPath": "Project", + }}); err != nil { + t.Fatalf("recordBrowserCapture: %v", err) + } + if errStr := app.RenameWorkspace("Project", "Client"); errStr != "" { + t.Fatalf("RenameWorkspace: %s", errStr) + } + if _, errStr := app.TrashWorkspace("Client"); errStr != "" { + t.Fatalf("TrashWorkspace: %s", errStr) + } + settings, err := app.storage.ReadPluginSettings(browserInboxPluginID) + if err != nil { + t.Fatalf("ReadPluginSettings: %v", err) + } + capture := settings[browserInboxGlobalKey].([]interface{})[0].(map[string]interface{}) + if capture["workspaceId"] != created.ID || capture["workspaceRootPath"] != "Client" || capture["workspaceState"] != "trashed" || capture["workspaceTrashId"] == "" { + t.Fatalf("workspace reference = %#v", capture) + } +} + func TestBrowserInboxRejectsCaptureWithoutOpenVault(t *testing.T) { v := vault.NewVault(nil) app := &App{