1use std::cell::RefCell;
2use std::collections::{BTreeMap, BTreeSet};
3use std::fmt::Write as _;
4use std::fs;
5use std::fs::OpenOptions;
6use std::io::Write as _;
7use std::ops::Range;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use std::rc::Rc;
11
12use gpui_rhai::{
13 AppManifest, ComponentDefinition, ComponentInstancePath, ComponentMetadata, ComponentRegistry,
14 DirectoryAssetProvider, EmbeddedScriptSource, FontSource, LocaleManager, ModuleId,
15 RUNTIME_API_VERSION, RestrictedModuleResolver, RuntimeEngine, ScriptAsset, ScriptLifecycle,
16 ThemeManager, ThemeSelection, UiRuntimeState, load_component_styles, load_locale_source,
17 load_theme_source, parse_component_header, validate_font_sources,
18};
19use semver::Version;
20use serde::{Deserialize, Serialize};
21use thiserror::Error;
22use toml_edit::{Array, DocumentMut, InlineTable, Item, Value};
23
24pub mod gallery;
25#[cfg(feature = "test-support")]
26#[doc(hidden)]
27pub mod gallery_app;
28#[cfg(not(feature = "test-support"))]
29mod gallery_app;
30pub mod theme_studio;
31
32use gpui_rhai_registry::{
33 AR_LOCALE, BUNDLED_ASSET_SOURCES, BUNDLED_CHART_SOURCES_BY_ID, BUNDLED_COMPONENT_SOURCES_BY_ID,
34 BUNDLED_MOTION_SOURCES_BY_ID, BUNDLED_THEME_SOURCES, DEFAULT_THEME, EN_LOCALE, STUDIO_SOURCE,
35 ZH_CN_LOCALE,
36};
37#[cfg(test)]
38use gpui_rhai_registry::{
39 CALENDAR_SVG, CHECK_SVG, CHEVRON_DOWN_SVG, CHEVRON_LEFT_SVG, CHEVRON_RIGHT_SVG, CHEVRON_UP_SVG,
40 CLOSE_SVG, DATE_NEXT_SVG, DATE_PREVIOUS_SVG, DISCLOSURE_DOWN_SVG, ETHEREAL_THEME, HELP_SVG,
41 INFO_SVG, MINUS_SVG, PLUS_SVG, SEARCH_SVG, SORT_ASCENDING_SVG, SORT_DESCENDING_SVG,
42 WARNING_SVG,
43};
44
45#[derive(Clone, Debug)]
46struct RegistryEntry {
47 metadata: ComponentMetadata,
48 source: &'static str,
49 assets: Vec<RegistryAsset>,
50}
51
52#[derive(Clone, Debug)]
53struct RegistryAsset {
54 path: &'static str,
55 source: &'static str,
56}
57
58const DEFAULT_COMPONENT_STYLES: &str = r"// Application-wide typed component overrides.
59fn component_styles() {
60 #{}
61}
62";
63
64#[derive(Clone, Debug, Default)]
65pub struct BundledRegistry {
66 entries: BTreeMap<ModuleId, RegistryEntry>,
67}
68
69impl BundledRegistry {
70 pub fn load() -> Result<Self, ProjectError> {
77 let mut registry = Self::default();
78 let asset_sources = BUNDLED_ASSET_SOURCES
79 .iter()
80 .copied()
81 .collect::<BTreeMap<_, _>>();
82 for &(catalog_id, source) in BUNDLED_COMPONENT_SOURCES_BY_ID
83 .iter()
84 .chain(BUNDLED_MOTION_SOURCES_BY_ID)
85 .chain(BUNDLED_CHART_SOURCES_BY_ID)
86 {
87 let metadata = parse_component_header(source)?;
88 let id = metadata.id.clone();
89 if id.as_str() != catalog_id {
90 return Err(ProjectError::RegistryIdMismatch {
91 catalog: catalog_id.to_owned(),
92 metadata: id,
93 });
94 }
95 validate_component_documentation(source, &id)?;
96 let declared_assets = metadata
97 .assets
98 .iter()
99 .map(String::as_str)
100 .collect::<BTreeSet<_>>();
101 let assets = metadata
102 .assets
103 .iter()
104 .filter_map(|path| {
105 asset_sources
106 .get_key_value(path.as_str())
107 .map(|(&path, &source)| RegistryAsset { path, source })
108 })
109 .collect::<Vec<_>>();
110 let bundled_assets = assets
111 .iter()
112 .map(|asset| asset.path)
113 .collect::<BTreeSet<_>>();
114 if declared_assets != bundled_assets {
115 return Err(ProjectError::RegistryAssetMismatch {
116 component: id,
117 declared: declared_assets.into_iter().map(ToOwned::to_owned).collect(),
118 bundled: bundled_assets.into_iter().map(ToOwned::to_owned).collect(),
119 });
120 }
121 let id = metadata.id.clone();
122 if registry
123 .entries
124 .insert(
125 id.clone(),
126 RegistryEntry {
127 metadata,
128 source,
129 assets,
130 },
131 )
132 .is_some()
133 {
134 return Err(ProjectError::DuplicateRegistry(id));
135 }
136 }
137 Ok(registry)
138 }
139
140 fn resolve(&self, requested: &[String]) -> Result<Vec<ModuleId>, ProjectError> {
141 let mut ordered = Vec::new();
142 let mut complete = BTreeSet::new();
143 let mut stack = Vec::new();
144 for request in requested {
145 let normalized = if request.contains('/') {
146 request.clone()
147 } else if self
148 .entries
149 .contains_key(&ModuleId::parse(format!("charts/{request}"))?)
150 {
151 format!("charts/{request}")
152 } else {
153 format!("components/{request}")
154 };
155 let id = ModuleId::parse(normalized)?;
156 self.visit(&id, &mut stack, &mut complete, &mut ordered)?;
157 }
158 Ok(ordered)
159 }
160
161 fn visit(
162 &self,
163 id: &ModuleId,
164 stack: &mut Vec<ModuleId>,
165 complete: &mut BTreeSet<ModuleId>,
166 ordered: &mut Vec<ModuleId>,
167 ) -> Result<(), ProjectError> {
168 if complete.contains(id) {
169 return Ok(());
170 }
171 if let Some(start) = stack.iter().position(|active| active == id) {
172 let mut cycle = stack[start..].to_vec();
173 cycle.push(id.clone());
174 return Err(ProjectError::RegistryCycle(cycle));
175 }
176 let entry = self
177 .entries
178 .get(id)
179 .ok_or_else(|| ProjectError::UnknownComponent(id.clone()))?;
180 stack.push(id.clone());
181 for dependency in &entry.metadata.dependencies {
182 self.visit(dependency, stack, complete, ordered)?;
183 }
184 stack.pop();
185 complete.insert(id.clone());
186 ordered.push(id.clone());
187 Ok(())
188 }
189}
190
191#[derive(Clone, Debug)]
192pub struct Project {
193 root: PathBuf,
194}
195
196impl Project {
197 #[must_use]
198 pub fn new(root: impl Into<PathBuf>) -> Self {
199 Self { root: root.into() }
200 }
201
202 pub fn plan_init(&self) -> Result<ProjectPlan, ProjectError> {
209 let cargo_path = self.root.join("Cargo.toml");
210 let cargo_source = read(&cargo_path)?;
211 let mut cargo =
212 cargo_source
213 .parse::<DocumentMut>()
214 .map_err(|source| ProjectError::CargoToml {
215 path: cargo_path.clone(),
216 source,
217 })?;
218 if cargo
219 .get("dependencies")
220 .and_then(|dependencies| dependencies.get("gpui-rhai"))
221 .is_none()
222 {
223 let mut features = Array::new();
224 features.push("dev-reload");
225 let mut dependency = InlineTable::new();
226 dependency.insert("version", Value::from("0.1"));
227 dependency.insert("features", Value::Array(features));
228 cargo["dependencies"]["gpui-rhai"] = Item::Value(Value::InlineTable(dependency));
229 }
230
231 let mut plan = ProjectPlan::new(self.root.clone());
232 plan.update(cargo_path, cargo.to_string(), cargo_source);
233 let main_path = self.root.join("src/main.rs");
234 if main_path.exists() {
235 plan.create(self.root.join("gpui-rhai-host-snippet.rs"), host_source())?;
236 } else {
237 plan.create(main_path, host_source())?;
238 }
239 plan.create(self.root.join("ui/main.rhai"), starter_ui())?;
240 plan.create(self.root.join("ui/theme.rhai"), DEFAULT_THEME.to_owned())?;
241 plan.create(
242 self.root.join("ui/styles.rhai"),
243 DEFAULT_COMPONENT_STYLES.to_owned(),
244 )?;
245 for &(name, source) in BUNDLED_THEME_SOURCES
246 .iter()
247 .filter(|(name, _)| *name != "default_dark.rhai")
248 {
249 plan.create(self.root.join("ui/themes").join(name), source.to_owned())?;
250 }
251 plan.create(self.root.join("ui/locales/en.rhai"), EN_LOCALE.to_owned())?;
252 plan.create(
253 self.root.join("ui/locales/zh_cn.rhai"),
254 ZH_CN_LOCALE.to_owned(),
255 )?;
256 plan.create(self.root.join("ui/locales/ar.rhai"), AR_LOCALE.to_owned())?;
257 plan.create(self.root.join("ui/app.toml"), app_manifest_source()?)?;
258 plan.create(
259 self.root.join(".gpui-rhai/manifest.toml"),
260 toml::to_string_pretty(&LocalManifest::default())?,
261 )?;
262 Ok(plan)
263 }
264
265 pub fn plan_add(
272 &self,
273 registry: &BundledRegistry,
274 requested: &[String],
275 ) -> Result<ProjectPlan, ProjectError> {
276 if requested.is_empty() {
277 return Err(ProjectError::NoComponents);
278 }
279 let manifest_path = self.root.join(".gpui-rhai/manifest.toml");
280 let manifest_source = read(&manifest_path)?;
281 let mut manifest: LocalManifest = toml::from_str(&manifest_source)?;
282 let resolved = registry.resolve(requested)?;
283 let mut plan = ProjectPlan::new(self.root.clone());
284 if resolved.iter().any(|id| id.as_str().starts_with("charts/")) {
285 self.plan_enable_dependency_feature(&mut plan, "charts")?;
286 }
287 for id in resolved {
288 if manifest.components.contains_key(id.as_str()) {
289 continue;
290 }
291 let entry = registry
292 .entries
293 .get(&id)
294 .ok_or_else(|| ProjectError::UnknownComponent(id.clone()))?;
295 if !entry.metadata.runtime_api.contains(RUNTIME_API_VERSION) {
296 return Err(ProjectError::IncompatibleRuntime(id));
297 }
298 let relative = component_relative_path(&id)?;
299 plan.create(
300 self.root.join("ui").join(&relative),
301 entry.source.to_owned(),
302 )?;
303 plan.create(
304 self.root.join(".gpui-rhai/baselines").join(&relative),
305 entry.source.to_owned(),
306 )?;
307 for asset in &entry.assets {
308 plan.create(
309 self.root.join("ui/assets").join(asset.path),
310 asset.source.to_owned(),
311 )?;
312 plan.create(
313 self.root
314 .join(".gpui-rhai/baselines/assets")
315 .join(asset.path),
316 asset.source.to_owned(),
317 )?;
318 }
319 let hash = ScriptAsset::new(id.clone(), entry.source.to_owned()).content_hash;
320 manifest.components.insert(
321 id.as_str().to_owned(),
322 InstalledComponent {
323 version: entry.metadata.version.clone(),
324 content_hash: format!("{hash:016x}"),
325 dependencies: entry
326 .metadata
327 .dependencies
328 .iter()
329 .map(ToString::to_string)
330 .collect(),
331 },
332 );
333 }
334 plan.update(
335 manifest_path,
336 toml::to_string_pretty(&manifest)?,
337 manifest_source,
338 );
339 Ok(plan)
340 }
341
342 fn plan_enable_dependency_feature(
343 &self,
344 plan: &mut ProjectPlan,
345 feature: &str,
346 ) -> Result<(), ProjectError> {
347 let path = self.root.join("Cargo.toml");
348 let source = read(&path)?;
349 let mut document =
350 source
351 .parse::<DocumentMut>()
352 .map_err(|source| ProjectError::CargoToml {
353 path: path.clone(),
354 source,
355 })?;
356 let dependency = document
357 .get_mut("dependencies")
358 .and_then(Item::as_table_mut)
359 .and_then(|dependencies| dependencies.get_mut("gpui-rhai"))
360 .ok_or_else(|| ProjectError::MissingRuntimeDependency(path.clone()))?;
361 match dependency {
362 Item::Value(Value::String(version)) => {
363 let version = version.value().to_owned();
364 let mut features = Array::new();
365 features.push(feature);
366 let mut table = InlineTable::new();
367 table.insert("version", Value::from(version));
368 table.insert("features", Value::Array(features));
369 *dependency = Item::Value(Value::InlineTable(table));
370 }
371 Item::Value(Value::InlineTable(table)) => {
372 let features = table
373 .entry("features")
374 .or_insert(Value::Array(Array::new()));
375 let Value::Array(features) = features else {
376 return Err(ProjectError::InvalidRuntimeFeatures(path));
377 };
378 if !features.iter().any(|value| value.as_str() == Some(feature)) {
379 features.push(feature);
380 }
381 }
382 Item::Table(table) => {
383 if !table.contains_key("features") {
384 table.insert("features", Item::Value(Value::Array(Array::new())));
385 }
386 let features = table
387 .get_mut("features")
388 .and_then(Item::as_value_mut)
389 .and_then(Value::as_array_mut)
390 .ok_or_else(|| ProjectError::InvalidRuntimeFeatures(path.clone()))?;
391 if !features.iter().any(|value| value.as_str() == Some(feature)) {
392 features.push(feature);
393 }
394 }
395 _ => return Err(ProjectError::InvalidRuntimeDependency(path)),
396 }
397 plan.update(path, document.to_string(), source);
398 Ok(())
399 }
400
401 pub fn check(&self) -> Result<CheckReport, ProjectError> {
408 validate_gpui_dependency_family(&self.root.join("Cargo.toml"))?;
409 let manifest_path = self.root.join(".gpui-rhai/manifest.toml");
410 let manifest: LocalManifest = toml::from_str(&read(&manifest_path)?)?;
411 if manifest.runtime_api != RUNTIME_API_VERSION {
412 return Err(ProjectError::ManifestRuntime {
413 required: manifest.runtime_api,
414 actual: RUNTIME_API_VERSION,
415 });
416 }
417 let mut modules = BTreeMap::new();
418 let mut headers = BTreeMap::new();
419 for (raw_id, installed) in &manifest.components {
420 let id = ModuleId::parse(raw_id.clone())?;
421 let relative = component_relative_path(&id)?;
422 let baseline_source = read(&self.root.join(".gpui-rhai/baselines").join(&relative))?;
423 let baseline_hash = ScriptAsset::new(id.clone(), baseline_source).content_hash;
424 if format!("{baseline_hash:016x}") != installed.content_hash {
425 return Err(ProjectError::BaselineHash(id));
426 }
427 let source = read(&self.root.join("ui").join(relative))?;
428 let header = parse_component_header(&source)?;
429 validate_component_documentation(&source, &id)?;
430 if header.id != id || header.version != installed.version {
431 return Err(ProjectError::InstalledMetadata(id));
432 }
433 headers.insert(id.clone(), header);
434 modules.insert(id, source);
435 }
436
437 let app: AppManifest = toml::from_str(&read(&self.root.join("ui/app.toml"))?)?;
438 if app.entry.as_str() != "main" {
439 return Err(ProjectError::ManifestEntry(app.entry));
440 }
441 if app.runtime_api != RUNTIME_API_VERSION {
442 return Err(ProjectError::ManifestRuntime {
443 required: app.runtime_api,
444 actual: RUNTIME_API_VERSION,
445 });
446 }
447 let components = validate_component_exports(&modules, &headers)?;
448 app.validate_components(&components)?;
449 let host_validation_required = !app.capabilities.is_empty();
450 let initial_view_validated =
451 validate_entry(&self.root, &modules, &components, !host_validation_required)?;
452 let styles_path = self.root.join("ui/styles.rhai");
453 if styles_path.exists() {
454 load_component_styles(
455 RuntimeEngine::new().engine(),
456 "ui/styles.rhai",
457 &read(&styles_path)?,
458 &components,
459 )
460 .map_err(|error| ProjectError::ComponentStyle(error.to_string()))?;
461 }
462 let theme_source = read(&self.root.join("ui/theme.rhai"))?;
463 let theme_runtime = RuntimeEngine::new();
464 let primary = load_theme_source(theme_runtime.engine(), "ui/theme.rhai", &theme_source)
465 .map_err(|error| ProjectError::Theme(error.to_string()))?;
466 let mut variants = vec![primary.clone()];
467 for path in collect_paths(&self.root.join("ui/themes"))? {
468 let source = read(&path)?;
469 variants.push(
470 load_theme_source(theme_runtime.engine(), &path.to_string_lossy(), &source)
471 .map_err(|error| ProjectError::Theme(error.to_string()))?,
472 );
473 }
474 ThemeManager::from_variants(variants, ThemeSelection::new(primary.family, primary.name))
475 .map_err(|error| ProjectError::Theme(error.to_string()))?;
476 validate_locales(&self.root)?;
477 validate_embedded_assets(&self.root)?;
478 validate_fonts(&self.root)?;
479 Ok(CheckReport {
480 components: manifest.components.len(),
481 entry: app.entry,
482 host_validation_required,
483 initial_view_validated,
484 })
485 }
486
487 pub fn plan_editor_metadata(&self) -> Result<ProjectPlan, ProjectError> {
493 let manifest: LocalManifest =
494 toml::from_str(&read(&self.root.join(".gpui-rhai/manifest.toml"))?)?;
495 let mut modules = BTreeMap::new();
496 let mut headers = BTreeMap::new();
497 for raw_id in manifest.components.keys() {
498 let id = ModuleId::parse(raw_id.clone())?;
499 let source = read(&self.root.join("ui").join(component_relative_path(&id)?))?;
500 headers.insert(id.clone(), parse_component_header(&source)?);
501 modules.insert(id, source);
502 }
503 let registry = validate_component_exports(&modules, &headers)?;
504 let metadata = EditorMetadata {
505 runtime_api: RUNTIME_API_VERSION,
506 components: registry
507 .iter()
508 .map(|(_, component)| component.clone())
509 .collect(),
510 };
511 let snippets = editor_snippets(®istry);
512 let definitions = RuntimeEngine::new().definition_source();
513 let mut plan = ProjectPlan::new(self.root.clone());
514 plan.replace_or_create(
515 self.root.join(".gpui-rhai/editor/components.json"),
516 format!("{}\n", serde_json::to_string_pretty(&metadata)?),
517 )?;
518 plan.replace_or_create(
519 self.root.join(".gpui-rhai/editor/snippets.json"),
520 format!("{}\n", serde_json::to_string_pretty(&snippets)?),
521 )?;
522 plan.replace_or_create(
523 self.root
524 .join(".gpui-rhai/editor/definitions/gpui_rhai.d.rhai"),
525 definitions,
526 )?;
527 Ok(plan)
528 }
529
530 pub fn diff(&self) -> Result<Vec<String>, ProjectError> {
536 let manifest: LocalManifest =
537 toml::from_str(&read(&self.root.join(".gpui-rhai/manifest.toml"))?)?;
538 manifest
539 .components
540 .keys()
541 .map(|raw_id| {
542 let id = ModuleId::parse(raw_id.clone())?;
543 let relative = component_relative_path(&id)?;
544 let source = read(&self.root.join("ui").join(&relative))?;
545 let baseline = read(&self.root.join(".gpui-rhai/baselines").join(relative))?;
546 Ok(format!(
547 "{raw_id}: {}",
548 if source == baseline {
549 "unchanged"
550 } else {
551 "modified"
552 }
553 ))
554 })
555 .collect()
556 }
557
558 pub fn plan_update(&self, registry: &BundledRegistry) -> Result<ProjectPlan, ProjectError> {
569 let manifest_path = self.root.join(".gpui-rhai/manifest.toml");
570 let manifest_source = read(&manifest_path)?;
571 let mut manifest: LocalManifest = toml::from_str(&manifest_source)?;
572 let mut plan = ProjectPlan::new(self.root.clone());
573 let requested = manifest.components.keys().cloned().collect::<Vec<_>>();
574 for id in registry.resolve(&requested)? {
575 let upstream = registry
576 .entries
577 .get(&id)
578 .ok_or_else(|| ProjectError::UnknownComponent(id.clone()))?;
579 if !upstream.metadata.runtime_api.contains(RUNTIME_API_VERSION) {
580 return Err(ProjectError::IncompatibleRuntime(id));
581 }
582 let relative = component_relative_path(&id)?;
583 let source_path = self.root.join("ui").join(&relative);
584 let baseline_path = self.root.join(".gpui-rhai/baselines").join(&relative);
585 let installed = manifest.components.get(id.as_str()).cloned();
586 if let Some(installed) = &installed
587 && upstream.metadata.version <= installed.version
588 {
589 continue;
590 }
591 let write_checkpoint = plan.writes.len();
592 if installed.is_some() {
593 let source = read(&source_path)?;
594 let baseline = read(&baseline_path)?;
595 let Ok(merged) = three_way_merge(&baseline, &source, upstream.source) else {
596 let conflict_path = self.root.join(".gpui-rhai/conflicts").join(&relative);
597 plan.replace_or_create(
598 conflict_path,
599 conflict_artifact(&source, &baseline, upstream.source),
600 )?;
601 plan.conflicts.push(id);
602 continue;
603 };
604 plan.update(source_path, merged, source);
605 plan.update(baseline_path, upstream.source.to_owned(), baseline);
606 } else {
607 plan.create(source_path, upstream.source.to_owned())?;
608 plan.create(baseline_path, upstream.source.to_owned())?;
609 }
610 let mut asset_conflict = false;
611 for asset in &upstream.assets {
612 asset_conflict |= self.plan_asset_update(&mut plan, asset)?;
613 }
614 if asset_conflict {
615 let added = plan.writes.split_off(write_checkpoint);
616 let conflicts = self.root.join(".gpui-rhai/conflicts");
617 plan.writes.extend(
618 added
619 .into_iter()
620 .filter(|write| write.path.starts_with(&conflicts)),
621 );
622 plan.conflicts.push(id);
623 continue;
624 }
625 let content_hash = format!(
626 "{:016x}",
627 ScriptAsset::new(id.clone(), upstream.source.to_owned()).content_hash
628 );
629 manifest.components.insert(
630 id.as_str().to_owned(),
631 InstalledComponent {
632 version: upstream.metadata.version.clone(),
633 content_hash,
634 dependencies: upstream
635 .metadata
636 .dependencies
637 .iter()
638 .map(ToString::to_string)
639 .collect(),
640 },
641 );
642 }
643 for &(name, source) in BUNDLED_THEME_SOURCES
644 .iter()
645 .filter(|(name, _)| *name != "default_dark.rhai")
646 {
647 let path = self.root.join("ui/themes").join(name);
648 if !path.exists() {
649 plan.create(path, source.to_owned())?;
650 }
651 }
652 let styles = self.root.join("ui/styles.rhai");
653 if !styles.exists() {
654 plan.create(styles, DEFAULT_COMPONENT_STYLES.to_owned())?;
655 }
656 plan.update(
657 manifest_path,
658 toml::to_string_pretty(&manifest)?,
659 manifest_source,
660 );
661 Ok(plan)
662 }
663
664 fn plan_asset_update(
665 &self,
666 plan: &mut ProjectPlan,
667 asset: &RegistryAsset,
668 ) -> Result<bool, ProjectError> {
669 let source_path = self.root.join("ui/assets").join(asset.path);
670 let baseline_path = self
671 .root
672 .join(".gpui-rhai/baselines/assets")
673 .join(asset.path);
674 match (source_path.exists(), baseline_path.exists()) {
675 (false, false) => {
676 plan.create(source_path, asset.source.to_owned())?;
677 plan.create(baseline_path, asset.source.to_owned())?;
678 }
679 (true, true) => {
680 let source = read(&source_path)?;
681 let baseline = read(&baseline_path)?;
682 let Ok(merged) = three_way_merge(&baseline, &source, asset.source) else {
683 let conflict_path = self
684 .root
685 .join(".gpui-rhai/conflicts/assets")
686 .join(asset.path);
687 plan.replace_or_create(
688 conflict_path,
689 conflict_artifact(&source, &baseline, asset.source),
690 )?;
691 return Ok(true);
692 };
693 plan.update(source_path, merged, source);
694 plan.update(baseline_path, asset.source.to_owned(), baseline);
695 }
696 _ => return Err(ProjectError::IncompleteAssetBaseline(asset.path.to_owned())),
697 }
698 Ok(false)
699 }
700
701 pub fn plan_embed(&self) -> Result<ProjectPlan, ProjectError> {
708 let manifest: LocalManifest =
709 toml::from_str(&read(&self.root.join(".gpui-rhai/manifest.toml"))?)?;
710 let app: AppManifest = toml::from_str(&read(&self.root.join("ui/app.toml"))?)?;
711 if app.entry.as_str() != "main" {
712 return Err(ProjectError::ManifestEntry(app.entry));
713 }
714 let mut modules = vec![(app.entry.to_string(), "../ui/main.rhai".to_owned())];
715 for raw_id in manifest.components.keys() {
716 let id = ModuleId::parse(raw_id.clone())?;
717 let relative = component_relative_path(&id)?;
718 modules.push((id.to_string(), format!("../ui/{}", slash_path(&relative))));
719 }
720 modules.sort();
721
722 let locales = collect_relative_files(&self.root.join("ui/locales"), "../ui/locales")?;
723 let themes = collect_relative_files(&self.root.join("ui/themes"), "../ui/themes")?;
724 let assets = collect_relative_files(&self.root.join("ui/assets"), "../ui/assets")?;
725 let fonts = collect_relative_files(&self.root.join("ui/fonts"), "../ui/fonts")?;
726 let generated = generated_embed_module(&app, &modules, &locales, &themes, &assets, &fonts)?;
727 let path = self.root.join("src/gpui_rhai_embedded.rs");
728 let mut plan = ProjectPlan::new(self.root.clone());
729 match fs::read_to_string(&path) {
730 Ok(existing) => plan.update(path, generated, existing),
731 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
732 plan.create(path, generated)?;
733 }
734 Err(source) => return Err(ProjectError::Io { path, source }),
735 }
736 Ok(plan)
737 }
738
739 pub fn dev(&self) -> Result<(), ProjectError> {
745 let status = Command::new("cargo")
746 .arg("run")
747 .current_dir(&self.root)
748 .status()
749 .map_err(ProjectError::Spawn)?;
750 if status.success() {
751 Ok(())
752 } else {
753 Err(ProjectError::DevFailed(status.code()))
754 }
755 }
756}
757
758fn validate_gpui_dependency_family(path: &Path) -> Result<(), ProjectError> {
759 const GPUI_PRE_VERSION: &str = "=0.3.7";
760 let source = read(path)?;
761 let manifest: toml::Value = toml::from_str(&source)?;
762 let mut tables = Vec::new();
763 for name in ["dependencies", "dev-dependencies", "build-dependencies"] {
764 if let Some(table) = manifest.get(name).and_then(toml::Value::as_table) {
765 tables.push(table);
766 }
767 }
768 if let Some(targets) = manifest.get("target").and_then(toml::Value::as_table) {
769 for target in targets.values().filter_map(toml::Value::as_table) {
770 for name in ["dependencies", "dev-dependencies", "build-dependencies"] {
771 if let Some(table) = target.get(name).and_then(toml::Value::as_table) {
772 tables.push(table);
773 }
774 }
775 }
776 }
777 for table in tables {
778 for (alias, dependency) in table {
779 let (package, version) = match dependency {
780 toml::Value::String(version) => (alias.clone(), Some(version.clone())),
781 toml::Value::Table(spec)
782 if spec.get("workspace").and_then(toml::Value::as_bool) == Some(true) =>
783 {
784 workspace_dependency(path, alias)?.ok_or_else(|| {
785 ProjectError::GpuiDependency(format!(
786 "`{alias}` inherits from workspace dependencies, but no workspace declaration was found"
787 ))
788 })?
789 }
790 toml::Value::Table(spec) => (
791 spec.get("package")
792 .and_then(toml::Value::as_str)
793 .unwrap_or(alias)
794 .to_owned(),
795 spec.get("version")
796 .and_then(toml::Value::as_str)
797 .map(ToOwned::to_owned),
798 ),
799 _ => continue,
800 };
801 let expected = match package.as_str() {
802 "gpui" | "gpui-pre" => Some(("gpui", "gpui-pre")),
803 "gpui-pre-platform" => Some(("gpui_platform", "gpui-pre-platform")),
804 _ => None,
805 };
806 let Some((expected_alias, expected_package)) = expected else {
807 continue;
808 };
809 if alias != expected_alias
810 || package != expected_package
811 || version.as_deref() != Some(GPUI_PRE_VERSION)
812 {
813 return Err(ProjectError::GpuiDependency(format!(
814 "`{alias}` resolves to `{package}` at {version:?}; use `{expected_alias} = {{ package = \"{expected_package}\", version = \"{GPUI_PRE_VERSION}\", default-features = false }}`"
815 )));
816 }
817 }
818 }
819 Ok(())
820}
821
822fn workspace_dependency(
823 member_manifest: &Path,
824 alias: &str,
825) -> Result<Option<(String, Option<String>)>, ProjectError> {
826 let Some(directory) = member_manifest.parent() else {
827 return Ok(None);
828 };
829 for ancestor in directory.ancestors() {
830 let candidate = ancestor.join("Cargo.toml");
831 if !candidate.exists() {
832 continue;
833 }
834 let manifest: toml::Value = toml::from_str(&read(&candidate)?)?;
835 let Some(spec) = manifest
836 .get("workspace")
837 .and_then(|workspace| workspace.get("dependencies"))
838 .and_then(|dependencies| dependencies.get(alias))
839 else {
840 continue;
841 };
842 return Ok(match spec {
843 toml::Value::String(version) => Some((alias.to_owned(), Some(version.clone()))),
844 toml::Value::Table(spec) => Some((
845 spec.get("package")
846 .and_then(toml::Value::as_str)
847 .unwrap_or(alias)
848 .to_owned(),
849 spec.get("version")
850 .and_then(toml::Value::as_str)
851 .map(ToOwned::to_owned),
852 )),
853 _ => None,
854 });
855 }
856 Ok(None)
857}
858
859fn validate_component_documentation(source: &str, id: &ModuleId) -> Result<(), ProjectError> {
860 let lower = source.to_ascii_lowercase();
861 for (label, present) in [
862 ("props", source.contains("// Props:")),
863 (
864 "state",
865 lower.contains("state:") || lower.contains("state/"),
866 ),
867 (
868 "events",
869 lower.contains("event") || lower.contains("emits no events"),
870 ),
871 ("example", source.contains("// Example:")),
872 (
873 "render_component wrapper",
874 source.contains("render_component("),
875 ),
876 ] {
877 if !present {
878 return Err(ProjectError::ComponentDocumentation {
879 component: id.clone(),
880 missing: label,
881 });
882 }
883 }
884 Ok(())
885}
886
887fn validate_locales(root: &Path) -> Result<(), ProjectError> {
888 let engine = RuntimeEngine::new();
889 let mut bundles = Vec::new();
890 for path in collect_paths(&root.join("ui/locales"))? {
891 let source = read(&path)?;
892 bundles.push(
893 load_locale_source(engine.engine(), &path.to_string_lossy(), &source)
894 .map_err(|error| ProjectError::Locale(error.to_string()))?,
895 );
896 }
897 if bundles.is_empty() {
898 return Ok(());
899 }
900 let fallback = bundles
901 .iter()
902 .find(|bundle| bundle.locale == "en")
903 .unwrap_or(&bundles[0])
904 .locale
905 .clone();
906 LocaleManager::new(bundles, fallback.clone(), fallback)
907 .map(|_| ())
908 .map_err(|error| ProjectError::Locale(error.to_string()))
909}
910
911fn validate_embedded_assets(root: &Path) -> Result<(), ProjectError> {
912 let assets = collect_relative_files(&root.join("ui/assets"), "../ui/assets")?;
913 let mut logical = BTreeSet::new();
914 for (name, _) in assets {
915 embedded_asset_mime(&name)?;
916 let name = name
917 .rsplit_once('.')
918 .map_or(name.as_str(), |(stem, _)| stem);
919 if !logical.insert(name.to_owned()) {
920 return Err(ProjectError::DuplicateAssetLogical(name.to_owned()));
921 }
922 }
923 Ok(())
924}
925
926fn validate_fonts(root: &Path) -> Result<(), ProjectError> {
927 let files = collect_relative_files(&root.join("ui/fonts"), "../ui/fonts")?;
928 let mut fonts = Vec::with_capacity(files.len());
929 for (name, _) in files {
930 validate_font_extension(&name)?;
931 let path = root.join("ui/fonts").join(&name);
932 let bytes = fs::read(&path).map_err(|source| ProjectError::Io {
933 path: path.clone(),
934 source,
935 })?;
936 fonts.push(
937 FontSource::new(name, bytes).map_err(|error| ProjectError::Font(error.to_string()))?,
938 );
939 }
940 validate_font_sources(&fonts).map_err(|error| ProjectError::Font(error.to_string()))
941}
942
943#[derive(Clone, Debug, Serialize)]
944struct EditorMetadata {
945 runtime_api: u32,
946 components: Vec<ComponentDefinition>,
947}
948
949#[derive(Clone, Debug, Serialize)]
950struct EditorSnippet {
951 prefix: String,
952 body: Vec<String>,
953 description: String,
954}
955
956fn editor_snippets(registry: &ComponentRegistry) -> BTreeMap<String, EditorSnippet> {
957 registry
958 .iter()
959 .map(|(id, component)| {
960 let alias = id.as_str().rsplit('/').next().unwrap_or("component");
961 let mut body = vec![
962 format!("import \"{id}\" as {alias};"),
963 format!("{alias}::{}(#{{", component.metadata.export),
964 ];
965 let mut placeholder = 1;
966 for (name, field) in &component.schema.props {
967 if field.required {
968 body.push(format!(" {name}: ${{{placeholder}:{name}}},"));
969 placeholder += 1;
970 }
971 }
972 body.push("})".to_owned());
973 (
974 component.metadata.export.clone(),
975 EditorSnippet {
976 prefix: component.metadata.export.clone(),
977 body,
978 description: format!(
979 "{} from {} {}",
980 component.metadata.export, id, component.metadata.version
981 ),
982 },
983 )
984 })
985 .collect()
986}
987
988fn validate_component_exports(
989 modules: &BTreeMap<ModuleId, String>,
990 headers: &BTreeMap<ModuleId, ComponentMetadata>,
991) -> Result<ComponentRegistry, ProjectError> {
992 if modules.is_empty() {
993 return Ok(ComponentRegistry::default());
994 }
995 let source = EmbeddedScriptSource::new(modules.clone());
996 let mut runtime = RuntimeEngine::new();
997 runtime.set_module_resolver(RestrictedModuleResolver::from_source(&source)?);
998 let imports = modules
999 .keys()
1000 .enumerate()
1001 .map(|(index, id)| format!("import \"{id}\" as component_{index};"))
1002 .collect::<Vec<_>>()
1003 .join("\n");
1004 runtime.compile_self_contained_named("<component-check>", &imports)?;
1005 let exported = runtime.component_exports()?;
1006 for (id, header) in headers {
1007 exported
1008 .get(id)
1009 .ok_or_else(|| ProjectError::MissingExport(id.clone()))?
1010 .validate_header(header)?;
1011 }
1012 Ok(exported)
1013}
1014
1015fn validate_entry(
1016 root: &Path,
1017 modules: &BTreeMap<ModuleId, String>,
1018 components: &ComponentRegistry,
1019 run_hostless_lifecycle: bool,
1020) -> Result<bool, ProjectError> {
1021 let entry_path = root.join("ui/main.rhai");
1022 let entry = read(&entry_path)?;
1023 let source = EmbeddedScriptSource::new(modules.clone());
1024 let mut runtime = RuntimeEngine::new();
1025 let call_diagnostics = runtime.lint_known_calls("ui/main.rhai", &entry, modules)?;
1026 if !call_diagnostics.is_empty() {
1027 return Err(ProjectError::KnownCalls(
1028 call_diagnostics
1029 .iter()
1030 .map(ToString::to_string)
1031 .collect::<Vec<_>>()
1032 .join("\n"),
1033 ));
1034 }
1035 runtime.set_module_resolver(RestrictedModuleResolver::from_source(&source)?);
1036 let compiled = runtime.compile_self_contained_named("ui/main.rhai", &entry)?;
1037 if !compiled.has_function("view", 1) {
1038 return Err(ProjectError::MissingView);
1039 }
1040 let has_init = compiled.has_function("init", 1);
1041 let schema = runtime.root_state_schema(&compiled)?;
1042 let primary_source = read(&root.join("ui/theme.rhai"))?;
1043 let primary = load_theme_source(runtime.engine(), "ui/theme.rhai", &primary_source)
1044 .map_err(|error| ProjectError::Theme(error.to_string()))?;
1045 let mut themes = vec![primary.clone()];
1046 for path in collect_paths(&root.join("ui/themes"))? {
1047 themes.push(
1048 load_theme_source(runtime.engine(), &path.to_string_lossy(), &read(&path)?)
1049 .map_err(|error| ProjectError::Theme(error.to_string()))?,
1050 );
1051 }
1052 let mut state = UiRuntimeState::new();
1053 let styles_path = root.join("ui/styles.rhai");
1054 if styles_path.exists() {
1055 let styles = load_component_styles(
1056 runtime.engine(),
1057 &styles_path.to_string_lossy(),
1058 &read(&styles_path)?,
1059 components,
1060 )
1061 .map_err(|error| ProjectError::ComponentStyle(error.to_string()))?;
1062 state.replace_component_styles_from_host(styles);
1063 }
1064 state.theme = Some(
1065 ThemeManager::from_variants(
1066 themes,
1067 ThemeSelection::new(primary.family.clone(), primary.name.clone()),
1068 )
1069 .map_err(|error| ProjectError::Theme(error.to_string()))?,
1070 );
1071 state.locale = load_check_locales(root)?;
1072 let asset_root = root.join("ui/assets");
1073 if asset_root.exists() {
1074 state
1075 .assets
1076 .register(
1077 "app",
1078 DirectoryAssetProvider::new(&asset_root)
1079 .map_err(|error| ProjectError::Asset(error.to_string()))?,
1080 )
1081 .map_err(|error| ProjectError::Asset(error.to_string()))?;
1082 }
1083 let runtime_state = Rc::new(RefCell::new(state));
1084 let mut lifecycle = ScriptLifecycle::new(
1085 compiled,
1086 runtime_state,
1087 ComponentInstancePath::root("App", "check"),
1088 Some("check".to_owned()),
1089 BTreeMap::new(),
1090 &schema,
1091 )?;
1092 if run_hostless_lifecycle {
1093 lifecycle.start(&mut runtime)?;
1094 Ok(true)
1095 } else if !has_init {
1096 lifecycle.validate_initial_view_without_init(&mut runtime)?;
1097 Ok(true)
1098 } else {
1099 Ok(false)
1100 }
1101}
1102
1103fn load_check_locales(root: &Path) -> Result<Option<LocaleManager>, ProjectError> {
1104 let runtime = RuntimeEngine::new();
1105 let mut bundles = Vec::new();
1106 for path in collect_paths(&root.join("ui/locales"))? {
1107 bundles.push(
1108 load_locale_source(runtime.engine(), &path.to_string_lossy(), &read(&path)?)
1109 .map_err(|error| ProjectError::Locale(error.to_string()))?,
1110 );
1111 }
1112 if bundles.is_empty() {
1113 return Ok(None);
1114 }
1115 let selected = bundles
1116 .iter()
1117 .find(|bundle| bundle.locale == "en")
1118 .unwrap_or(&bundles[0])
1119 .locale
1120 .clone();
1121 LocaleManager::new(bundles, selected.clone(), selected)
1122 .map(Some)
1123 .map_err(|error| ProjectError::Locale(error.to_string()))
1124}
1125
1126#[derive(Clone, Debug, Eq, PartialEq)]
1127struct LineChange {
1128 base: Range<usize>,
1129 replacement: Vec<String>,
1130}
1131
1132fn three_way_merge(base: &str, local: &str, upstream: &str) -> Result<String, ()> {
1133 if local == base {
1134 return Ok(upstream.to_owned());
1135 }
1136 if upstream == base || local == upstream {
1137 return Ok(local.to_owned());
1138 }
1139 let base_lines = split_lines(base);
1140 let local_changes = line_changes(&base_lines, &split_lines(local))?;
1141 let upstream_changes = line_changes(&base_lines, &split_lines(upstream))?;
1142 for local in &local_changes {
1143 for upstream in &upstream_changes {
1144 if changes_overlap(local, upstream) && local != upstream {
1145 return Err(());
1146 }
1147 }
1148 }
1149 let mut changes = local_changes;
1150 for change in upstream_changes {
1151 if !changes.contains(&change) {
1152 changes.push(change);
1153 }
1154 }
1155 changes.sort_by_key(|change| (change.base.start, change.base.end));
1156 let mut output = String::new();
1157 let mut cursor = 0;
1158 for change in changes {
1159 if change.base.start < cursor {
1160 return Err(());
1161 }
1162 output.extend(
1163 base_lines[cursor..change.base.start]
1164 .iter()
1165 .map(String::as_str),
1166 );
1167 output.extend(change.replacement.iter().map(String::as_str));
1168 cursor = change.base.end;
1169 }
1170 output.extend(base_lines[cursor..].iter().map(String::as_str));
1171 Ok(output)
1172}
1173
1174fn split_lines(source: &str) -> Vec<String> {
1175 source
1176 .split_inclusive('\n')
1177 .map(ToOwned::to_owned)
1178 .collect()
1179}
1180
1181fn line_changes(base: &[String], target: &[String]) -> Result<Vec<LineChange>, ()> {
1182 const MAX_LCS_CELLS: usize = 4_000_000;
1183 if base.len().saturating_mul(target.len()) > MAX_LCS_CELLS {
1184 return Err(());
1185 }
1186 let mut lcs = vec![vec![0_u32; target.len().saturating_add(1)]; base.len().saturating_add(1)];
1187 for base_index in (0..base.len()).rev() {
1188 for target_index in (0..target.len()).rev() {
1189 lcs[base_index][target_index] = if base[base_index] == target[target_index] {
1190 lcs[base_index + 1][target_index + 1].saturating_add(1)
1191 } else {
1192 lcs[base_index + 1][target_index].max(lcs[base_index][target_index + 1])
1193 };
1194 }
1195 }
1196 let mut changes = Vec::new();
1197 let mut current: Option<LineChange> = None;
1198 let (mut base_index, mut target_index) = (0, 0);
1199 while base_index < base.len() || target_index < target.len() {
1200 if base_index < base.len()
1201 && target_index < target.len()
1202 && base[base_index] == target[target_index]
1203 {
1204 if let Some(change) = current.take() {
1205 changes.push(change);
1206 }
1207 base_index += 1;
1208 target_index += 1;
1209 } else if target_index < target.len()
1210 && (base_index == base.len()
1211 || lcs[base_index][target_index + 1] > lcs[base_index + 1][target_index])
1212 {
1213 current
1214 .get_or_insert_with(|| LineChange {
1215 base: base_index..base_index,
1216 replacement: Vec::new(),
1217 })
1218 .replacement
1219 .push(target[target_index].clone());
1220 target_index += 1;
1221 } else {
1222 let change = current.get_or_insert_with(|| LineChange {
1223 base: base_index..base_index,
1224 replacement: Vec::new(),
1225 });
1226 base_index += 1;
1227 change.base.end = base_index;
1228 }
1229 }
1230 if let Some(change) = current {
1231 changes.push(change);
1232 }
1233 Ok(changes)
1234}
1235
1236fn changes_overlap(left: &LineChange, right: &LineChange) -> bool {
1237 if left.base.is_empty() && right.base.is_empty() {
1238 left.base.start == right.base.start
1239 } else if left.base.is_empty() {
1240 right.base.start < left.base.start && left.base.start < right.base.end
1241 } else if right.base.is_empty() {
1242 left.base.start < right.base.start && right.base.start < left.base.end
1243 } else {
1244 left.base.start < right.base.end && right.base.start < left.base.end
1245 }
1246}
1247
1248fn conflict_artifact(local: &str, baseline: &str, upstream: &str) -> String {
1249 format!(
1250 "<<<<<<< local\n{}||||||| installed baseline\n{}=======\n{}>>>>>>> bundled registry\n",
1251 with_final_newline(local),
1252 with_final_newline(baseline),
1253 with_final_newline(upstream)
1254 )
1255}
1256
1257fn with_final_newline(source: &str) -> String {
1258 if source.ends_with('\n') {
1259 source.to_owned()
1260 } else {
1261 format!("{source}\n")
1262 }
1263}
1264
1265fn component_relative_path(id: &ModuleId) -> Result<PathBuf, ProjectError> {
1266 let path = id.as_str();
1267 let (directory, component) = path
1268 .strip_prefix("components/")
1269 .map(|name| ("components", name))
1270 .or_else(|| path.strip_prefix("motion/").map(|name| ("motion", name)))
1271 .or_else(|| path.strip_prefix("charts/").map(|name| ("charts", name)))
1272 .ok_or_else(|| ProjectError::InvalidComponentPath(id.clone()))?;
1273 Ok(PathBuf::from(directory)
1274 .join(component)
1275 .with_extension("rhai"))
1276}
1277
1278fn slash_path(path: &Path) -> String {
1279 path.components()
1280 .map(|component| component.as_os_str().to_string_lossy())
1281 .collect::<Vec<_>>()
1282 .join("/")
1283}
1284
1285fn collect_relative_files(
1286 directory: &Path,
1287 include_prefix: &str,
1288) -> Result<Vec<(String, String)>, ProjectError> {
1289 if !directory.exists() {
1290 return Ok(Vec::new());
1291 }
1292 let mut pending = vec![directory.to_path_buf()];
1293 let mut files = Vec::new();
1294 while let Some(current) = pending.pop() {
1295 for entry in fs::read_dir(¤t).map_err(|source| ProjectError::Io {
1296 path: current.clone(),
1297 source,
1298 })? {
1299 let entry = entry.map_err(|source| ProjectError::Io {
1300 path: current.clone(),
1301 source,
1302 })?;
1303 let path = entry.path();
1304 if path.is_dir() {
1305 pending.push(path);
1306 } else {
1307 let relative = path
1308 .strip_prefix(directory)
1309 .map_err(|_| ProjectError::InvalidPath(path.clone()))?;
1310 let logical = slash_path(relative);
1311 files.push((logical.clone(), format!("{include_prefix}/{logical}")));
1312 }
1313 }
1314 }
1315 files.sort();
1316 Ok(files)
1317}
1318
1319fn collect_paths(directory: &Path) -> Result<Vec<PathBuf>, ProjectError> {
1320 if !directory.exists() {
1321 return Ok(Vec::new());
1322 }
1323 let mut paths = fs::read_dir(directory)
1324 .map_err(|source| ProjectError::Io {
1325 path: directory.to_path_buf(),
1326 source,
1327 })?
1328 .map(|entry| {
1329 entry
1330 .map(|entry| entry.path())
1331 .map_err(|source| ProjectError::Io {
1332 path: directory.to_path_buf(),
1333 source,
1334 })
1335 })
1336 .collect::<Result<Vec<_>, _>>()?;
1337 paths.retain(|path| path.extension().and_then(|value| value.to_str()) == Some("rhai"));
1338 paths.sort();
1339 Ok(paths)
1340}
1341
1342fn generated_embed_module(
1343 manifest: &AppManifest,
1344 modules: &[(String, String)],
1345 locales: &[(String, String)],
1346 themes: &[(String, String)],
1347 assets: &[(String, String)],
1348 fonts: &[(String, String)],
1349) -> Result<String, ProjectError> {
1350 let mut output =
1351 String::from("// @generated by `gpui-rhai embed`; edit files under `ui/` instead.\n\n");
1352 let _ = writeln!(
1353 output,
1354 "pub fn app_manifest() -> gpui_rhai::AppManifest {{\n let manifest = gpui_rhai::AppManifest::new(gpui_rhai::ModuleId::parse({:?}).expect(\"generated entry id\"));",
1355 manifest.entry.as_str()
1356 );
1357 for (id, requirement) in &manifest.capabilities {
1358 let _ = writeln!(
1359 output,
1360 " let manifest = manifest.with_capability({:?}, {:?}).expect(\"generated capability requirement\");",
1361 id.as_str(),
1362 requirement.to_string()
1363 );
1364 }
1365 output.push_str(" manifest\n}\n\n");
1366 output.push_str("pub fn script_source() -> gpui_rhai::EmbeddedScriptSource {\n");
1367 output
1368 .push_str(" gpui_rhai::EmbeddedScriptSource::new(std::collections::BTreeMap::from([\n");
1369 for (id, path) in modules {
1370 let _ = writeln!(
1371 output,
1372 " (gpui_rhai::ModuleId::parse({id:?}).expect(\"generated module id\"), include_str!({path:?}).to_owned()),"
1373 );
1374 }
1375 output.push_str(" ]))\n}\n\n");
1376 output.push_str("pub const THEME_SOURCE: &str = include_str!(\"../ui/theme.rhai\");\n\n");
1377 output.push_str(
1378 "pub const COMPONENT_STYLES_SOURCE: &str = include_str!(\"../ui/styles.rhai\");\n\n",
1379 );
1380 output.push_str("pub const LOCALES: &[(&str, &str)] = &[\n");
1381 for (name, path) in locales {
1382 let _ = writeln!(output, " ({name:?}, include_str!({path:?})),");
1383 }
1384 output.push_str("];\n\n");
1385 output.push_str("pub const THEMES: &[(&str, &str)] = &[\n");
1386 for (name, path) in themes {
1387 let _ = writeln!(output, " ({name:?}, include_str!({path:?})),");
1388 }
1389 output.push_str("];\n\n");
1390 output.push_str("pub const ASSETS: &[(&str, &[u8])] = &[\n");
1391 for (name, path) in assets {
1392 let _ = writeln!(output, " ({name:?}, include_bytes!({path:?})),");
1393 }
1394 output.push_str("];\n\n");
1395 output.push_str("pub fn asset_sources() -> Vec<(String, gpui_rhai::AssetData)> {\n vec![\n");
1396 let mut logical_assets = BTreeSet::new();
1397 for (name, path) in assets {
1398 let mime_type = embedded_asset_mime(name)?;
1399 let logical_name = name
1400 .rsplit_once('.')
1401 .map_or(name.as_str(), |(stem, _)| stem);
1402 if !logical_assets.insert(logical_name) {
1403 return Err(ProjectError::DuplicateAssetLogical(logical_name.to_owned()));
1404 }
1405 let _ = writeln!(
1406 output,
1407 " ({logical_name:?}.to_owned(), gpui_rhai::AssetData {{ mime_type: {mime_type:?}.to_owned(), bytes: include_bytes!({path:?}).to_vec() }}),"
1408 );
1409 }
1410 output.push_str(" ]\n}\n");
1411 output.push_str("\npub const FONTS: &[(&str, &[u8])] = &[\n");
1412 for (name, path) in fonts {
1413 validate_font_extension(name)?;
1414 let _ = writeln!(output, " ({name:?}, include_bytes!({path:?})),");
1415 }
1416 output.push_str("];\n\n");
1417 output.push_str("pub fn font_sources() -> Vec<gpui_rhai::FontSource> {\n vec![\n");
1418 for (name, path) in fonts {
1419 let _ = writeln!(
1420 output,
1421 " gpui_rhai::FontSource::new({name:?}, include_bytes!({path:?}).to_vec()).expect(\"generated font source\"),"
1422 );
1423 }
1424 output.push_str(" ]\n}\n");
1425 Ok(output)
1426}
1427
1428fn validate_font_extension(path: &str) -> Result<(), ProjectError> {
1429 match path.rsplit('.').next().unwrap_or_default() {
1430 "ttf" | "otf" | "ttc" => Ok(()),
1431 _ => Err(ProjectError::UnsupportedFont(path.to_owned())),
1432 }
1433}
1434
1435fn embedded_asset_mime(path: &str) -> Result<&'static str, ProjectError> {
1436 match path.rsplit('.').next().unwrap_or_default() {
1437 "svg" => Ok("image/svg+xml"),
1438 "png" => Ok("image/png"),
1439 "jpg" | "jpeg" => Ok("image/jpeg"),
1440 "gif" => Ok("image/gif"),
1441 "webp" => Ok("image/webp"),
1442 "bmp" => Ok("image/bmp"),
1443 "tif" | "tiff" => Ok("image/tiff"),
1444 _ => Err(ProjectError::UnsupportedAsset(path.to_owned())),
1445 }
1446}
1447
1448fn host_source() -> String {
1449 r#"fn main() {
1450 let view = gpui_rhai::FileScriptView::new("ui/main.rhai")
1451 .prepare()
1452 .expect("GPUI Rhai view preparation failed");
1453 gpui_rhai::ScriptApplication::new(view)
1454 .run()
1455 .expect("GPUI Rhai application failed");
1456}
1457"#
1458 .to_owned()
1459}
1460
1461fn starter_ui() -> String {
1462 r#"fn view(ctx) {
1463 column([
1464 text("GPUI Rhai"),
1465 text("Run `gpui-rhai add button label` to install source components.")
1466 ]).with_style(style().gap(px(8)).padding(px(16)))
1467}
1468"#
1469 .to_owned()
1470}
1471
1472fn app_manifest_source() -> Result<String, ProjectError> {
1473 Ok(toml::to_string_pretty(&AppManifest {
1474 entry: ModuleId::parse("main")?,
1475 runtime_api: RUNTIME_API_VERSION,
1476 capabilities: BTreeMap::new(),
1477 })?)
1478}
1479
1480fn read(path: &Path) -> Result<String, ProjectError> {
1481 fs::read_to_string(path).map_err(|source| ProjectError::Io {
1482 path: path.to_path_buf(),
1483 source,
1484 })
1485}
1486
1487#[derive(Clone, Debug)]
1488enum ExpectedFile {
1489 Missing,
1490 Exact(String),
1491}
1492
1493#[derive(Clone, Debug)]
1494struct PlannedWrite {
1495 path: PathBuf,
1496 content: String,
1497 expected: ExpectedFile,
1498}
1499
1500struct StagedProjectWrite<'a> {
1501 write: &'a PlannedWrite,
1502 temporary: PathBuf,
1503 backup: Option<PathBuf>,
1504 committed: bool,
1505}
1506
1507struct ProjectApplyLock {
1508 path: PathBuf,
1509}
1510
1511impl ProjectApplyLock {
1512 fn acquire(root: &Path) -> Result<Self, ProjectError> {
1513 let path = root.join(".gpui-rhai-apply.lock");
1514 let mut lock = match OpenOptions::new().write(true).create_new(true).open(&path) {
1515 Ok(lock) => lock,
1516 Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
1517 return Err(ProjectError::ConcurrentChange {
1518 path,
1519 detail: "another apply is active or crash recovery is required".to_owned(),
1520 });
1521 }
1522 Err(source) => {
1523 return Err(ProjectError::Io { path, source });
1524 }
1525 };
1526 let guard = Self { path: path.clone() };
1527 writeln!(lock, "pid={}", std::process::id()).map_err(|source| ProjectError::Io {
1528 path: path.clone(),
1529 source,
1530 })?;
1531 Ok(guard)
1532 }
1533}
1534
1535impl Drop for ProjectApplyLock {
1536 fn drop(&mut self) {
1537 let _ = fs::remove_file(&self.path);
1538 }
1539}
1540
1541#[derive(Clone, Debug)]
1542pub struct ProjectPlan {
1543 root: PathBuf,
1544 writes: Vec<PlannedWrite>,
1545 conflicts: Vec<ModuleId>,
1546}
1547
1548impl ProjectPlan {
1549 fn new(root: PathBuf) -> Self {
1550 Self {
1551 root,
1552 writes: Vec::new(),
1553 conflicts: Vec::new(),
1554 }
1555 }
1556
1557 fn create(&mut self, path: PathBuf, content: String) -> Result<(), ProjectError> {
1558 match fs::read_to_string(&path) {
1559 Ok(existing) if existing == content => Ok(()),
1560 Ok(_) => Err(ProjectError::WouldOverwrite(path)),
1561 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1562 self.writes.push(PlannedWrite {
1563 path,
1564 content,
1565 expected: ExpectedFile::Missing,
1566 });
1567 Ok(())
1568 }
1569 Err(source) => Err(ProjectError::Io { path, source }),
1570 }
1571 }
1572
1573 fn update(&mut self, path: PathBuf, content: String, existing: String) {
1574 if content != existing {
1575 self.writes.push(PlannedWrite {
1576 path,
1577 content,
1578 expected: ExpectedFile::Exact(existing),
1579 });
1580 }
1581 }
1582
1583 fn replace_or_create(&mut self, path: PathBuf, content: String) -> Result<(), ProjectError> {
1584 match fs::read_to_string(&path) {
1585 Ok(existing) => {
1586 self.update(path, content, existing);
1587 Ok(())
1588 }
1589 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1590 self.create(path, content)
1591 }
1592 Err(source) => Err(ProjectError::Io { path, source }),
1593 }
1594 }
1595
1596 #[must_use]
1597 pub fn conflicts(&self) -> &[ModuleId] {
1598 &self.conflicts
1599 }
1600
1601 #[must_use]
1602 pub fn summary(&self) -> String {
1603 let mut lines = if self.writes.is_empty() {
1604 vec!["No filesystem changes required.".to_owned()]
1605 } else {
1606 vec![format!("{} file change(s):", self.writes.len())]
1607 };
1608 lines.extend(self.writes.iter().map(|write| {
1609 format!(
1610 "- {} {}",
1611 match &write.expected {
1612 ExpectedFile::Missing => "create",
1613 ExpectedFile::Exact(_) => "update",
1614 },
1615 write
1616 .path
1617 .strip_prefix(&self.root)
1618 .unwrap_or(&write.path)
1619 .display()
1620 )
1621 }));
1622 if !self.conflicts.is_empty() {
1623 lines.push(format!(
1624 "{} merge conflict(s): {}",
1625 self.conflicts.len(),
1626 self.conflicts
1627 .iter()
1628 .map(ToString::to_string)
1629 .collect::<Vec<_>>()
1630 .join(", ")
1631 ));
1632 }
1633 lines.join("\n")
1634 }
1635
1636 #[allow(clippy::too_many_lines)] pub fn apply(self) -> Result<(), ProjectError> {
1644 let _lock = ProjectApplyLock::acquire(&self.root)?;
1645 for write in &self.writes {
1646 match (&write.expected, fs::read_to_string(&write.path)) {
1647 (ExpectedFile::Missing, Err(error))
1648 if error.kind() == std::io::ErrorKind::NotFound => {}
1649 (ExpectedFile::Exact(expected), Ok(actual)) if expected == &actual => {}
1650 (_, result) => {
1651 return Err(ProjectError::ConcurrentChange {
1652 path: write.path.clone(),
1653 detail: result.map_or_else(
1654 |error| error.to_string(),
1655 |_| "content changed".to_owned(),
1656 ),
1657 });
1658 }
1659 }
1660 }
1661 let nonce = std::time::SystemTime::now()
1662 .duration_since(std::time::UNIX_EPOCH)
1663 .unwrap_or_default()
1664 .as_nanos();
1665 let mut staged = Vec::with_capacity(self.writes.len());
1666 for (index, write) in self.writes.iter().enumerate() {
1667 let parent = write
1668 .path
1669 .parent()
1670 .ok_or_else(|| ProjectError::InvalidPath(write.path.clone()))?;
1671 fs::create_dir_all(parent).map_err(|source| ProjectError::Io {
1672 path: parent.to_path_buf(),
1673 source,
1674 })?;
1675 let temporary = parent.join(format!(
1676 ".gpui-rhai-tmp-{}-{nonce}-{index}",
1677 std::process::id()
1678 ));
1679 let mut file = match OpenOptions::new()
1680 .write(true)
1681 .create_new(true)
1682 .open(&temporary)
1683 {
1684 Ok(file) => file,
1685 Err(source) => {
1686 cleanup_staged_files(&staged);
1687 return Err(ProjectError::Io {
1688 path: temporary,
1689 source,
1690 });
1691 }
1692 };
1693 if let Err(source) = file.write_all(write.content.as_bytes()) {
1694 let _ = fs::remove_file(&temporary);
1695 cleanup_staged_files(&staged);
1696 return Err(ProjectError::Io {
1697 path: temporary,
1698 source,
1699 });
1700 }
1701 staged.push(StagedProjectWrite {
1702 write,
1703 temporary,
1704 backup: matches!(write.expected, ExpectedFile::Exact(_)).then(|| {
1705 parent.join(format!(
1706 ".gpui-rhai-backup-{}-{nonce}-{index}",
1707 std::process::id()
1708 ))
1709 }),
1710 committed: false,
1711 });
1712 }
1713
1714 for staged_write in &staged {
1717 if !expectation_matches(staged_write.write) {
1718 cleanup_staged_files(&staged);
1719 return Err(ProjectError::ConcurrentChange {
1720 path: staged_write.write.path.clone(),
1721 detail: "content changed while staging the update".to_owned(),
1722 });
1723 }
1724 }
1725
1726 for index in 0..staged.len() {
1727 let commit = (|| -> Result<(), std::io::Error> {
1728 if let Some(backup) = &staged[index].backup {
1729 fs::rename(&staged[index].write.path, backup)?;
1730 }
1731 fs::rename(&staged[index].temporary, &staged[index].write.path)?;
1732 staged[index].committed = true;
1733 Ok(())
1734 })();
1735 if let Err(source) = commit {
1736 let rollback = rollback_staged_files(&mut staged, index);
1737 if let Err(rollback) = rollback {
1738 return Err(ProjectError::ApplyRollback {
1739 path: staged[index].write.path.clone(),
1740 source,
1741 rollback,
1742 });
1743 }
1744 return Err(ProjectError::Io {
1745 path: staged[index].write.path.clone(),
1746 source,
1747 });
1748 }
1749 }
1750 for staged_write in &staged {
1751 if let Some(backup) = &staged_write.backup {
1752 let _ = fs::remove_file(backup);
1753 }
1754 }
1755 Ok(())
1756 }
1757}
1758
1759fn expectation_matches(write: &PlannedWrite) -> bool {
1760 match (&write.expected, fs::read_to_string(&write.path)) {
1761 (ExpectedFile::Missing, Err(error)) if error.kind() == std::io::ErrorKind::NotFound => true,
1762 (ExpectedFile::Exact(expected), Ok(actual)) => expected == &actual,
1763 _ => false,
1764 }
1765}
1766
1767fn cleanup_staged_files(staged: &[StagedProjectWrite<'_>]) {
1768 for staged in staged {
1769 let _ = fs::remove_file(&staged.temporary);
1770 }
1771}
1772
1773fn rollback_staged_files(
1774 staged: &mut [StagedProjectWrite<'_>],
1775 failed: usize,
1776) -> Result<(), String> {
1777 let mut errors = Vec::new();
1778 for index in (0..=failed).rev() {
1779 let item = &mut staged[index];
1780 if item.committed
1781 && let Err(error) = fs::remove_file(&item.write.path)
1782 {
1783 errors.push(format!("remove {}: {error}", item.write.path.display()));
1784 }
1785 if let Some(backup) = &item.backup
1786 && backup.exists()
1787 && let Err(error) = fs::rename(backup, &item.write.path)
1788 {
1789 errors.push(format!(
1790 "restore {} from {}: {error}",
1791 item.write.path.display(),
1792 backup.display()
1793 ));
1794 }
1795 if item.temporary.exists()
1796 && let Err(error) = fs::remove_file(&item.temporary)
1797 {
1798 errors.push(format!("remove {}: {error}", item.temporary.display()));
1799 }
1800 }
1801 for item in staged.iter().skip(failed + 1) {
1802 if let Err(error) = fs::remove_file(&item.temporary) {
1803 errors.push(format!("remove {}: {error}", item.temporary.display()));
1804 }
1805 }
1806 if errors.is_empty() {
1807 Ok(())
1808 } else {
1809 Err(errors.join("; "))
1810 }
1811}
1812
1813#[derive(Clone, Debug, Serialize, Deserialize)]
1814struct LocalManifest {
1815 runtime_api: u32,
1816 #[serde(default)]
1817 components: BTreeMap<String, InstalledComponent>,
1818}
1819
1820impl Default for LocalManifest {
1821 fn default() -> Self {
1822 Self {
1823 runtime_api: RUNTIME_API_VERSION,
1824 components: BTreeMap::new(),
1825 }
1826 }
1827}
1828
1829#[derive(Clone, Debug, Serialize, Deserialize)]
1830struct InstalledComponent {
1831 version: Version,
1832 content_hash: String,
1833 #[serde(default)]
1834 dependencies: Vec<String>,
1835}
1836
1837#[derive(Clone, Debug)]
1838pub struct CheckReport {
1839 pub components: usize,
1840 pub entry: ModuleId,
1841 pub host_validation_required: bool,
1842 pub initial_view_validated: bool,
1843}
1844
1845impl CheckReport {
1846 #[must_use]
1847 pub fn summary(&self) -> String {
1848 if self.host_validation_required && !self.initial_view_validated {
1849 format!(
1850 "Static check passed: entry `{}`, {} installed component(s). Host capability lifecycle and its dependent initial view were not executed.",
1851 self.entry, self.components
1852 )
1853 } else if self.host_validation_required {
1854 format!(
1855 "Static check passed: entry `{}`, {} installed component(s). Host capability lifecycle validation was not executed.",
1856 self.entry, self.components
1857 )
1858 } else {
1859 format!(
1860 "Check passed: entry `{}`, {} installed component(s), including hostless lifecycle validation.",
1861 self.entry, self.components
1862 )
1863 }
1864 }
1865}
1866
1867#[derive(Debug, Error)]
1868pub enum ProjectError {
1869 #[error("I/O failed for `{path}`: {source}")]
1870 Io {
1871 path: PathBuf,
1872 #[source]
1873 source: std::io::Error,
1874 },
1875 #[error("I/O failed for `{path}`: {source}; rollback was incomplete: {rollback}")]
1876 ApplyRollback {
1877 path: PathBuf,
1878 #[source]
1879 source: std::io::Error,
1880 rollback: String,
1881 },
1882 #[error("invalid Cargo.toml `{path}`: {source}")]
1883 CargoToml {
1884 path: PathBuf,
1885 source: toml_edit::TomlError,
1886 },
1887 #[error("Cargo.toml `{0}` has no gpui-rhai dependency to configure")]
1888 MissingRuntimeDependency(PathBuf),
1889 #[error("Cargo.toml `{0}` uses an unsupported gpui-rhai dependency shape")]
1890 InvalidRuntimeDependency(PathBuf),
1891 #[error("Cargo.toml `{0}` has a non-array gpui-rhai feature list")]
1892 InvalidRuntimeFeatures(PathBuf),
1893 #[error("incompatible GPUI dependency family: {0}")]
1894 GpuiDependency(String),
1895 #[error("initialization would overwrite existing file `{0}`")]
1896 WouldOverwrite(PathBuf),
1897 #[error("file `{path}` changed after planning: {detail}")]
1898 ConcurrentChange { path: PathBuf, detail: String },
1899 #[error("path `{0}` has no parent directory")]
1900 InvalidPath(PathBuf),
1901 #[error("component registry contains duplicate `{0}`")]
1902 DuplicateRegistry(ModuleId),
1903 #[error("component catalog id `{catalog}` differs from source metadata `{metadata}`")]
1904 RegistryIdMismatch { catalog: String, metadata: ModuleId },
1905 #[error(
1906 "component `{component}` asset metadata differs from bundled assets: declared {declared:?}, bundled {bundled:?}"
1907 )]
1908 RegistryAssetMismatch {
1909 component: ModuleId,
1910 declared: BTreeSet<String>,
1911 bundled: BTreeSet<String>,
1912 },
1913 #[error("component `{0}` is not in the bundled registry")]
1914 UnknownComponent(ModuleId),
1915 #[error("component registry dependency cycle: {0:?}")]
1916 RegistryCycle(Vec<ModuleId>),
1917 #[error("at least one component name is required")]
1918 NoComponents,
1919 #[error("component `{0}` is incompatible with this runtime API")]
1920 IncompatibleRuntime(ModuleId),
1921 #[error("module `{0}` is not an official component path")]
1922 InvalidComponentPath(ModuleId),
1923 #[error("baseline hash for `{0}` does not match the install manifest")]
1924 BaselineHash(ModuleId),
1925 #[error("asset `{0}` has source without a baseline or a baseline without source")]
1926 IncompleteAssetBaseline(String),
1927 #[error("installed metadata for `{0}` does not match its manifest")]
1928 InstalledMetadata(ModuleId),
1929 #[error("component `{0}` did not call define_component")]
1930 MissingExport(ModuleId),
1931 #[error("component `{component}` source documentation is missing {missing}")]
1932 ComponentDocumentation {
1933 component: ModuleId,
1934 missing: &'static str,
1935 },
1936 #[error("application entry must declare `view(ctx)`")]
1937 MissingView,
1938 #[error("Rhai known-call validation failed:\n{0}")]
1939 KnownCalls(String),
1940 #[error("manifest requires runtime API {required}, current API is {actual}")]
1941 ManifestRuntime { required: u32, actual: u32 },
1942 #[error("generated host requires manifest entry `main`, got `{0}`")]
1943 ManifestEntry(ModuleId),
1944 #[error("embedded asset `{0}` has an unsupported image extension")]
1945 UnsupportedAsset(String),
1946 #[error("font `{0}` has an unsupported extension; expected ttf, otf, or ttc")]
1947 UnsupportedFont(String),
1948 #[error("font validation failed: {0}")]
1949 Font(String),
1950 #[error("asset validation failed: {0}")]
1951 Asset(String),
1952 #[error("embedded assets resolve to duplicate logical ID `app/{0}`")]
1953 DuplicateAssetLogical(String),
1954 #[error("theme validation failed: {0}")]
1955 Theme(String),
1956 #[error("component stylesheet validation failed: {0}")]
1957 ComponentStyle(String),
1958 #[error("locale validation failed: {0}")]
1959 Locale(String),
1960 #[error("component update conflicts require inspection: {0:?}")]
1961 UpdateConflictsWritten(Vec<ModuleId>),
1962 #[error("failed to start Cargo: {0}")]
1963 Spawn(std::io::Error),
1964 #[error("Cargo application exited unsuccessfully with code {0:?}")]
1965 DevFailed(Option<i32>),
1966 #[error(transparent)]
1967 ModuleId(#[from] gpui_rhai::ModuleIdError),
1968 #[error(transparent)]
1969 Header(#[from] gpui_rhai::ComponentHeaderError),
1970 #[error(transparent)]
1971 Component(#[from] gpui_rhai::ComponentError),
1972 #[error(transparent)]
1973 ComponentExport(#[from] gpui_rhai::ComponentExportError),
1974 #[error(transparent)]
1975 Capability(#[from] gpui_rhai::CapabilityError),
1976 #[error(transparent)]
1977 Runtime(#[from] gpui_rhai::RuntimeError),
1978 #[error(transparent)]
1979 KnownCallLint(#[from] gpui_rhai::KnownCallLintError),
1980 #[error(transparent)]
1981 Lifecycle(#[from] gpui_rhai::LifecycleError),
1982 #[error(transparent)]
1983 Source(#[from] gpui_rhai::ScriptSourceError),
1984 #[error(transparent)]
1985 TomlSerialize(#[from] toml::ser::Error),
1986 #[error(transparent)]
1987 TomlDeserialize(#[from] toml::de::Error),
1988 #[error(transparent)]
1989 JsonSerialize(#[from] serde_json::Error),
1990 #[error("Theme Studio failed: {0}")]
1991 ThemeStudio(String),
1992 #[error("Gallery failed: {0}")]
1993 Gallery(String),
1994}
1995
1996#[cfg(test)]
1997mod tests {
1998 use super::*;
1999
2000 fn fixture() -> tempfile::TempDir {
2001 let directory = tempfile::tempdir().unwrap();
2002 fs::create_dir_all(directory.path().join("src")).unwrap();
2003 fs::write(
2004 directory.path().join("Cargo.toml"),
2005 "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\n",
2006 )
2007 .unwrap();
2008 directory
2009 }
2010
2011 #[test]
2012 fn bundled_component_icons_share_an_explicit_24px_coordinate_space() {
2013 for (name, source) in [
2014 ("check", CHECK_SVG),
2015 ("close", CLOSE_SVG),
2016 ("chevron_left", CHEVRON_LEFT_SVG),
2017 ("chevron_right", CHEVRON_RIGHT_SVG),
2018 ("calendar", CALENDAR_SVG),
2019 ("date_previous", DATE_PREVIOUS_SVG),
2020 ("date_next", DATE_NEXT_SVG),
2021 ("disclosure_down", DISCLOSURE_DOWN_SVG),
2022 ("sort_ascending", SORT_ASCENDING_SVG),
2023 ("sort_descending", SORT_DESCENDING_SVG),
2024 ("chevron_down", CHEVRON_DOWN_SVG),
2025 ("chevron_up", CHEVRON_UP_SVG),
2026 ("minus", MINUS_SVG),
2027 ("plus", PLUS_SVG),
2028 ("search", SEARCH_SVG),
2029 ("info", INFO_SVG),
2030 ("warning", WARNING_SVG),
2031 ("help", HELP_SVG),
2032 ] {
2033 assert!(source.contains("width=\"24\""), "{name} has no 24px width");
2034 assert!(
2035 source.contains("height=\"24\""),
2036 "{name} has no 24px height"
2037 );
2038 assert!(
2039 source.contains("viewBox=\"0 0 24 24\""),
2040 "{name} has a non-standard viewBox"
2041 );
2042 }
2043 }
2044
2045 #[test]
2046 fn dry_run_plan_has_zero_writes_until_applied() {
2047 let directory = fixture();
2048 let project = Project::new(directory.path());
2049 let plan = project.plan_init().unwrap();
2050 assert!(!plan.writes.is_empty());
2051 assert!(!directory.path().join("ui/main.rhai").exists());
2052 drop(plan);
2053 assert!(!directory.path().join("ui/main.rhai").exists());
2054 }
2055
2056 #[test]
2057 fn init_add_and_check_form_a_reproducible_flow() {
2058 let directory = fixture();
2059 let project = Project::new(directory.path());
2060 project.plan_init().unwrap().apply().unwrap();
2061 project
2062 .plan_add(
2063 &BundledRegistry::load().unwrap(),
2064 &["button".to_owned(), "label".to_owned()],
2065 )
2066 .unwrap()
2067 .apply()
2068 .unwrap();
2069 let report = project.check().unwrap();
2070 assert_eq!(report.components, 2);
2071 assert!(
2072 directory
2073 .path()
2074 .join(".gpui-rhai/baselines/components/button.rhai")
2075 .exists()
2076 );
2077 }
2078
2079 #[test]
2080 fn check_rejects_old_or_misaligned_gpui_package_identity() {
2081 let directory = fixture();
2082 let cargo = directory.path().join("Cargo.toml");
2083 fs::write(
2084 &cargo,
2085 "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\ngpui = \"=0.2.2\"\n",
2086 )
2087 .unwrap();
2088 assert!(matches!(
2089 validate_gpui_dependency_family(&cargo),
2090 Err(ProjectError::GpuiDependency(_))
2091 ));
2092
2093 fs::write(
2094 &cargo,
2095 "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\ngpui = { package = \"gpui-pre\", version = \"=0.3.7\", default-features = false }\ngpui_platform = { package = \"gpui-pre-platform\", version = \"=0.3.7\", default-features = false }\n",
2096 )
2097 .unwrap();
2098 validate_gpui_dependency_family(&cargo).unwrap();
2099
2100 fs::write(
2101 &cargo,
2102 "[workspace]\nmembers = []\n\n[workspace.dependencies]\ngpui = { package = \"gpui-pre\", version = \"=0.3.7\", default-features = false }\ngpui_platform = { package = \"gpui-pre-platform\", version = \"=0.3.7\", default-features = false }\n\n[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\ngpui.workspace = true\ngpui_platform.workspace = true\n",
2103 )
2104 .unwrap();
2105 validate_gpui_dependency_family(&cargo).unwrap();
2106 }
2107
2108 #[test]
2109 fn check_validates_the_typed_component_stylesheet() {
2110 let directory = fixture();
2111 let project = Project::new(directory.path());
2112 project.plan_init().unwrap().apply().unwrap();
2113 project
2114 .plan_add(&BundledRegistry::load().unwrap(), &["button".to_owned()])
2115 .unwrap()
2116 .apply()
2117 .unwrap();
2118 fs::write(
2119 directory.path().join("ui/main.rhai"),
2120 r#"import "components/button" as button;
2121 fn view(ctx) { button::Button(#{ text: "Styled" }) }
2122 "#,
2123 )
2124 .unwrap();
2125 let styles = directory.path().join("ui/styles.rhai");
2126 fs::write(
2127 &styles,
2128 r#"fn component_styles() {
2129 #{ "components/button": #{ root: style().height(px(34)) } }
2130 }
2131 "#,
2132 )
2133 .unwrap();
2134 project.check().unwrap();
2135
2136 fs::write(
2137 styles,
2138 r#"fn component_styles() {
2139 #{ "components/button": #{ missing: style() } }
2140 }
2141 "#,
2142 )
2143 .unwrap();
2144 let error = project.check().unwrap_err();
2145 assert!(matches!(error, ProjectError::ComponentStyle(_)));
2146 assert!(error.to_string().contains("unknown part"));
2147 }
2148
2149 #[test]
2150 fn check_executes_the_headless_initial_view() {
2151 let directory = fixture();
2152 let project = Project::new(directory.path());
2153 project.plan_init().unwrap().apply().unwrap();
2154 fs::write(
2155 directory.path().join("ui/main.rhai"),
2156 "fn view(ctx) { throw \"headless render failed\"; }\n",
2157 )
2158 .unwrap();
2159 assert!(matches!(
2160 project.check(),
2161 Err(ProjectError::Lifecycle(_) | ProjectError::Runtime(_))
2162 ));
2163 }
2164
2165 #[test]
2166 fn check_separates_declared_host_capabilities_from_static_view_validation() {
2167 let directory = fixture();
2168 let project = Project::new(directory.path());
2169 project.plan_init().unwrap().apply().unwrap();
2170 fs::write(
2171 directory.path().join("ui/app.toml"),
2172 "entry = \"main\"\nruntime_api = 2\n\n[capabilities]\n\"app.demo\" = \"*\"\n",
2173 )
2174 .unwrap();
2175 fs::write(
2176 directory.path().join("ui/main.rhai"),
2177 r#"
2178 fn init(ctx) { let value = ctx.call_capability("app.demo", "ping", ()); }
2179 fn view(ctx) { column([text("hello")]) }
2180 "#,
2181 )
2182 .unwrap();
2183 let report = project.check().unwrap();
2184 assert!(report.host_validation_required);
2185 assert!(!report.initial_view_validated);
2186 assert!(report.summary().contains("not executed"));
2187
2188 fs::write(
2189 directory.path().join("ui/main.rhai"),
2190 r#"
2191 fn state_schema() { #{ fields: #{ ready: #{ schema: #{ type: "bool" },
2192 "default": #{ type: "bool", value: false } } } } }
2193 fn init(ctx) { ctx.set_state("ready", true); }
2194 fn view(ctx) {
2195 if !ctx.get_state("ready") { throw "init state is required"; }
2196 text("ready")
2197 }
2198 "#,
2199 )
2200 .unwrap();
2201 assert!(!project.check().unwrap().initial_view_validated);
2202
2203 fs::write(
2204 directory.path().join("ui/main.rhai"),
2205 "fn view(ctx) { throw \"invalid initial view\"; }\n",
2206 )
2207 .unwrap();
2208 assert!(matches!(
2209 project.check(),
2210 Err(ProjectError::Lifecycle(_) | ProjectError::Runtime(_))
2211 ));
2212 }
2213
2214 #[test]
2215 fn check_accepts_nested_templates_and_imports_after_them() {
2216 let directory = fixture();
2217 let project = Project::new(directory.path());
2218 project.plan_init().unwrap().apply().unwrap();
2219 project
2220 .plan_add(&BundledRegistry::load().unwrap(), &["button".to_owned()])
2221 .unwrap()
2222 .apply()
2223 .unwrap();
2224 fs::write(
2225 directory.path().join("ui/main.rhai"),
2226 r#"
2227 fn nested_label() {
2228 `外层${if true { "中文" } else { `内层${1}` }}结束`
2229 }
2230 import "components/button" as button;
2231 fn view(ctx) {
2232 column([
2233 text(nested_label()),
2234 button::Button(#{ text: "Ready" }),
2235 ])
2236 }
2237 "#,
2238 )
2239 .unwrap();
2240
2241 let report = project.check().unwrap();
2242 assert_eq!(report.components, 1);
2243 }
2244
2245 #[test]
2246 fn check_lints_known_calls_in_unexecuted_branches() {
2247 let directory = fixture();
2248 let project = Project::new(directory.path());
2249 project.plan_init().unwrap().apply().unwrap();
2250 fs::write(
2251 directory.path().join("ui/main.rhai"),
2252 r#"
2253 fn view(ctx) {
2254 if false { text(); }
2255 text("still renders")
2256 }
2257 "#,
2258 )
2259 .unwrap();
2260
2261 let error = project.check().unwrap_err();
2262 assert!(matches!(error, ProjectError::KnownCalls(_)));
2263 assert!(error.to_string().contains("text/0"));
2264 assert!(error.to_string().contains("expected 1"));
2265 }
2266
2267 #[test]
2268 fn init_never_overwrites_existing_main() {
2269 let directory = fixture();
2270 fs::write(directory.path().join("src/main.rs"), "fn main() {}\n").unwrap();
2271 let project = Project::new(directory.path());
2272 project.plan_init().unwrap().apply().unwrap();
2273 assert_eq!(
2274 read(&directory.path().join("src/main.rs")).unwrap(),
2275 "fn main() {}\n"
2276 );
2277 assert!(directory.path().join("gpui-rhai-host-snippet.rs").exists());
2278 }
2279
2280 #[test]
2281 fn apply_detects_changes_after_planning() {
2282 let directory = fixture();
2283 let project = Project::new(directory.path());
2284 let plan = project.plan_init().unwrap();
2285 fs::write(
2286 directory.path().join("Cargo.toml"),
2287 "[package]\nname = \"changed\"\nversion = \"0.1.0\"\n",
2288 )
2289 .unwrap();
2290 assert!(matches!(
2291 plan.apply(),
2292 Err(ProjectError::ConcurrentChange { .. })
2293 ));
2294 }
2295
2296 #[test]
2297 fn apply_stages_every_write_before_replacing_any_target() {
2298 let directory = fixture();
2299 let original = read(&directory.path().join("Cargo.toml")).unwrap();
2300 let blocked = directory.path().join("blocked");
2301 fs::write(&blocked, "not a directory").unwrap();
2302 let mut plan = ProjectPlan::new(directory.path().to_path_buf());
2303 plan.update(
2304 directory.path().join("Cargo.toml"),
2305 format!("{original}\n# planned\n"),
2306 original.clone(),
2307 );
2308 plan.writes.push(PlannedWrite {
2309 path: blocked.join("child.rhai"),
2310 content: "fn view() {}".to_owned(),
2311 expected: ExpectedFile::Missing,
2312 });
2313 assert!(plan.apply().is_err());
2314 assert_eq!(
2315 read(&directory.path().join("Cargo.toml")).unwrap(),
2316 original
2317 );
2318 }
2319
2320 #[test]
2321 fn embed_plan_contains_entry_components_theme_locale_and_assets() {
2322 let directory = fixture();
2323 let project = Project::new(directory.path());
2324 project.plan_init().unwrap().apply().unwrap();
2325 project
2326 .plan_add(&BundledRegistry::load().unwrap(), &["button".to_owned()])
2327 .unwrap()
2328 .apply()
2329 .unwrap();
2330 fs::create_dir_all(directory.path().join("ui/locales")).unwrap();
2331 fs::create_dir_all(directory.path().join("ui/assets")).unwrap();
2332 fs::create_dir_all(directory.path().join("ui/fonts")).unwrap();
2333 fs::write(directory.path().join("ui/locales/en.rhai"), "#{}\n").unwrap();
2334 fs::write(directory.path().join("ui/assets/check.svg"), "<svg/>\n").unwrap();
2335 fs::write(
2336 directory.path().join("ui/fonts/art.otf"),
2337 [b"OTTO".as_slice(), &[0, 1, 2]].concat(),
2338 )
2339 .unwrap();
2340
2341 project.plan_embed().unwrap().apply().unwrap();
2342 let generated = read(&directory.path().join("src/gpui_rhai_embedded.rs")).unwrap();
2343 assert!(generated.contains("pub fn app_manifest()"));
2344 assert!(generated.contains("AppManifest::new"));
2345 assert!(generated.contains("pub fn asset_sources()"));
2346 assert!(generated.contains("pub fn font_sources()"));
2347 assert!(generated.contains("image/svg+xml"));
2348 assert!(generated.contains("components/button"));
2349 assert!(generated.contains("THEME_SOURCE"));
2350 assert!(generated.contains("COMPONENT_STYLES_SOURCE"));
2351 assert!(generated.contains("locales/en.rhai"));
2352 assert!(generated.contains("assets/check.svg"));
2353 assert!(generated.contains("fonts/art.otf"));
2354 }
2355
2356 #[test]
2357 fn embedded_manifest_uses_immutable_bindings_with_or_without_capabilities() {
2358 for with_capabilities in [false, true] {
2359 let mut manifest = AppManifest::new(ModuleId::parse("main").unwrap());
2360 if with_capabilities {
2361 manifest = manifest
2362 .with_capability("app.alpha", "^1.0")
2363 .unwrap()
2364 .with_capability("app.beta", "*")
2365 .unwrap();
2366 }
2367 let generated = generated_embed_module(&manifest, &[], &[], &[], &[], &[]).unwrap();
2368 assert!(generated.contains("let manifest = gpui_rhai::AppManifest::new("));
2369 assert!(!generated.contains("let mut manifest"));
2370 assert!(!generated.contains("#[allow(unused_mut)]"));
2371 assert_eq!(
2372 generated
2373 .matches("let manifest = manifest.with_capability(")
2374 .count(),
2375 usize::from(with_capabilities) * 2
2376 );
2377 if with_capabilities {
2378 assert!(generated.contains(
2379 "let manifest = manifest.with_capability(\"app.alpha\", \"^1.0\").expect(\"generated capability requirement\");"
2380 ));
2381 assert!(generated.contains(
2382 "let manifest = manifest.with_capability(\"app.beta\", \"*\").expect(\"generated capability requirement\");"
2383 ));
2384 }
2385 }
2386 }
2387
2388 #[test]
2389 fn adding_icon_installs_core_svg_asset_pack() {
2390 let directory = fixture();
2391 let project = Project::new(directory.path());
2392 project.plan_init().unwrap().apply().unwrap();
2393 project
2394 .plan_add(&BundledRegistry::load().unwrap(), &["icon".to_owned()])
2395 .unwrap()
2396 .apply()
2397 .unwrap();
2398 project.check().unwrap();
2399 assert!(directory.path().join("ui/assets/icons/check.svg").exists());
2400 assert!(directory.path().join("ui/assets/icons/close.svg").exists());
2401 assert!(directory.path().join("ui/assets/icons/search.svg").exists());
2402 assert!(
2403 directory
2404 .path()
2405 .join(".gpui-rhai/baselines/assets/icons/check.svg")
2406 .exists()
2407 );
2408 }
2409
2410 #[test]
2411 fn adding_chart_installs_source_and_enables_the_runtime_feature() {
2412 let directory = fixture();
2413 let project = Project::new(directory.path());
2414 project.plan_init().unwrap().apply().unwrap();
2415 project
2416 .plan_add(&BundledRegistry::load().unwrap(), &["bar_chart".to_owned()])
2417 .unwrap()
2418 .apply()
2419 .unwrap();
2420
2421 assert!(directory.path().join("ui/charts/chart.rhai").exists());
2422 assert!(directory.path().join("ui/charts/bar_chart.rhai").exists());
2423 let cargo = read(&directory.path().join("Cargo.toml")).unwrap();
2424 let cargo = cargo.parse::<DocumentMut>().unwrap();
2425 let features = cargo["dependencies"]["gpui-rhai"]["features"]
2426 .as_array()
2427 .unwrap();
2428 assert!(
2429 features
2430 .iter()
2431 .any(|value| value.as_str() == Some("charts"))
2432 );
2433 project.check().unwrap();
2434 }
2435
2436 #[test]
2437 fn complex_components_install_transitive_sources_and_assets() {
2438 let directory = fixture();
2439 let project = Project::new(directory.path());
2440 project.plan_init().unwrap().apply().unwrap();
2441 project
2442 .plan_add(
2443 &BundledRegistry::load().unwrap(),
2444 &[
2445 "pagination".to_owned(),
2446 "table".to_owned(),
2447 "textarea".to_owned(),
2448 "date_picker".to_owned(),
2449 ],
2450 )
2451 .unwrap()
2452 .apply()
2453 .unwrap();
2454 let report = project.check().unwrap();
2455 assert_eq!(report.components, 10);
2456 for component in [
2457 "badge.rhai",
2458 "button.rhai",
2459 "icon.rhai",
2460 "input.rhai",
2461 "combobox.rhai",
2462 "select.rhai",
2463 "pagination.rhai",
2464 "table.rhai",
2465 "textarea.rhai",
2466 "date_picker.rhai",
2467 ] {
2468 assert!(
2469 directory
2470 .path()
2471 .join("ui/components")
2472 .join(component)
2473 .exists()
2474 );
2475 }
2476 for asset in [
2477 "icons/check.svg",
2478 "icons/close.svg",
2479 "icons/search.svg",
2480 "icons/chevron_left.svg",
2481 "icons/chevron_right.svg",
2482 "icons/chevron_down.svg",
2483 "icons/chevron_up.svg",
2484 "icons/calendar.svg",
2485 "icons/date_previous.svg",
2486 "icons/date_next.svg",
2487 "icons/disclosure_down.svg",
2488 "icons/sort_ascending.svg",
2489 "icons/sort_descending.svg",
2490 ] {
2491 assert!(directory.path().join("ui/assets").join(asset).exists());
2492 }
2493 }
2494
2495 #[test]
2496 fn every_bundled_component_passes_known_call_validation() {
2497 let directory = fixture();
2498 let project = Project::new(directory.path());
2499 project.plan_init().unwrap().apply().unwrap();
2500 let registry = BundledRegistry::load().unwrap();
2501 assert_eq!(registry.entries.len(), 77);
2502 let requested = registry
2503 .entries
2504 .keys()
2505 .map(ToString::to_string)
2506 .collect::<Vec<_>>();
2507 project
2508 .plan_add(®istry, &requested)
2509 .unwrap()
2510 .apply()
2511 .unwrap();
2512
2513 let report = project.check().unwrap();
2514 assert_eq!(report.components, registry.entries.len());
2515 }
2516
2517 #[test]
2518 fn check_rejects_invalid_locales_and_duplicate_logical_assets() {
2519 let directory = fixture();
2520 let project = Project::new(directory.path());
2521 project.plan_init().unwrap().apply().unwrap();
2522 fs::write(
2523 directory.path().join("ui/locales/en.rhai"),
2524 "fn locale() { #{ locale: \"en\", direction: \"left_to_right\", messages: #{} } }",
2525 )
2526 .unwrap();
2527 assert!(matches!(project.check(), Err(ProjectError::Locale(_))));
2528
2529 fs::write(directory.path().join("ui/locales/en.rhai"), EN_LOCALE).unwrap();
2530 fs::create_dir_all(directory.path().join("ui/assets/icons")).unwrap();
2531 fs::write(directory.path().join("ui/assets/icons/check.svg"), "<svg/>").unwrap();
2532 fs::write(directory.path().join("ui/assets/icons/check.png"), [0u8]).unwrap();
2533 assert!(matches!(
2534 project.check(),
2535 Err(ProjectError::DuplicateAssetLogical(name)) if name == "icons/check"
2536 ));
2537 }
2538
2539 fn bumped_button_registry(upstream_purpose: &str) -> BundledRegistry {
2540 let mut registry = BundledRegistry::load().unwrap();
2541 let id = ModuleId::parse("components/button").unwrap();
2542 let entry = registry.entries.get_mut(&id).unwrap();
2543 let installed_version = entry.metadata.version.to_string();
2544 let source = entry
2545 .source
2546 .replace(&installed_version, "0.2.0")
2547 .replace("// Button presents a desktop action.", upstream_purpose);
2548 let source: &'static str = Box::leak(source.into_boxed_str());
2549 entry.metadata = parse_component_header(source).unwrap();
2550 entry.source = source;
2551 registry
2552 }
2553
2554 #[test]
2555 fn update_three_way_merges_non_overlapping_local_changes() {
2556 let directory = fixture();
2557 let project = Project::new(directory.path());
2558 project.plan_init().unwrap().apply().unwrap();
2559 project
2560 .plan_add(&BundledRegistry::load().unwrap(), &["button".to_owned()])
2561 .unwrap()
2562 .apply()
2563 .unwrap();
2564 let source_path = directory.path().join("ui/components/button.rhai");
2565 let local = format!(
2566 "{}\n// application-owned footer\n",
2567 read(&source_path).unwrap()
2568 );
2569 fs::write(&source_path, &local).unwrap();
2570 let registry = bumped_button_registry("// Button presents a refined desktop action.");
2571 let plan = project.plan_update(®istry).unwrap();
2572 assert!(plan.conflicts().is_empty());
2573 plan.apply().unwrap();
2574
2575 let merged = read(&source_path).unwrap();
2576 assert!(merged.contains("refined desktop action"));
2577 assert!(merged.contains("application-owned footer"));
2578 assert!(merged.contains("0.2.0"));
2579 assert_eq!(
2580 read(
2581 &directory
2582 .path()
2583 .join(".gpui-rhai/baselines/components/button.rhai")
2584 )
2585 .unwrap(),
2586 registry.entries[&ModuleId::parse("components/button").unwrap()].source
2587 );
2588 }
2589
2590 #[test]
2591 fn update_installs_new_transitive_dependencies_and_records_the_target_graph() {
2592 let directory = fixture();
2593 let project = Project::new(directory.path());
2594 project.plan_init().unwrap().apply().unwrap();
2595 project
2596 .plan_add(&BundledRegistry::load().unwrap(), &["button".to_owned()])
2597 .unwrap()
2598 .apply()
2599 .unwrap();
2600
2601 let mut registry = BundledRegistry::load().unwrap();
2602 let button = ModuleId::parse("components/button").unwrap();
2603 let entry = registry.entries.get_mut(&button).unwrap();
2604 let installed_version = entry.metadata.version.to_string();
2605 let source = entry
2606 .source
2607 .replace(&installed_version, "0.2.0")
2608 .replace("dependencies: []", "dependencies: [\"components/badge\"]")
2609 .replace(
2610 "\"dependencies\": []",
2611 "\"dependencies\": [\"components/badge\"]",
2612 );
2613 let source: &'static str = Box::leak(source.into_boxed_str());
2614 entry.metadata = parse_component_header(source).unwrap();
2615 entry.source = source;
2616
2617 project.plan_update(®istry).unwrap().apply().unwrap();
2618 assert!(directory.path().join("ui/components/badge.rhai").exists());
2619 let manifest: LocalManifest =
2620 toml::from_str(&read(&directory.path().join(".gpui-rhai/manifest.toml")).unwrap())
2621 .unwrap();
2622 assert!(manifest.components.contains_key("components/badge"));
2623 assert_eq!(
2624 manifest.components["components/button"].dependencies,
2625 vec!["components/badge"]
2626 );
2627 }
2628
2629 #[test]
2630 fn update_adds_missing_bundled_themes_without_overwriting_owned_sources() {
2631 let directory = fixture();
2632 let project = Project::new(directory.path());
2633 project.plan_init().unwrap().apply().unwrap();
2634 let owned = directory.path().join("ui/themes/nord.rhai");
2635 fs::write(&owned, "// application-owned Nord\n").unwrap();
2636 let missing = directory.path().join("ui/themes/ethereal.rhai");
2637 fs::remove_file(&missing).unwrap();
2638 let styles = directory.path().join("ui/styles.rhai");
2639 fs::remove_file(&styles).unwrap();
2640
2641 project
2642 .plan_update(&BundledRegistry::load().unwrap())
2643 .unwrap()
2644 .apply()
2645 .unwrap();
2646
2647 assert_eq!(read(&owned).unwrap(), "// application-owned Nord\n");
2648 assert_eq!(read(&missing).unwrap(), ETHEREAL_THEME);
2649 assert_eq!(read(&styles).unwrap(), DEFAULT_COMPONENT_STYLES);
2650 }
2651
2652 #[test]
2653 fn update_conflict_preserves_sources_and_writes_artifact() {
2654 let directory = fixture();
2655 let project = Project::new(directory.path());
2656 project.plan_init().unwrap().apply().unwrap();
2657 project
2658 .plan_add(&BundledRegistry::load().unwrap(), &["button".to_owned()])
2659 .unwrap()
2660 .apply()
2661 .unwrap();
2662 let source_path = directory.path().join("ui/components/button.rhai");
2663 let baseline_path = directory
2664 .path()
2665 .join(".gpui-rhai/baselines/components/button.rhai");
2666 let baseline = read(&baseline_path).unwrap();
2667 let local = baseline.replace(
2668 "// Button presents a desktop action.",
2669 "// Button presents the application's custom action.",
2670 );
2671 fs::write(&source_path, &local).unwrap();
2672 let registry = bumped_button_registry("// Button presents the registry action.");
2673 let plan = project.plan_update(®istry).unwrap();
2674 assert_eq!(
2675 plan.conflicts(),
2676 &[ModuleId::parse("components/button").unwrap()]
2677 );
2678 plan.apply().unwrap();
2679
2680 assert_eq!(read(&source_path).unwrap(), local);
2681 assert_eq!(read(&baseline_path).unwrap(), baseline);
2682 let artifact = read(
2683 &directory
2684 .path()
2685 .join(".gpui-rhai/conflicts/components/button.rhai"),
2686 )
2687 .unwrap();
2688 assert!(artifact.contains("<<<<<<< local"));
2689 assert!(artifact.contains("||||||| installed baseline"));
2690 assert!(artifact.contains(">>>>>>> bundled registry"));
2691 }
2692
2693 #[test]
2694 fn line_merge_combines_independent_insertions() {
2695 let merged = three_way_merge(
2696 "alpha\nbeta\n",
2697 "local\nalpha\nbeta\n",
2698 "alpha\nbeta\nupstream\n",
2699 )
2700 .unwrap();
2701 assert_eq!(merged, "local\nalpha\nbeta\nupstream\n");
2702 }
2703
2704 #[test]
2705 fn editor_metadata_comes_from_installed_component_schemas() {
2706 let directory = fixture();
2707 let project = Project::new(directory.path());
2708 project.plan_init().unwrap().apply().unwrap();
2709 project
2710 .plan_add(
2711 &BundledRegistry::load().unwrap(),
2712 &["combobox".to_owned(), "button".to_owned()],
2713 )
2714 .unwrap()
2715 .apply()
2716 .unwrap();
2717 let plan = project.plan_editor_metadata().unwrap();
2718 assert_eq!(plan.writes.len(), 3);
2719 plan.apply().unwrap();
2720
2721 let metadata: serde_json::Value = serde_json::from_str(
2722 &read(&directory.path().join(".gpui-rhai/editor/components.json")).unwrap(),
2723 )
2724 .unwrap();
2725 let components = metadata["components"].as_array().unwrap();
2726 let installed = components
2727 .iter()
2728 .map(|component| component["metadata"]["id"].as_str().unwrap())
2729 .collect::<BTreeSet<_>>();
2730 assert_eq!(
2731 installed,
2732 BTreeSet::from([
2733 "components/button",
2734 "components/combobox",
2735 "components/input",
2736 ])
2737 );
2738 assert!(components.iter().any(|component| {
2739 component["metadata"]["id"] == "components/combobox"
2740 && component["schema"]["parts"]
2741 .as_array()
2742 .is_some_and(|parts| parts.iter().any(|part| part == "option"))
2743 }));
2744 let snippets = read(&directory.path().join(".gpui-rhai/editor/snippets.json")).unwrap();
2745 assert!(snippets.contains("import \\\"components/combobox\\\" as combobox;"));
2746 assert!(snippets.contains("${1:key}"));
2747 assert!(snippets.contains("label: ${2:label}"));
2748 assert!(snippets.contains("options: ${4:options}"));
2749 let definitions = read(
2750 &directory
2751 .path()
2752 .join(".gpui-rhai/editor/definitions/gpui_rhai.d.rhai"),
2753 )
2754 .unwrap();
2755 assert!(definitions.contains("fn render_component"));
2756 assert!(definitions.contains("fn canvas_fill_path"));
2757 }
2758}