终端模块初版

This commit is contained in:
zhongluofeng
2026-09-18 18:28:13 +08:00
parent f6c1cc250e
commit b018abd922
63 changed files with 26695 additions and 185 deletions
@@ -0,0 +1,573 @@
<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>