| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "slices" |
| 7 | "strings" |
| 8 | |
| 9 | "reasonix/desktop/internal/workspacestate" |
| 10 | "reasonix/internal/session" |
| 11 | ) |
| 12 | |
| 13 | type SessionOrganizationWorkspace struct { |
| 14 | Scope string `json:"scope"` |
| 15 | WorkspaceRoot string `json:"workspaceRoot,omitempty"` |
| 16 | HostID string `json:"hostId,omitempty"` |
| 17 | } |
| 18 | type SessionOrganizationSnapshot struct { |
| 19 | Revision uint64 `json:"revision"` |
| 20 | Applied bool `json:"applied"` |
| 21 | ManualOrderEnabled bool `json:"manualOrderEnabled"` |
| 22 | Order []string `json:"order"` |
| 23 | Groups []desktopGroup `json:"groups"` |
| 24 | } |
| 25 | type SessionOrganizationMutation struct { |
| 26 | Kind string `json:"kind"` |
| 27 | Target *SessionSelector `json:"target,omitempty"` |
| 28 | Anchor *SessionSelector `json:"anchor,omitempty"` |
| 29 | Position string `json:"position,omitempty"` |
| 30 | GroupID string `json:"groupId,omitempty"` |
| 31 | Title string `json:"title,omitempty"` |
| 32 | } |
| 33 | |
| 34 | func organizationSnapshot(o workspacestate.Organization, applied bool) SessionOrganizationSnapshot { |
| 35 | groups := []desktopGroup{} |
| 36 | for _, g := range o.Groups { |
| 37 | groups = append(groups, desktopGroup{ID: g.ID, Title: g.Title, SessionKeys: append([]string{}, g.Members...)}) |
| 38 | } |
| 39 | return SessionOrganizationSnapshot{Revision: o.Revision, Applied: applied, ManualOrderEnabled: o.ManualOrderEnabled, Order: append([]string{}, o.Order...), Groups: groups} |
| 40 | } |
| 41 | |
| 42 | // Import known sources incrementally. Imported includes explicit ungrouped |
| 43 | // choices, so later discoveries never reinstate a topic-level preference. |
| 44 | func (a *App) ensureSessionOrganization(scope, root string) (string, workspacestate.Organization, error) { |
| 45 | scope, root, err := normalizeOrganizationTarget(scope, root) |
| 46 | if err != nil { |
| 47 | return "", workspacestate.Organization{}, err |
| 48 | } |
| 49 | id, err := a.ensureDesktopWorkspace(a.bootContext(), scope, root) |
| 50 | if err != nil { |
| 51 | return "", workspacestate.Organization{}, err |
| 52 | } |
| 53 | state, err := a.workspaceRegistry().Load(a.bootContext()) |
| 54 | if err != nil { |
| 55 | return "", workspacestate.Organization{}, err |
| 56 | } |
| 57 | workspace := state.Workspaces[id] |
| 58 | projects := loadProjectsFile() |
| 59 | groups := projects.GlobalGroups |
| 60 | order := projects.GlobalSessionOrder |
| 61 | topicOrder := projects.GlobalTopics |
| 62 | manual := projects.GlobalManualSessionOrder || projects.GlobalManualTopicOrder |
| 63 | if scope == "project" { |
| 64 | if i := projectIndexByRoot(projects.Projects, root); i >= 0 { |
| 65 | p := projects.Projects[i] |
| 66 | groups, order, manual = p.Groups, p.SessionOrder, p.ManualSessionOrder || p.ManualTopicOrder |
| 67 | topicOrder = p.Topics |
| 68 | } |
| 69 | } |
| 70 | nodes := []ProjectNode{} |
| 71 | for _, sid := range workspace.SessionIDs { |
| 72 | p := state.Presentation[sid] |
| 73 | ref := session.SessionRef{HostID: localDesktopHostID, SessionID: sid} |
| 74 | node := ProjectNode{Session: &ref, TopicID: p.TopicID, SessionPath: sessionRoute(sid)} |
| 75 | node.IdentityAliases = sourceAliases(state, id, sid) |
| 76 | nodes = append(nodes, node) |
| 77 | } |
| 78 | req := ProjectTopicPageRequest{Scope: scope, WorkspaceRoot: root, Limit: 200} |
| 79 | for { |
| 80 | page, e := a.listProjectTopics(req) |
| 81 | if e != nil { |
| 82 | return "", workspacestate.Organization{}, e |
| 83 | } |
| 84 | for _, node := range page.Items { |
| 85 | nodes = append(nodes, expandSessionSourceRows(node)...) |
| 86 | } |
| 87 | if page.NextCursor == "" { |
| 88 | break |
| 89 | } |
| 90 | if page.NextCursor == req.Cursor { |
| 91 | return "", workspacestate.Organization{}, fmt.Errorf("legacy cursor did not advance") |
| 92 | } |
| 93 | req.Cursor = page.NextCursor |
| 94 | } |
| 95 | canonicalByAlias := map[string]string{} |
| 96 | for _, n := range nodes { |
| 97 | if n.Session != nil { |
| 98 | for _, alias := range n.IdentityAliases { |
| 99 | canonicalByAlias[alias] = projectNodeSessionKey(n) |
| 100 | } |
| 101 | } |
| 102 | } |
| 103 | org, _, err := a.workspaceRegistry().UpdateOrganization(a.bootContext(), id, nil, func(o *workspacestate.Organization) error { |
| 104 | initial := o.MigrationVersion == 0 |
| 105 | if initial { |
| 106 | o.ManualOrderEnabled = manual |
| 107 | for _, g := range groups { |
| 108 | o.Groups = append(o.Groups, workspacestate.OrganizationGroup{ID: g.ID, Title: g.Title, Members: []string{}}) |
| 109 | } |
| 110 | } |
| 111 | importOrganizationMembers(o, nodes, groups, canonicalByAlias) |
| 112 | if initial { |
| 113 | importOrganizationOrder(o, nodes, order, topicOrder, canonicalByAlias, manual) |
| 114 | } |
| 115 | o.MigrationVersion = 1 |
| 116 | return nil |
| 117 | }) |
| 118 | return id, org, err |
| 119 | } |
| 120 | |
| 121 | func sourceAliases(state workspacestate.State, workspaceID, sessionID string) []string { |
| 122 | aliases := []string{} |
| 123 | for _, m := range state.SourceMappings { |
| 124 | if m.WorkspaceID != workspaceID || m.SessionID != sessionID { |
| 125 | continue |
| 126 | } |
| 127 | aliases = append(aliases, "source\x00local\x00"+m.SourceKey) |
| 128 | if sourceMappingHasPathAlias(m) { |
| 129 | aliases = append(aliases, "path\x00"+m.Path) |
| 130 | } |
| 131 | } |
| 132 | slices.Sort(aliases) |
| 133 | return aliases |
| 134 | } |
| 135 | |
| 136 | func (a *App) GetSessionOrganization(workspace SessionOrganizationWorkspace) (SessionOrganizationSnapshot, error) { |
| 137 | if workspace.HostID != "" && workspace.HostID != localDesktopHostID { |
| 138 | return a.remoteSessionOrganization(workspace, nil, nil) |
| 139 | } |
| 140 | _, o, err := a.ensureSessionOrganization(workspace.Scope, workspace.WorkspaceRoot) |
| 141 | return organizationSnapshot(o, err == nil), err |
| 142 | } |
| 143 | |
| 144 | func (a *App) UpdateSessionOrganization(workspace SessionOrganizationWorkspace, expectedRevision uint64, mutation SessionOrganizationMutation) (SessionOrganizationSnapshot, error) { |
| 145 | if workspace.HostID != "" && workspace.HostID != localDesktopHostID { |
| 146 | return a.remoteSessionOrganization(workspace, &expectedRevision, &mutation) |
| 147 | } |
| 148 | id, _, err := a.ensureSessionOrganization(workspace.Scope, workspace.WorkspaceRoot) |
| 149 | if err != nil { |
| 150 | return SessionOrganizationSnapshot{}, err |
| 151 | } |
| 152 | resolved := []SessionTarget{} |
| 153 | resolve := func(selector *SessionSelector) (string, error) { |
| 154 | if selector == nil { |
| 155 | return "", newSessionOperationError("target_not_found", "Select a session.") |
| 156 | } |
| 157 | target, e := a.resolveSessionTarget(*selector) |
| 158 | if e != nil { |
| 159 | return "", e |
| 160 | } |
| 161 | targetWorkspaceID, e := a.resolveDesktopWorkspaceID(a.bootContext(), target.Scope, target.WorkspaceRoot) |
| 162 | if e != nil { |
| 163 | return "", e |
| 164 | } |
| 165 | if targetWorkspaceID != id { |
| 166 | return "", newSessionOperationError("target_changed", "The session moved to another workspace.") |
| 167 | } |
| 168 | resolved = append(resolved, target) |
| 169 | var ref *session.SessionRef |
| 170 | if target.SessionRef.SessionID != "" { |
| 171 | ref = &target.SessionRef |
| 172 | } |
| 173 | return projectNodeSessionKey(ProjectNode{Session: ref, SessionPath: target.SessionPath, Source: selector.Source}), nil |
| 174 | } |
| 175 | key, anchor := "", "" |
| 176 | if mutation.Kind == "move" || mutation.Kind == "set-group" { |
| 177 | key, err = resolve(mutation.Target) |
| 178 | if err != nil { |
| 179 | return SessionOrganizationSnapshot{}, err |
| 180 | } |
| 181 | } |
| 182 | if mutation.Kind == "move" { |
| 183 | anchor, err = resolve(mutation.Anchor) |
| 184 | if err != nil { |
| 185 | return SessionOrganizationSnapshot{}, err |
| 186 | } |
| 187 | } |
| 188 | o, applied, err := a.workspaceRegistry().UpdateOrganizationWithState(a.bootContext(), id, &expectedRevision, func(state *workspacestate.State, o *workspacestate.Organization) error { |
| 189 | for _, target := range resolved { |
| 190 | if target.SessionRef.SessionID == "" { |
| 191 | continue |
| 192 | } |
| 193 | current := state.SessionStates[target.SessionRef.SessionID] |
| 194 | if current.Lifecycle != workspacestate.Active || current.Generation != target.LifecycleGeneration || !slices.Contains(state.Workspaces[id].SessionIDs, target.SessionRef.SessionID) { |
| 195 | return workspacestate.ErrMutationConflict |
| 196 | } |
| 197 | } |
| 198 | return applyOrganizationMutation(o, mutation, key, anchor) |
| 199 | }) |
| 200 | if err == nil && applied { |
| 201 | a.emitProjectTreeMetadataChanged() |
| 202 | } |
| 203 | return organizationSnapshot(o, applied), err |
| 204 | } |
| 205 | |
| 206 | // replaceSessionOrganizationGroups retains old RPC signatures while moving their |
| 207 | // persistence into the same transaction as ordering and lifecycle mutations. |
| 208 | func (a *App) replaceSessionOrganizationGroups(ctx context.Context, scope, root string, revision *uint64, groups []desktopGroup) (ProjectGroupsSnapshot, error) { |
| 209 | id, _, err := a.ensureSessionOrganization(scope, root) |
| 210 | if err != nil { |
| 211 | return ProjectGroupsSnapshot{}, err |
| 212 | } |
| 213 | if err = validateSessionGroups(groups); err != nil { |
| 214 | return ProjectGroupsSnapshot{}, err |
| 215 | } |
| 216 | state, err := a.workspaceRegistry().Load(ctx) |
| 217 | if err != nil { |
| 218 | return ProjectGroupsSnapshot{}, err |
| 219 | } |
| 220 | legacy, err := a.unadoptedLegacyTopics(ProjectTopicPageRequest{Scope: scope, WorkspaceRoot: root, Limit: 200}, map[string]bool{}, map[string]bool{}) |
| 221 | if err != nil { |
| 222 | return ProjectGroupsSnapshot{}, err |
| 223 | } |
| 224 | nodes := append([]ProjectNode{}, legacy.Items...) |
| 225 | for _, sid := range state.Workspaces[id].SessionIDs { |
| 226 | if state.SessionStates[sid].Lifecycle != workspacestate.Active { |
| 227 | continue |
| 228 | } |
| 229 | ref := session.SessionRef{HostID: localDesktopHostID, SessionID: sid} |
| 230 | nodes = append(nodes, ProjectNode{Session: &ref, TopicID: state.Presentation[sid].TopicID}) |
| 231 | } |
| 232 | o, applied, err := a.workspaceRegistry().UpdateOrganization(ctx, id, revision, func(o *workspacestate.Organization) error { |
| 233 | next := []workspacestate.OrganizationGroup{} |
| 234 | for _, g := range groups { |
| 235 | members := []string{} |
| 236 | for _, node := range nodes { |
| 237 | key := projectNodeSessionKey(node) |
| 238 | if o.Imported[key] && desktopGroupContainsNode(g, node) && !slices.Contains(members, key) { |
| 239 | members = append(members, key) |
| 240 | } |
| 241 | } |
| 242 | for _, key := range g.SessionKeys { |
| 243 | if !o.Imported[key] { |
| 244 | return workspacestate.ErrMutationConflict |
| 245 | } |
| 246 | if !slices.Contains(members, key) { |
| 247 | members = append(members, key) |
| 248 | } |
| 249 | } |
| 250 | group := workspacestate.OrganizationGroup{ID: g.ID, Title: g.Title, Members: members} |
| 251 | for _, existing := range o.Groups { |
| 252 | if existing.ID == g.ID { |
| 253 | group = existing |
| 254 | group.Title, group.Members = g.Title, members |
| 255 | break |
| 256 | } |
| 257 | } |
| 258 | next = append(next, group) |
| 259 | } |
| 260 | o.Groups = next |
| 261 | return nil |
| 262 | }) |
| 263 | if err == nil && applied { |
| 264 | a.emitProjectTreeMetadataChanged() |
| 265 | } |
| 266 | s := organizationSnapshot(o, applied) |
| 267 | return ProjectGroupsSnapshot{Groups: s.Groups, Revision: s.Revision, Applied: applied}, err |
| 268 | } |
| 269 | |
| 270 | func applyOrganizationMutation(o *workspacestate.Organization, mutation SessionOrganizationMutation, key, anchor string) error { |
| 271 | switch mutation.Kind { |
| 272 | case "move": |
| 273 | if key == anchor { |
| 274 | return nil |
| 275 | } |
| 276 | if !o.Imported[key] || !o.Imported[anchor] { |
| 277 | return workspacestate.ErrMutationConflict |
| 278 | } |
| 279 | if mutation.Position != "before" && mutation.Position != "after" { |
| 280 | return fmt.Errorf("invalid position") |
| 281 | } |
| 282 | o.Order = slices.DeleteFunc(o.Order, func(v string) bool { return v == key }) |
| 283 | i := slices.Index(o.Order, anchor) |
| 284 | if i < 0 { |
| 285 | return workspacestate.ErrMutationConflict |
| 286 | } |
| 287 | if mutation.Position == "after" { |
| 288 | i++ |
| 289 | } |
| 290 | o.Order = slices.Insert(o.Order, i, key) |
| 291 | o.ManualOrderEnabled = true |
| 292 | case "set-group": |
| 293 | if !o.Imported[key] { |
| 294 | return workspacestate.ErrMutationConflict |
| 295 | } |
| 296 | found := mutation.GroupID == "" |
| 297 | for _, g := range o.Groups { |
| 298 | found = found || g.ID == mutation.GroupID |
| 299 | } |
| 300 | if !found { |
| 301 | return workspacestate.ErrMutationConflict |
| 302 | } |
| 303 | for i := range o.Groups { |
| 304 | o.Groups[i].Members = slices.DeleteFunc(o.Groups[i].Members, func(v string) bool { return v == key }) |
| 305 | if o.Groups[i].ID == mutation.GroupID { |
| 306 | o.Groups[i].Members = append(o.Groups[i].Members, key) |
| 307 | } |
| 308 | } |
| 309 | case "create-group": |
| 310 | if len(o.Groups) >= maxSessionGroups { |
| 311 | return fmt.Errorf("group limit exceeded") |
| 312 | } |
| 313 | if err := validateSessionGroups([]desktopGroup{{ID: mutation.GroupID, Title: mutation.Title}}); err != nil { |
| 314 | return err |
| 315 | } |
| 316 | if strings.TrimSpace(mutation.GroupID) == "" || strings.TrimSpace(mutation.Title) == "" { |
| 317 | return fmt.Errorf("group id and title required") |
| 318 | } |
| 319 | for _, g := range o.Groups { |
| 320 | if g.ID == mutation.GroupID { |
| 321 | return workspacestate.ErrMutationConflict |
| 322 | } |
| 323 | } |
| 324 | o.Groups = append(o.Groups, workspacestate.OrganizationGroup{ID: mutation.GroupID, Title: strings.TrimSpace(mutation.Title), Members: []string{}}) |
| 325 | case "rename-group", "delete-group": |
| 326 | if mutation.Kind == "rename-group" { |
| 327 | if err := validateSessionGroups([]desktopGroup{{ID: mutation.GroupID, Title: mutation.Title}}); err != nil { |
| 328 | return err |
| 329 | } |
| 330 | } |
| 331 | index := slices.IndexFunc(o.Groups, func(g workspacestate.OrganizationGroup) bool { return g.ID == mutation.GroupID }) |
| 332 | if index < 0 { |
| 333 | return workspacestate.ErrMutationConflict |
| 334 | } |
| 335 | if mutation.Kind == "delete-group" { |
| 336 | o.Groups = slices.Delete(o.Groups, index, index+1) |
| 337 | } else { |
| 338 | if strings.TrimSpace(mutation.Title) == "" { |
| 339 | return fmt.Errorf("title required") |
| 340 | } |
| 341 | o.Groups[index].Title = strings.TrimSpace(mutation.Title) |
| 342 | } |
| 343 | default: |
| 344 | return fmt.Errorf("unsupported organization mutation") |
| 345 | } |
| 346 | return nil |
| 347 | } |
| 348 | |
| 349 | func importOrganizationMembers(o *workspacestate.Organization, nodes []ProjectNode, groups []desktopGroup, canonicalByAlias map[string]string) { |
| 350 | for _, n := range nodes { |
| 351 | if n.Session == nil && n.SessionPath == "" { |
| 352 | continue |
| 353 | } |
| 354 | key := projectNodeSessionKey(n) |
| 355 | if _, adopted := canonicalByAlias[key]; adopted { |
| 356 | continue |
| 357 | } |
| 358 | if o.Imported[key] { |
| 359 | continue |
| 360 | } |
| 361 | for _, old := range groups { |
| 362 | included := desktopGroupContainsNode(old, n) |
| 363 | for _, alias := range n.IdentityAliases { |
| 364 | if slices.Contains(old.ExcludedSessionKeys, alias) { |
| 365 | included = false |
| 366 | break |
| 367 | } |
| 368 | if slices.Contains(old.SessionKeys, alias) { |
| 369 | included = true |
| 370 | } |
| 371 | } |
| 372 | if included { |
| 373 | for i := range o.Groups { |
| 374 | if o.Groups[i].ID == old.ID { |
| 375 | o.Groups[i].Members = append(o.Groups[i].Members, key) |
| 376 | break |
| 377 | } |
| 378 | } |
| 379 | } |
| 380 | } |
| 381 | if !slices.Contains(o.Order, key) { |
| 382 | o.Order = append(o.Order, key) |
| 383 | } |
| 384 | o.Imported[key] = true |
| 385 | } |
| 386 | } |
| 387 | |
| 388 | func importOrganizationOrder(o *workspacestate.Organization, nodes []ProjectNode, order, topicOrder []string, canonicalByAlias map[string]string, manual bool) { |
| 389 | if manual && len(order) == 0 { |
| 390 | for _, topic := range topicOrder { |
| 391 | for _, node := range nodes { |
| 392 | if node.TopicID == topic { |
| 393 | order = append(order, projectNodeSessionKey(node)) |
| 394 | } |
| 395 | } |
| 396 | } |
| 397 | } |
| 398 | if len(order) > 0 { |
| 399 | next := []string{} |
| 400 | for _, key := range order { |
| 401 | if canonical, ok := canonicalByAlias[key]; ok { |
| 402 | key = canonical |
| 403 | } |
| 404 | if o.Imported[key] && !slices.Contains(next, key) { |
| 405 | next = append(next, key) |
| 406 | } |
| 407 | } |
| 408 | for _, key := range o.Order { |
| 409 | if !slices.Contains(next, key) { |
| 410 | next = append(next, key) |
| 411 | } |
| 412 | } |
| 413 | o.Order = next |
| 414 | } |
| 415 | } |
| 416 |