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