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
168/// `~/.fiddee/workspaces/RecentlyOpened.json`, deduping by URI and capping at
169/// 50 entries (the VS Code default). Swallows every error - a failed write
170/// must not prevent the workspace change. The dotfile root is resolved
171/// through the `FiddeeRoot` atom so a future rename touches a single file.
172fn PersistRecentlyOpened(Added:&[WorkspaceFolderStateDTO]) {
173	if Added.is_empty() {
174		return;
175	}
176
177	let Path = crate::IPC::WindServiceHandlers::Utilities::FiddeeRoot::Fn()
178		.join("workspaces")
179		.join("RecentlyOpened.json");
180
181	let mut Current:serde_json::Map<String, serde_json::Value> = std::fs::read_to_string(&Path)
182		.ok()
183		.and_then(|Contents| serde_json::from_str::<serde_json::Value>(&Contents).ok())
184		.and_then(|V| V.as_object().cloned())
185		.unwrap_or_default();
186
187	let mut Workspaces = Current
188		.get("workspaces")
189		.and_then(|V| V.as_array())
190		.cloned()
191		.unwrap_or_default();
192
193	for Folder in Added {
194		let Uri = Folder.URI.to_string();
195
196		Workspaces.retain(|Entry| Entry.get("uri").and_then(|V| V.as_str()).unwrap_or("") != Uri);
197
198		Workspaces.insert(
199			0,
200			serde_json::json!({
201				"uri": Uri,
202				"label": Folder.GetDisplayName(),
203			}),
204		);
205	}
206
207	Workspaces.truncate(50);
208
209	Current.insert("workspaces".into(), serde_json::Value::Array(Workspaces));
210
211	if !Current.contains_key("files") {
212		Current.insert("files".into(), serde_json::json!([]));
213	}
214
215	if let Some(Parent) = Path.parent() {
216		let _ = std::fs::create_dir_all(Parent);
217	}
218
219	if let Ok(Serialised) = serde_json::to_vec_pretty(&serde_json::Value::Object(Current)) {
220		let _ = std::fs::write(&Path, Serialised);
221	}
222}