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	let RemovedWire:Vec<serde_json::Value> = Removed.iter().map(FolderToWire).collect();
55
56	dev_log!(
57		"workspaces",
58		"[LandFix:WsDelta] $deltaWorkspaceFolders +{} -{} (first added={})",
59		AddedWire.len(),
60		RemovedWire.len(),
61		Added.first().map(|F| F.URI.as_str()).unwrap_or("<none>")
62	);
63
64	let Payload = json!({
65		"added": AddedWire,
66		"removed": RemovedWire,
67	});
68
69	if let Err(Error) =
70		Client::SendNotification::Fn("cocoon-main".to_string(), "$deltaWorkspaceFolders".to_string(), Payload).await
71	{
72		dev_log!(
73			"workspaces",
74			"warn: [LandFix:WsDelta] $deltaWorkspaceFolders notification failed: {}",
75			Error
76		);
77	}
78}
79
80/// Convenience wrapper: update the state and fire the delta in one call.
81///
82/// Spawns the notification on the current tokio runtime so callers in sync
83/// contexts (Tauri command handlers, boot-time seeding) don't have to build an
84/// async scope just to reach Cocoon. If no runtime is available (very early
85/// boot, unit tests), the notification is dropped - the state still mutates.
86pub fn UpdateWorkspaceFoldersAndNotify(
87	State:&crate::ApplicationState::State::WorkspaceState::WorkspaceState::State,
88	Folders:Vec<WorkspaceFolderStateDTO>,
89) {
90	let (Added, Removed) = State.SetWorkspaceFoldersReturnDelta(Folders);
91	if Added.is_empty() && Removed.is_empty() {
92		return;
93	}
94	if let Ok(Handle) = tokio::runtime::Handle::try_current() {
95		Handle.spawn(async move {
96			DispatchDeltaWorkspaceFolders(Added, Removed).await;
97		});
98	} else {
99		dev_log!(
100			"workspaces",
101			"warn: [LandFix:WsDelta] No tokio runtime available - delta dropped ({} added, {} removed)",
102			Added.len(),
103			Removed.len()
104		);
105	}
106}
107
108/// Variant that additionally emits a `sky://workspaces/changed` Tauri event
109/// so Wind/Sky can update their own caches (recent-folders list, sidebar
110/// breadcrumb) without polling `workspaces:getFolders`. Preferred call site
111/// whenever the caller already has an `AppHandle` in scope.
112pub fn UpdateWorkspaceFoldersAndBroadcast<R:tauri::Runtime>(
113	ApplicationHandle:&tauri::AppHandle<R>,
114	State:&crate::ApplicationState::State::WorkspaceState::WorkspaceState::State,
115	Folders:Vec<WorkspaceFolderStateDTO>,
116) {
117	// `tauri::Emitter` was previously imported here because the body
118	// called `.emit(...)` directly. Now routed through `LogSkyEmit`
119	// (which imports `Emitter` itself), so the local import would be
120	// dead code - removed to keep the file warning-clean.
121	let (Added, Removed) = State.SetWorkspaceFoldersReturnDelta(Folders);
122	if Added.is_empty() && Removed.is_empty() {
123		return;
124	}
125	let AddedWire:Vec<serde_json::Value> = Added.iter().map(FolderToWire).collect();
126	let RemovedWire:Vec<serde_json::Value> = Removed.iter().map(FolderToWire).collect();
127	let BroadcastPayload = serde_json::json!({
128		"added": AddedWire.clone(),
129		"removed": RemovedWire.clone(),
130		"folders": State
131			.GetWorkspaceFolders()
132			.iter()
133			.map(FolderToWire)
134			.collect::<Vec<_>>(),
135	});
136	if let Err(Error) = LogSkyEmit(ApplicationHandle, SkyEvent::WorkspacesChanged.AsStr(), BroadcastPayload) {
137		dev_log!(
138			"workspaces",
139			"warn: [LandFix:WsDelta] sky://workspaces/changed emit failed: {}",
140			Error
141		);
142	}
143
144	// Persist the additions into the recently-opened list so the next boot's
145	// File → Open Recent menu and the Welcome screen can surface them.
146	// Mirrors VS Code's `ElectronMainWorkspacesMainService` behaviour.
147	PersistRecentlyOpened(&Added);
148
149	if let Ok(Handle) = tokio::runtime::Handle::try_current() {
150		Handle.spawn(async move {
151			DispatchDeltaWorkspaceFolders(Added, Removed).await;
152		});
153	}
154}
155
156/// Append every folder in `Added` to `~/.land/workspaces/RecentlyOpened.json`,
157/// deduping by URI and capping at 50 entries (the VS Code default). Swallows
158/// every error - a failed write must not prevent the workspace change.
159fn PersistRecentlyOpened(Added:&[WorkspaceFolderStateDTO]) {
160	if Added.is_empty() {
161		return;
162	}
163	let Home = std::env::var("HOME")
164		.or_else(|_| std::env::var("USERPROFILE"))
165		.unwrap_or_default();
166	if Home.is_empty() {
167		return;
168	}
169	let Path = std::path::PathBuf::from(Home)
170		.join(".land")
171		.join("workspaces")
172		.join("RecentlyOpened.json");
173	let mut Current:serde_json::Map<String, serde_json::Value> = std::fs::read_to_string(&Path)
174		.ok()
175		.and_then(|Contents| serde_json::from_str::<serde_json::Value>(&Contents).ok())
176		.and_then(|V| V.as_object().cloned())
177		.unwrap_or_default();
178	let mut Workspaces = Current
179		.get("workspaces")
180		.and_then(|V| V.as_array())
181		.cloned()
182		.unwrap_or_default();
183	for Folder in Added {
184		let Uri = Folder.URI.to_string();
185		Workspaces.retain(|Entry| Entry.get("uri").and_then(|V| V.as_str()).unwrap_or("") != Uri);
186		Workspaces.insert(
187			0,
188			serde_json::json!({
189				"uri": Uri,
190				"label": Folder.GetDisplayName(),
191			}),
192		);
193	}
194	Workspaces.truncate(50);
195	Current.insert("workspaces".into(), serde_json::Value::Array(Workspaces));
196	if !Current.contains_key("files") {
197		Current.insert("files".into(), serde_json::json!([]));
198	}
199	if let Some(Parent) = Path.parent() {
200		let _ = std::fs::create_dir_all(Parent);
201	}
202	if let Ok(Serialised) = serde_json::to_vec_pretty(&serde_json::Value::Object(Current)) {
203		let _ = std::fs::write(&Path, Serialised);
204	}
205}