Mountain/Vine/Server/Notification/RegisterScmProvider.rs
1//! Cocoon → Mountain `register_scm_provider` notification.
2//!
3//! Replaces the previous behaviour where this wire-method fell through
4//! the language-providers OR-block in `MountainVinegRPCService.rs` and
5//! got registered as a `ProviderType::SourceControl` *language* provider
6//! (wrong - the SCM viewlet binds to `ApplicationState::SourceControl`,
7//! not the language-feature provider registry, so the panel stayed
8//! empty even though `vscode.scm.createSourceControl(...)` succeeded
9//! inside Cocoon).
10//!
11//! Cocoon emits this from `ScmNamespace.ts:14` with payload shape:
12//!
13//! ```ignore
14//! { handle: u32, id, label, root_uri, extension_id }
15//! ```
16//!
17//! Three side effects happen here:
18//! 1. `ProviderRegistration::RegisterProvider` records the handle so future
19//! language-feature dispatches that look up by SCM handle (rare but
20//! possible) resolve.
21//! 2. `SourceControlManagementProvider::CreateSourceControl` mutates
22//! `ApplicationState::Feature::Markers::SourceControlManagementProviders`
23//! and emits `SkyEvent::SCMProviderAdded` - this is the canonical
24//! state-tracking path the SCM view uses.
25//! 3. A direct `sky://scm/register` Tauri emit covers any renderer path that
26//! listens for the simpler legacy event shape (gitlens, future custom SCM
27//! views).
28//!
29//! All three are best-effort and independent: the trait call may fail
30//! when `root_uri` is missing (extensions occasionally register an SCM
31//! before opening a folder); the registry write is infallible; the
32//! Sky emit is fire-and-forget.
33
34use serde_json::{Value, json};
35// `tauri::Emitter` previously imported for direct `.emit()` calls;
36// emits now route through `LogSkyEmit` which carries the trait. No
37// remaining `.emit()` callsites in this file.
38use CommonLibrary::SourceControlManagement::SourceControlManagementProvider::SourceControlManagementProvider;
39
40use crate::{
41 ApplicationState::DTO::ProviderRegistrationDTO::ProviderRegistrationDTO,
42 Vine::Server::MountainVinegRPCService::MountainVinegRPCService,
43 dev_log,
44};
45
46pub async fn RegisterScmProvider(Service:&MountainVinegRPCService, Parameter:&Value) {
47 // Wire-shape contract: producer (`Cocoon/.../ScmNamespace.ts`) emits
48 // camelCase keys (`rootUri`, `extensionId`) post 2026-04-27 wire audit.
49 // Probe camelCase first; keep snake_case as a transitional fallback so
50 // a partial rebuild (Mountain ahead of Cocoon) doesn't silently drop.
51 let ScmId = Parameter
52 .get("id")
53 .or_else(|| Parameter.get("scmId"))
54 .or_else(|| Parameter.get("scm_id"))
55 .and_then(Value::as_str)
56 .unwrap_or("")
57 .to_string();
58
59 let Label = Parameter.get("label").and_then(Value::as_str).unwrap_or(&ScmId).to_string();
60
61 let ExtensionId = Parameter
62 .get("extensionId")
63 .or_else(|| Parameter.get("extension_id"))
64 .and_then(Value::as_str)
65 .unwrap_or("")
66 .to_string();
67
68 let RootUri = Parameter
69 .get("rootUri")
70 .or_else(|| Parameter.get("root_uri"))
71 .cloned()
72 .unwrap_or(Value::Null);
73
74 if ScmId.is_empty() {
75 dev_log!("provider-register", "[ProviderRegister] scm skip: missing scm_id");
76
77 return;
78 }
79
80 // Cocoon's `ScmNamespace.ts` uses a process-local sequential
81 // `NextProviderHandle()` and includes that handle on the wire
82 // payload. Subsequent `register_scm_resource_group`,
83 // `update_scm_group`, and `unregister_scm_provider` notifications
84 // reference the SAME sequential handle as `scm_handle`, so we must
85 // preserve it here verbatim - otherwise the registry write below
86 // keys under DJB-hash-of-id and the resource-group/update path
87 // keys under Cocoon's sequential, and the SCM viewlet sees a
88 // provider with no groups regardless of how many resources arrive.
89 //
90 // Fall back to the DJB hash only when Cocoon (or a third-party
91 // caller) omits the field, so this keeps working with the legacy
92 // shape without forcing a Cocoon upgrade.
93 let Handle = Parameter
94 .get("handle")
95 .or_else(|| Parameter.get("scmHandle"))
96 .or_else(|| Parameter.get("scm_handle"))
97 .and_then(Value::as_u64)
98 .map(|H| H as u32)
99 .unwrap_or_else(|| {
100 ScmId
101 .as_bytes()
102 .iter()
103 .fold(0u32, |Acc, B| Acc.wrapping_mul(31).wrapping_add(*B as u32))
104 });
105
106 use CommonLibrary::LanguageFeature::DTO::ProviderType::ProviderType;
107
108 let RegistrationDto = ProviderRegistrationDTO {
109 Handle,
110
111 ProviderType:ProviderType::SourceControl,
112
113 Selector:json!([{ "scmId": &ScmId }]),
114
115 SideCarIdentifier:"cocoon-main".to_string(),
116
117 ExtensionIdentifier:json!(&ExtensionId),
118
119 Options:Some(json!({ "scmId": &ScmId, "label": &Label })),
120 };
121
122 Service
123 .RunTime()
124 .Environment
125 .ApplicationState
126 .Extension
127 .ProviderRegistration
128 .RegisterProvider(Handle, RegistrationDto);
129
130 // Trait wiring populates `ApplicationState::Feature::Markers`
131 // + emits the typed `SkyEvent::SCMProviderAdded`. RootUri is
132 // expected to be a parseable URL string; when extensions pass null
133 // (rare - usually a workspace folder URI) we substitute the empty
134 // `file:///` so the trait still records the provider.
135 //
136 // vscode.git's `repository.ts:983` calls `Uri.file(repository.root)`
137 // which serialises to a UriComponents object: `{scheme:"file",
138 // authority:"", path:"/Volumes/...", query:"", fragment:""}`. The
139 // previous extractor read `O.get("path")` which is the **path
140 // component only** (no scheme prefix) and passed it through to
141 // `URLSerializationHelper`'s `Url::parse(...)`, which fails with
142 // "relative URL without a base" because `/Volumes/...` has no
143 // scheme. Rebuild a proper `<scheme>://<authority><path>` triple
144 // from the components first; only fall back to `external` (already
145 // a string URL) or `path` if the triple can't be assembled.
146 let BuildUrlFromComponents = |O:&serde_json::Map<String, Value>| -> Option<String> {
147 let Scheme = O.get("scheme").and_then(Value::as_str)?;
148
149 if Scheme.is_empty() {
150 return None;
151 }
152
153 let Authority = O.get("authority").and_then(Value::as_str).unwrap_or("");
154
155 let Path = O.get("path").and_then(Value::as_str).unwrap_or("");
156
157 let Query = O.get("query").and_then(Value::as_str).unwrap_or("");
158
159 let Fragment = O.get("fragment").and_then(Value::as_str).unwrap_or("");
160
161 let mut Url = format!("{}://{}{}", Scheme, Authority, Path);
162
163 if !Query.is_empty() {
164 Url.push('?');
165
166 Url.push_str(Query);
167 }
168
169 if !Fragment.is_empty() {
170 Url.push('#');
171
172 Url.push_str(Fragment);
173 }
174
175 Some(Url)
176 };
177
178 let RootUriString = match &RootUri {
179 Value::String(S) => S.clone(),
180
181 Value::Object(O) => {
182 BuildUrlFromComponents(O)
183 .or_else(|| O.get("external").and_then(Value::as_str).map(str::to_string))
184 .or_else(|| {
185 // Last-resort: prepend file:// to a bare path so
186 // URLSerializationHelper at least gets a parseable
187 // scheme. Never silently emit a relative URL.
188 O.get("path")
189 .and_then(Value::as_str)
190 .filter(|P| P.starts_with('/'))
191 .map(|P| format!("file://{}", P))
192 })
193 .unwrap_or_else(|| "file:///".to_string())
194 },
195
196 _ => "file:///".to_string(),
197 };
198
199 // Field names must match `SourceControlCreateDTO`'s camelCase wire
200 // shape (post-DTO-audit): `id`, `label`, `rootUri`. Earlier revisions
201 // passed PascalCase keys here and the trait silently failed with
202 // `missing field "id"` because the DTO's serde rename uses camelCase.
203 //
204 // `handle` is the Cocoon-allocated sequential provider handle (read
205 // above from the Parameter). Including it on the wire makes
206 // `MountainEnvironment::CreateSourceControl` key its marker maps
207 // under the SAME handle that subsequent `register_scm_resource_group`
208 // and `update_scm_group` notifications reference - without this,
209 // every group update warns "Received group update for unknown
210 // provider handle: <H>" because the marker map was keyed by a
211 // fresh Mountain-allocated handle Cocoon never sees.
212 let CreateData = json!({
213 "handle": Handle,
214 "id": &ScmId,
215 "label": &Label,
216 "rootUri": RootUriString,
217 });
218
219 if let Err(Error) = Service.RunTime().Environment.CreateSourceControl(CreateData).await {
220 dev_log!("grpc", "warn: [Scm] CreateSourceControl trait failed for {}: {}", ScmId, Error);
221 }
222
223 // Legacy listener channel kept active alongside the typed event so
224 // renderer code that hasn't migrated to the markers-backed view
225 // (gitlens-side custom panels, hand-rolled tests) still sees the
226 // register signal. Routed through `LogSkyEmit` so `sky-emit` /
227 // `grpc` dev-log tags surface delivery success/failure - the
228 // fire-and-forget path was previously invisible, making it
229 // impossible to tell whether Sky's `Register("sky://scm/register")`
230 // listener was hit when the SCM panel stayed empty.
231 if let Err(Error) = crate::IPC::SkyEmit::LogSkyEmit(
232 Service.ApplicationHandle(),
233 "sky://scm/register",
234 json!({
235 "scmId": &ScmId,
236 "label": &Label,
237 "rootUri": &RootUriString,
238 "extensionId": &ExtensionId,
239 "handle": Handle,
240 }),
241 ) {
242 dev_log!("grpc", "warn: [Scm] sky://scm/register emit failed for {}: {}", ScmId, Error);
243 }
244
245 dev_log!(
246 "grpc",
247 "[Scm] register provider scmId={} label={} ext={} handle={}",
248 ScmId,
249 Label,
250 ExtensionId,
251 Handle
252 );
253}