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