返回 DeepSeek-Reasonix
remote_project_registry.go
根目录 / desktop / remote_project_registry.go
1 package main
2
3 import (
4 "fmt"
5 "path"
6 "strings"
7
8 "reasonix/internal/config"
9 "reasonix/internal/store"
10 )
11
12 // ListRemoteProjects returns every pinned remote workspace in config order.
13 func (a *App) ListRemoteProjects() ([]RemoteProjectView, error) {
14 cfg, err := config.Load()
15 if err != nil {
16 return nil, err
17 }
18 out := make([]RemoteProjectView, 0, len(cfg.Remote.Projects))
19 for _, p := range cfg.Remote.Projects {
20 out = append(out, remoteProjectEntryToView(p))
21 }
22 return out, nil
23 }
24
25 func (a *App) AddRemoteProject(hostID, workspace string) (RemoteProjectView, error) {
26 hostID = strings.TrimSpace(hostID)
27 workspace = strings.TrimSpace(workspace)
28 var view RemoteProjectView
29 err := editUserConfigIfChanged(func(c *config.Config) (bool, error) {
30 // Overlapping pins on one host collapse into the existing group.
31 // This avoids duplicate serves over the same files and returns the
32 // canonical workspace with Merged set.
33 if merged, ok := resolveOverlappingWorkspace(c.Remote.Projects, hostID, workspace); ok {
34 workspace = merged
35 stored, retained := c.RemoteProject(hostID, merged)
36 if !retained {
37 return false, fmt.Errorf("remote project was not retained")
38 }
39 view = remoteProjectEntryToView(stored)
40 view.Merged = true
41 return false, nil
42 }
43 entry := config.RemoteProjectEntry{
44 HostID: hostID,
45 Workspace: workspace,
46 }
47 if err := c.UpsertRemoteProject(entry); err != nil {
48 return false, err
49 }
50 stored, ok := c.RemoteProject(entry.HostID, entry.Workspace)
51 if !ok {
52 return false, fmt.Errorf("remote project was not retained")
53 }
54 view = remoteProjectEntryToView(stored)
55 return true, nil
56 })
57 if err != nil {
58 return RemoteProjectView{}, err
59 }
60 return view, nil
61 }
62
63 // resolveOverlappingWorkspace finds the existing pin on the same host that the
64 // requested workspace should merge into: an exact match wins, then the
65 // nearest ancestor pin, then the shallowest descendant pin. Remote paths are
66 // POSIX; "~" and unresolvable relatives simply never overlap (safe default).
67 func resolveOverlappingWorkspace(existing []config.RemoteProjectEntry, hostID, workspace string) (string, bool) {
68 target := cleanRemoteWorkspace(workspace)
69 if target == "" {
70 return "", false
71 }
72 ancestor, ancestorDepth := "", -1
73 descendant, descendantDepth := "", 1<<30
74 for _, p := range existing {
75 if p.HostID != hostID {
76 continue
77 }
78 cand := cleanRemoteWorkspace(p.Workspace)
79 if cand == "" {
80 continue
81 }
82 switch {
83 case cand == target:
84 return p.Workspace, true
85 case isRemoteSubpath(cand, target): // existing pin is an ancestor of the request
86 if d := pathDepth(cand); ancestor == "" || d > ancestorDepth {
87 ancestor, ancestorDepth = p.Workspace, d
88 }
89 case isRemoteSubpath(target, cand): // existing pin is a descendant of the request
90 if d := pathDepth(cand); descendant == "" || d < descendantDepth {
91 descendant, descendantDepth = p.Workspace, d
92 }
93 }
94 }
95 if ancestor != "" {
96 return ancestor, true
97 }
98 return descendant, descendant != ""
99 }
100
101 func cleanRemoteWorkspace(ws string) string {
102 ws = strings.TrimSpace(ws)
103 if ws == "" || ws == "~" {
104 return ws
105 }
106 return path.Clean(strings.TrimRight(ws, "/"))
107 }
108
109 // isRemoteSubpath reports parent/child nesting between two cleaned POSIX
110 // paths; equal paths are deliberately not subpaths of each other.
111 func isRemoteSubpath(parent, child string) bool {
112 if parent == "/" {
113 return strings.HasPrefix(child, "/") && child != "/"
114 }
115 return strings.HasPrefix(child, parent+"/")
116 }
117
118 func pathDepth(cleaned string) int {
119 if cleaned == "" || cleaned == "/" {
120 return 0
121 }
122 return strings.Count(cleaned, "/")
123 }
124
125 func (a *App) RemoveRemoteProject(hostID, workspace string) error {
126 return editUserConfig(func(c *config.Config) error {
127 c.RemoveRemoteProject(strings.TrimSpace(hostID), strings.TrimSpace(workspace))
128 return nil
129 })
130 }
131
132 func remoteProjectEntryToView(p config.RemoteProjectEntry) RemoteProjectView {
133 return RemoteProjectView{HostID: p.HostID, Workspace: p.Workspace, Title: p.Title}
134 }
135
136 // remoteProjectNodes lists pinned remote workspaces as project group shells
137 // for the tree snapshot. Read failures degrade to "no remote projects" at the
138 // caller — a broken config must not take the whole tree down.
139 func (a *App) remoteProjectNodes() ([]ProjectNode, error) {
140 cfg, err := config.Load()
141 if err != nil {
142 return nil, err
143 }
144 out := make([]ProjectNode, 0, len(cfg.Remote.Projects))
145 for _, p := range cfg.Remote.Projects {
146 label := strings.TrimSpace(p.Title)
147 if label == "" {
148 label = remoteWorkspaceName(p.Workspace)
149 }
150 out = append(out, ProjectNode{
151 Key: "project_remote_" + store.RemoteWorkspaceSlug(p.HostID+":"+p.Workspace),
152 Kind: "project",
153 Label: label,
154 // Root participates in tree selection and drag identity. Qualify it
155 // with the host so identical paths on two hosts never alias.
156 Root: "remote-project:" + p.HostID + ":" + p.Workspace,
157 Remote: &RemoteTabRef{HostID: p.HostID, Workspace: p.Workspace},
158 })
159 }
160 return out, nil
161 }
162
162 lines GO