Files
fizzy/app/javascript/controllers/terminal_controller.js
T
Jorge Manrubia 179ae45bf3 Show command errors
I went with placing htis logic at the command level since it plays well with the composite structure, where you don't know whether you are handling one command or many commands, and you want to aggregate all the errors. I didn't leverage the existing support with rails' model errors because that customizing those message is cumbersome (either i18n or using custom validation logic for everything).
2025-06-30 23:10:39 +02:00

192 lines
4.8 KiB
JavaScript

import { Controller } from "@hotwired/stimulus"
import { HttpStatus } from "helpers/http_helpers"
import { isMultiLineString } from "helpers/text_helpers";
import { marked } from "marked"
export default class extends Controller {
static targets = [ "input", "form", "output", "confirmation", "recentCommands" ]
static classes = [ "error", "confirmation", "help", "output", "busy" ]
static values = { originalInput: String, waitingForConfirmation: Boolean }
connect() {
if (this.waitingForConfirmationValue) { this.focus() }
}
// Actions
focus() {
this.inputTarget.setSelectionRange(this.inputTarget.value.length, this.inputTarget.value.length)
this.inputTarget.focus()
}
executeCommand(event) {
if (this.#showHelpCommandEntered) {
this.#showHelpMenu()
event.preventDefault()
event.stopPropagation()
} else {
this.#hideHelpMenu()
}
if (!this.inputTarget.value.trim()) {
event.preventDefault()
}
}
hideMenus() {
this.#hideHelpMenu()
this.#hideOutput()
}
handleKeyPress(event) {
if (this.waitingForConfirmationValue) {
this.#handleConfirmationKey(event.key.toLowerCase())
event.preventDefault()
}
}
handleCommandResponse(event) {
const response = event.detail.fetchResponse?.response
if (event.detail.success) {
this.#handleSuccessResponse(response)
} else if (response) {
this.#handleErrorResponse(response)
}
}
restoreCommand(event) {
const target = event.target.querySelector("[data-line]") || event.target
if (target.dataset.line) {
this.#reset(target.dataset.line)
this.focus()
}
}
hideError() {
this.#hideOutput()
this.element.classList.remove(this.errorClass)
}
commandSubmitted() {
this.element.classList.add(this.busyClass)
}
#reset(inputValue = "") {
this.inputTarget.value = inputValue
this.confirmationTarget.value = ""
this.waitingForConfirmationValue = false
this.originalInputValue = null
this.element.classList.remove(this.errorClass)
this.element.classList.remove(this.confirmationClass)
this.element.classList.remove(this.busyClass)
}
get #showHelpCommandEntered() {
return [ "/help", "/?" ].includes(this.inputTarget.value)
}
get #isHelpMenuOpened() {
return this.element.classList.contains(this.helpClass)
}
#showHelpMenu() {
this.element.classList.add(this.helpClass)
}
#hideHelpMenu() {
if (this.#showHelpCommandEntered) { this.#reset() }
this.element.classList.remove(this.helpClass)
}
#handleSuccessResponse(response) {
if (response.headers.get("Content-Type")?.includes("application/json")) {
response.json().then((responseJson) => {
this.#handleJsonResponse(responseJson)
})
}
this.recentCommandsTarget.reload()
this.#reset()
}
async #handleErrorResponse(response) {
const status = response.status
if (status === HttpStatus.UNPROCESSABLE) {
this.#showError(response)
} else if (status === HttpStatus.CONFLICT) {
await this.#handleConflictResponse(response)
}
}
async #showError(response) {
const message = await response.json()
this.#showOutput(message.error)
this.element.classList.add(this.errorClass)
}
async #handleConflictResponse(response) {
this.originalInputValue = this.inputTarget.value
this.#handleJsonResponse(await response.json())
}
#handleJsonResponse(responseJson) {
const { confirmation, message, redirect_to } = responseJson
if (message) {
this.#showOutput(message)
}
if (confirmation) {
this.#requestConfirmation(confirmation)
}
if (redirect_to) {
Turbo.visit(redirect_to)
}
}
async #requestConfirmation(confirmationPrompt) {
this.element.classList.add(this.confirmationClass)
this.#showConfirmationPrompt(confirmationPrompt)
this.waitingForConfirmationValue = true
}
#showConfirmationPrompt(confirmationPrompt) {
if (isMultiLineString(confirmationPrompt)) {
this.#showOutput(confirmationPrompt)
this.inputTarget.value = `Apply these changes? [Y/n] `
} else {
this.inputTarget.value = `${confirmationPrompt}? [Y/n] `
}
}
#handleConfirmationKey(key) {
if (key === "enter" || key === "y") {
this.#submitWithConfirmation()
} else if (key === "escape" || key === "n") {
this.#reset(this.originalInputValue)
this.#hideOutput()
}
}
#submitWithConfirmation() {
this.inputTarget.value = this.originalInputValue
this.confirmationTarget.value = "confirmed"
this.#hideOutput()
this.formTarget.requestSubmit()
this.#reset()
}
#showOutput(markdown) {
const html = marked.parse(markdown)
this.element.classList.add(this.outputClass)
this.outputTarget.innerHTML = html
}
#hideOutput(html) {
this.element.classList.remove(this.outputClass)
}
}