github.com/pluswing/datasync@v1.1.1-0.20240218052257-9077f6fc4ae3/cmd/init.go (about) 1 package cmd 2 3 import ( 4 "errors" 5 "fmt" 6 "os" 7 "path/filepath" 8 "strconv" 9 "strings" 10 "time" 11 12 "github.com/charmbracelet/bubbles/filepicker" 13 "github.com/charmbracelet/bubbles/textinput" 14 tea "github.com/charmbracelet/bubbletea" 15 "github.com/charmbracelet/lipgloss" 16 "github.com/go-yaml/yaml" 17 "github.com/pluswing/datasync/data" 18 "github.com/pluswing/datasync/file" 19 "github.com/spf13/cobra" 20 ) 21 22 var initCmd = &cobra.Command{ 23 Use: "init", 24 Short: "generate datasync.yaml", 25 Long: ``, 26 Run: func(cmd *cobra.Command, args []string) { 27 28 _, err := file.FindCurrentDir() 29 if err == nil { 30 fmt.Println("already datasync.yaml file.") 31 return 32 } 33 34 if _, err := tea.NewProgram(initialModel()).Run(); err != nil { 35 fmt.Printf("could not start program: %s\n", err) 36 os.Exit(1) 37 } 38 }, 39 } 40 41 func init() { 42 rootCmd.AddCommand(initCmd) 43 } 44 45 var ( 46 focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) 47 cursorStyle = focusedStyle.Copy() 48 noStyle = lipgloss.NewStyle() 49 ) 50 51 type ScreenType int 52 53 const ( 54 SelectTargetKind ScreenType = iota 55 InputMysql 56 InputFile 57 ConfirmAddTarget 58 ConfirmSetupRemote 59 // SelectRemoteKind 60 InputGcs 61 ) 62 63 type clearErrorMsg struct{} 64 65 func clearErrorAfter(t time.Duration) tea.Cmd { 66 return tea.Tick(t, func(_ time.Time) tea.Msg { 67 return clearErrorMsg{} 68 }) 69 } 70 71 type model struct { 72 screenType ScreenType 73 74 // 入力共有 75 focusIndex int 76 inputs []textinput.Model 77 78 // ファイル選択 79 filepicker filepicker.Model 80 err error 81 82 targets []data.TargetType 83 storage data.StorageType 84 } 85 86 func initialModel() model { 87 m := model{ 88 screenType: SelectTargetKind, 89 targets: make([]data.TargetType, 0), 90 } 91 return m 92 } 93 94 func (m model) Init() tea.Cmd { 95 return textinput.Blink 96 } 97 98 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 99 switch msg := msg.(type) { 100 case tea.KeyMsg: 101 switch msg.String() { 102 case "ctrl+c", "esc": 103 return m, tea.Quit 104 } 105 } 106 107 switch m.screenType { 108 case SelectTargetKind: 109 return updateSelectTargetKind(m, msg) 110 case InputMysql: 111 return updateInputMysql(m, msg) 112 case InputFile: 113 return updateInputFile(m, msg) 114 case ConfirmAddTarget: 115 return updateConfirmAddTarget(m, msg) 116 case ConfirmSetupRemote: 117 return updateConfirmSetupRemote(m, msg) 118 case InputGcs: 119 return updateInputGcs(m, msg) 120 default: 121 panic("invalid screenType") 122 } 123 } 124 125 func writeConfig(m model) error { 126 cwd, err := os.Getwd() 127 if err != nil { 128 return err 129 } 130 s := data.SettingType{ 131 Targets: m.targets, 132 Storage: m.storage, 133 } 134 135 b, err := yaml.Marshal(s) 136 if err != nil { 137 return err 138 } 139 err = os.WriteFile(filepath.Join(cwd, "datasync.yaml"), b, os.ModePerm) 140 if err != nil { 141 return err 142 } 143 return nil 144 } 145 146 func finish() { 147 fmt.Println("created datasync.yaml.") 148 os.Exit(0) 149 } 150 151 func updateConfirmAddTarget(m model, msg tea.Msg) (tea.Model, tea.Cmd) { 152 switch msg := msg.(type) { 153 case tea.KeyMsg: 154 switch msg.String() { 155 case "up": 156 if m.focusIndex == 1 { 157 m.focusIndex = 0 158 } 159 case "down": 160 if m.focusIndex == 0 { 161 m.focusIndex = 1 162 } 163 case "enter": 164 if m.focusIndex == 0 { 165 m.screenType = SelectTargetKind 166 m.focusIndex = 0 167 } else { 168 m.screenType = ConfirmSetupRemote 169 m.focusIndex = 0 170 } 171 } 172 } 173 cmd := m.updateInputs(msg) 174 return m, cmd 175 } 176 177 func updateSelectTargetKind(m model, msg tea.Msg) (tea.Model, tea.Cmd) { 178 switch msg := msg.(type) { 179 case tea.KeyMsg: 180 switch msg.String() { 181 case "up": 182 if m.focusIndex == 1 { 183 m.focusIndex = 0 184 } 185 case "down": 186 if m.focusIndex == 0 { 187 m.focusIndex = 1 188 } 189 case "enter": 190 if m.focusIndex == 0 { 191 m.screenType = InputMysql 192 m.focusIndex = 0 193 m.inputs = makeMysqlInputs() 194 } else { 195 m.screenType = InputFile 196 m.focusIndex = 0 197 m.inputs = make([]textinput.Model, 0) 198 fp := filepicker.New() 199 fp.DirAllowed = true 200 fp.CurrentDirectory, _ = os.Getwd() 201 fp.Height = 10 202 fp.ShowHidden = true 203 cmd := fp.Init() 204 m.filepicker = fp 205 return m, cmd 206 } 207 } 208 } 209 cmd := m.updateInputs(msg) 210 return m, cmd 211 } 212 213 func updateInputMysql(m model, msg tea.Msg) (tea.Model, tea.Cmd) { 214 switch msg := msg.(type) { 215 case tea.KeyMsg: 216 switch msg.String() { 217 case "enter", "up", "down": 218 s := msg.String() 219 if s == "up" { 220 m.focusIndex -= 1 221 if m.focusIndex < 0 { 222 m.focusIndex = 0 223 } 224 } else if s == "down" { 225 m.focusIndex += 1 226 if m.focusIndex >= len(m.inputs) { 227 m.focusIndex = len(m.inputs) - 1 228 } 229 } else if s == "enter" { 230 if m.focusIndex == len(m.inputs)-1 { 231 port, err := strconv.Atoi(m.inputs[1].Value()) 232 if err != nil { 233 fmt.Println("invalut port") 234 } 235 var t = data.TargetType{ 236 Kind: "mysql", 237 Config: data.TargetMysqlType{ 238 Host: m.inputs[0].Value(), 239 Port: port, 240 User: m.inputs[2].Value(), 241 Password: m.inputs[3].Value(), 242 Database: m.inputs[4].Value(), 243 }, 244 } 245 m.targets = append(m.targets, t) 246 // 次のスクリーンに行く。 247 m.screenType = ConfirmAddTarget 248 m.focusIndex = 1 249 m.inputs = make([]textinput.Model, 0) 250 } else { 251 m.focusIndex += 1 252 if m.focusIndex >= len(m.inputs) { 253 m.focusIndex = len(m.inputs) - 1 254 } 255 } 256 } 257 return updateInputFocus(m) 258 } 259 } 260 cmd := m.updateInputs(msg) 261 return m, cmd 262 } 263 264 func updateInputFile(m model, msg tea.Msg) (tea.Model, tea.Cmd) { 265 switch msg.(type) { 266 case clearErrorMsg: 267 m.err = nil 268 } 269 270 // TODO CWDより上にはいけないように制御したい。 271 // => msgをここで取って、←と→の回数をカウントしておいて、うまいことやる 272 273 var cmd tea.Cmd 274 m.filepicker, cmd = m.filepicker.Update(msg) 275 276 if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect { 277 cwd, _ := os.Getwd() 278 path = "." + strings.Replace(path, cwd, "", 1) 279 var t = data.TargetType{ 280 Kind: "file", 281 Config: data.TargetFileType{ 282 Path: path, 283 }, 284 } 285 m.targets = append(m.targets, t) 286 m.screenType = ConfirmAddTarget 287 m.focusIndex = 1 288 m.inputs = make([]textinput.Model, 0) 289 } 290 291 if didSelect, path := m.filepicker.DidSelectDisabledFile(msg); didSelect { 292 m.err = errors.New(path + " is not valid.") 293 return m, tea.Batch(cmd, clearErrorAfter(2*time.Second)) 294 } 295 296 return m, cmd 297 } 298 299 func updateConfirmSetupRemote(m model, msg tea.Msg) (tea.Model, tea.Cmd) { 300 switch msg := msg.(type) { 301 case tea.KeyMsg: 302 switch msg.String() { 303 case "up": 304 if m.focusIndex == 1 { 305 m.focusIndex = 0 306 } 307 case "down": 308 if m.focusIndex == 0 { 309 m.focusIndex = 1 310 } 311 case "enter": 312 if m.focusIndex == 0 { 313 m.screenType = InputGcs 314 m.focusIndex = 0 315 m.inputs = makeGcsInputs() 316 } else { 317 err := writeConfig(m) 318 cobra.CheckErr(err) 319 finish() 320 } 321 } 322 } 323 cmd := m.updateInputs(msg) 324 return m, cmd 325 } 326 327 func updateInputGcs(m model, msg tea.Msg) (tea.Model, tea.Cmd) { 328 switch msg := msg.(type) { 329 case tea.KeyMsg: 330 switch msg.String() { 331 case "enter", "up", "down": 332 s := msg.String() 333 if s == "up" { 334 m.focusIndex -= 1 335 if m.focusIndex < 0 { 336 m.focusIndex = 0 337 } 338 } else if s == "down" { 339 m.focusIndex += 1 340 if m.focusIndex >= len(m.inputs) { 341 m.focusIndex = len(m.inputs) - 1 342 } 343 } else if s == "enter" { 344 if m.focusIndex == len(m.inputs)-1 { 345 var s = data.StorageType{ 346 Kind: "gcs", 347 Config: data.StorageGcsType{ 348 Bucket: m.inputs[0].Value(), 349 Dir: m.inputs[1].Value(), 350 }, 351 } 352 m.storage = s 353 err := writeConfig(m) 354 cobra.CheckErr(err) 355 finish() 356 } else { 357 m.focusIndex += 1 358 if m.focusIndex >= len(m.inputs) { 359 m.focusIndex = len(m.inputs) - 1 360 } 361 } 362 } 363 return updateInputFocus(m) 364 } 365 } 366 cmd := m.updateInputs(msg) 367 return m, cmd 368 } 369 370 func updateInputFocus(m model) (tea.Model, tea.Cmd) { 371 cmds := make([]tea.Cmd, len(m.inputs)) 372 for i := 0; i <= len(m.inputs)-1; i++ { 373 if i == m.focusIndex { 374 cmds[i] = m.inputs[i].Focus() 375 m.inputs[i].PromptStyle = focusedStyle 376 m.inputs[i].TextStyle = focusedStyle 377 continue 378 } 379 m.inputs[i].Blur() 380 m.inputs[i].PromptStyle = noStyle 381 m.inputs[i].TextStyle = noStyle 382 } 383 return m, tea.Batch(cmds...) 384 } 385 386 func makeMysqlInputs() []textinput.Model { 387 var inputs []textinput.Model 388 var t textinput.Model 389 inputs = make([]textinput.Model, 0) 390 391 t = textinput.New() 392 t.Cursor.Style = cursorStyle 393 t.Placeholder = "Hostname (default: localhost)" 394 t.Focus() 395 t.PromptStyle = focusedStyle 396 t.TextStyle = focusedStyle 397 inputs = append(inputs, t) 398 399 // TODO 数値のみにしたい 400 t = textinput.New() 401 t.Placeholder = "port (default: 3306)" 402 inputs = append(inputs, t) 403 404 t = textinput.New() 405 t.Placeholder = "user (default: root)" 406 inputs = append(inputs, t) 407 408 t = textinput.New() 409 t.Placeholder = "password (default: '')" 410 inputs = append(inputs, t) 411 412 t = textinput.New() 413 t.Placeholder = "database" 414 inputs = append(inputs, t) 415 416 return inputs 417 } 418 419 func makeGcsInputs() []textinput.Model { 420 var inputs []textinput.Model 421 var t textinput.Model 422 inputs = make([]textinput.Model, 0) 423 424 t = textinput.New() 425 t.Cursor.Style = cursorStyle 426 t.Placeholder = "Bucket" 427 t.Focus() 428 t.PromptStyle = focusedStyle 429 t.TextStyle = focusedStyle 430 inputs = append(inputs, t) 431 432 t = textinput.New() 433 t.Placeholder = "Path" 434 inputs = append(inputs, t) 435 436 return inputs 437 } 438 439 func (m *model) updateInputs(msg tea.Msg) tea.Cmd { 440 cmds := make([]tea.Cmd, len(m.inputs)) 441 for i := range m.inputs { 442 m.inputs[i], cmds[i] = m.inputs[i].Update(msg) 443 } 444 445 return tea.Batch(cmds...) 446 } 447 448 func (m model) View() string { 449 var b strings.Builder 450 switch m.screenType { 451 case SelectTargetKind: 452 b.WriteString("? How kind of dump target? …\n") 453 viewSelect(&b, m.focusIndex, []string{"MySQL", "File(s)"}) 454 case InputMysql: 455 b.WriteString("Input mysql setting …\n") // FIXME これだけ残る。なんとかする。 456 viewInputs(&b, m.inputs) 457 case InputFile: 458 viewFilePicker(&b, m.filepicker, m.err) 459 case ConfirmAddTarget: 460 b.WriteString("? Add dump target? …\n") 461 viewSelect(&b, m.focusIndex, []string{"Yes", "No"}) 462 case ConfirmSetupRemote: 463 b.WriteString("? Setup remote server? …\n") 464 viewSelect(&b, m.focusIndex, []string{"Yes", "No"}) 465 case InputGcs: 466 b.WriteString("Input GCS setting …\n") 467 viewInputs(&b, m.inputs) 468 } 469 return b.String() 470 } 471 472 func viewFilePicker(b *strings.Builder, filepicker filepicker.Model, err error) { 473 if err != nil { 474 b.WriteString(filepicker.Styles.DisabledFile.Render(err.Error())) 475 } else { 476 b.WriteString("Pick a file or directory:\n") 477 // TODO 使い方を出したい。 478 // b.WriteString("<- : Parent Directory, -> : Dig Directory, enter : select") 479 } 480 b.WriteString("\n\n" + filepicker.View() + "\n") 481 } 482 483 func viewSelect(b *strings.Builder, focusIndex int, texts []string) { 484 for i, t := range texts { 485 if focusIndex == i { 486 b.WriteString(focusedStyle.Render(fmt.Sprintf("❯ %s\n", t))) 487 } else { 488 b.WriteString(fmt.Sprintf("\r %s\n", t)) 489 } 490 } 491 } 492 493 func viewInputs(b *strings.Builder, inputs []textinput.Model) { 494 for i := range inputs { 495 b.WriteString(inputs[i].View()) 496 if i < len(inputs)-1 { 497 b.WriteRune('\n') 498 } 499 } 500 } 501 502 /* 503 ? How kind of dump target? … 504 ❯ MySQL 505 File(s) 506 507 -- mysql 508 ? MySQL server hostname / port / username / password / databasename 509 > 510 511 -- file 512 ? Select directory or file 513 > picker 514 515 516 ? Add dump target? 517 Yes 518 ❯ No 519 520 521 ? Setup remote server? 522 ❯ Yes 523 No 524 525 ? Remote server type? 526 ❯ Google Cloud Storage 527 Amazon S3 528 Samba 529 530 -- GCS 531 ? GCS bucket / path 532 > 533 534 */