返回 DeepSeek-Reasonix
project_tree_organization.go
根目录 / desktop / project_tree_organization.go
1 package main
2
3 import (
4 "fmt"
5 "reasonix/desktop/internal/workspacestate"
6 "strings"
7 "unicode/utf8"
8 )
9
10 const (
11 maxSessionGroups = 100
12 maxSessionGroupIDRunes = 160
13 maxSessionGroupRunes = 120
14 maxSessionGroupTopics = 10_000
15 )
16
17 type desktopProject struct {
18 Root string `json:"root"`
19 Title string `json:"title,omitempty"`
20 Color string `json:"color,omitempty"`
21 Topics []string `json:"topics"`
22 PinnedTopics []string `json:"pinnedTopics,omitempty"`
23 ManualTopicOrder bool `json:"manualTopicOrder,omitempty"`
24 SessionOrder []string `json:"sessionOrder,omitempty"`
25 ManualSessionOrder bool `json:"manualSessionOrder,omitempty"`
26 Groups []desktopGroup `json:"groups,omitempty"`
27 GroupsRevision uint64 `json:"-"`
28 }
29
30 type desktopGroup struct {
31 ID string `json:"id"`
32 Title string `json:"title"`
33 TopicIDs []string `json:"topicIds,omitempty"`
34 SessionKeys []string `json:"sessionKeys,omitempty"`
35 ExcludedSessionKeys []string `json:"excludedSessionKeys,omitempty"`
36 }
37
38 type ProjectGroupsSnapshot struct {
39 Groups []desktopGroup `json:"groups"`
40 Revision uint64 `json:"revision"`
41 Applied bool `json:"applied"`
42 }
43
44 type desktopProjectFile struct {
45 GlobalTitle string `json:"globalTitle,omitempty"`
46 GlobalColor string `json:"globalColor,omitempty"`
47 GlobalTopics []string `json:"globalTopics,omitempty"`
48 GlobalPinnedTopics []string `json:"globalPinnedTopics,omitempty"`
49 GlobalManualTopicOrder bool `json:"globalManualTopicOrder,omitempty"`
50 GlobalSessionOrder []string `json:"globalSessionOrder,omitempty"`
51 GlobalManualSessionOrder bool `json:"globalManualSessionOrder,omitempty"`
52 GlobalGroups []desktopGroup `json:"globalGroups,omitempty"`
53 GlobalGroupsRevision uint64 `json:"-"`
54 DeletedTopics []string `json:"deletedTopics,omitempty"`
55 PinnedProjects []string `json:"pinnedProjects,omitempty"`
56 SidebarOrder []string `json:"sidebarOrder,omitempty"`
57 Projects []desktopProject `json:"projects"`
58 }
59
60 // ReorderSessions enables stable per-session manual ordering. Older topic
61 // order fields remain available for downgrade compatibility and for rows that
62 // have not yet acquired an explicit session identity.
63 func (a *App) ReorderSessions(scope, workspaceRoot string, orderedSessionKeys []string) error {
64 id, _, err := a.ensureSessionOrganization(scope, workspaceRoot)
65 if err != nil {
66 return err
67 }
68 _, _, err = a.workspaceRegistry().UpdateOrganization(a.bootContext(), id, nil, func(o *workspacestate.Organization) error {
69 if len(orderedSessionKeys) == 0 {
70 return fmt.Errorf("orderedSessionKeys is required")
71 }
72 seen := map[string]bool{}
73 for _, key := range orderedSessionKeys {
74 if key == "" || seen[key] || !o.Imported[key] {
75 return fmt.Errorf("unknown or duplicate session order key")
76 }
77 seen[key] = true
78 }
79 i := 0
80 for j, key := range o.Order {
81 if seen[key] {
82 o.Order[j] = orderedSessionKeys[i]
83 i++
84 }
85 }
86 o.ManualOrderEnabled = true
87 return nil
88 })
89 if err == nil {
90 a.emitProjectTreeMetadataChanged()
91 }
92 return err
93 }
94
95 func normalizeGroups(groups []desktopGroup) []desktopGroup {
96 out := make([]desktopGroup, 0, len(groups))
97 seenGroups := make(map[string]bool, len(groups))
98 seenTopics := make(map[string]bool)
99 seenSessions := make(map[string]bool)
100 for _, group := range groups {
101 group.ID = strings.TrimSpace(group.ID)
102 group.Title = strings.TrimSpace(group.Title)
103 if group.ID == "" || seenGroups[group.ID] {
104 continue
105 }
106 seenGroups[group.ID] = true
107 topics := make([]string, 0, len(group.TopicIDs))
108 for _, topicID := range group.TopicIDs {
109 topicID = strings.TrimSpace(topicID)
110 if topicID == "" || seenTopics[topicID] {
111 continue
112 }
113 seenTopics[topicID] = true
114 topics = append(topics, topicID)
115 }
116 group.TopicIDs = topics
117 var sessions []string
118 if len(group.SessionKeys) > 0 {
119 sessions = make([]string, 0, len(group.SessionKeys))
120 }
121 for _, key := range group.SessionKeys {
122 key = strings.TrimSpace(key)
123 if key == "" || seenSessions[key] {
124 continue
125 }
126 seenSessions[key] = true
127 sessions = append(sessions, key)
128 }
129 group.SessionKeys = sessions
130 var excluded []string
131 if len(group.ExcludedSessionKeys) > 0 {
132 excluded = make([]string, 0, len(group.ExcludedSessionKeys))
133 }
134 seenExcluded := map[string]bool{}
135 for _, key := range group.ExcludedSessionKeys {
136 key = strings.TrimSpace(key)
137 if key == "" || seenExcluded[key] {
138 continue
139 }
140 seenExcluded[key] = true
141 excluded = append(excluded, key)
142 }
143 group.ExcludedSessionKeys = excluded
144 out = append(out, group)
145 }
146 return out
147 }
148
149 func mergeDesktopGroups(left, right []desktopGroup) []desktopGroup {
150 merged := append(append([]desktopGroup(nil), left...), right...)
151 byID := make(map[string]int, len(merged))
152 out := make([]desktopGroup, 0, len(merged))
153 for _, group := range merged {
154 group.ID = strings.TrimSpace(group.ID)
155 if group.ID == "" {
156 continue
157 }
158 if index, ok := byID[group.ID]; ok {
159 if out[index].Title == "" {
160 out[index].Title = strings.TrimSpace(group.Title)
161 }
162 out[index].TopicIDs = append(out[index].TopicIDs, group.TopicIDs...)
163 out[index].SessionKeys = append(out[index].SessionKeys, group.SessionKeys...)
164 out[index].ExcludedSessionKeys = append(out[index].ExcludedSessionKeys, group.ExcludedSessionKeys...)
165 continue
166 }
167 byID[group.ID] = len(out)
168 out = append(out, group)
169 }
170 return normalizeGroups(out)
171 }
172
173 func validateSessionGroups(groups []desktopGroup) error {
174 if len(groups) > maxSessionGroups {
175 return fmt.Errorf("too many session groups: %d", len(groups))
176 }
177 seenGroups := make(map[string]bool, len(groups))
178 seenTopics := make(map[string]bool)
179 seenSessions := make(map[string]bool)
180 for _, group := range groups {
181 id, title := strings.TrimSpace(group.ID), strings.TrimSpace(group.Title)
182 if id == "" || title == "" {
183 return fmt.Errorf("session group id and title are required")
184 }
185 if utf8.RuneCountInString(id) > maxSessionGroupIDRunes || utf8.RuneCountInString(title) > maxSessionGroupRunes {
186 return fmt.Errorf("session group id or title is too long")
187 }
188 if seenGroups[id] {
189 return fmt.Errorf("duplicate session group %q", id)
190 }
191 seenGroups[id] = true
192 if len(group.TopicIDs)+len(group.SessionKeys)+len(group.ExcludedSessionKeys) > maxSessionGroupTopics {
193 return fmt.Errorf("session group %q has too many topics", id)
194 }
195 for _, topicID := range group.TopicIDs {
196 topicID = strings.TrimSpace(topicID)
197 if topicID == "" || seenTopics[topicID] {
198 return fmt.Errorf("invalid or duplicate grouped topic %q", topicID)
199 }
200 seenTopics[topicID] = true
201 }
202 for _, key := range group.SessionKeys {
203 key = strings.TrimSpace(key)
204 if key == "" || seenSessions[key] {
205 return fmt.Errorf("invalid or duplicate grouped session %q", key)
206 }
207 seenSessions[key] = true
208 }
209 seenExcluded := map[string]bool{}
210 for _, key := range group.ExcludedSessionKeys {
211 key = strings.TrimSpace(key)
212 if key == "" || seenExcluded[key] {
213 return fmt.Errorf("invalid or duplicate excluded session %q", key)
214 }
215 seenExcluded[key] = true
216 }
217 }
218 return nil
219 }
220
221 func completeTopicOrder(orderedTopicIDs, previous []string) ([]string, error) {
222 available := make(map[string]bool, len(previous))
223 for _, id := range previous {
224 available[id] = true
225 }
226 seen := make(map[string]bool, len(orderedTopicIDs))
227 ordered := make([]string, 0, len(orderedTopicIDs))
228 for _, id := range orderedTopicIDs {
229 id = strings.TrimSpace(id)
230 if id == "" || !available[id] {
231 return nil, fmt.Errorf("unknown topic %q", id)
232 }
233 if seen[id] {
234 return nil, fmt.Errorf("duplicate topic %q", id)
235 }
236 seen[id] = true
237 ordered = append(ordered, id)
238 }
239 // A paged sidebar only sends the rows it has loaded. Replace those rows in
240 // their existing slots so omitted topics keep both their relative order and
241 // their position among the visible subset.
242 next := append([]string(nil), previous...)
243 orderedIndex := 0
244 for index, id := range previous {
245 if seen[id] {
246 next[index] = ordered[orderedIndex]
247 orderedIndex++
248 }
249 }
250 return next, nil
251 }
252
253 func normalizeOrganizationTarget(scope, workspaceRoot string) (string, string, error) {
254 scope = strings.TrimSpace(scope)
255 if scope == "global" {
256 return scope, "", nil
257 }
258 if scope != "project" {
259 return "", "", fmt.Errorf("unsupported scope %q", scope)
260 }
261 workspaceRoot = normalizeProjectRoot(workspaceRoot)
262 if workspaceRoot == "" {
263 return "", "", fmt.Errorf("workspaceRoot is required for project scope")
264 }
265 return scope, workspaceRoot, nil
266 }
267
268 // ReorderTopics persists a manual order without dropping topics omitted by a
269 // partially loaded client. The manual flag preserves activity sorting for
270 // existing users until they explicitly drag a topic.
271 func (a *App) ReorderTopics(scope, workspaceRoot string, orderedTopicIDs []string) error {
272 scope, workspaceRoot, err := normalizeOrganizationTarget(scope, workspaceRoot)
273 if err != nil {
274 return err
275 }
276 if len(orderedTopicIDs) == 0 {
277 return fmt.Errorf("orderedTopicIDs is required")
278 }
279 if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
280 if scope == "global" {
281 next, orderErr := completeTopicOrder(orderedTopicIDs, f.GlobalTopics)
282 if orderErr != nil {
283 return false, orderErr
284 }
285 changed := !sameStringList(next, f.GlobalTopics) || !f.GlobalManualTopicOrder
286 f.GlobalTopics, f.GlobalManualTopicOrder = next, true
287 return changed, nil
288 }
289 i := projectIndexByRoot(f.Projects, workspaceRoot)
290 if i < 0 {
291 return false, fmt.Errorf("project %q not found", workspaceRoot)
292 }
293 next, orderErr := completeTopicOrder(orderedTopicIDs, f.Projects[i].Topics)
294 if orderErr != nil {
295 return false, orderErr
296 }
297 changed := !sameStringList(next, f.Projects[i].Topics) || !f.Projects[i].ManualTopicOrder
298 f.Projects[i].Topics, f.Projects[i].ManualTopicOrder = next, true
299 return changed, nil
300 }); err != nil {
301 return err
302 }
303 a.emitProjectTreeMetadataChanged()
304 return nil
305 }
306
307 func (a *App) ListProjectGroups(scope, workspaceRoot string) ([]desktopGroup, error) {
308 snapshot, err := a.GetSessionOrganization(SessionOrganizationWorkspace{Scope: scope, WorkspaceRoot: workspaceRoot})
309 return snapshot.Groups, err
310 }
311
312 func nonNilGroups(groups []desktopGroup) []desktopGroup {
313 groups = normalizeGroups(groups)
314 if groups == nil {
315 return []desktopGroup{}
316 }
317 return groups
318 }
319
320 // GetProjectGroups is the versioned organization read used by current
321 // frontends. ListProjectGroups remains for old binaries during the transition.
322 func (a *App) GetProjectGroups(scope, workspaceRoot string) (ProjectGroupsSnapshot, error) {
323 snapshot, err := a.GetSessionOrganization(SessionOrganizationWorkspace{Scope: scope, WorkspaceRoot: workspaceRoot})
324 return ProjectGroupsSnapshot{Groups: snapshot.Groups, Revision: snapshot.Revision, Applied: snapshot.Applied}, err
325 }
326
327 func (a *App) SaveSessionGroups(scope, workspaceRoot string, groups []desktopGroup) error {
328 _, err := a.replaceSessionOrganizationGroups(a.bootContext(), scope, workspaceRoot, nil, groups)
329 return err
330 }
331
332 // SaveSessionGroupsVersioned is a compare-and-swap over one workspace. It
333 // prevents two windows (and archive cleanup) from overwriting each other's
334 // full group snapshots. On conflict the current state is returned so the
335 // frontend can reapply its semantic mutation and retry.
336 func (a *App) SaveSessionGroupsVersioned(scope, workspaceRoot string, expectedRevision uint64, groups []desktopGroup) (ProjectGroupsSnapshot, error) {
337 return a.replaceSessionOrganizationGroups(a.bootContext(), scope, workspaceRoot, &expectedRevision, groups)
338 }
339
340 func groupsWithoutTopic(groups []desktopGroup, topicID string) ([]desktopGroup, bool) {
341 next := make([]desktopGroup, len(groups))
342 changed := false
343 for i, group := range groups {
344 next[i] = group
345 next[i].TopicIDs = removeString(group.TopicIDs, topicID)
346 changed = changed || !sameStringList(next[i].TopicIDs, group.TopicIDs)
347 }
348 return next, changed
349 }
350
350 lines GO