feat(ui): add dispose instance and rehydrate
Adds a dispose instance action to the instance info view, POSTing to /instance/dispose and rehydrating per-instance stores; also handles server.instance.disposed events and adds danger button styling.
This commit is contained in:
@@ -4,6 +4,6 @@
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.2.4"
|
||||
"@opencode-ai/plugin": "1.2.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
|
||||
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
||||
import { getInstanceLogs, instances, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import InstanceInfo from "./instance-info"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
@@ -86,8 +86,8 @@ const InfoView: Component<InfoViewProps> = (props) => {
|
||||
return (
|
||||
<div class="log-container">
|
||||
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-hidden">
|
||||
<div class="lg:w-80 flex-shrink-0 overflow-y-auto">
|
||||
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} />}</Show>
|
||||
<div class="lg:w-80 flex-shrink-0 min-h-0 overflow-y-auto max-h-[40vh] lg:max-h-none">
|
||||
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} showDisposeButton />}</Show>
|
||||
</div>
|
||||
|
||||
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { Component, For, Show, createMemo } from "solid-js"
|
||||
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||
import type { Instance } from "../types/instance"
|
||||
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||
import InstanceServiceStatus from "./instance-service-status"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { showConfirmDialog } from "../stores/alerts"
|
||||
import { disposeInstance } from "../stores/instances"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
interface InstanceInfoProps {
|
||||
instance: Instance
|
||||
compact?: boolean
|
||||
showDisposeButton?: boolean
|
||||
}
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const metadataContext = useOptionalInstanceMetadataContext()
|
||||
@@ -16,6 +23,8 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
|
||||
const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata)
|
||||
|
||||
const [isDisposing, setIsDisposing] = createSignal(false)
|
||||
|
||||
const currentInstance = () => instanceAccessor()
|
||||
const metadata = () => metadataAccessor()
|
||||
const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
|
||||
@@ -25,6 +34,46 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
return env ? Object.entries(env) : []
|
||||
})
|
||||
|
||||
const disposeEnabled = createMemo(() => Boolean(currentInstance()?.client) && !isDisposing())
|
||||
|
||||
const handleDisposeInstance = async () => {
|
||||
if (!disposeEnabled()) return
|
||||
|
||||
const confirmed = await showConfirmDialog(t("infoView.dispose.confirm.message"), {
|
||||
title: t("infoView.dispose.confirm.title"),
|
||||
variant: "warning",
|
||||
confirmLabel: t("infoView.dispose.confirm.confirmLabel"),
|
||||
cancelLabel: t("infoView.dispose.confirm.cancelLabel"),
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
setIsDisposing(true)
|
||||
try {
|
||||
const ok = await disposeInstance(currentInstance().id)
|
||||
if (ok) {
|
||||
showToastNotification({
|
||||
message: t("infoView.dispose.toast.success"),
|
||||
variant: "success",
|
||||
duration: 8000,
|
||||
})
|
||||
} else {
|
||||
showToastNotification({
|
||||
message: t("infoView.dispose.toast.error"),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to dispose instance", error)
|
||||
showToastNotification({
|
||||
message: t("infoView.dispose.toast.error"),
|
||||
variant: "error",
|
||||
})
|
||||
} finally {
|
||||
setIsDisposing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
@@ -156,6 +205,19 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={props.showDisposeButton}>
|
||||
<div class="pt-3 border-t border-base">
|
||||
<button
|
||||
type="button"
|
||||
class="button-danger button-small w-full"
|
||||
onClick={handleDisposeInstance}
|
||||
disabled={!disposeEnabled()}
|
||||
>
|
||||
{isDisposing() ? t("infoView.dispose.actions.disposing") : t("infoView.dispose.actions.dispose")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -15,4 +15,13 @@ export const logMessages = {
|
||||
"infoView.logs.paused.description": "Enable streaming to watch your OpenCode server activity.",
|
||||
"infoView.logs.empty.waiting": "Waiting for server output...",
|
||||
"infoView.logs.scrollToBottom": "Scroll to bottom",
|
||||
|
||||
"infoView.dispose.actions.dispose": "Dispose instance",
|
||||
"infoView.dispose.actions.disposing": "Disposing...",
|
||||
"infoView.dispose.confirm.title": "Dispose instance?",
|
||||
"infoView.dispose.confirm.message": "This clears cached per-project state for this directory and reloads the instance.",
|
||||
"infoView.dispose.confirm.confirmLabel": "Dispose",
|
||||
"infoView.dispose.confirm.cancelLabel": "Cancel",
|
||||
"infoView.dispose.toast.success": "Instance disposed. Reloading...",
|
||||
"infoView.dispose.toast.error": "Failed to dispose instance.",
|
||||
} as const
|
||||
|
||||
@@ -15,4 +15,13 @@ export const logMessages = {
|
||||
"infoView.logs.paused.description": "Activa el streaming para ver la actividad de tu servidor de OpenCode.",
|
||||
"infoView.logs.empty.waiting": "Esperando la salida del servidor...",
|
||||
"infoView.logs.scrollToBottom": "Desplazarse al final",
|
||||
|
||||
"infoView.dispose.actions.dispose": "Desechar instancia",
|
||||
"infoView.dispose.actions.disposing": "Desechando...",
|
||||
"infoView.dispose.confirm.title": "¿Desechar instancia?",
|
||||
"infoView.dispose.confirm.message": "Esto borra el estado en caché por proyecto para este directorio y recarga la instancia.",
|
||||
"infoView.dispose.confirm.confirmLabel": "Desechar",
|
||||
"infoView.dispose.confirm.cancelLabel": "Cancelar",
|
||||
"infoView.dispose.toast.success": "Instancia desechada. Recargando...",
|
||||
"infoView.dispose.toast.error": "No se pudo desechar la instancia.",
|
||||
} as const
|
||||
|
||||
@@ -15,4 +15,13 @@ export const logMessages = {
|
||||
"infoView.logs.paused.description": "Activez le streaming pour suivre l'activité de votre serveur OpenCode.",
|
||||
"infoView.logs.empty.waiting": "En attente de la sortie du serveur...",
|
||||
"infoView.logs.scrollToBottom": "Aller en bas",
|
||||
|
||||
"infoView.dispose.actions.dispose": "Réinitialiser l'instance",
|
||||
"infoView.dispose.actions.disposing": "Réinitialisation...",
|
||||
"infoView.dispose.confirm.title": "Réinitialiser l'instance ?",
|
||||
"infoView.dispose.confirm.message": "Cela efface l'état en cache pour ce répertoire et recharge l'instance.",
|
||||
"infoView.dispose.confirm.confirmLabel": "Réinitialiser",
|
||||
"infoView.dispose.confirm.cancelLabel": "Annuler",
|
||||
"infoView.dispose.toast.success": "Instance réinitialisée. Rechargement...",
|
||||
"infoView.dispose.toast.error": "Impossible de réinitialiser l'instance.",
|
||||
} as const
|
||||
|
||||
@@ -15,4 +15,13 @@ export const logMessages = {
|
||||
"infoView.logs.paused.description": "ストリーミングを有効にして OpenCode サーバーの動作を監視します。",
|
||||
"infoView.logs.empty.waiting": "サーバー出力を待機中...",
|
||||
"infoView.logs.scrollToBottom": "最下部へスクロール",
|
||||
|
||||
"infoView.dispose.actions.dispose": "インスタンスを破棄",
|
||||
"infoView.dispose.actions.disposing": "破棄しています...",
|
||||
"infoView.dispose.confirm.title": "インスタンスを破棄しますか?",
|
||||
"infoView.dispose.confirm.message": "このディレクトリのプロジェクト状態キャッシュをクリアし、インスタンスを再読み込みします。",
|
||||
"infoView.dispose.confirm.confirmLabel": "破棄",
|
||||
"infoView.dispose.confirm.cancelLabel": "キャンセル",
|
||||
"infoView.dispose.toast.success": "インスタンスを破棄しました。再読み込み中...",
|
||||
"infoView.dispose.toast.error": "インスタンスの破棄に失敗しました。",
|
||||
} as const
|
||||
|
||||
@@ -15,4 +15,13 @@ export const logMessages = {
|
||||
"infoView.logs.paused.description": "Включите стриминг, чтобы наблюдать за активностью сервера OpenCode.",
|
||||
"infoView.logs.empty.waiting": "Ожидание вывода сервера…",
|
||||
"infoView.logs.scrollToBottom": "Прокрутить вниз",
|
||||
|
||||
"infoView.dispose.actions.dispose": "Сбросить инстанс",
|
||||
"infoView.dispose.actions.disposing": "Сброс...",
|
||||
"infoView.dispose.confirm.title": "Сбросить инстанс?",
|
||||
"infoView.dispose.confirm.message": "Это очистит кэш состояния проекта для этого каталога и перезагрузит инстанс.",
|
||||
"infoView.dispose.confirm.confirmLabel": "Сбросить",
|
||||
"infoView.dispose.confirm.cancelLabel": "Отмена",
|
||||
"infoView.dispose.toast.success": "Инстанс сброшен. Перезагрузка...",
|
||||
"infoView.dispose.toast.error": "Не удалось сбросить инстанс.",
|
||||
} as const
|
||||
|
||||
@@ -15,4 +15,13 @@ export const logMessages = {
|
||||
"infoView.logs.paused.description": "启用流式输出以查看 OpenCode 服务器活动。",
|
||||
"infoView.logs.empty.waiting": "正在等待服务器输出...",
|
||||
"infoView.logs.scrollToBottom": "滚动到底部",
|
||||
|
||||
"infoView.dispose.actions.dispose": "释放实例",
|
||||
"infoView.dispose.actions.disposing": "正在释放...",
|
||||
"infoView.dispose.confirm.title": "要释放实例吗?",
|
||||
"infoView.dispose.confirm.message": "这将清除此目录的项目缓存状态,并重新加载实例。",
|
||||
"infoView.dispose.confirm.confirmLabel": "释放",
|
||||
"infoView.dispose.confirm.cancelLabel": "取消",
|
||||
"infoView.dispose.toast.success": "实例已释放。正在重新加载...",
|
||||
"infoView.dispose.toast.error": "释放实例失败。",
|
||||
} as const
|
||||
|
||||
@@ -46,7 +46,7 @@ class SDKManager {
|
||||
|
||||
export type { OpencodeClient }
|
||||
|
||||
function buildInstanceBaseUrl(proxyPath: string): string {
|
||||
export function buildInstanceBaseUrl(proxyPath: string): string {
|
||||
const normalized = normalizeProxyPath(proxyPath)
|
||||
const base = stripTrailingSlashes(CODENOMAD_API_BASE)
|
||||
return `${base}${normalized}/`
|
||||
|
||||
@@ -54,6 +54,13 @@ interface BackgroundProcessRemovedEvent {
|
||||
}
|
||||
}
|
||||
|
||||
interface ServerInstanceDisposedEvent {
|
||||
type: "server.instance.disposed"
|
||||
properties: {
|
||||
directory: string
|
||||
}
|
||||
}
|
||||
|
||||
type SSEEvent =
|
||||
| MessageUpdateEvent
|
||||
| MessageRemovedEvent
|
||||
@@ -74,6 +81,7 @@ type SSEEvent =
|
||||
| TuiToastEvent
|
||||
| BackgroundProcessUpdatedEvent
|
||||
| BackgroundProcessRemovedEvent
|
||||
| ServerInstanceDisposedEvent
|
||||
| { type: string; properties?: Record<string, unknown> }
|
||||
|
||||
type ConnectionStatus = InstanceStreamStatus
|
||||
@@ -173,6 +181,9 @@ class SSEManager {
|
||||
case "background.process.removed":
|
||||
this.onBackgroundProcessRemoved?.(instanceId, event as BackgroundProcessRemovedEvent)
|
||||
break
|
||||
case "server.instance.disposed":
|
||||
this.onInstanceDisposed?.(instanceId, event as ServerInstanceDisposedEvent)
|
||||
break
|
||||
default:
|
||||
log.warn("Unknown SSE event type", { type: event.type })
|
||||
}
|
||||
@@ -205,6 +216,7 @@ class SSEManager {
|
||||
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
|
||||
onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void
|
||||
onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void
|
||||
onInstanceDisposed?: (instanceId: string, event: ServerInstanceDisposedEvent) => void
|
||||
onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void>
|
||||
|
||||
getStatus(instanceId: string): ConnectionStatus | null {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getPermissionCreatedAt, getPermissionSessionId } from "../types/permiss
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { getQuestionSessionId } from "../types/question"
|
||||
import { requestData } from "../lib/opencode-api"
|
||||
import { sdkManager } from "../lib/sdk-manager"
|
||||
import { buildInstanceBaseUrl, sdkManager } from "../lib/sdk-manager"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { serverEvents } from "../lib/server-events"
|
||||
@@ -18,7 +18,14 @@ import {
|
||||
fetchProviders,
|
||||
clearInstanceDraftPrompts,
|
||||
} from "./sessions"
|
||||
import { ensureWorktreesLoaded, ensureWorktreeMapLoaded, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
|
||||
import {
|
||||
ensureWorktreesLoaded,
|
||||
ensureWorktreeMapLoaded,
|
||||
getOrCreateWorktreeClient,
|
||||
getWorktreeSlugForSession,
|
||||
reloadWorktreeMap,
|
||||
reloadWorktrees,
|
||||
} from "./worktrees"
|
||||
import { fetchCommands, clearCommands } from "./commands"
|
||||
import { serverSettings } from "./preferences"
|
||||
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
|
||||
@@ -76,6 +83,9 @@ const [disconnectedInstance, setDisconnectedInstance] = createSignal<Disconnecte
|
||||
|
||||
const MAX_LOG_ENTRIES = 1000
|
||||
|
||||
const pendingDisposeRequests = new Map<string, Promise<boolean>>()
|
||||
const pendingRehydrations = new Map<string, Promise<void>>()
|
||||
|
||||
function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instance {
|
||||
const existing = instances().get(descriptor.id)
|
||||
return {
|
||||
@@ -228,10 +238,15 @@ async function syncPendingQuestions(instanceId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateInstanceData(instanceId: string) {
|
||||
async function hydrateInstanceData(instanceId: string, options?: { force?: boolean }) {
|
||||
try {
|
||||
await ensureWorktreesLoaded(instanceId)
|
||||
await ensureWorktreeMapLoaded(instanceId)
|
||||
if (options?.force) {
|
||||
await reloadWorktrees(instanceId)
|
||||
await reloadWorktreeMap(instanceId)
|
||||
} else {
|
||||
await ensureWorktreesLoaded(instanceId)
|
||||
await ensureWorktreeMapLoaded(instanceId)
|
||||
}
|
||||
await fetchSessions(instanceId)
|
||||
await fetchAgents(instanceId)
|
||||
await fetchProviders(instanceId)
|
||||
@@ -246,6 +261,91 @@ async function hydrateInstanceData(instanceId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function postInstanceDispose(instanceId: string): Promise<boolean> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance?.proxyPath) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const baseUrl = buildInstanceBaseUrl(instance.proxyPath)
|
||||
const url = new URL("instance/dispose", baseUrl)
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text().catch(() => "")
|
||||
throw new Error(message || `Dispose request failed with ${response.status}`)
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? ""
|
||||
if (contentType.includes("application/json")) {
|
||||
const data = await response.json().catch(() => undefined)
|
||||
if (typeof data === "boolean") return data
|
||||
if (data && typeof data === "object" && "data" in (data as any)) {
|
||||
return Boolean((data as any).data)
|
||||
}
|
||||
return Boolean(data)
|
||||
}
|
||||
|
||||
const text = await response.text().catch(() => "")
|
||||
if (text.trim() === "true") return true
|
||||
if (text.trim() === "false") return false
|
||||
return Boolean(text)
|
||||
}
|
||||
|
||||
async function rehydrateInstance(instanceId: string, options?: { reason?: string }): Promise<void> {
|
||||
if (pendingRehydrations.has(instanceId)) {
|
||||
return pendingRehydrations.get(instanceId)
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance?.client) {
|
||||
return
|
||||
}
|
||||
|
||||
log.info("Rehydrating instance", { instanceId, reason: options?.reason })
|
||||
clearCacheForInstance(instanceId)
|
||||
clearCommands(instanceId)
|
||||
clearInstanceMetadata(instanceId)
|
||||
clearInstanceDraftPrompts(instanceId)
|
||||
clearPermissionQueue(instanceId)
|
||||
clearQuestionQueue(instanceId)
|
||||
|
||||
await hydrateInstanceData(instanceId, { force: true })
|
||||
})().finally(() => {
|
||||
pendingRehydrations.delete(instanceId)
|
||||
})
|
||||
|
||||
pendingRehydrations.set(instanceId, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
async function disposeInstance(instanceId: string): Promise<boolean> {
|
||||
if (pendingDisposeRequests.has(instanceId)) {
|
||||
return pendingDisposeRequests.get(instanceId)!
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
const ok = await postInstanceDispose(instanceId)
|
||||
if (ok) {
|
||||
await rehydrateInstance(instanceId, { reason: "disposed" })
|
||||
}
|
||||
return ok
|
||||
})().finally(() => {
|
||||
pendingDisposeRequests.delete(instanceId)
|
||||
})
|
||||
|
||||
pendingDisposeRequests.set(instanceId, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
void (async function initializeWorkspaces() {
|
||||
try {
|
||||
const workspaces = await serverApi.fetchWorkspaces()
|
||||
@@ -939,6 +1039,30 @@ sseManager.onLspUpdated = async (instanceId) => {
|
||||
}
|
||||
}
|
||||
|
||||
sseManager.onInstanceDisposed = (sourceInstanceId, event) => {
|
||||
const directory = event?.properties?.directory
|
||||
if (!directory) {
|
||||
void rehydrateInstance(sourceInstanceId, { reason: "disposed" })
|
||||
return
|
||||
}
|
||||
|
||||
const matchingInstanceIds: string[] = []
|
||||
for (const instance of instances().values()) {
|
||||
if (instance.folder === directory) {
|
||||
matchingInstanceIds.push(instance.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (matchingInstanceIds.length === 0) {
|
||||
void rehydrateInstance(sourceInstanceId, { reason: "disposed" })
|
||||
return
|
||||
}
|
||||
|
||||
for (const instanceId of matchingInstanceIds) {
|
||||
void rehydrateInstance(instanceId, { reason: "disposed" })
|
||||
}
|
||||
}
|
||||
|
||||
async function acknowledgeDisconnectedInstance(): Promise<void> {
|
||||
const pending = disconnectedInstance()
|
||||
if (!pending) {
|
||||
@@ -995,4 +1119,5 @@ export {
|
||||
disconnectedInstance,
|
||||
acknowledgeDisconnectedInstance,
|
||||
fetchLspStatus,
|
||||
disposeInstance,
|
||||
}
|
||||
|
||||
@@ -54,3 +54,28 @@ button.button-tertiary:hover:not(:disabled) {
|
||||
button.button-tertiary:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
|
||||
}
|
||||
|
||||
.button-danger,
|
||||
button.button-danger {
|
||||
@apply px-6 py-3 text-base rounded-lg;
|
||||
background-color: var(--button-danger-bg);
|
||||
color: var(--button-danger-text);
|
||||
border-color: var(--button-danger-bg);
|
||||
}
|
||||
|
||||
.button-danger:hover:not(:disabled),
|
||||
button.button-danger:hover:not(:disabled) {
|
||||
background-color: var(--button-danger-hover-bg);
|
||||
border-color: var(--button-danger-hover-bg);
|
||||
}
|
||||
|
||||
.button-danger:focus-visible,
|
||||
button.button-danger:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
|
||||
}
|
||||
|
||||
/* Smaller sizing variant for destructive actions in tight spaces. */
|
||||
.button-danger.button-small,
|
||||
button.button-danger.button-small {
|
||||
@apply px-4 py-2 text-sm;
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@
|
||||
button.button-primary,
|
||||
.button-secondary,
|
||||
button.button-secondary,
|
||||
.button-danger,
|
||||
button.button-danger,
|
||||
.button-tertiary,
|
||||
button.button-tertiary) {
|
||||
@apply inline-flex items-center justify-center gap-2 font-medium transition-colors rounded-md;
|
||||
@@ -74,6 +76,8 @@
|
||||
button.button-primary,
|
||||
.button-secondary,
|
||||
button.button-secondary,
|
||||
.button-danger,
|
||||
button.button-danger,
|
||||
.button-tertiary,
|
||||
button.button-tertiary):focus-visible {
|
||||
outline: none;
|
||||
@@ -84,6 +88,8 @@
|
||||
button.button-primary,
|
||||
.button-secondary,
|
||||
button.button-secondary,
|
||||
.button-danger,
|
||||
button.button-danger,
|
||||
.button-tertiary,
|
||||
button.button-tertiary):disabled {
|
||||
@apply cursor-not-allowed opacity-50;
|
||||
|
||||
Reference in New Issue
Block a user