Skip to main content

Mountain/ApplicationState/State/WorkspaceState/
WorkspaceDelta.rs

1//! # WorkspaceDelta
2//!
3//! Dispatches `$deltaWorkspaceFolders` notifications from Mountain to Cocoon
4//! whenever the open workspace folder set mutates. Called by every site that
5//! flips the folder list (boot-time seed, the `MountainWorkspaceOpen*`
6//! commands, pick-folder navigation, Wind add/remove, and the Cocoon-driven
7//! `$updateWorkspaceFolders` request).
8//!
9//! The delta is computed by
10//! [`WorkspaceState::SetWorkspaceFoldersReturnDelta`] and shipped as a
11//! fire-and-forget Vine notification: Cocoon's `NotificationHandler` converts
12//! it into a `didChangeWorkspaceFolders` event on
13//! `WorkspaceEventEmitter`, which powers every extension's
14//! `vscode.workspace.onDidChangeWorkspaceFolders` subscription. The same
15//! payload primes the local workspace snapshot in `WorkspaceNamespace` so
16//! `vscode.workspace.workspaceFolders` returns the fresh list on subsequent
17//! synchronous reads.
18
19use CommonLibrary::IPC::SkyEvent::SkyEvent;
20use serde_json::json;
21
22use crate::{
23	ApplicationState::DTO::WorkspaceFolderStateDTO::WorkspaceFolderStateDTO,
24	IPC::SkyEmit::LogSkyEmit,
25	Vine::Client,
26	dev_log,
27};
28
29/// Serialisation shape matching the Cocoon-side Workspace shim. Mirrors the
30/// camelCase DTO Sky already serialises for `workspaces:getFolders`, so the
31/// Cocoon handler can pass the payload through to extension listeners without
32/// renaming fields.
33fn FolderToWire(Folder:&WorkspaceFolderStateDTO) -> serde_json::Value {
34	json!({
35		"uri": Folder.URI.to_string(),
36		"name": Folder.GetDisplayName(),
37		"index": Folder.Index,
38	})
39}
40
41/// Dispatch `$deltaWorkspaceFolders` to Cocoon. Returns immediately if both
42/// arrays are empty - no point waking the sidecar for a no-op mutation.
43///
44/// Errors are logged and swallowed: the workspace state is already updated by
45/// the caller, so a failed notification should not roll the mutation back. The
46/// log tag `[LandFix:WsDelta]` keeps the event grep-able in dev logs and is
47/// deliberately consistent with `[LandFix:WsNs]` on the Cocoon side.
48pub async fn DispatchDeltaWorkspaceFolders(Added:Vec<WorkspaceFolderStateDTO>, Removed:Vec<WorkspaceFolderStateDTO>) {
49	if Added.is_empty() && Removed.is_empty() {
50		return;
51	}
52
53	let AddedWire:Vec<serde_json::Value> = Added.iter().map(FolderToWire).collect();
54
55	let RemovedWire:Vec<serde_json::Value> = Removed.iter().map(FolderToWire).collect();
56
57	dev_log!(
58		"workspaces",
59		"[LandFix:WsDelta] $deltaWorkspaceFolders +{} -{} (first added={})",
60		AddedWire.len(),
61		RemovedWire.len(),
62		Added.first().map(|F| F.URI.as_str()).unwrap_or("<none>")
63	);
64
65	let Payload = json!({
66		"added": AddedWire,
67		"removed": RemovedWire,
68	});
69
70	if let Err(Error) =
71		Client::SendNotification::Fn("cocoon-main".to_string(), "$deltaWorkspaceFolders".to_string(), Payload).await
72	{
73		dev_log!(
74			"workspaces",
75			"warn: [LandFix:WsDelta] $deltaWorkspaceFolders notification failed: {}",
76			Error
77		);
78	}
79}
80
81/// Convenience wrapper: update the state and fire the delta in one call.
82///
83/// Spawns the notification on the current tokio runtime so callers in sync
84/// contexts (Tauri command handlers, boot-time seeding) don't have to build an
85/// async scope just to reach Cocoon. If no runtime is available (very early
86/// boot, unit tests), the notification is dropped - the state still mutates.
87pub fn UpdateWorkspaceFoldersAndNotify(
88	State:&crate::ApplicationState::State::WorkspaceState::WorkspaceState::State,
89
90	Folders:Vec<WorkspaceFolderStateDTO>,
91) {
92	let (Added, Removed) = State.SetWorkspaceFoldersReturnDelta(Folders);
93
94	if Added.is_empty() && Removed.is_empty() {
95		return;
96	}
97
98	if let Ok(Handle) = tokio::runtime::Handle::try_current() {
99		Handle.spawn(async move {
100			DispatchDeltaWorkspaceFolders(Added, Removed).await;
101		});
102	} else {
103		dev_log!(
104			"workspaces",
105			"warn: [LandFix:WsDelta] No tokio runtime available - delta dropped ({} added, {} removed)",
106			Added.len(),
107			Removed.len()
108		);
109	}
110}
111
112/// Variant that additionally emits a `sky://workspaces/changed` Tauri event
113/// so Wind/Sky can update their own caches (recent-folders list, sidebar
114/// breadcrumb) without polling `workspaces:getFolders`. Preferred call site
115/// whenever the caller already has an `AppHandle` in scope.
116pub fn UpdateWorkspaceFoldersAndBroadcast<R:tauri::Runtime>(
117	ApplicationHandle:&tauri::AppHandle<R>,
118
119	State:&crate::ApplicationState::State::WorkspaceState::WorkspaceState::State,
120
121	Folders:Vec<WorkspaceFolderStateDTO>,
122) {
123	// `tauri::Emitter` was previously imported here because the body
124	// called `.emit(...)` directly. Now routed through `LogSkyEmit`
125	// (which imports `Emitter` itself), so the local import would be
126	// dead code - removed to keep the file warning-clean.
127	let (Added, Removed) = State.SetWorkspaceFoldersReturnDelta(Folders);
128
129	if Added.is_empty() && Removed.is_empty() {
130		return;
131	}
132
133	let AddedWire:Vec<serde_json::Value> = Added.iter().map(FolderToWire).collect();
134
135	let RemovedWire:Vec<serde_json::Value> = Removed.iter().map(FolderToWire).collect();
136
137	let BroadcastPayload = serde_json::json!({
138		"added": AddedWire.clone(),
139		"removed": RemovedWire.clone(),
140		"folders": State
141			.GetWorkspaceFolders()
142			.iter()
143			.map(FolderToWire)
144			.collect::<Vec<_>>(),
145	});
146
147	if let Err(Error) = LogSkyEmit(ApplicationHandle, SkyEvent::WorkspacesChanged.AsStr(), BroadcastPayload) {
148		dev_log!(
149			"workspaces",
150			"warn: [LandFix:WsDelta] sky://workspaces/changed emit failed: {}",
151			Error
152		);
153	}
154
155	// Persist the additions into the recently-opened list so the next boot's
156	// File → Open Recent menu and the Welcome screen can surface them.
157	// Mirrors VS Code's `ElectronMainWorkspacesMainService` behaviour.
158	PersistRecentlyOpened(&Added);
159
160	if let Ok(Handle) = tokio::runtime::Handle::try_current() {
161		Handle.spawn(async move {
162			DispatchDeltaWorkspaceFolders(Added, Removed).await;
163		});
164	}
165}
166
167/// Append every folder in `Added` to `~/.land/workspaces/RecentlyOpened.json`,
168/// deduping by URI and capping at 50 entries (the VS Code default). Swallows
169/// every error - a failed write must not prevent the workspace change.
170fn PersistRecentlyOpened(Added:&[WorkspaceFolderStateDTO]) {
171	if Added.is_empty() {
172		return;
173	}
174
175	let Home = std::env::var("HOME")
176		.or_else(|_| std::env::var("USERPROFILE"))
177		.unwrap_or_default();
178
179	if Home.is_empty() {
180		return;
181	}
182
183	let Path = std::path::PathBuf::from(Home)
184		.join(".land")
185		.join("workspaces")
186		.join("RecentlyOpened.json");
187
188	let mut Current:serde_json::Map<String, serde_json::Value> = std::fs::read_to_string(&Path)
189		.ok()
190		.and_then(|Contents| serde_json::from_str::<serde_json::Value>(&Contents).ok())
191		.and_then(|V| V.as_object().cloned())
192		.unwrap_or_default();
193
194	let mut Workspaces = Current
195		.get("workspaces")
196		.and_then(|V| V.as_array())
197		.cloned()
198		.unwrap_or_default();
199
200	for Folder in Added {
201		let Uri = Folder.URI.to_string();
202
203		Workspaces.retain(|Entry| Entry.get("uri").and_then(|V| V.as_str()).unwrap_or("") != Uri);
204
205		Workspaces.insert(
206			0,
207			serde_json::json!({
208				"uri": Uri,
209				"label": Folder.GetDisplayName(),
210			}),
211		);
212	}
213
214	Workspaces.truncate(50);
215
216	Current.insert("workspaces".into(), serde_json::Value::Array(Workspaces));
217
218	if !Current.contains_key("files") {
219		Current.insert("files".into(), serde_json::json!([]));
220	}
221
222	if let Some(Parent) = Path.parent() {
223		let _ = std::fs::create_dir_all(Parent);
224	}
225
226	if let Ok(Serialised) = serde_json::to_vec_pretty(&serde_json::Value::Object(Current)) {
227		let _ = std::fs::write(&Path, Serialised);
228	}
229}