| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "reflect" |
| 8 | "slices" |
| 9 | "strings" |
| 10 | |
| 11 | "reasonix/desktop/internal/workspacestate" |
| 12 | "reasonix/internal/session" |
| 13 | ) |
| 14 | |
| 15 | type SessionOrganizationWorkspace struct { |
| 16 | Scope string `json:"scope"` |
| 17 | WorkspaceRoot string `json:"workspaceRoot,omitempty"` |
| 18 | HostID string `json:"hostId,omitempty"` |
| 19 | } |
| 20 | type SessionOrganizationSnapshot struct { |
| 21 | Revision uint64 `json:"revision"` |
| 22 | Applied bool `json:"applied"` |
| 23 | ManualOrderEnabled bool `json:"manualOrderEnabled"` |
| 24 | Order []string `json:"order"` |
| 25 | Groups []desktopGroup `json:"groups"` |
| 26 | } |
| 27 | type SessionOrganizationMutation struct { |
| 28 | Kind string `json:"kind"` |
| 29 | Target *SessionSelector `json:"target,omitempty"` |
| 30 | Anchor *SessionSelector `json:"anchor,omitempty"` |
| 31 | Position string `json:"position,omitempty"` |
| 32 | GroupID string `json:"groupId,omitempty"` |
| 33 | Title string `json:"title,omitempty"` |
| 34 | // SortMode is the activity order the sidebar shows while no manual order exists. |
| 35 | SortMode string `json:"sortMode,omitempty"` |
| 36 | } |
| 37 | |
| 38 | func organizationSnapshot(o workspacestate.Organization, applied bool) SessionOrganizationSnapshot { |
| 39 | groups := []desktopGroup{} |
| 40 | for _, g := range o.Groups { |
| 41 | groups = append(groups, desktopGroup{ID: g.ID, Title: g.Title, SessionKeys: append([]string{}, g.Members...)}) |
| 42 | } |
| 43 | return SessionOrganizationSnapshot{Revision: o.Revision, Applied: applied, ManualOrderEnabled: o.ManualOrderEnabled, Order: append([]string{}, o.Order...), Groups: groups} |
| 44 | } |
| 45 | |
| 46 | // Import known sources incrementally. Imported includes explicit ungrouped |
| 47 | // choices, so later discoveries never reinstate a topic-level preference. |
| 48 | func (a *App) ensureSessionOrganization(scope, root string) (string, workspacestate.Organization, error) { |
| 49 | return a.ensureSessionOrganizationSources(scope, root, false) |
| 50 | } |
| 51 | |
| 52 | func (a *App) ensureSessionOrganizationSources(scope, root string, mutation bool) (string, workspacestate.Organization, error) { |
| 53 | scope, root, err := normalizeOrganizationTarget(scope, root) |
| 54 | if err != nil { |
| 55 | return "", workspacestate.Organization{}, err |
| 56 | } |
| 57 | state, err := a.workspaceRegistry().LoadProjection(a.bootContext()) |
| 58 | if err != nil { |
| 59 | return "", workspacestate.Organization{}, err |
| 60 | } |
| 61 | id, found := workspacestate.FindWorkspace(state, desktopWorkspaceRoot(scope, root)) |
| 62 | if !found { |
| 63 | id, err = a.ensureDesktopWorkspace(a.bootContext(), scope, root) |
| 64 | if err != nil { |
| 65 | return "", workspacestate.Organization{}, err |
| 66 | } |
| 67 | state, err = a.workspaceRegistry().LoadProjection(a.bootContext()) |
| 68 | if err != nil { |
| 69 | return "", workspacestate.Organization{}, err |
| 70 | } |
| 71 | } |
| 72 | workspace := state.Workspaces[id] |
| 73 | projects := loadProjectsFile() |
| 74 | importKey, cacheable := a.organizationImportKey(scope, root, state, id, projects) |
| 75 | if cacheable && !mutation { |
| 76 | if organization, ok := a.desktopSessions.organizations.get(importKey); ok { |
| 77 | return id, organization, nil |
| 78 | } |
| 79 | } |
| 80 | legacy := legacyOrganizationPreferences(projects, scope, root) |
| 81 | nodes := []ProjectNode{} |
| 82 | // Registry identities are already loaded; retain them for transactional |
| 83 | // fork/group attachment without consulting any session file or catalog page. |
| 84 | aliases := workspaceSourceAliases(state, id) |
| 85 | for _, sid := range workspace.SessionIDs { |
| 86 | p := state.Presentation[sid] |
| 87 | ref := session.SessionRef{HostID: localDesktopHostID, SessionID: sid} |
| 88 | nodes = append(nodes, ProjectNode{Session: &ref, TopicID: p.TopicID, SessionPath: sessionRoute(sid), IdentityAliases: aliases[sid]}) |
| 89 | } |
| 90 | // Default organization has no source-dependent preferences to import. |
| 91 | // Do not enumerate every history page just to establish an empty group and |
| 92 | // automatic ordering projection on the first sidebar request. |
| 93 | if mutation || legacy.ManualSessionOrder || legacy.ManualTopicOrder || len(legacy.Groups) > 0 { |
| 94 | req := ProjectTopicPageRequest{Scope: scope, WorkspaceRoot: root, Limit: 200} |
| 95 | for { |
| 96 | page, e := a.listProjectTopics(req) |
| 97 | if e != nil { |
| 98 | return "", workspacestate.Organization{}, e |
| 99 | } |
| 100 | for _, node := range page.Items { |
| 101 | nodes = append(nodes, expandSessionSourceRows(node)...) |
| 102 | } |
| 103 | if page.NextCursor == "" { |
| 104 | break |
| 105 | } |
| 106 | if page.NextCursor == req.Cursor { |
| 107 | return "", workspacestate.Organization{}, fmt.Errorf("legacy cursor did not advance") |
| 108 | } |
| 109 | req.Cursor = page.NextCursor |
| 110 | } |
| 111 | } |
| 112 | importSources := func(o *workspacestate.Organization) error { |
| 113 | projectLegacyOrganization(o, nodes, legacy) |
| 114 | return nil |
| 115 | } |
| 116 | // A settled import is a read. Do not enter the cross-process write lock or |
| 117 | // serialize the whole registry merely to discover that nothing changed. |
| 118 | if workspace.Organization != nil { |
| 119 | candidate := workspace.Organization.Clone() |
| 120 | if err := importSources(&candidate); err != nil { |
| 121 | return "", workspacestate.Organization{}, err |
| 122 | } |
| 123 | if reflect.DeepEqual(candidate, *workspace.Organization) { |
| 124 | if cacheable { |
| 125 | a.desktopSessions.organizations.put(importKey, candidate) |
| 126 | } |
| 127 | return id, candidate, nil |
| 128 | } |
| 129 | } |
| 130 | org, _, err := a.workspaceRegistry().UpdateOrganization(a.bootContext(), id, nil, importSources) |
| 131 | return id, org, err |
| 132 | } |
| 133 | |
| 134 | func legacyOrganizationPreferences(projects desktopProjectFile, scope, root string) desktopProject { |
| 135 | if scope == "project" { |
| 136 | // Most callers use the persisted spelling; avoid filesystem comparisons |
| 137 | // unless this request actually uses an alias. |
| 138 | for _, project := range projects.Projects { |
| 139 | if project.Root == root { |
| 140 | return project |
| 141 | } |
| 142 | } |
| 143 | if i := projectIndexByRoot(projects.Projects, root); i >= 0 { |
| 144 | return projects.Projects[i] |
| 145 | } |
| 146 | return desktopProject{} |
| 147 | } |
| 148 | return desktopProject{Groups: projects.GlobalGroups, SessionOrder: projects.GlobalSessionOrder, |
| 149 | Topics: projects.GlobalTopics, ManualSessionOrder: projects.GlobalManualSessionOrder, ManualTopicOrder: projects.GlobalManualTopicOrder} |
| 150 | } |
| 151 | |
| 152 | // projectLegacyOrganization shares the import semantics with the writer, but |
| 153 | // changes only the caller-owned organization. Snapshot callers supply already |
| 154 | // materialized nodes and never perform a second discovery pass or persist it. |
| 155 | func projectLegacyOrganization(o *workspacestate.Organization, nodes []ProjectNode, legacy desktopProject) { |
| 156 | if o.Imported == nil { |
| 157 | o.Imported = map[string]bool{} |
| 158 | } |
| 159 | canonicalByAlias := map[string]string{} |
| 160 | for _, node := range nodes { |
| 161 | if node.Session != nil { |
| 162 | for _, alias := range node.IdentityAliases { |
| 163 | canonicalByAlias[alias] = projectNodeSessionKey(node) |
| 164 | } |
| 165 | } |
| 166 | } |
| 167 | initial := o.MigrationVersion == 0 |
| 168 | manual := legacy.ManualSessionOrder || legacy.ManualTopicOrder |
| 169 | if initial { |
| 170 | o.ManualOrderEnabled = manual |
| 171 | for _, group := range legacy.Groups { |
| 172 | o.Groups = append(o.Groups, workspacestate.OrganizationGroup{ID: group.ID, Title: group.Title, Members: []string{}}) |
| 173 | } |
| 174 | } |
| 175 | importOrganizationMembers(o, nodes, legacy.Groups, canonicalByAlias) |
| 176 | if initial { |
| 177 | importOrganizationOrder(o, nodes, legacy.SessionOrder, legacy.Topics, canonicalByAlias, manual) |
| 178 | } |
| 179 | o.MigrationVersion = 1 |
| 180 | } |
| 181 | |
| 182 | func projectedShellOrganization(workspace workspacestate.Workspace, state workspacestate.State, sources []ProjectNode, legacy desktopProject) workspacestate.Organization { |
| 183 | org := workspacestate.Organization{} |
| 184 | if workspace.Organization != nil { |
| 185 | org = workspace.Organization.Clone() |
| 186 | } |
| 187 | // Shells have no group filter. Without an old manual order there is |
| 188 | // nothing to project, so ordinary refreshes do not rebuild import members. |
| 189 | if org.MigrationVersion != 0 || !legacy.ManualSessionOrder && !legacy.ManualTopicOrder { |
| 190 | return org |
| 191 | } |
| 192 | aliases := workspaceSourceAliases(state, workspace.ID) |
| 193 | nodes := make([]ProjectNode, 0, len(workspace.SessionIDs)+len(sources)) |
| 194 | for _, id := range workspace.SessionIDs { |
| 195 | ref := session.SessionRef{HostID: localDesktopHostID, SessionID: id} |
| 196 | nodes = append(nodes, ProjectNode{Session: &ref, TopicID: state.Presentation[id].TopicID, IdentityAliases: aliases[id]}) |
| 197 | } |
| 198 | nodes = append(nodes, sources...) |
| 199 | projectLegacyOrganization(&org, nodes, legacy) |
| 200 | return org |
| 201 | } |
| 202 | |
| 203 | func workspaceSourceAliases(state workspacestate.State, workspaceID string) map[string][]string { |
| 204 | result := map[string][]string{} |
| 205 | // A topic aliases its initial placeholder only while it has one owner. |
| 206 | // Build this once per workspace, not once per row of a large history page. |
| 207 | owners := map[string]string{} |
| 208 | for _, id := range state.Workspaces[workspaceID].SessionIDs { |
| 209 | if topic := state.Presentation[id].TopicID; topic != "" { |
| 210 | if _, seen := owners[topic]; seen { |
| 211 | owners[topic] = "" |
| 212 | } else { |
| 213 | owners[topic] = id |
| 214 | } |
| 215 | } |
| 216 | } |
| 217 | for topic, id := range owners { |
| 218 | if id != "" { |
| 219 | result[id] = append(result[id], "topic\x00"+topic) |
| 220 | } |
| 221 | } |
| 222 | for _, m := range state.SourceMappings { |
| 223 | if m.WorkspaceID != workspaceID { |
| 224 | continue |
| 225 | } |
| 226 | for _, key := range state.SourceKeys(m.SourceKey) { |
| 227 | result[m.SessionID] = append(result[m.SessionID], "source\x00local\x00"+key) |
| 228 | } |
| 229 | if sourceMappingHasPathAlias(m) { |
| 230 | // Lazy catalog pages use a path source key even for a single-head |
| 231 | // DAG. Include both displayed identities in runtime merges and |
| 232 | // lifecycle receipts, without claiming independent sibling heads. |
| 233 | result[m.SessionID] = append(result[m.SessionID], "path\x00"+m.Path, |
| 234 | "source\x00local\x00"+desktopSourceKey(m.Path, "")) |
| 235 | } |
| 236 | } |
| 237 | for id, aliases := range result { |
| 238 | slices.Sort(aliases) |
| 239 | result[id] = slices.Compact(aliases) |
| 240 | } |
| 241 | return result |
| 242 | } |
| 243 | |
| 244 | func sourceAliases(state workspacestate.State, workspaceID, sessionID string) []string { |
| 245 | return append([]string{}, workspaceSourceAliases(state, workspaceID)[sessionID]...) |
| 246 | } |
| 247 | |
| 248 | func (a *App) GetSessionOrganization(workspace SessionOrganizationWorkspace) (SessionOrganizationSnapshot, error) { |
| 249 | if workspace.HostID != "" && workspace.HostID != localDesktopHostID { |
| 250 | return a.remoteSessionOrganization(workspace, nil, nil) |
| 251 | } |
| 252 | _, o, err := a.ensureSessionOrganization(workspace.Scope, workspace.WorkspaceRoot) |
| 253 | return organizationSnapshot(o, err == nil), err |
| 254 | } |
| 255 | |
| 256 | func (a *App) UpdateSessionOrganization(workspace SessionOrganizationWorkspace, expectedRevision uint64, mutation SessionOrganizationMutation) (SessionOrganizationSnapshot, error) { |
| 257 | if workspace.HostID != "" && workspace.HostID != localDesktopHostID { |
| 258 | return a.remoteSessionOrganization(workspace, &expectedRevision, &mutation) |
| 259 | } |
| 260 | // Group edits address either a group or one explicit source. Only moving |
| 261 | // in a manual order needs the complete relative order of other sources. |
| 262 | // Old source-dependent preferences still take their normal import path. |
| 263 | id, current, err := a.ensureSessionOrganizationSources(workspace.Scope, workspace.WorkspaceRoot, mutation.Kind == "move") |
| 264 | if err != nil { |
| 265 | return SessionOrganizationSnapshot{}, err |
| 266 | } |
| 267 | var shown []string |
| 268 | if mutation.Kind == "move" && !current.ManualOrderEnabled { |
| 269 | if shown, err = a.shownSessionOrder(workspace.Scope, workspace.WorkspaceRoot, mutation.SortMode); err != nil { |
| 270 | return SessionOrganizationSnapshot{}, err |
| 271 | } |
| 272 | } |
| 273 | resolved := []SessionTarget{} |
| 274 | resolve := func(selector *SessionSelector) (string, error) { |
| 275 | if selector == nil { |
| 276 | return "", newSessionOperationError("target_not_found", "Select a session.") |
| 277 | } |
| 278 | target, e := a.resolveSessionTarget(*selector) |
| 279 | if e != nil { |
| 280 | return "", e |
| 281 | } |
| 282 | targetWorkspaceID, e := a.resolveDesktopWorkspaceID(a.bootContext(), target.Scope, target.WorkspaceRoot) |
| 283 | if e != nil { |
| 284 | return "", e |
| 285 | } |
| 286 | if targetWorkspaceID != id { |
| 287 | return "", newSessionOperationError("target_changed", "The session moved to another workspace.") |
| 288 | } |
| 289 | var ref *session.SessionRef |
| 290 | if target.SessionRef.SessionID != "" { |
| 291 | ref = &target.SessionRef |
| 292 | } else { |
| 293 | if _, err := os.Stat(target.SessionPath); err != nil { |
| 294 | return "", newSessionOperationError("target_not_found", "The historical source is unavailable.") |
| 295 | } |
| 296 | // Both path-only compatibility selectors and explicit source refs |
| 297 | // must name the same physical member used by the sidebar. |
| 298 | head := "" |
| 299 | if target.Source != nil { |
| 300 | head = target.Source.HeadID |
| 301 | } else if selector.Source != nil { |
| 302 | head = selector.Source.HeadID |
| 303 | } |
| 304 | target.Source = &SessionSourceRef{HostID: localDesktopHostID, Path: target.SessionPath, |
| 305 | HeadID: head, SourceKey: desktopSourceKey(target.SessionPath, head)} |
| 306 | } |
| 307 | resolved = append(resolved, target) |
| 308 | return projectNodeSessionKey(ProjectNode{Session: ref, SessionPath: target.SessionPath, Source: target.Source}), nil |
| 309 | } |
| 310 | key, anchor := "", "" |
| 311 | if mutation.Kind == "move" || mutation.Kind == "set-group" { |
| 312 | key, err = resolve(mutation.Target) |
| 313 | if err != nil { |
| 314 | return SessionOrganizationSnapshot{}, err |
| 315 | } |
| 316 | } |
| 317 | if mutation.Kind == "move" { |
| 318 | anchor, err = resolve(mutation.Anchor) |
| 319 | if err != nil { |
| 320 | return SessionOrganizationSnapshot{}, err |
| 321 | } |
| 322 | } |
| 323 | o, applied, err := a.workspaceRegistry().UpdateOrganizationWithState(a.bootContext(), id, &expectedRevision, func(state *workspacestate.State, o *workspacestate.Organization) error { |
| 324 | if mutation.Kind == "move" && !o.ManualOrderEnabled { |
| 325 | seedManualOrder(o, shown) |
| 326 | } |
| 327 | return applyResolvedOrganizationMutation(state, id, o, resolved, mutation, key, anchor) |
| 328 | }) |
| 329 | if err == nil && applied { |
| 330 | a.emitProjectTreeMetadataChanged() |
| 331 | } |
| 332 | return organizationSnapshot(o, applied), err |
| 333 | } |
| 334 | |
| 335 | // shownSessionOrder lists a workspace in the activity order the sidebar is |
| 336 | // showing, so the first manual move starts from what the user was looking at. |
| 337 | func (a *App) shownSessionOrder(scope, root, sortMode string) ([]string, error) { |
| 338 | keys := []string{} |
| 339 | req := ProjectTopicPageRequest{Scope: scope, WorkspaceRoot: root, Limit: 200, SortMode: sortMode} |
| 340 | for { |
| 341 | page, err := a.unifiedProjectTopics(req) |
| 342 | a.ReleaseReadSnapshot(page.SnapshotID) |
| 343 | if err != nil { |
| 344 | return nil, err |
| 345 | } |
| 346 | for _, node := range page.Items { |
| 347 | for _, row := range expandSessionSourceRows(node) { |
| 348 | keys = append(keys, projectNodeSessionKey(row)) |
| 349 | } |
| 350 | } |
| 351 | if page.NextCursor == "" { |
| 352 | return keys, nil |
| 353 | } |
| 354 | if page.NextCursor == req.Cursor { |
| 355 | return nil, fmt.Errorf("session order cursor did not advance") |
| 356 | } |
| 357 | req.Cursor = page.NextCursor |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | // seedManualOrder replaces the attach order, which is oldest first and never |
| 362 | // what the sidebar showed, with the shown order. Unlisted keys keep their place |
| 363 | // after the listed ones. |
| 364 | func seedManualOrder(o *workspacestate.Organization, shown []string) { |
| 365 | rank := make(map[string]int, len(shown)) |
| 366 | for i, key := range shown { |
| 367 | if _, ok := rank[key]; !ok { |
| 368 | rank[key] = i |
| 369 | } |
| 370 | } |
| 371 | slices.SortStableFunc(o.Order, func(left, right string) int { |
| 372 | l, lok := rank[left] |
| 373 | r, rok := rank[right] |
| 374 | switch { |
| 375 | case lok && rok: |
| 376 | return l - r |
| 377 | case lok: |
| 378 | return -1 |
| 379 | case rok: |
| 380 | return 1 |
| 381 | } |
| 382 | return 0 |
| 383 | }) |
| 384 | } |
| 385 | |
| 386 | // Run inside the registry transaction: resolution precedes the write lock, so |
| 387 | // source adoption and lifecycle changes must be checked again at commit time. |
| 388 | func applyResolvedOrganizationMutation(state *workspacestate.State, workspaceID string, o *workspacestate.Organization, resolved []SessionTarget, mutation SessionOrganizationMutation, key, anchor string) error { |
| 389 | for _, target := range resolved { |
| 390 | if target.SessionRef.SessionID == "" { |
| 391 | if target.Source == nil || target.Source.SourceKey == "" { |
| 392 | return workspacestate.ErrMutationConflict |
| 393 | } |
| 394 | if _, adopted, err := state.ResolveSource(target.Source.SourceKey); adopted || err != nil { |
| 395 | return workspacestate.ErrMutationConflict |
| 396 | } |
| 397 | continue |
| 398 | } |
| 399 | current := state.SessionStates[target.SessionRef.SessionID] |
| 400 | if current.Lifecycle != workspacestate.Active || current.Generation != target.LifecycleGeneration || !slices.Contains(state.Workspaces[workspaceID].SessionIDs, target.SessionRef.SessionID) { |
| 401 | return workspacestate.ErrMutationConflict |
| 402 | } |
| 403 | } |
| 404 | if mutation.Kind == "set-group" && !o.Imported[key] { |
| 405 | // Imported also records explicit ungrouping so later preference |
| 406 | // discovery cannot restore an older assignment. |
| 407 | o.Imported[key] = true |
| 408 | if !slices.Contains(o.Order, key) { |
| 409 | o.Order = append(o.Order, key) |
| 410 | } |
| 411 | } |
| 412 | return applyOrganizationMutation(o, mutation, key, anchor) |
| 413 | } |
| 414 | |
| 415 | // replaceSessionOrganizationGroups retains old RPC signatures while moving their |
| 416 | // persistence into the same transaction as ordering and lifecycle mutations. |
| 417 | func (a *App) replaceSessionOrganizationGroups(ctx context.Context, scope, root string, revision *uint64, groups []desktopGroup) (ProjectGroupsSnapshot, error) { |
| 418 | id, _, err := a.ensureSessionOrganizationSources(scope, root, true) |
| 419 | if err != nil { |
| 420 | return ProjectGroupsSnapshot{}, err |
| 421 | } |
| 422 | if err = validateSessionGroups(groups); err != nil { |
| 423 | return ProjectGroupsSnapshot{}, err |
| 424 | } |
| 425 | state, err := a.workspaceRegistry().LoadProjection(ctx) |
| 426 | if err != nil { |
| 427 | return ProjectGroupsSnapshot{}, err |
| 428 | } |
| 429 | legacy, err := a.unadoptedLegacyTopics(ProjectTopicPageRequest{Scope: scope, WorkspaceRoot: root, Limit: 200}, map[string]bool{}, map[string]bool{}) |
| 430 | if err != nil { |
| 431 | return ProjectGroupsSnapshot{}, err |
| 432 | } |
| 433 | nodes := append([]ProjectNode{}, legacy.Items...) |
| 434 | for _, sid := range state.Workspaces[id].SessionIDs { |
| 435 | if state.SessionStates[sid].Lifecycle != workspacestate.Active { |
| 436 | continue |
| 437 | } |
| 438 | ref := session.SessionRef{HostID: localDesktopHostID, SessionID: sid} |
| 439 | nodes = append(nodes, ProjectNode{Session: &ref, TopicID: state.Presentation[sid].TopicID}) |
| 440 | } |
| 441 | o, applied, err := a.workspaceRegistry().UpdateOrganization(ctx, id, revision, func(o *workspacestate.Organization) error { |
| 442 | next := []workspacestate.OrganizationGroup{} |
| 443 | for _, g := range groups { |
| 444 | members := []string{} |
| 445 | for _, node := range nodes { |
| 446 | key := projectNodeSessionKey(node) |
| 447 | if o.Imported[key] && desktopGroupContainsNode(g, node) && !slices.Contains(members, key) { |
| 448 | members = append(members, key) |
| 449 | } |
| 450 | } |
| 451 | for _, key := range g.SessionKeys { |
| 452 | if !o.Imported[key] { |
| 453 | return workspacestate.ErrMutationConflict |
| 454 | } |
| 455 | if !slices.Contains(members, key) { |
| 456 | members = append(members, key) |
| 457 | } |
| 458 | } |
| 459 | group := workspacestate.OrganizationGroup{ID: g.ID, Title: g.Title, Members: members} |
| 460 | for _, existing := range o.Groups { |
| 461 | if existing.ID == g.ID { |
| 462 | group = existing |
| 463 | group.Title, group.Members = g.Title, members |
| 464 | break |
| 465 | } |
| 466 | } |
| 467 | next = append(next, group) |
| 468 | } |
| 469 | o.Groups = next |
| 470 | return nil |
| 471 | }) |
| 472 | if err == nil && applied { |
| 473 | a.emitProjectTreeMetadataChanged() |
| 474 | } |
| 475 | s := organizationSnapshot(o, applied) |
| 476 | return ProjectGroupsSnapshot{Groups: s.Groups, Revision: s.Revision, Applied: applied}, err |
| 477 | } |
| 478 | |
| 479 | func applyOrganizationMutation(o *workspacestate.Organization, mutation SessionOrganizationMutation, key, anchor string) error { |
| 480 | switch mutation.Kind { |
| 481 | case "reset-order": |
| 482 | // Keep the recorded order and groups; time sorting ignores ranks while disabled. |
| 483 | o.ManualOrderEnabled = false |
| 484 | case "move": |
| 485 | if key == anchor { |
| 486 | return nil |
| 487 | } |
| 488 | if !o.Imported[key] || !o.Imported[anchor] { |
| 489 | return workspacestate.ErrMutationConflict |
| 490 | } |
| 491 | if mutation.Position != "before" && mutation.Position != "after" { |
| 492 | return fmt.Errorf("invalid position") |
| 493 | } |
| 494 | o.Order = slices.DeleteFunc(o.Order, func(v string) bool { return v == key }) |
| 495 | i := slices.Index(o.Order, anchor) |
| 496 | if i < 0 { |
| 497 | return workspacestate.ErrMutationConflict |
| 498 | } |
| 499 | if mutation.Position == "after" { |
| 500 | i++ |
| 501 | } |
| 502 | o.Order = slices.Insert(o.Order, i, key) |
| 503 | o.ManualOrderEnabled = true |
| 504 | case "set-group": |
| 505 | if !o.Imported[key] { |
| 506 | return workspacestate.ErrMutationConflict |
| 507 | } |
| 508 | found := mutation.GroupID == "" |
| 509 | for _, g := range o.Groups { |
| 510 | found = found || g.ID == mutation.GroupID |
| 511 | } |
| 512 | if !found { |
| 513 | return workspacestate.ErrMutationConflict |
| 514 | } |
| 515 | for i := range o.Groups { |
| 516 | o.Groups[i].Members = slices.DeleteFunc(o.Groups[i].Members, func(v string) bool { return v == key }) |
| 517 | if o.Groups[i].ID == mutation.GroupID { |
| 518 | o.Groups[i].Members = append(o.Groups[i].Members, key) |
| 519 | } |
| 520 | } |
| 521 | case "create-group": |
| 522 | if len(o.Groups) >= maxSessionGroups { |
| 523 | return fmt.Errorf("group limit exceeded") |
| 524 | } |
| 525 | if err := validateSessionGroups([]desktopGroup{{ID: mutation.GroupID, Title: mutation.Title}}); err != nil { |
| 526 | return err |
| 527 | } |
| 528 | if strings.TrimSpace(mutation.GroupID) == "" || strings.TrimSpace(mutation.Title) == "" { |
| 529 | return fmt.Errorf("group id and title required") |
| 530 | } |
| 531 | for _, g := range o.Groups { |
| 532 | if g.ID == mutation.GroupID { |
| 533 | return workspacestate.ErrMutationConflict |
| 534 | } |
| 535 | } |
| 536 | o.Groups = append(o.Groups, workspacestate.OrganizationGroup{ID: mutation.GroupID, Title: strings.TrimSpace(mutation.Title), Members: []string{}}) |
| 537 | case "rename-group", "delete-group": |
| 538 | if mutation.Kind == "rename-group" { |
| 539 | if err := validateSessionGroups([]desktopGroup{{ID: mutation.GroupID, Title: mutation.Title}}); err != nil { |
| 540 | return err |
| 541 | } |
| 542 | } |
| 543 | index := slices.IndexFunc(o.Groups, func(g workspacestate.OrganizationGroup) bool { return g.ID == mutation.GroupID }) |
| 544 | if index < 0 { |
| 545 | return workspacestate.ErrMutationConflict |
| 546 | } |
| 547 | if mutation.Kind == "delete-group" { |
| 548 | o.Groups = slices.Delete(o.Groups, index, index+1) |
| 549 | } else { |
| 550 | if strings.TrimSpace(mutation.Title) == "" { |
| 551 | return fmt.Errorf("title required") |
| 552 | } |
| 553 | o.Groups[index].Title = strings.TrimSpace(mutation.Title) |
| 554 | } |
| 555 | default: |
| 556 | return fmt.Errorf("unsupported organization mutation") |
| 557 | } |
| 558 | return nil |
| 559 | } |
| 560 | |
| 561 | func importOrganizationMembers(o *workspacestate.Organization, nodes []ProjectNode, groups []desktopGroup, canonicalByAlias map[string]string) { |
| 562 | ordered := make(map[string]bool, len(o.Order)+len(nodes)) |
| 563 | for _, key := range o.Order { |
| 564 | ordered[key] = true |
| 565 | } |
| 566 | for _, n := range nodes { |
| 567 | if n.Session == nil && n.SessionPath == "" { |
| 568 | continue |
| 569 | } |
| 570 | key := projectNodeSessionKey(n) |
| 571 | if _, adopted := canonicalByAlias[key]; adopted { |
| 572 | continue |
| 573 | } |
| 574 | if o.Imported[key] { |
| 575 | continue |
| 576 | } |
| 577 | for _, old := range groups { |
| 578 | included := desktopGroupContainsNode(old, n) |
| 579 | for _, alias := range n.IdentityAliases { |
| 580 | if slices.Contains(old.ExcludedSessionKeys, alias) { |
| 581 | included = false |
| 582 | break |
| 583 | } |
| 584 | if slices.Contains(old.SessionKeys, alias) { |
| 585 | included = true |
| 586 | } |
| 587 | } |
| 588 | if included { |
| 589 | for i := range o.Groups { |
| 590 | if o.Groups[i].ID == old.ID { |
| 591 | o.Groups[i].Members = append(o.Groups[i].Members, key) |
| 592 | break |
| 593 | } |
| 594 | } |
| 595 | } |
| 596 | } |
| 597 | if !ordered[key] { |
| 598 | o.Order = append(o.Order, key) |
| 599 | ordered[key] = true |
| 600 | } |
| 601 | o.Imported[key] = true |
| 602 | } |
| 603 | } |
| 604 | |
| 605 | func importOrganizationOrder(o *workspacestate.Organization, nodes []ProjectNode, order, topicOrder []string, canonicalByAlias map[string]string, manual bool) { |
| 606 | if manual && len(order) == 0 { |
| 607 | for _, topic := range topicOrder { |
| 608 | for _, node := range nodes { |
| 609 | if node.TopicID == topic { |
| 610 | order = append(order, projectNodeSessionKey(node)) |
| 611 | } |
| 612 | } |
| 613 | } |
| 614 | } |
| 615 | if len(order) > 0 { |
| 616 | next := []string{} |
| 617 | for _, key := range order { |
| 618 | if canonical, ok := canonicalByAlias[key]; ok { |
| 619 | key = canonical |
| 620 | } |
| 621 | if o.Imported[key] && !slices.Contains(next, key) { |
| 622 | next = append(next, key) |
| 623 | } |
| 624 | } |
| 625 | for _, key := range o.Order { |
| 626 | if !slices.Contains(next, key) { |
| 627 | next = append(next, key) |
| 628 | } |
| 629 | } |
| 630 | o.Order = next |
| 631 | } |
| 632 | } |
| 633 |