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