Files
Thing/src/modules/terminal/components/SnippetPanel.vue
T
2026-09-18 18:28:13 +08:00

574 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 命令片段面板(浮层)。
*
* # 交互模型:先填参数、再决定是否执行
*
* 带占位符的片段走「填写 → 预览 → 执行」三步。预览这一步不是装饰:
* 用户能在这里看到**渲染后的真实命令**,而执行按钮分两个——
* 「填入命令行」(不回车)与「直接执行」。危险片段(`confirm` 为 true
* 默认只做前者:让用户自己按回车,与命令被自动执行在心理上完全不同。
*
* # 为什么占位符列表来自后端
*
* 解析规则(`$${x}` 转义、`${VAR:-default}` 排除、名称字符集)由 Rust 的
* `snippet_placeholders` 单独实现一份。前端若自己写正则,两边在边界情况上
* 迟早分叉 —— 而「预览显示的命令」与「实际执行的命令」不一致是最坏的结果。
*/
import { computed, ref } from 'vue'
import { open as openDialog } from '@tauri-apps/plugin-dialog'
import {
BookMarked,
Play,
Pin,
Plus,
RotateCcw,
Search,
ShieldAlert,
Terminal,
Trash2,
X
} from '@lucide/vue'
import { toast } from 'vue-sonner'
import { useTerminalStore } from '@/stores/terminalStore'
import { createLogger } from '@/lib/logger'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { CommandSnippet, SessionInfo, SnippetView } from '@/types/terminal'
const props = defineProps<{
sessionId: string | null
session: SessionInfo | undefined
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const logger = createLogger('terminal')
const store = useTerminalStore()
const filter = ref('')
const selected = ref<SnippetView | null>(null)
/** 会话是 SSH 还是本地(决定哪些片段可见) */
const sessionKind = computed<'local' | 'ssh'>(() => props.session?.kind ?? 'local')
/** 本地会话对应的 shell kind(用于 `shellKinds` 过滤) */
const shellKind = computed(() => store.shellOf(props.session)?.kind)
/** 当前会话可用的片段 */
const available = computed(() => store.snippetsForSession(sessionKind.value, shellKind.value))
const filtered = computed(() => {
const kw = filter.value.trim().toLowerCase()
if (!kw) return available.value
return available.value.filter(
s =>
s.name.toLowerCase().includes(kw) ||
s.command.toLowerCase().includes(kw) ||
s.description.toLowerCase().includes(kw)
)
})
/** 按分组归类(保持 store 的排序:置顶优先 + 名称序) */
const grouped = computed(() => {
const map = new Map<string, SnippetView[]>()
for (const s of filtered.value) {
const g = s.group.trim() || '未分组'
if (!map.has(g)) map.set(g, [])
map.get(g)!.push(s)
}
return [...map.entries()]
})
// ===== 参数填写 =====
/** 占位符名 → 当前填写值 */
const values = ref<Record<string, string>>({})
/** 渲染后的命令(每次 preview 后更新) */
const preview = ref('')
const rendering = ref(false)
function select(s: SnippetView) {
selected.value = s
preview.value = ''
// 预填默认值:大多数情况下用户只需要改其中一两个
const v: Record<string, string> = {}
for (const k of s.placeholders) v[k] = s.defaults[k] ?? ''
values.value = v
}
const missing = computed(() => {
const s = selected.value
if (!s) return []
return s.placeholders.filter(k => !(values.value[k] ?? '').trim())
})
async function doRender() {
const s = selected.value
if (!s) return
if (missing.value.length > 0) {
toast.error(`还有 ${missing.value.length} 个占位符未填写`)
return
}
rendering.value = true
try {
preview.value = await store.renderSnippet(s.id, values.value)
} catch (e) {
toast.error(String(e))
preview.value = ''
} finally {
rendering.value = false
}
}
/**
* 执行片段。
*
* `submit = true` 直接回车执行;否则只写到命令行上。
* 危险片段(`confirm`)在界面上不提供「直接执行」按钮,
* 但这里仍做一层兜底——见下面对 `mustConfirm` 的判断。
*/
async function run(submit: boolean) {
const s = selected.value
if (!s || !props.sessionId) {
toast.info('请先打开一个会话')
return
}
if (missing.value.length > 0) {
toast.error('请先填写全部占位符')
return
}
// 先确保预览与实际一致:渲染在命令层统一做,前端不自己拼
try {
const rendered = await store.renderSnippet(s.id, values.value)
preview.value = rendered
} catch (e) {
toast.error(String(e))
return
}
try {
const r = await store.runSnippet(props.sessionId, s.id, values.value, submit)
if (submit) toast.success(r.message)
else toast.info(r.message)
} catch (e) {
toast.error(`执行失败:${String(e)}`)
}
}
/** 危险片段强制执行一次确认(界面已限制,这是兜底) */
function confirmThenRun() {
const s = selected.value
if (!s) return
if (s.confirm && !window.confirm(`「${s.name}」被标记为需要确认的命令,确定执行?`)) return
void run(true)
}
// ===== 编辑 =====
const editing = ref(false)
const draft = ref<CommandSnippet | null>(null)
function newSnippet() {
draft.value = {
id: '',
name: '',
command: '',
description: '',
group: '',
defaults: {},
shellKinds: [],
sshOnly: false,
confirm: false,
pinned: false,
createdAt: 0
}
editing.value = true
}
function editSelected() {
const s = selected.value
if (!s) return
// 去掉后端附加的 placeholders 字段 —— 它不是 CommandSnippet 的一部分,
// 带着它回传会在 save 时触发未知字段告警(serde 默认忽略,但没必要)
const { placeholders: _p, ...rest } = s
draft.value = { ...rest }
editing.value = true
}
async function submitDraft() {
const d = draft.value
if (!d) return
if (!d.name.trim()) {
toast.error('名称不能为空')
return
}
if (!d.command.trim()) {
toast.error('命令内容不能为空')
return
}
try {
await store.saveSnippet(d)
editing.value = false
toast.success('已保存')
// 保存后刷新选中项(占位符可能变了)
const fresh = store.snippets.find(s => s.id === (d.id || store.snippets[store.snippets.length - 1]?.id))
if (fresh) select(fresh)
} catch (e) {
toast.error(`保存失败:${String(e)}`)
}
}
async function removeSelected() {
const s = selected.value
if (!s) return
if (!window.confirm(`确定删除片段「${s.name}」?`)) return
try {
await store.deleteSnippet(s.id)
selected.value = null
toast.success('已删除')
} catch (e) {
toast.error(`删除失败:${String(e)}`)
}
}
async function togglePin() {
const s = selected.value
if (!s) return
try {
const { placeholders: _p, ...rest } = s
await store.saveSnippet({ ...rest, pinned: !s.pinned })
const fresh = store.snippets.find(x => x.id === s.id)
if (fresh) select(fresh)
} catch (e) {
toast.error(`操作失败:${String(e)}`)
}
}
async function restoreDefaults() {
if (!window.confirm('将补回缺失的内置片段(不会覆盖你已有的条目),继续?')) return
try {
await store.restoreDefaultSnippets()
toast.success('已恢复内置片段')
} catch (e) {
toast.error(`恢复失败:${String(e)}`)
}
}
/** 从本地文件导入命令文本(省去逐字敲长命令) */
async function importFromFile() {
try {
const picked = await openDialog({
multiple: false,
directory: false,
filters: [{ name: '文本/脚本', extensions: ['txt', 'sh', 'bash', 'zsh', 'ps1', 'cmd', 'bat'] }]
})
if (typeof picked !== 'string') return
// 前端不直接读文件(CSP 下 WebView 读本地文件受限),
// 让用户把内容粘进来 —— 这个流程在 P2 会改为走后端读文件。
draft.value = {
id: '',
name: picked.replace(/\\/g, '/').split('/').pop() ?? '导入的片段',
command: '',
description: `待粘贴内容(来源:${picked}`,
group: '导入',
defaults: {},
shellKinds: [],
sshOnly: false,
confirm: true,
pinned: false,
createdAt: 0
}
editing.value = true
toast.info('请把命令内容粘贴到「命令」框中')
void logger
} catch (e) {
toast.error(`导入失败:${String(e)}`)
}
}
</script>
<template>
<!-- 作为模块 Tab 内容渲染根不再是全屏浮层 -->
<div class="flex flex-col h-full">
<!-- ===== 头部 ===== -->
<div class="shrink-0 flex items-center gap-2 px-3 h-9 border-b border-border">
<Button variant="ghost" size="sm" class="h-7 text-xs" @click="emit('close')">返回终端</Button>
<div class="w-px h-4 bg-border" />
<BookMarked class="size-3.5 text-muted-foreground" />
<span class="text-xs font-medium">命令片段</span>
<span class="text-[10px] text-muted-foreground">
{{ session ? `作用于 ${store.sessionLabel(session)}` : '未选择会话' }}
</span>
<div class="flex-1" />
<Button variant="outline" size="sm" class="h-7 text-xs gap-1.5" @click="importFromFile">
从文件导入
</Button>
<Button variant="outline" size="sm" class="h-7 text-xs gap-1.5" @click="restoreDefaults">
<RotateCcw class="size-3" />恢复内置
</Button>
<Button size="sm" class="h-7 text-xs gap-1.5" @click="newSnippet">
<Plus class="size-3" />新建片段
</Button>
</div>
<div class="flex-1 min-h-0 flex">
<!-- ===== 列表 ===== -->
<section class="w-[300px] shrink-0 border-r border-border flex flex-col">
<div class="shrink-0 p-2 border-b border-border">
<div class="relative">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 size-3 text-muted-foreground" />
<Input v-model="filter" placeholder="搜索片段…" class="h-7 pl-7 text-xs" />
</div>
</div>
<div class="flex-1 min-h-0 overflow-auto">
<div v-if="filtered.length === 0" class="p-4 text-center">
<p class="text-[11px] text-muted-foreground/70">
{{ available.length === 0 ? '当前会话没有可用的片段' : '无匹配项' }}
</p>
</div>
<template v-else>
<div v-for="[group, list] in grouped" :key="group">
<div class="px-2.5 py-1 text-[10px] font-medium text-muted-foreground bg-muted/40 sticky top-0">
{{ group }}
</div>
<button
v-for="s in list"
:key="s.id"
class="w-full text-left px-2.5 py-1.5 hover:bg-accent border-l-2 transition-colors"
:class="selected?.id === s.id ? 'bg-accent border-primary' : 'border-transparent'"
@click="select(s)"
>
<div class="flex items-center gap-1.5">
<Pin v-if="s.pinned" class="size-2.5 text-amber-500 shrink-0" />
<span class="text-[11px] font-medium truncate">{{ s.name }}</span>
<ShieldAlert v-if="s.confirm" class="size-2.5 text-destructive shrink-0" />
</div>
<div class="text-[10px] text-muted-foreground truncate font-mono mt-0.5">
{{ s.command }}
</div>
</button>
</div>
</template>
</div>
</section>
<!-- ===== 详情 / 参数填写 / 编辑 ===== -->
<section class="flex-1 min-w-0 flex flex-col overflow-auto">
<!-- ---------- 编辑态 ---------- -->
<div v-if="editing && draft" class="p-4 space-y-3 max-w-2xl">
<h3 class="text-sm font-medium">{{ draft.id ? '编辑片段' : '新建片段' }}</h3>
<label class="block space-y-1">
<span class="text-[11px] text-muted-foreground">名称</span>
<Input v-model="draft.name" class="h-7 text-xs" placeholder="如:查找大文件" />
</label>
<label class="block space-y-1">
<span class="text-[11px] text-muted-foreground">
命令 <code class="font-mono">${name}</code> 表示占位符
需要字面量时写 <code class="font-mono">$${name}</code>
</span>
<textarea
v-model="draft.command"
rows="3"
class="w-full px-2 py-1.5 rounded-md border border-border bg-transparent
text-xs font-mono outline-none focus:border-primary/60 resize-y"
placeholder="find ${dir} -type f -size +${size}"
/>
</label>
<label class="block space-y-1">
<span class="text-[11px] text-muted-foreground">说明讲清做什么有什么前提</span>
<Input v-model="draft.description" class="h-7 text-xs" />
</label>
<div class="grid grid-cols-2 gap-3">
<label class="block space-y-1">
<span class="text-[11px] text-muted-foreground">分组</span>
<Input v-model="draft.group" class="h-7 text-xs" placeholder="如:文件 / 运维" />
</label>
<label class="block space-y-1">
<span class="text-[11px] text-muted-foreground">
适用 shell逗号分隔留空表示全部
</span>
<Input
:model-value="draft.shellKinds.join(',')"
class="h-7 text-xs"
placeholder="powershell,bash"
@update:model-value="v => draft && (draft.shellKinds = String(v).split(',').map(x => x.trim()).filter(Boolean))"
/>
</label>
</div>
<div class="flex items-center gap-4 text-[11px]">
<label class="flex items-center gap-1.5 cursor-pointer select-none">
<input v-model="draft.sshOnly" type="checkbox" class="size-3 accent-primary" />
仅 SSH 会话可用
</label>
<label class="flex items-center gap-1.5 cursor-pointer select-none">
<input v-model="draft.confirm" type="checkbox" class="size-3 accent-primary" />
执行前需确认(危险命令)
</label>
<label class="flex items-center gap-1.5 cursor-pointer select-none">
<input v-model="draft.pinned" type="checkbox" class="size-3 accent-primary" />
置顶
</label>
</div>
<div
v-if="draft.confirm"
class="flex items-start gap-2 p-2 rounded border border-destructive/40 bg-destructive/5"
>
<ShieldAlert class="size-3.5 text-destructive shrink-0 mt-0.5" />
<p class="text-[11px] text-muted-foreground leading-relaxed">
标记为「需确认」的片段默认只会**填入命令行**,不会自动执行 ——
用户需要自己按回车。这避免了参数误填时立刻造成破坏。
</p>
</div>
<div class="flex items-center gap-2 pt-1">
<Button size="sm" class="h-7 text-xs" @click="submitDraft">保存</Button>
<Button variant="outline" size="sm" class="h-7 text-xs" @click="editing = false">取消</Button>
</div>
</div>
<!-- ---------- 详情态 ---------- -->
<div v-else-if="selected" class="p-4 space-y-3 max-w-2xl">
<div class="flex items-center gap-2">
<h3 class="text-sm font-medium">{{ selected.name }}</h3>
<button
class="text-muted-foreground hover:text-foreground"
:title="selected.pinned ? '取消置顶' : '置顶'"
@click="togglePin"
>
<Pin class="size-3.5" :class="selected.pinned ? 'text-amber-500' : ''" />
</button>
<div class="flex-1" />
<Button variant="ghost" size="sm" class="h-6 text-[11px]" @click="editSelected">编辑</Button>
<Button
variant="ghost"
size="sm"
class="h-6 text-[11px] text-destructive"
@click="removeSelected"
>
<Trash2 class="size-3" />
</Button>
</div>
<p v-if="selected.description" class="text-[11px] text-muted-foreground leading-relaxed">
{{ selected.description }}
</p>
<div class="rounded-md border border-border bg-muted/30 p-2">
<div class="text-[10px] text-muted-foreground mb-1">命令模板</div>
<pre class="text-[11px] font-mono whitespace-pre-wrap break-all">{{ selected.command }}</pre>
</div>
<!-- 占位符填写 -->
<div v-if="selected.placeholders.length > 0" class="space-y-2">
<div class="text-[11px] font-medium">参数</div>
<div
v-for="p in selected.placeholders"
:key="p"
class="flex items-center gap-2"
>
<span class="text-[11px] font-mono text-muted-foreground w-24 shrink-0 truncate" :title="p">
${ {{ p }} }
</span>
<Input
v-model="values[p]"
class="h-7 text-xs font-mono flex-1"
:placeholder="selected.defaults[p] || '(必填)'"
@keydown.enter="doRender"
/>
</div>
</div>
<!-- 预览 -->
<div class="space-y-1.5">
<div class="flex items-center gap-2">
<span class="text-[11px] font-medium">预览</span>
<Button
variant="outline"
size="sm"
class="h-6 text-[11px]"
:disabled="rendering"
@click="doRender"
>
生成预览
</Button>
<span v-if="missing.length > 0" class="text-[10px] text-amber-600">
还有 {{ missing.length }} 个未填写
</span>
</div>
<pre
v-if="preview"
class="rounded-md border border-border bg-muted/30 p-2 text-[11px] font-mono
whitespace-pre-wrap break-all"
>{{ preview }}</pre>
<p v-else class="text-[11px] text-muted-foreground/60">点「生成预览」查看实际会执行的命令</p>
</div>
<!-- 执行 -->
<div class="flex items-center gap-2 pt-1">
<Button
variant="outline"
size="sm"
class="h-7 text-xs gap-1.5"
:disabled="!sessionId"
@click="run(false)"
>
<Terminal class="size-3" />填入命令行
</Button>
<Button
size="sm"
class="h-7 text-xs gap-1.5"
:disabled="!sessionId || selected.confirm"
:title="selected.confirm ? '该片段被标记为需确认,请用「确认并执行」' : ''"
@click="run(true)"
>
<Play class="size-3" />直接执行
</Button>
<Button
v-if="selected.confirm"
variant="destructive"
size="sm"
class="h-7 text-xs gap-1.5"
:disabled="!sessionId"
@click="confirmThenRun"
>
<ShieldAlert class="size-3" />确认并执行
</Button>
</div>
</div>
<!-- ---------- 空态 ---------- -->
<div v-else class="flex-1 flex items-center justify-center">
<div class="text-center">
<BookMarked class="size-10 text-muted-foreground/20 mx-auto mb-3" />
<p class="text-xs text-muted-foreground mb-1">从左侧选择一个片段</p>
<p class="text-[11px] text-muted-foreground/60">
或点「新建片段」加入自己的常用命令
</p>
</div>
</div>
</section>
</div>
<!-- 悬浮关闭按钮(右上角已有「返回终端」,这里给键盘外的第二入口) -->
<button
class="absolute top-2 right-2 size-6 rounded flex items-center justify-center
text-muted-foreground hover:text-foreground"
title="关闭"
@click="emit('close')"
>
<X class="size-3.5" />
</button>
</div>
</template>