1use anyhow::Result;
4use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5use ratatui::{
6 layout::{Constraint, Direction, Layout, Rect},
7 style::{Color, Style},
8 text::{Line, Span},
9 widgets::{Block, Borders, Paragraph, Wrap},
10 Frame,
11};
12
13use crate::config::Config;
14use super::command_input::CommandInput;
15use super::components::{render_header, render_status_bar};
16use super::migration_list::MigrationListView;
17use super::migration_detail::MigrationDetailView;
18use super::help::HelpView;
19use super::output_stream::OutputStreamWidget;
20use super::theme::ModernTheme;
21use super::database::DatabaseInfo;
22use super::migration_creator::MigrationCreator;
23use super::migration_loader::MigrationLoader;
24use super::migration_executor::MigrationExecutor;
25use super::migration_viewer::{MigrationViewer, MigrationFileType};
26use super::migration_content_view::MigrationContentView;
27
28#[derive(Debug, Clone, PartialEq)]
29pub enum AppMode {
30 Normal,
31 CommandInput,
32 Help,
33}
34
35#[derive(Debug, Clone, PartialEq)]
36pub enum View {
37 MigrationList,
38 MigrationDetail { version: i64 },
39 DatabaseConfig,
40 Logs,
41}
42
43pub struct App {
44 pub mode: AppMode,
45 pub view: View,
46 pub command_input: CommandInput,
47 pub migration_list: MigrationListView,
48 pub migration_detail: MigrationDetailView,
49 pub help_view: HelpView,
50 pub output_stream: OutputStreamWidget,
51 pub migration_content_view: MigrationContentView,
52 pub database_url: Option<String>,
53 pub config: Config,
54 pub verbose: bool,
55 pub messages: Vec<(String, MessageType)>,
56 pub should_quit: bool,
57}
58
59#[derive(Debug, Clone)]
60pub enum MessageType {
61 Info,
62 Success,
63 Warning,
64 Error,
65}
66
67impl App {
68 pub fn new(database_url: Option<String>, config: Config, verbose: bool) -> Self {
69 let mut app = Self {
70 mode: AppMode::Normal,
71 view: View::MigrationList,
72 command_input: CommandInput::new(),
73 migration_list: MigrationListView::new(),
74 migration_detail: MigrationDetailView::new(),
75 help_view: HelpView::new(),
76 output_stream: OutputStreamWidget::new(1000),
77 migration_content_view: MigrationContentView::new(),
78 database_url,
79 config,
80 verbose,
81 messages: Vec::new(),
82 should_quit: false,
83 };
84
85 app.refresh_data();
87
88 app
89 }
90
91 pub fn refresh_data(&mut self) {
92 if let Some(ref db_url) = self.database_url {
94 self.output_stream.add_info("Refreshing migration data...".to_string());
95
96 let migrations_dir = std::path::PathBuf::from(&self.config.migrations.directory);
98 let loader = MigrationLoader::new(migrations_dir, self.config.migrations.to_parsql_migrations_config());
99
100 match loader.load_sql_migrations() {
102 Ok(sql_migrations) => {
103 self.output_stream.add_info(format!("Found {} migration files", sql_migrations.len()));
104
105 match loader.get_migration_status_blocking(db_url) {
107 Ok(statuses) => {
108 self.migration_list.set_migrations(statuses);
110
111 let applied_count = self.migration_list.migrations.iter()
112 .filter(|m| m.applied)
113 .count();
114 let pending_count = self.migration_list.migrations.len() - applied_count;
115
116 self.output_stream.add_success(format!(
117 "Loaded {} migrations ({} applied, {} pending)",
118 self.migration_list.migrations.len(),
119 applied_count,
120 pending_count
121 ));
122 }
123 Err(e) => {
124 self.output_stream.add_error(format!("Failed to get migration status: {}", e));
125 }
126 }
127 }
128 Err(e) => {
129 self.output_stream.add_error(format!("Failed to load migrations: {}", e));
130 }
131 }
132 } else {
133 self.output_stream.add_warning("No database connection. Use /connect to set database URL".to_string());
134 self.add_message("No database connection. Use /connect to set database URL".to_string(), MessageType::Warning);
135 }
136 }
137
138 pub fn add_message(&mut self, message: String, msg_type: MessageType) {
139 self.messages.push((message, msg_type));
140 if self.messages.len() > 10 {
142 self.messages.remove(0);
143 }
144 }
145
146 pub fn handle_key_event(&mut self, key: KeyEvent) -> Result<bool> {
147 match self.mode {
148 AppMode::Normal => self.handle_normal_mode_key(key),
149 AppMode::CommandInput => self.handle_command_input_key(key),
150 AppMode::Help => self.handle_help_mode_key(key),
151 }
152 }
153
154 fn handle_normal_mode_key(&mut self, key: KeyEvent) -> Result<bool> {
155 if self.migration_content_view.is_visible() {
157 match key.code {
158 KeyCode::Esc | KeyCode::Char('q') => {
159 self.migration_content_view.hide();
160 Ok(false)
161 }
162 KeyCode::Up | KeyCode::Char('k') => {
163 self.migration_content_view.scroll_up();
164 Ok(false)
165 }
166 KeyCode::Down | KeyCode::Char('j') => {
167 self.migration_content_view.scroll_down(20); Ok(false)
169 }
170 KeyCode::PageUp => {
171 self.migration_content_view.scroll_page_up(20);
172 Ok(false)
173 }
174 KeyCode::PageDown => {
175 self.migration_content_view.scroll_page_down(20);
176 Ok(false)
177 }
178 _ => Ok(false)
179 }
180 } else {
181 match key.code {
182 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
183 self.should_quit = true;
184 Ok(true)
185 }
186 KeyCode::Char('/') => {
187 self.mode = AppMode::CommandInput;
188 self.command_input.clear();
189 self.command_input.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
191 Ok(false)
192 }
193 KeyCode::Char('?') => {
194 self.mode = AppMode::Help;
195 Ok(false)
196 }
197 KeyCode::Tab => {
198 self.view = match self.view {
200 View::MigrationList => View::Logs,
201 View::MigrationDetail { .. } => View::MigrationList,
202 View::DatabaseConfig => View::MigrationList,
203 View::Logs => View::MigrationList,
204 };
205 Ok(false)
206 }
207 _ => {
208 self.handle_view_key(key)
210 }
211 }
212 }
213 }
214
215 fn handle_command_input_key(&mut self, key: KeyEvent) -> Result<bool> {
216 match key.code {
217 KeyCode::Esc => {
218 self.mode = AppMode::Normal;
219 self.command_input.clear();
220 Ok(false)
221 }
222 KeyCode::Enter => {
223 let command = self.command_input.get_command();
224 self.mode = AppMode::Normal;
225 self.execute_command(&command)?;
226 self.command_input.clear();
227 Ok(false)
228 }
229 KeyCode::Tab => {
230 self.command_input.complete_suggestion();
231 Ok(false)
232 }
233 _ => {
234 self.command_input.handle_key(key);
235 Ok(false)
236 }
237 }
238 }
239
240 fn handle_help_mode_key(&mut self, key: KeyEvent) -> Result<bool> {
241 match key.code {
242 KeyCode::Esc | KeyCode::Char('q') => {
243 self.mode = AppMode::Normal;
244 Ok(false)
245 }
246 _ => {
247 self.help_view.handle_key(key);
248 Ok(false)
249 }
250 }
251 }
252
253 fn execute_command(&mut self, command: &str) -> Result<()> {
254 self.output_stream.add_command(command.to_string());
256
257 let parts: Vec<&str> = command.split_whitespace().collect();
258 if parts.is_empty() {
259 return Ok(());
260 }
261
262 match parts[0] {
263 "/help" | "/h" => {
264 self.mode = AppMode::Help;
265 }
266 "/quit" | "/q" => {
267 self.should_quit = true;
268 }
269 "/connect" => {
270 if parts.len() > 1 {
271 let db_url = parts[1..].join(" ");
272 self.output_stream.add_info(format!("Connecting to database: {}", db_url));
273
274 match DatabaseInfo::parse(&db_url) {
275 Ok(db_info) => {
276 self.output_stream.add_info(format!("Parsed connection: {}", db_info.display_path));
277
278 match db_info.test_connection() {
280 Ok(_) => {
281 self.database_url = Some(db_info.connection_string.clone());
282 self.output_stream.add_success(format!("Successfully connected to: {}", db_info.display_path));
283 self.add_message(format!("Connected to: {}", db_info.display_path), MessageType::Success);
284
285 if let super::database::DatabaseType::SQLite = db_info.db_type {
287 if let Some(path) = db_info.connection_string.strip_prefix("sqlite:") {
288 if path != ":memory:" {
289 self.output_stream.add_info(format!("Database file: {}", path));
290 }
291 }
292 }
293
294 self.refresh_data();
295 }
296 Err(e) => {
297 self.output_stream.add_error(format!("Connection failed: {}", e));
298 self.add_message(format!("Connection failed: {}", e), MessageType::Error);
299 }
300 }
301 }
302 Err(e) => {
303 self.output_stream.add_error(format!("Invalid database URL: {}", e));
304 self.add_message(format!("Invalid database URL: {}", e), MessageType::Error);
305 }
306 }
307 } else {
308 self.output_stream.add_error("Usage: /connect <database_url>".to_string());
309 self.add_message("Usage: /connect <database_url>".to_string(), MessageType::Error);
310 }
311 }
312 "/create" => {
313 if parts.len() > 1 {
314 let name = parts[1..].join("_");
315 let migration_type = "sql"; self.output_stream.add_info(format!("Creating {} migration: {}", migration_type.to_uppercase(), name));
318 self.output_stream.add_progress("Generating migration files...".to_string());
319
320 let migrations_dir = std::path::PathBuf::from(&self.config.migrations.directory);
322 let creator = MigrationCreator::new(migrations_dir.clone());
323
324 match creator.create_migration(&name, migration_type) {
325 Ok(files) => {
326 self.output_stream.add_success(format!("Created migration: {} (v{})", files.name, files.version));
327 self.output_stream.add_info(format!(" Up file: {}", files.up_file));
328 if let Some(down_file) = files.down_file {
329 self.output_stream.add_info(format!(" Down file: {}", down_file));
330 }
331 self.output_stream.add_info(format!("Edit the migration files in: {}", migrations_dir.display()));
332 self.add_message(format!("Created migration: {}", name), MessageType::Success);
333
334 self.refresh_data();
336 }
337 Err(e) => {
338 self.output_stream.add_error(format!("Failed to create migration: {}", e));
339 self.add_message(format!("Failed to create migration: {}", e), MessageType::Error);
340 }
341 }
342 } else {
343 self.output_stream.add_error("Usage: /create <migration_name>".to_string());
344 self.add_message("Usage: /create <migration_name>".to_string(), MessageType::Error);
345 }
346 }
347 "/run" => {
348 if self.database_url.is_none() {
349 self.output_stream.add_error("No database connection. Use /connect first".to_string());
350 self.add_message("No database connection".to_string(), MessageType::Error);
351 return Ok(());
352 }
353
354 let db_url = self.database_url.as_ref().unwrap();
355 self.output_stream.add_info("Checking for pending migrations...".to_string());
356
357 let migrations_dir = std::path::PathBuf::from(&self.config.migrations.directory);
359 let loader = MigrationLoader::new(migrations_dir.clone(), self.config.migrations.to_parsql_migrations_config());
360
361 match loader.load_sql_migrations() {
362 Ok(sql_migrations) => {
363 let pending_count = self.migration_list.get_pending_count();
365 if pending_count == 0 {
366 self.output_stream.add_info("No pending migrations to run".to_string());
367 return Ok(());
368 }
369
370 self.output_stream.add_progress(format!("Running {} pending migrations...", pending_count));
371
372 let executor = MigrationExecutor::new(self.config.migrations.to_parsql_migrations_config());
374
375 if db_url.starts_with("sqlite:") {
376 let db_path = db_url.strip_prefix("sqlite:").unwrap();
377 match executor.run_sqlite_migrations(db_path, sql_migrations, &mut self.output_stream) {
378 Ok(count) => {
379 self.output_stream.add_success(format!("Successfully ran {} migrations", count));
380 self.add_message(format!("Ran {} migrations", count), MessageType::Success);
381 self.refresh_data(); }
383 Err(e) => {
384 self.output_stream.add_error(format!("Migration failed: {}", e));
385 self.add_message(format!("Migration failed: {}", e), MessageType::Error);
386 }
387 }
388 } else if db_url.starts_with("postgresql://") || db_url.starts_with("postgres://") {
389 match executor.run_postgres_migrations(db_url, sql_migrations, &mut self.output_stream) {
390 Ok(count) => {
391 self.output_stream.add_success(format!("Successfully ran {} migrations", count));
392 self.add_message(format!("Ran {} migrations", count), MessageType::Success);
393 self.refresh_data(); }
395 Err(e) => {
396 self.output_stream.add_error(format!("Migration failed: {}", e));
397 self.add_message(format!("Migration failed: {}", e), MessageType::Error);
398 }
399 }
400 } else {
401 self.output_stream.add_error("Unsupported database URL format".to_string());
402 }
403 }
404 Err(e) => {
405 self.output_stream.add_error(format!("Failed to load migrations: {}", e));
406 }
407 }
408 }
409 "/rollback" => {
410 if self.database_url.is_none() {
411 self.output_stream.add_error("No database connection. Use /connect first".to_string());
412 self.add_message("No database connection".to_string(), MessageType::Error);
413 return Ok(());
414 }
415
416 if parts.len() > 1 {
417 if let Ok(target_version) = parts[1].parse::<i64>() {
418 let db_url = self.database_url.as_ref().unwrap();
419 self.output_stream.add_info(format!("Rolling back to version: {}", target_version));
420
421 let migrations_dir = std::path::PathBuf::from(&self.config.migrations.directory);
423 let loader = MigrationLoader::new(migrations_dir.clone(), self.config.migrations.to_parsql_migrations_config());
424
425 match loader.load_sql_migrations() {
426 Ok(sql_migrations) => {
427 let executor = MigrationExecutor::new(self.config.migrations.to_parsql_migrations_config());
428
429 if db_url.starts_with("sqlite:") {
430 let db_path = db_url.strip_prefix("sqlite:").unwrap();
431 match executor.rollback_sqlite(db_path, target_version, sql_migrations, &mut self.output_stream) {
432 Ok(count) => {
433 self.output_stream.add_success(format!("Successfully rolled back {} migrations", count));
434 self.add_message(format!("Rolled back {} migrations", count), MessageType::Success);
435 self.refresh_data(); }
437 Err(e) => {
438 self.output_stream.add_error(format!("Rollback failed: {}", e));
439 self.add_message(format!("Rollback failed: {}", e), MessageType::Error);
440 }
441 }
442 } else if db_url.starts_with("postgresql://") || db_url.starts_with("postgres://") {
443 match executor.rollback_postgres(db_url, target_version, sql_migrations, &mut self.output_stream) {
444 Ok(count) => {
445 self.output_stream.add_success(format!("Successfully rolled back {} migrations", count));
446 self.add_message(format!("Rolled back {} migrations", count), MessageType::Success);
447 self.refresh_data(); }
449 Err(e) => {
450 self.output_stream.add_error(format!("Rollback failed: {}", e));
451 self.add_message(format!("Rollback failed: {}", e), MessageType::Error);
452 }
453 }
454 } else {
455 self.output_stream.add_error("Unsupported database URL format".to_string());
456 }
457 }
458 Err(e) => {
459 self.output_stream.add_error(format!("Failed to load migrations: {}", e));
460 }
461 }
462 } else {
463 self.output_stream.add_error("Invalid version number".to_string());
464 self.add_message("Invalid version number".to_string(), MessageType::Error);
465 }
466 } else {
467 self.output_stream.add_error("Usage: /rollback <version>".to_string());
468 self.add_message("Usage: /rollback <version>".to_string(), MessageType::Error);
469 }
470 }
471 "/status" => {
472 self.view = View::MigrationList;
473 self.refresh_data();
474 }
475 "/logs" => {
476 self.view = View::Logs;
477 }
478 "/view" => {
479 if parts.len() > 1 {
480 if let Ok(version) = parts[1].parse::<i64>() {
481 let file_type = if parts.len() > 2 && parts[2] == "down" {
482 MigrationFileType::Down
483 } else {
484 MigrationFileType::Up
485 };
486
487 let migrations_dir = std::path::PathBuf::from(&self.config.migrations.directory);
488 let viewer = MigrationViewer::new(migrations_dir);
489
490 match viewer.view_migration(version, file_type, &mut self.output_stream) {
491 Ok(content) => {
492 let title = format!("Migration {} ({})", version, if matches!(file_type, MigrationFileType::Up) { "up" } else { "down" });
493 self.migration_content_view.show_content(title, content);
494 }
495 Err(e) => {
496 self.output_stream.add_error(format!("Failed to view migration: {}", e));
497 self.add_message(format!("Failed to view migration: {}", e), MessageType::Error);
498 }
499 }
500 } else {
501 self.output_stream.add_error("Invalid version number".to_string());
502 }
503 } else {
504 self.output_stream.add_error("Usage: /view <version> [up|down]".to_string());
505 self.add_message("Usage: /view <version> [up|down]".to_string(), MessageType::Error);
506 }
507 }
508 "/edit" => {
509 if parts.len() > 1 {
510 if let Ok(version) = parts[1].parse::<i64>() {
511 let file_type = if parts.len() > 2 && parts[2] == "down" {
512 MigrationFileType::Down
513 } else {
514 MigrationFileType::Up
515 };
516
517 let migrations_dir = std::path::PathBuf::from(&self.config.migrations.directory);
518 let viewer = MigrationViewer::new(migrations_dir);
519
520 self.output_stream.add_info("Launching editor...".to_string());
521
522 match viewer.edit_migration(version, file_type, &mut self.output_stream) {
525 Ok(_) => {
526 self.output_stream.add_success("Editor closed".to_string());
527 self.add_message("Migration edited successfully".to_string(), MessageType::Success);
528 }
529 Err(e) => {
530 self.output_stream.add_error(format!("Failed to edit migration: {}", e));
531 self.add_message(format!("Failed to edit migration: {}", e), MessageType::Error);
532 }
533 }
534 } else {
535 self.output_stream.add_error("Invalid version number".to_string());
536 }
537 } else {
538 self.output_stream.add_error("Usage: /edit <version> [up|down]".to_string());
539 self.add_message("Usage: /edit <version> [up|down]".to_string(), MessageType::Error);
540 }
541 }
542 _ => {
543 self.output_stream.add_error(format!("Unknown command: {}", parts[0]));
544 self.add_message(format!("Unknown command: {}", parts[0]), MessageType::Error);
545 }
546 }
547
548 Ok(())
549 }
550
551 fn handle_view_key(&mut self, key: KeyEvent) -> Result<bool> {
552 match &self.view {
553 View::MigrationList => {
554 match key.code {
556 KeyCode::Up | KeyCode::Char('k') => {
557 self.migration_list.previous();
558 }
559 KeyCode::Down | KeyCode::Char('j') => {
560 self.migration_list.next();
561 }
562 KeyCode::Enter => {
563 if let Some(version) = self.migration_list.get_selected_version() {
564 self.view = View::MigrationDetail { version };
565 }
566 }
567 KeyCode::Char('r') => {
568 self.add_message("Refreshing migration list...".to_string(), MessageType::Info);
569 self.refresh_data();
570 }
571 KeyCode::Char('a') => {
572 let pending_count = self.migration_list.get_pending_count();
573 if pending_count > 0 {
574 self.add_message(
575 format!("Running {} pending migrations...", pending_count),
576 MessageType::Info,
577 );
578 } else {
580 self.add_message(
581 "No pending migrations to run".to_string(),
582 MessageType::Warning,
583 );
584 }
585 }
586 _ => {}
587 }
588 }
589 View::MigrationDetail { .. } => {
590 match key.code {
592 KeyCode::Esc | KeyCode::Char('q') => {
593 self.view = View::MigrationList;
594 }
595 KeyCode::Char('r') => {
596 self.add_message("Running this migration...".to_string(), MessageType::Info);
597 }
599 KeyCode::Char('b') => {
600 self.add_message("Rolling back to before this migration...".to_string(), MessageType::Info);
601 }
603 _ => {}
604 }
605 }
606 _ => {}
607 }
608 Ok(false)
609 }
610
611 pub fn tick(&mut self) {
612 }
614
615 pub fn draw(&mut self, f: &mut Frame) {
616 f.render_widget(
618 Block::default().style(Style::default().bg(ModernTheme::BG_PRIMARY)),
619 f.area(),
620 );
621
622 let chunks = Layout::default()
623 .direction(Direction::Vertical)
624 .constraints([
625 Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), ])
629 .split(f.area());
630
631 render_header(f, chunks[0], &self.database_url);
633
634 let main_chunks = Layout::default()
636 .direction(Direction::Horizontal)
637 .constraints([
638 Constraint::Percentage(50), Constraint::Percentage(50), ])
641 .split(chunks[1]);
642
643 match &self.view {
645 View::MigrationList => self.migration_list.render(f, main_chunks[0]),
646 View::MigrationDetail { version } => self.migration_detail.render(f, main_chunks[0], *version),
647 View::DatabaseConfig => self.render_database_config(f, main_chunks[0]),
648 View::Logs => {
649 self.output_stream.render(f, chunks[1], "Output Stream");
651 }
652 }
653
654 if !matches!(self.view, View::Logs) {
656 self.output_stream.render(f, main_chunks[1], "Output");
657 }
658
659 match self.mode {
661 AppMode::CommandInput => {
662 self.command_input.render(f, chunks[2]);
663 }
664 _ => {
665 render_status_bar(f, chunks[2], &self.view, &self.mode);
666 }
667 }
668
669 if self.mode == AppMode::Help {
671 self.help_view.render(f, f.area());
672 }
673
674 if self.migration_content_view.is_visible() {
676 let area = centered_rect(80, 80, f.area());
677 self.migration_content_view.render(f, area);
678 }
679 }
680
681 fn render_messages(&self, _f: &mut Frame, _area: Rect) {
682 }
684
685 fn render_database_config(&self, f: &mut Frame, area: Rect) {
686 let config_text = vec![
687 Line::from(vec![
688 Span::raw("Database URL: "),
689 Span::styled(
690 self.database_url.as_deref().unwrap_or("Not configured"),
691 Style::default().fg(Color::Yellow)
692 ),
693 ]),
694 Line::from(""),
695 Line::from("Migration Settings:"),
696 Line::from(format!(" Directory: {}", self.config.migrations.directory)),
697 Line::from(format!(" Table Name: {}", self.config.migrations.table_name)),
698 Line::from(format!(" Transaction per migration: {}", self.config.migrations.transaction_per_migration)),
699 Line::from(format!(" Verify checksums: {}", self.config.migrations.verify_checksums)),
700 ];
701
702 let paragraph = Paragraph::new(config_text)
703 .block(Block::default().borders(Borders::ALL).title("Database Configuration"))
704 .wrap(Wrap { trim: true });
705
706 f.render_widget(paragraph, area);
707 }
708
709 fn render_logs(&self, f: &mut Frame, area: Rect) {
710 let logs_text = self.messages
711 .iter()
712 .map(|(msg, msg_type)| {
713 let prefix = match msg_type {
714 MessageType::Info => "[INFO] ",
715 MessageType::Success => "[SUCCESS] ",
716 MessageType::Warning => "[WARN] ",
717 MessageType::Error => "[ERROR] ",
718 };
719 Line::from(format!("{}{}", prefix, msg))
720 })
721 .collect::<Vec<_>>();
722
723 let paragraph = Paragraph::new(logs_text)
724 .block(Block::default().borders(Borders::ALL).title("Logs"))
725 .wrap(Wrap { trim: true });
726
727 f.render_widget(paragraph, area);
728 }
729}
730
731fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
733 let popup_layout = Layout::default()
734 .direction(Direction::Vertical)
735 .constraints([
736 Constraint::Percentage((100 - percent_y) / 2),
737 Constraint::Percentage(percent_y),
738 Constraint::Percentage((100 - percent_y) / 2),
739 ])
740 .split(r);
741
742 Layout::default()
743 .direction(Direction::Horizontal)
744 .constraints([
745 Constraint::Percentage((100 - percent_x) / 2),
746 Constraint::Percentage(percent_x),
747 Constraint::Percentage((100 - percent_x) / 2),
748 ])
749 .split(popup_layout[1])[1]
750}