574 lines
20 KiB
Vue
574 lines
20 KiB
Vue
<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>
|