返回 DeepSeek-Reasonix
session_organization.go
根目录 / desktop / session_organization.go
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
633 lines GO