1use anyhow::Result;
2use fission::op::{Color, Fill};
3use fission::prelude::*;
4use fission_command_core::{read_project_config, DistributionProvider, Target};
5use fission_command_package::{CheckSeverity, CheckStatus, ReadinessCheck};
6use fission_command_release::{release_config_readiness_checks, release_content_readiness_checks};
7use std::path::PathBuf;
8
9#[derive(Clone, Debug)]
10pub struct ReleaseConfigEditorOptions {
11 pub project_dir: PathBuf,
12 pub provider: Option<DistributionProvider>,
13 pub screenshot: Option<PathBuf>,
14 pub exit_after_render: bool,
15 pub width: Option<u16>,
16 pub height: Option<u16>,
17}
18
19#[derive(Clone, Debug, PartialEq)]
20pub struct ReleaseConfigEditorState {
21 pub project_dir: PathBuf,
22 pub app_name: String,
23 pub app_id: String,
24 pub provider: DistributionProvider,
25 pub current_step: usize,
26 pub config_checks: Vec<EditorCheck>,
27 pub content_checks: Vec<EditorCheck>,
28 pub status_message: String,
29 pub theme_mode: EditorThemeMode,
30}
31
32impl GlobalState for ReleaseConfigEditorState {}
33
34impl ReleaseConfigEditorState {
35 pub fn load(options: ReleaseConfigEditorOptions) -> Self {
36 let project = read_project_config(&options.project_dir).ok();
37 let provider = options
38 .provider
39 .or_else(|| project.as_ref().and_then(default_provider_for_project))
40 .unwrap_or(DistributionProvider::PlayStore);
41 let mut state = Self {
42 project_dir: options.project_dir,
43 app_name: project
44 .as_ref()
45 .map(|project| project.app.name.clone())
46 .unwrap_or_else(|| "workspace".to_string()),
47 app_id: project
48 .as_ref()
49 .map(|project| project.app.app_id.clone())
50 .unwrap_or_else(|| "unknown".to_string()),
51 provider,
52 current_step: 1,
53 config_checks: Vec::new(),
54 content_checks: Vec::new(),
55 status_message: "Loading release configuration".to_string(),
56 theme_mode: EditorThemeMode::Dark,
57 };
58 state.refresh_checks();
59 state
60 }
61
62 pub fn refresh_checks(&mut self) {
63 self.config_checks =
64 release_config_readiness_checks(&self.project_dir, Some(self.provider))
65 .unwrap_or_else(|err| vec![readiness_error("release_config.editor.config", err)])
66 .into_iter()
67 .map(EditorCheck::from)
68 .collect();
69 self.content_checks =
70 release_content_readiness_checks(&self.project_dir, Some(self.provider))
71 .unwrap_or_else(|err| vec![readiness_error("release_config.editor.content", err)])
72 .into_iter()
73 .map(EditorCheck::from)
74 .collect();
75 self.status_message = "Release checks refreshed".to_string();
76 }
77
78 pub fn next_step(&mut self) {
79 self.current_step = (self.current_step + 1).min(RELEASE_CONFIG_STEPS.len());
80 }
81
82 pub fn previous_step(&mut self) {
83 self.current_step = self.current_step.saturating_sub(1).max(1);
84 }
85
86 pub fn handle_key(&mut self, code: &fission::KeyCode) -> bool {
87 match code {
88 fission::KeyCode::Left | fission::KeyCode::Up => {
89 self.previous_step();
90 true
91 }
92 fission::KeyCode::Right | fission::KeyCode::Down | fission::KeyCode::Enter => {
93 self.next_step();
94 true
95 }
96 _ => false,
97 }
98 }
99}
100
101#[derive(Clone, Debug, PartialEq)]
102pub struct EditorCheck {
103 pub id: String,
104 pub severity: CheckSeverity,
105 pub status: CheckStatus,
106 pub summary: String,
107 pub details: Option<String>,
108 pub remediation: Vec<String>,
109}
110
111impl From<ReadinessCheck> for EditorCheck {
112 fn from(check: ReadinessCheck) -> Self {
113 Self {
114 id: check.id,
115 severity: check.severity,
116 status: check.status,
117 summary: check.summary,
118 details: check.details,
119 remediation: check.remediation,
120 }
121 }
122}
123
124#[derive(Clone, Copy, Debug, Eq, PartialEq)]
125pub enum EditorThemeMode {
126 Dark,
127 Light,
128}
129
130impl EditorThemeMode {
131 fn toggle(self) -> Self {
132 match self {
133 Self::Dark => Self::Light,
134 Self::Light => Self::Dark,
135 }
136 }
137}
138
139#[derive(Clone)]
140pub struct ReleaseConfigEditorApp;
141
142impl From<ReleaseConfigEditorApp> for Widget {
143 fn from(_app: ReleaseConfigEditorApp) -> Self {
144 let (_ctx, view) = fission::build::current::<ReleaseConfigEditorState>();
145 let palette = EditorPalette::for_mode(view.state().theme_mode);
146 let viewport = view.env().viewport_size;
147 let width = viewport.width.max(100.0);
148 let height = viewport.height.max(34.0);
149 let body_h = (height - 9.0).max(22.0);
150 Container::new(Column {
151 gap: Some(1.0),
152 children: widgets![
153 EditorHeader,
154 Row {
155 gap: Some(1.0),
156 children: widgets![
157 EditorSidebar {
158 width: 28.0,
159 height: body_h
160 },
161 EditorBody {
162 width: (width - 33.0).max(68.0),
163 height: body_h
164 },
165 ],
166 ..Default::default()
167 },
168 EditorFooter,
169 ],
170 ..Default::default()
171 })
172 .width(width)
173 .height(height)
174 .padding([1.0, 1.0, 1.0, 1.0])
175 .bg(palette.background)
176 .into()
177 }
178}
179
180#[derive(Clone)]
181struct EditorHeader;
182
183impl From<EditorHeader> for Widget {
184 fn from(_header: EditorHeader) -> Self {
185 let (ctx, view) = fission::build::current::<ReleaseConfigEditorState>();
186 let palette = EditorPalette::for_mode(view.state().theme_mode);
187 let refresh = with_reducer!(ctx, ReleaseConfigRefresh, release_config_refresh);
188 let theme = with_reducer!(ctx, ReleaseConfigToggleTheme, release_config_toggle_theme);
189 Container::new(Row {
190 gap: Some(2.0),
191 children: widgets![
192 Column {
193 gap: Some(0.0),
194 children: widgets![
195 Text::new("Fission Release Config").color(palette.accent_text),
196 Text::new(format!(
197 "{} - {}",
198 view.state().app_name,
199 view.state().app_id
200 ))
201 .color(palette.accent_text),
202 ],
203 ..Default::default()
204 },
205 Spacer {
206 width: Some(2.0),
207 ..Default::default()
208 },
209 EditorButton {
210 label: "Refresh".into(),
211 action: Some(refresh),
212 tone: ButtonTone::Quiet,
213 width: 12.0
214 },
215 EditorButton {
216 label: "Theme".into(),
217 action: Some(theme),
218 tone: ButtonTone::Quiet,
219 width: 10.0
220 },
221 ],
222 ..Default::default()
223 })
224 .height(4.0)
225 .padding([1.0, 1.0, 1.0, 1.0])
226 .bg(palette.accent)
227 .into()
228 }
229}
230
231#[derive(Clone)]
232struct EditorSidebar {
233 width: f32,
234 height: f32,
235}
236
237impl From<EditorSidebar> for Widget {
238 fn from(sidebar: EditorSidebar) -> Self {
239 let (_ctx, view) = fission::build::current::<ReleaseConfigEditorState>();
240 let palette = EditorPalette::for_mode(view.state().theme_mode);
241 let mut children = widgets![Text::new("Workflow").color(palette.accent)];
242 for (index, step) in RELEASE_CONFIG_STEPS.iter().enumerate() {
243 let selected = view.state().current_step == index + 1;
244 let marker = if selected { ">" } else { " " };
245 children.push(
246 Text::new(format!("{marker} {}. {}", index + 1, step.title))
247 .color(if selected {
248 palette.accent
249 } else {
250 palette.text
251 })
252 .into(),
253 );
254 }
255 Container::new(Column {
256 gap: Some(1.0),
257 children,
258 ..Default::default()
259 })
260 .width(sidebar.width)
261 .height(sidebar.height)
262 .padding([1.0, 1.0, 1.0, 1.0])
263 .bg(palette.panel)
264 .border(palette.border, 1.0)
265 .into()
266 }
267}
268
269#[derive(Clone)]
270struct EditorBody {
271 width: f32,
272 height: f32,
273}
274
275impl From<EditorBody> for Widget {
276 fn from(body: EditorBody) -> Self {
277 let (_ctx, view) = fission::build::current::<ReleaseConfigEditorState>();
278 let palette = EditorPalette::for_mode(view.state().theme_mode);
279 let step = RELEASE_CONFIG_STEPS[view.state().current_step.saturating_sub(1)];
280 let checks = checks_for_current_step(view.state());
281 Container::new(Scroll {
282 direction: FlexDirection::Column,
283 width: Some((body.width - 2.0).max(1.0)),
284 height: Some((body.height - 2.0).max(1.0)),
285 show_scrollbar: true,
286 child: Some(
287 Column {
288 gap: Some(1.0),
289 children: widgets![
290 Text::new(format!(
291 "Step {} of {} - {}",
292 view.state().current_step,
293 RELEASE_CONFIG_STEPS.len(),
294 step.title
295 ))
296 .color(palette.accent),
297 Text::new(step.description).color(palette.text),
298 KeyValue {
299 label: "Provider".into(),
300 value: view.state().provider.as_str().into()
301 },
302 KeyValue {
303 label: "Project".into(),
304 value: view.state().project_dir.display().to_string()
305 },
306 CommandList {
307 commands: step.commands(view.state().provider)
308 },
309 CheckList {
310 title: step.check_title.into(),
311 checks
312 },
313 ],
314 ..Default::default()
315 }
316 .into(),
317 ),
318 ..Default::default()
319 })
320 .width(body.width)
321 .height(body.height)
322 .padding([1.0, 1.0, 1.0, 1.0])
323 .bg(palette.surface)
324 .border(palette.border, 1.0)
325 .into()
326 }
327}
328
329#[derive(Clone)]
330struct EditorFooter;
331
332impl From<EditorFooter> for Widget {
333 fn from(_footer: EditorFooter) -> Self {
334 let (ctx, view) = fission::build::current::<ReleaseConfigEditorState>();
335 let palette = EditorPalette::for_mode(view.state().theme_mode);
336 let previous = with_reducer!(ctx, ReleaseConfigPreviousStep, release_config_previous_step);
337 let next = with_reducer!(ctx, ReleaseConfigNextStep, release_config_next_step);
338 Container::new(Row {
339 gap: Some(1.0),
340 children: widgets![
341 Text::new(view.state().status_message.clone()).color(palette.muted),
342 Spacer {
343 width: Some(2.0),
344 ..Default::default()
345 },
346 EditorButton {
347 label: "Previous".into(),
348 action: Some(previous),
349 tone: ButtonTone::Quiet,
350 width: 12.0
351 },
352 EditorButton {
353 label: "Next".into(),
354 action: Some(next),
355 tone: ButtonTone::Primary,
356 width: 10.0
357 },
358 Text::new("Arrows move steps").color(palette.muted),
359 ],
360 ..Default::default()
361 })
362 .height(3.0)
363 .padding([0.0, 1.0, 0.0, 1.0])
364 .bg(palette.panel)
365 .into()
366 }
367}
368
369#[derive(Clone)]
370struct KeyValue {
371 label: String,
372 value: String,
373}
374
375impl From<KeyValue> for Widget {
376 fn from(row: KeyValue) -> Self {
377 let (_ctx, view) = fission::build::current::<ReleaseConfigEditorState>();
378 let palette = EditorPalette::for_mode(view.state().theme_mode);
379 Row {
380 gap: Some(1.0),
381 children: widgets![
382 Text::new(format!("{}:", row.label))
383 .color(palette.muted)
384 .width(14.0),
385 Text::new(row.value).color(palette.text),
386 ],
387 ..Default::default()
388 }
389 .into()
390 }
391}
392
393#[derive(Clone)]
394struct CommandList {
395 commands: Vec<String>,
396}
397
398impl From<CommandList> for Widget {
399 fn from(list: CommandList) -> Self {
400 let (_ctx, view) = fission::build::current::<ReleaseConfigEditorState>();
401 let palette = EditorPalette::for_mode(view.state().theme_mode);
402 let mut children = widgets![Text::new("Equivalent CLI").color(palette.accent)];
403 for command in list.commands {
404 children.push(Text::new(command).color(palette.text).into());
405 }
406 Column {
407 gap: Some(0.0),
408 children,
409 ..Default::default()
410 }
411 .into()
412 }
413}
414
415#[derive(Clone)]
416struct CheckList {
417 title: String,
418 checks: Vec<EditorCheck>,
419}
420
421impl From<CheckList> for Widget {
422 fn from(list: CheckList) -> Self {
423 let (_ctx, view) = fission::build::current::<ReleaseConfigEditorState>();
424 let palette = EditorPalette::for_mode(view.state().theme_mode);
425 let mut children = widgets![Text::new(list.title).color(palette.accent)];
426 if list.checks.is_empty() {
427 children.push(
428 Text::new("No checks available for this step yet.")
429 .color(palette.muted)
430 .into(),
431 );
432 }
433 for check in list.checks.into_iter().take(12) {
434 let color = match check.status {
435 CheckStatus::Passed => palette.success,
436 CheckStatus::Warning | CheckStatus::Skipped => palette.warning,
437 CheckStatus::Missing | CheckStatus::Failed => palette.error,
438 };
439 children.push(
440 Text::new(format!(
441 "[{}] {} - {}",
442 status_label(check.status),
443 check.id,
444 check.summary
445 ))
446 .color(color)
447 .into(),
448 );
449 if let Some(details) = check.details {
450 children.push(
451 Text::new(format!(" {details}"))
452 .color(palette.muted)
453 .into(),
454 );
455 }
456 }
457 Column {
458 gap: Some(0.0),
459 children,
460 ..Default::default()
461 }
462 .into()
463 }
464}
465
466#[derive(Clone)]
467struct EditorButton {
468 label: String,
469 action: Option<ActionEnvelope>,
470 tone: ButtonTone,
471 width: f32,
472}
473
474impl From<EditorButton> for Widget {
475 fn from(button: EditorButton) -> Self {
476 let (_ctx, view) = fission::build::current::<ReleaseConfigEditorState>();
477 let palette = EditorPalette::for_mode(view.state().theme_mode);
478 let bg = match button.tone {
479 ButtonTone::Primary => palette.accent,
480 ButtonTone::Quiet => palette.panel,
481 };
482 let fg = match button.tone {
483 ButtonTone::Primary => palette.accent_text,
484 ButtonTone::Quiet => palette.text,
485 };
486 Button {
487 on_press: button.action,
488 width: Some(button.width),
489 height: Some(3.0),
490 padding: Some([0.0, 1.0, 0.0, 1.0]),
491 background_fill: Some(Fill::Solid(bg)),
492 text_color: Some(fg),
493 child: Some(Text::new(button.label).color(fg).into()),
494 ..Default::default()
495 }
496 .into()
497 }
498}
499
500#[derive(Clone, Copy)]
501enum ButtonTone {
502 Primary,
503 Quiet,
504}
505
506#[derive(Clone, Copy)]
507struct EditorStep {
508 title: &'static str,
509 description: &'static str,
510 check_title: &'static str,
511}
512
513impl EditorStep {
514 fn commands(self, provider: DistributionProvider) -> Vec<String> {
515 match self.title {
516 "Manifest & identity" => vec![
517 "fission release-config validate --provider <provider>".to_string(),
518 "fission release-config set app.version <version> --dry-run --json".to_string(),
519 ],
520 "Release files" => vec![
521 "fission release-config write-file --release <id> --kind notes --locale en-US --from-file notes.md --dry-run --json".to_string(),
522 "fission release-content validate --provider <provider>".to_string(),
523 ],
524 "Provider baseline" => vec![
525 format!("fission release-config import --provider {} --dry-run --json", provider.as_str()),
526 format!("fission release-config lock --provider {} --yes", provider.as_str()),
527 ],
528 "Content assets" => vec![
529 format!("fission release-content render --provider {}", provider.as_str()),
530 format!("fission release-content push --provider {} --dry-run --json", provider.as_str()),
531 ],
532 _ => vec![
533 format!("fission release-config push --provider {} --dry-run --json", provider.as_str()),
534 "fission publish --provider <provider> --dry-run --json".to_string(),
535 ],
536 }
537 }
538}
539
540const RELEASE_CONFIG_STEPS: &[EditorStep] = &[
541 EditorStep {
542 title: "Manifest & identity",
543 description: "Check that fission.toml contains reviewable app identity, version/build, provider ids, and no secret material.",
544 check_title: "Release config checks",
545 },
546 EditorStep {
547 title: "Release files",
548 description: "Review the active release entry and sidecar files for notes, review info, privacy, and localized content.",
549 check_title: "Release config checks",
550 },
551 EditorStep {
552 title: "Provider baseline",
553 description: "Import or lock provider state before pushing local metadata, so remote changes are not overwritten accidentally.",
554 check_title: "Release config checks",
555 },
556 EditorStep {
557 title: "Content assets",
558 description: "Validate screenshots, videos, feature graphics, logos, and provider-specific asset directories before upload.",
559 check_title: "Release content checks",
560 },
561 EditorStep {
562 title: "Push & publish",
563 description: "Dry-run metadata and artifact publish paths, then use the shared publish workflow for the final mutation.",
564 check_title: "Release content checks",
565 },
566];
567
568#[derive(Clone, Copy)]
569struct EditorPalette {
570 background: Color,
571 surface: Color,
572 panel: Color,
573 border: Color,
574 text: Color,
575 muted: Color,
576 accent: Color,
577 accent_text: Color,
578 success: Color,
579 warning: Color,
580 error: Color,
581}
582
583impl EditorPalette {
584 fn for_mode(mode: EditorThemeMode) -> Self {
585 match mode {
586 EditorThemeMode::Dark => Self {
587 background: rgb(5, 10, 15),
588 surface: rgb(13, 22, 31),
589 panel: rgb(18, 30, 40),
590 border: rgb(53, 69, 82),
591 text: rgb(242, 247, 246),
592 muted: rgb(162, 177, 187),
593 accent: rgb(93, 213, 112),
594 accent_text: rgb(5, 22, 12),
595 success: rgb(93, 213, 112),
596 warning: rgb(244, 193, 70),
597 error: rgb(246, 84, 80),
598 },
599 EditorThemeMode::Light => Self {
600 background: rgb(238, 243, 240),
601 surface: rgb(250, 252, 249),
602 panel: rgb(235, 242, 238),
603 border: rgb(178, 194, 184),
604 text: rgb(17, 29, 33),
605 muted: rgb(80, 95, 101),
606 accent: rgb(24, 148, 72),
607 accent_text: rgb(245, 255, 248),
608 success: rgb(24, 148, 72),
609 warning: rgb(184, 117, 22),
610 error: rgb(200, 54, 55),
611 },
612 }
613 }
614}
615
616pub fn run_release_config_tui(options: ReleaseConfigEditorOptions) -> Result<()> {
617 let run_options = fission::terminal::TerminalRunOptions {
618 width: options.width,
619 height: options.height,
620 screenshot: options.screenshot.clone(),
621 exit_after_render: options.exit_after_render,
622 ..fission::terminal::TerminalRunOptions::default()
623 };
624 let state = ReleaseConfigEditorState::load(options);
625 fission::terminal::TerminalApp::with_state(ReleaseConfigEditorApp, state)
626 .with_title("Fission release config")
627 .with_env(|env| env.theme = fission::theme::Theme::dark())
628 .with_sync_env(|state, env| {
629 env.theme = match state.theme_mode {
630 EditorThemeMode::Dark => fission::theme::Theme::dark(),
631 EditorThemeMode::Light => fission::theme::Theme::default(),
632 };
633 })
634 .with_key_handler(release_config_key_handler)
635 .run_with_options(run_options)
636}
637
638fn release_config_key_handler(
639 state: &mut ReleaseConfigEditorState,
640 code: &fission::KeyCode,
641 _modifiers: u8,
642) -> bool {
643 state.handle_key(code)
644}
645
646#[fission_reducer(ReleaseConfigRefresh)]
647fn release_config_refresh(state: &mut ReleaseConfigEditorState) {
648 state.refresh_checks();
649}
650
651#[fission_reducer(ReleaseConfigNextStep)]
652fn release_config_next_step(state: &mut ReleaseConfigEditorState) {
653 state.next_step();
654}
655
656#[fission_reducer(ReleaseConfigPreviousStep)]
657fn release_config_previous_step(state: &mut ReleaseConfigEditorState) {
658 state.previous_step();
659}
660
661#[fission_reducer(ReleaseConfigToggleTheme)]
662fn release_config_toggle_theme(state: &mut ReleaseConfigEditorState) {
663 state.theme_mode = state.theme_mode.toggle();
664}
665
666fn checks_for_current_step(state: &ReleaseConfigEditorState) -> Vec<EditorCheck> {
667 match state.current_step {
668 4 | 5 => state.content_checks.clone(),
669 _ => state.config_checks.clone(),
670 }
671}
672
673fn default_provider_for_project(
674 project: &fission_command_core::FissionProject,
675) -> Option<DistributionProvider> {
676 if project.targets.contains(&Target::Android) {
677 Some(DistributionProvider::PlayStore)
678 } else if project.targets.contains(&Target::Ios) {
679 Some(DistributionProvider::AppStore)
680 } else if project.targets.contains(&Target::Windows) {
681 Some(DistributionProvider::MicrosoftStore)
682 } else if project.targets.contains(&Target::Site) {
683 Some(DistributionProvider::GithubPages)
684 } else {
685 None
686 }
687}
688
689fn readiness_error(id: &str, error: anyhow::Error) -> ReadinessCheck {
690 ReadinessCheck {
691 id: id.to_string(),
692 severity: CheckSeverity::Error,
693 status: CheckStatus::Failed,
694 summary: "release-config editor check failed".to_string(),
695 details: Some(error.to_string()),
696 remediation: vec!["Fix the reported error and refresh the editor.".to_string()],
697 }
698}
699
700fn status_label(status: CheckStatus) -> &'static str {
701 match status {
702 CheckStatus::Passed => "ok",
703 CheckStatus::Missing => "missing",
704 CheckStatus::Failed => "failed",
705 CheckStatus::Warning => "warning",
706 CheckStatus::Skipped => "skipped",
707 }
708}
709
710fn rgb(r: u8, g: u8, b: u8) -> Color {
711 Color { r, g, b, a: 255 }
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717
718 fn options(project_dir: PathBuf) -> ReleaseConfigEditorOptions {
719 ReleaseConfigEditorOptions {
720 project_dir,
721 provider: Some(DistributionProvider::PlayStore),
722 screenshot: None,
723 exit_after_render: false,
724 width: None,
725 height: None,
726 }
727 }
728
729 #[test]
730 fn release_config_editor_renders_release_config_flow() {
731 let state = ReleaseConfigEditorState::load(options(PathBuf::from(".")));
732 let mut app = fission::terminal::TerminalApp::with_state(ReleaseConfigEditorApp, state);
733 let frame = app
734 .render_frame(160, 60)
735 .expect("release config editor renders");
736 let text = frame.as_plain_text();
737 assert!(text.contains("Fission Release Config"));
738 assert!(text.contains("Step 1 of 5"));
739 assert!(text.contains("Manifest & identity"));
740 assert!(text.contains("Provider"));
741 assert!(text.contains("play-store"));
742 }
743
744 #[test]
745 fn release_config_editor_arrow_keys_move_between_steps() {
746 let mut state = ReleaseConfigEditorState::load(options(PathBuf::from(".")));
747 assert_eq!(state.current_step, 1);
748 assert!(state.handle_key(&fission::KeyCode::Right));
749 assert_eq!(state.current_step, 2);
750 assert!(state.handle_key(&fission::KeyCode::Down));
751 assert_eq!(state.current_step, 3);
752 assert!(state.handle_key(&fission::KeyCode::Left));
753 assert_eq!(state.current_step, 2);
754 assert!(state.handle_key(&fission::KeyCode::Up));
755 assert_eq!(state.current_step, 1);
756 }
757}