Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions packages/app/src/components/presets-manager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { createSignal, For, Show } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { TextField } from "@opencode-ai/ui/text-field"
import { useI18n } from "@opencode-ai/ui/context"
import type { Preset, PresetsStore } from "@/hooks/use-presets"

interface PresetsManagerProps {
store: PresetsStore
}

export function PresetsManager(props: PresetsManagerProps) {
const { t } = useI18n()
const [editingId, setEditingId] = createSignal<string | null>(null)
const [editName, setEditName] = createSignal("")
const [editContent, setEditContent] = createSignal("")
const [adding, setAdding] = createSignal(false)
const [newName, setNewName] = createSignal("")
const [newContent, setNewContent] = createSignal("")

const startEdit = (preset: Preset) => {
setEditingId(preset.id)
setEditName(preset.name)
setEditContent(preset.content)
}

const cancelEdit = () => {
setEditingId(null)
setEditName("")
setEditContent("")
}

const saveEdit = () => {
const id = editingId()
if (!id) return
const name = editName().trim()
const content = editContent().trim()
if (!name || !content) return
props.store.update(id, { name, content })
cancelEdit()
}

const startAdd = () => {
setAdding(true)
setNewName("")
setNewContent("")
}

const cancelAdd = () => {
setAdding(false)
setNewName("")
setNewContent("")
}

const saveAdd = () => {
const name = newName().trim()
const content = newContent().trim()
if (!name || !content) return
props.store.add(name, content)
cancelAdd()
}

return (
<Dialog title={t("presets.manage")} size="large">
<div class="flex flex-col gap-3 p-6">
<div class="flex items-center justify-between">
<span class="text-14-regular text-v2-text-text-muted">
{t("presets.count", { count: props.store.presets().length })}
</span>
<Button size="large" variant="secondary" onClick={startAdd}>
<Icon name="plus-small" size="small" />
<span>{t("presets.add")}</span>
</Button>
</div>

<Show when={adding()}>
<div class="flex flex-col gap-2 rounded-lg border border-v2-border-border-base p-2">
<TextField
type="text"
placeholder={t("presets.name")}
value={newName()}
onChange={setNewName}
autofocus
/>
<TextField
multiline
placeholder={t("presets.content.placeholder")}
value={newContent()}
onChange={setNewContent}
/>
<div class="flex justify-end gap-2">
<Button size="large" variant="ghost" onClick={cancelAdd}>
{t("common.cancel")}
</Button>
<Button size="large" variant="primary" onClick={saveAdd}>
{t("common.save")}
</Button>
</div>
</div>
</Show>

<div class="flex flex-col gap-0.5">
<For each={props.store.presets()}>
{(preset) => {
const isEditing = () => editingId() === preset.id
return (
<Show
when={isEditing()}
fallback={
<div class="flex items-center gap-2 rounded-lg px-2 py-1 hover:bg-surface-raised-base-hover transition-colors">
<div class="min-w-0 flex-1">
<div class="text-14-medium text-v2-text-text-strong truncate">
{preset.name}
</div>
<div class="text-13-regular text-v2-text-text-muted truncate">
{preset.content}
</div>
</div>
<div class="flex items-center gap-1">
<Tooltip value={t("presets.moveUp")}>
<IconButton
icon="chevron-down"
variant="ghost"
class="size-6 p-[5px] rotate-180"
onClick={() => props.store.moveUp(preset.id)}
/>
</Tooltip>
<Tooltip value={t("presets.moveDown")}>
<IconButton
icon="chevron-down"
variant="ghost"
class="size-6 p-[5px]"
onClick={() => props.store.moveDown(preset.id)}
/>
</Tooltip>
<Tooltip value={t("presets.edit")}>
<IconButton
icon="edit"
variant="ghost"
class="size-6 p-[5px]"
onClick={() => startEdit(preset)}
/>
</Tooltip>
<Tooltip value={t("presets.delete")}>
<IconButton
icon="trash"
variant="ghost"
class="size-6 p-[5px] text-red-500"
onClick={() => props.store.remove(preset.id)}
/>
</Tooltip>
</div>
</div>
}
>
<div class="flex flex-col gap-2 rounded-lg border border-v2-border-border-base p-2">
<TextField
type="text"
placeholder={t("presets.name")}
value={editName()}
onChange={setEditName}
/>
<TextField
multiline
placeholder={t("presets.content.placeholder")}
value={editContent()}
onChange={setEditContent}
/>
<div class="flex justify-end gap-2">
<Button size="large" variant="ghost" onClick={cancelEdit}>
{t("common.cancel")}
</Button>
<Button size="large" variant="primary" onClick={saveEdit}>
{t("common.save")}
</Button>
</div>
</div>
</Show>
)
}}
</For>
</div>

<Show when={props.store.presets().length === 0 && !adding()}>
<div class="flex flex-col items-center justify-center py-8 text-v2-text-text-muted">
<Icon name="checklist" size="large" />
<span class="mt-2 text-14-regular">{t("presets.empty")}</span>
<span class="text-13-regular">{t("presets.empty.hint")}</span>
</div>
</Show>
</div>
</Dialog>
)
}
170 changes: 170 additions & 0 deletions packages/app/src/components/presets-popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { createSignal, For, Show } from "solid-js"
import { Popover } from "@opencode-ai/ui/popover"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { TextField } from "@opencode-ai/ui/text-field"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useI18n } from "@opencode-ai/ui/context"
import { extractVariables, resolveVariables, type PresetsStore } from "@/hooks/use-presets"
import { PresetsManager } from "./presets-manager"

interface PresetsPopoverProps {
store: PresetsStore
onInsert: (text: string) => void
}

export function PresetsPopover(props: PresetsPopoverProps) {
const dialog = useDialog()
const { t } = useI18n()
const [open, setOpen] = createSignal(false)
const [pendingVariables, setPendingVariables] = createSignal<string[] | null>(null)
const [pendingContent, setPendingContent] = createSignal("")
const [pendingName, setPendingName] = createSignal("")
const [varValues, setVarValues] = createSignal<Record<string, string>>({})
const [currentVarIndex, setCurrentVarIndex] = createSignal(0)

const handleSelect = (name: string, content: string) => {
const vars = extractVariables(content)
if (vars.length === 0) {
props.onInsert(content)
setOpen(false)
return
}
setPendingVariables(vars)
setPendingContent(content)
setPendingName(name)
setVarValues({})
setCurrentVarIndex(0)
}

const handleVarInput = (value: string) => {
const vars = pendingVariables()
if (!vars) return
const idx = currentVarIndex()
const key = vars[idx]
setVarValues((prev) => ({ ...prev, [key]: value }))
}

const confirmVariable = () => {
const vars = pendingVariables()
if (!vars) return
const idx = currentVarIndex()
if (idx < vars.length - 1) {
setCurrentVarIndex(idx + 1)
} else {
const resolved = resolveVariables(pendingContent(), varValues())
props.onInsert(resolved)
setOpen(false)
resetPending()
}
}

const resetPending = () => {
setPendingVariables(null)
setPendingContent("")
setPendingName("")
setVarValues({})
setCurrentVarIndex(0)
}

const cancelPending = () => {
resetPending()
}

const openManager = () => {
setOpen(false)
dialog.show(() => <PresetsManager store={props.store} />)
}

return (
<Popover
open={open()}
onOpenChange={setOpen}
gutter={8}
placement="top-start"
trigger={
<Tooltip value={t("presets.title")}>
<IconButton
data-action="prompt-presets"
type="button"
icon="checklist"
variant="ghost"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
aria-label={t("presets.title")}
/>
</Tooltip>
}
>
<div class="flex w-[280px] flex-col">
<Show when={!pendingVariables()}>
<div class="max-h-[300px] overflow-y-auto flex flex-col gap-0.5 p-1">
<For each={props.store.presets()}>
{(preset) => (
<button
type="button"
class="flex w-full flex-col gap-0.5 rounded-md px-2 py-1 text-left hover:bg-surface-raised-base-hover transition-colors"
onClick={() => handleSelect(preset.name, preset.content)}
>
<span class="text-14-medium text-v2-text-text-strong truncate">
{preset.name}
</span>
<span class="text-13-regular text-v2-text-text-muted truncate">
{preset.content}
</span>
</button>
)}
</For>
<Show when={props.store.presets().length === 0}>
<div class="flex flex-col items-center justify-center py-6 text-v2-text-text-muted">
<span class="text-13-regular">{t("presets.empty")}</span>
</div>
</Show>
</div>
<div class="border-t border-v2-border-border p-1">
<button
type="button"
class="flex w-full items-center gap-2 rounded-md px-2 py-1 text-13-regular text-v2-text-text-muted hover:bg-surface-raised-base-hover transition-colors"
onClick={openManager}
>
<Icon name="settings-gear" size="small" />
{t("presets.manage")}
</button>
</div>
</Show>

<Show when={pendingVariables()}>
<div class="flex flex-col gap-2 px-1 py-2">
<div class="text-14-medium text-v2-text-text-strong px-2">
{t("presets.variables.title", { name: pendingName() })}
</div>
<div class="text-13-regular text-v2-text-text-muted px-2">
{pendingVariables()![currentVarIndex()]}({currentVarIndex() + 1}/{pendingVariables()!.length})
</div>
<TextField
type="text"
placeholder={pendingVariables()![currentVarIndex()]}
value={varValues()[pendingVariables()![currentVarIndex()]] ?? ""}
onChange={(value) => handleVarInput(value)}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === "Enter") confirmVariable()
if (e.key === "Escape") cancelPending()
}}
class="mx-1 w-[calc(100%-8px)]"
autofocus
/>
<div class="flex justify-end gap-2 px-1">
<Button size="large" variant="ghost" onClick={cancelPending}>
{t("common.cancel")}
</Button>
<Button size="large" variant="primary" onClick={confirmVariable}>
{currentVarIndex() < pendingVariables()!.length - 1 ? t("presets.variables.next") : t("presets.variables.insert")}
</Button>
</div>
</div>
</Show>
</div>
</Popover>
)
}
Loading
Loading