471 lines
19 KiB
Vue
471 lines
19 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 命令历史 popover(终端页控制栏的「历史」按钮)。
|
||
*
|
||
* # 这个组件是「按钮 + 浮层」的整体
|
||
*
|
||
* 触发器、浮层内容、快捷键用的开合状态都封在这里,控制栏只摆一个标签即可。
|
||
* 分开的代价是:填入后自动收起、Esc 关闭这些动作都要跨组件传意图 ——
|
||
* 变成「父组件管开、子组件管关」,而它们读写的本来就是同一份状态。
|
||
*
|
||
* # 与旧「记录」页版本的区别
|
||
*
|
||
* 逻辑照搬原「记录」页的命令历史面板(搜索即过滤、选中、双击填入、收藏、清空),
|
||
* 但**形态**从整页变成 380px 宽的浮层:去掉了页头工具条、分区开关、底部三行
|
||
* 提示(换成一行)与整页的筛选行排版。之所以能有这个自信,是因为历史的使用
|
||
* 模式本来就是「快速找回一条命令」——用户想看的是列表,不是页面。
|
||
*
|
||
* # 为什么「填入不执行」为默认,且单击不填入
|
||
*
|
||
* 历史里躺着用户过去敲过的所有命令,包括 `rm -rf`、`DROP TABLE`。
|
||
* 因此:单击只**选中**(便于键盘导航与读清楚),双击或回车才**填入**命令行,
|
||
* 执行一律要显式点「执行」按钮。
|
||
*
|
||
* 「单击不填入」还有一层实际考虑:填入是把文本写进当前 PTY 的命令行,
|
||
* 用户可能正在敲一条命令(命令行上有半截输入),单击即填入会把那半截冲掉或拼在
|
||
* 一起。选中不动手,填入是明确的意图表达。
|
||
*
|
||
* # 关闭时机
|
||
*
|
||
* 填入成功后立即收起浮层:用户的下一步是看着终端按回车,浮层挡在那里没有价值。
|
||
* 浮层开合状态由 `useRecordPopovers` 的模块级 ref 持有 —— 快捷键
|
||
* (Ctrl+Shift+H)与这里的触发按钮共用的就是它。
|
||
*/
|
||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
import {
|
||
ArrowDownToLine,
|
||
Clock,
|
||
Copy,
|
||
FolderOpen,
|
||
History,
|
||
Play,
|
||
Search,
|
||
Star,
|
||
Trash2
|
||
} from '@lucide/vue'
|
||
import { toast } from 'vue-sonner'
|
||
import { useTerminalStore } from '@/stores/terminalStore'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue
|
||
} from '@/components/ui/select'
|
||
import { StateBlock } from '@/components/common'
|
||
import { historyPopoverOpen, toggleHistoryPopover } from '../useRecordPopovers'
|
||
import type { CommandHistoryItem, SessionInfo } from '@/types/terminal'
|
||
|
||
const props = defineProps<{
|
||
/** 当前会话(把命令填给它;为空时动作不可用) */
|
||
sessionId: string | null
|
||
session: SessionInfo | undefined
|
||
}>()
|
||
|
||
const store = useTerminalStore()
|
||
|
||
// ===== 筛选 =====
|
||
|
||
const keyword = ref('')
|
||
const hostFilter = ref('')
|
||
const favoritedOnly = ref(false)
|
||
/** 来源下拉的「全部」哨兵值:reka 的 SelectItem 不接受空串 value */
|
||
const ALL_SOURCES = '__all_sources__'
|
||
/** 选中项(键盘导航用) */
|
||
const selectedId = ref<number | null>(null)
|
||
|
||
const items = computed(() => store.historyPage.items)
|
||
const total = computed(() => store.historyPage.total)
|
||
|
||
/** 当前会话的来源 id(「仅本会话」快捷筛选的判据) */
|
||
const currentHostId = computed(() => props.session?.targetId ?? '')
|
||
|
||
/**
|
||
* 加载。
|
||
*
|
||
* `force` 为真时连带刷新来源列表 —— 只在浮层首次打开时做(来源集合变化很慢,
|
||
* 每敲一个字都拉一遍纯属浪费)。
|
||
*/
|
||
async function reload(force = false) {
|
||
try {
|
||
await store.loadHistory({
|
||
keyword: keyword.value,
|
||
hostId: hostFilter.value,
|
||
favoritedOnly: favoritedOnly.value
|
||
})
|
||
if (force) await store.loadHistorySources()
|
||
} catch (e) {
|
||
toast.error(`加载历史失败:${String(e)}`)
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await reload(true)
|
||
})
|
||
|
||
// 输入即过滤,200ms 防抖:让每个字符都发一次 IPC 只会把通道占满
|
||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||
watch([keyword, favoritedOnly], () => {
|
||
if (searchTimer) clearTimeout(searchTimer)
|
||
searchTimer = setTimeout(() => void reload(), 200)
|
||
})
|
||
// 来源筛选是离散选择,无需防抖
|
||
watch(hostFilter, () => void reload())
|
||
|
||
// 卸载时清掉未触发的防抖,否则浮层关掉后还会发一次无主查询
|
||
onBeforeUnmount(() => {
|
||
if (searchTimer) clearTimeout(searchTimer)
|
||
})
|
||
|
||
// ===== 操作 =====
|
||
|
||
/** 把命令填入会话命令行(`submit` 为真则直接执行) */
|
||
async function useCommand(item: CommandHistoryItem, submit = false) {
|
||
if (!props.sessionId) {
|
||
toast.info('当前没有活跃会话')
|
||
return
|
||
}
|
||
try {
|
||
const r = await store.runHistory(props.sessionId, item.command, submit)
|
||
if (r.ok) {
|
||
if (submit) toast.success(r.message)
|
||
else toggleHistoryPopover(false)
|
||
} else {
|
||
toast.error(r.message)
|
||
}
|
||
} catch (e) {
|
||
toast.error(`操作失败:${String(e)}`)
|
||
}
|
||
}
|
||
|
||
async function copyCommand(item: CommandHistoryItem) {
|
||
try {
|
||
await navigator.clipboard.writeText(item.command)
|
||
toast.success('已复制到剪贴板')
|
||
} catch (e) {
|
||
toast.error(`复制失败:${String(e)}`)
|
||
}
|
||
}
|
||
|
||
async function toggleFavorite(item: CommandHistoryItem) {
|
||
try {
|
||
await store.toggleHistoryFavorite(item.id)
|
||
} catch (e) {
|
||
toast.error(`操作失败:${String(e)}`)
|
||
}
|
||
}
|
||
|
||
async function removeItem(item: CommandHistoryItem) {
|
||
try {
|
||
const r = await store.deleteHistory(item.id)
|
||
if (!r.ok) toast.info(r.message)
|
||
if (selectedId.value === item.id) selectedId.value = null
|
||
} catch (e) {
|
||
toast.error(`删除失败:${String(e)}`)
|
||
}
|
||
}
|
||
|
||
async function clearAll() {
|
||
// 二次确认,默认只清非收藏 —— 破坏性操作的默认值应当是最保守的那个。
|
||
// 用 window.confirm 而不是嵌套 Dialog:这是浮层里的浮层,
|
||
// 嵌一个 reka Dialog 会带来焦点陷阱与 z-index 的两难(原实现同款处理)。
|
||
const ok = window.confirm(
|
||
'确定清空命令历史吗?\n\n收藏的记录会保留(如需连同收藏一起清空,请先取消收藏)。'
|
||
)
|
||
if (!ok) return
|
||
try {
|
||
const r = await store.clearHistory(true)
|
||
toast.success(r.message)
|
||
} catch (e) {
|
||
toast.error(`清空失败:${String(e)}`)
|
||
}
|
||
}
|
||
|
||
// ===== 键盘导航 =====
|
||
|
||
/**
|
||
* ↑↓ 移动选中项,Enter 填入。
|
||
*
|
||
* 焦点落在行内按钮/下拉上时让路(它们自己要用方向键与 Enter),
|
||
* 搜索框不让路:在搜索框里按 ↑↓ 选条目、Enter 填入是刻意保留的操作方式。
|
||
*/
|
||
function onKeydown(e: KeyboardEvent) {
|
||
if (e.key === 'Escape') {
|
||
toggleHistoryPopover(false)
|
||
return
|
||
}
|
||
const target = e.target as HTMLElement | null
|
||
if (target?.closest('button,[data-slot="select-trigger"],select,textarea')) return
|
||
if (items.value.length === 0) return
|
||
|
||
const idx = items.value.findIndex(i => i.id === selectedId.value)
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault()
|
||
const next = idx < 0 ? 0 : Math.min(items.value.length - 1, idx + 1)
|
||
selectedId.value = items.value[next].id
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault()
|
||
const next = idx <= 0 ? 0 : idx - 1
|
||
selectedId.value = items.value[next].id
|
||
} else if (e.key === 'Enter') {
|
||
e.preventDefault()
|
||
const item = items.value.find(i => i.id === selectedId.value)
|
||
if (item) void useCommand(item, false)
|
||
}
|
||
}
|
||
|
||
// ===== 展示辅助 =====
|
||
|
||
/** 相对时间(「3 分钟前」比精确时间戳更有信息量) */
|
||
function relTime(ts: number): string {
|
||
const diff = Date.now() - ts
|
||
if (diff < 60_000) return '刚刚'
|
||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`
|
||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`
|
||
if (diff < 2_592_000_000) return `${Math.floor(diff / 86_400_000)} 天前`
|
||
return new Date(ts).toLocaleDateString('zh-CN')
|
||
}
|
||
|
||
/** 目录只显示最后一级(完整路径会挤走命令本身,悬停可看全) */
|
||
function cwdTail(cwd: string): string {
|
||
if (!cwd) return ''
|
||
const parts = cwd.split(/[\\/]/).filter(Boolean)
|
||
return parts.length > 0 ? parts[parts.length - 1] : cwd
|
||
}
|
||
|
||
/** 退出码非 0(用户找的常常正是「刚才那条报错的命令」) */
|
||
function isFailed(item: CommandHistoryItem): boolean {
|
||
return item.exitCode !== null && item.exitCode !== 0
|
||
}
|
||
|
||
/** 是否处于筛选态(决定空态文案:是「没匹配」还是「还没有历史」) */
|
||
const isFiltered = computed(() => !!keyword.value || !!hostFilter.value || favoritedOnly.value)
|
||
</script>
|
||
|
||
<template>
|
||
<!--
|
||
触发器 + 浮层一体。Tooltip 锚在包裹 div 上、Popover 在自己的子树里用 as-child,
|
||
两条 as-child 链互不相干(与「更多」菜单同款处理);浮层展开期间禁用提示 ——
|
||
鼠标此时还停在按钮上,提示会压在浮层内容上。
|
||
-->
|
||
<Tooltip :disabled="historyPopoverOpen">
|
||
<TooltipTrigger as-child>
|
||
<div class="inline-flex shrink-0">
|
||
<Popover v-model:open="historyPopoverOpen">
|
||
<PopoverTrigger as-child>
|
||
<Button variant="ghost" size="icon-sm" class="shrink-0">
|
||
<History class="size-3.5" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
|
||
<!-- 内容自带内边距:让开 popover 默认的 w-72 / p-4 -->
|
||
<PopoverContent align="start" class="w-auto p-0">
|
||
<!-- 键盘事件挂在内容根上:↑↓ / Esc 在浮层内任意位置都应生效 -->
|
||
<div class="w-[380px]" @keydown="onKeydown">
|
||
<!-- ===== 搜索 + 来源 ===== -->
|
||
<div class="flex items-center gap-2 p-2.5 border-b border-border">
|
||
<div class="relative flex-1 min-w-0">
|
||
<Search
|
||
class="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||
/>
|
||
<Input v-model="keyword" placeholder="搜索命令…" class="h-7 pl-7 text-xs" autofocus />
|
||
</div>
|
||
<!-- 只有一个来源时下拉没有筛选意义,纯属噪音 -->
|
||
<Select
|
||
v-if="store.historySources.length > 1"
|
||
:model-value="hostFilter || ALL_SOURCES"
|
||
@update:model-value="v => (hostFilter = String(v) === ALL_SOURCES ? '' : String(v))"
|
||
>
|
||
<SelectTrigger size="sm" class="w-[124px] shrink-0 text-xs">
|
||
<SelectValue placeholder="来源" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<!-- reka 的 SelectItem 不接受空串值,用哨兵值代表「不筛选」 -->
|
||
<SelectItem :value="ALL_SOURCES">全部来源</SelectItem>
|
||
<SelectItem v-for="s in store.historySources" :key="s.hostId" :value="s.hostId">
|
||
{{ s.hostName }}({{ s.count }})
|
||
</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<!-- ===== 快捷筛选 ===== -->
|
||
<div class="flex items-center gap-1.5 px-2.5 py-2 text-xs">
|
||
<Button
|
||
size="sm"
|
||
class="h-6 px-2 text-[11px]"
|
||
:variant="hostFilter === '' && !favoritedOnly ? 'default' : 'outline'"
|
||
@click="hostFilter = ''; favoritedOnly = false"
|
||
>
|
||
全部
|
||
</Button>
|
||
<Button
|
||
v-if="currentHostId"
|
||
size="sm"
|
||
class="h-6 px-2 text-[11px]"
|
||
:variant="hostFilter === currentHostId ? 'default' : 'outline'"
|
||
:title="`只看本会话(${store.sessionLabel(session)})`"
|
||
@click="hostFilter = currentHostId; favoritedOnly = false"
|
||
>
|
||
仅本会话
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
class="h-6 px-2 text-[11px]"
|
||
:variant="favoritedOnly ? 'default' : 'outline'"
|
||
@click="favoritedOnly = !favoritedOnly"
|
||
>
|
||
<Star class="size-3" />收藏
|
||
</Button>
|
||
<div class="flex-1" />
|
||
<span class="shrink-0 text-[10px] text-muted-foreground">{{ total }} 条</span>
|
||
</div>
|
||
|
||
<!-- ===== 列表 ===== -->
|
||
<ScrollArea class="h-[300px] border-t border-border">
|
||
<StateBlock
|
||
v-if="store.historyLoading && items.length === 0"
|
||
variant="loading"
|
||
:min-height="180"
|
||
/>
|
||
<StateBlock
|
||
v-else-if="items.length === 0"
|
||
variant="empty"
|
||
:title="isFiltered ? '没有匹配的命令' : '还没有命令历史'"
|
||
:description="
|
||
isFiltered ? undefined : '命令由 shell 集成 hook 上报,新开会话执行命令后即可在此查看。'
|
||
"
|
||
:min-height="180"
|
||
/>
|
||
<div v-else class="divide-y divide-divider">
|
||
<div
|
||
v-for="item in items"
|
||
:key="item.id"
|
||
class="group flex items-start gap-1.5 px-2.5 py-1.5 cursor-pointer transition-colors"
|
||
:class="selectedId === item.id ? 'bg-primary-soft' : 'hover:bg-hover'"
|
||
@click="selectedId = item.id"
|
||
@dblclick="useCommand(item, false)"
|
||
>
|
||
<!-- 收藏(不参与容量淘汰,因此可以随手留下想留的命令) -->
|
||
<button
|
||
class="shrink-0 mt-0.5 size-6 flex items-center justify-center transition-colors"
|
||
:class="
|
||
item.favorited ? 'text-warning' : 'text-muted-foreground/40 hover:text-warning'
|
||
"
|
||
:title="item.favorited ? '取消收藏' : '收藏(不参与容量淘汰)'"
|
||
@click.stop="toggleFavorite(item)"
|
||
>
|
||
<Star class="size-3.5" :fill="item.favorited ? 'currentColor' : 'none'" />
|
||
</button>
|
||
|
||
<div class="flex-1 min-w-0">
|
||
<div class="flex items-center gap-1.5">
|
||
<code
|
||
class="text-[11px] font-mono truncate"
|
||
:class="isFailed(item) ? 'text-danger' : 'text-foreground'"
|
||
:title="item.command"
|
||
>
|
||
{{ item.command }}
|
||
</code>
|
||
<!-- 执行次数:>1 说明是反复用到的命令,值得优先看 -->
|
||
<span
|
||
v-if="item.count > 1"
|
||
class="shrink-0 text-[10px] text-muted-foreground"
|
||
:title="`执行过 ${item.count} 次`"
|
||
>
|
||
×{{ item.count }}
|
||
</span>
|
||
</div>
|
||
<div class="flex items-center gap-2 mt-0.5 text-[10px] text-muted-foreground">
|
||
<span class="inline-flex items-center gap-0.5 shrink-0">
|
||
<Clock class="size-2.5" />{{ relTime(item.ts) }}
|
||
</span>
|
||
<span
|
||
v-if="item.cwd"
|
||
class="inline-flex items-center gap-0.5 min-w-0"
|
||
:title="item.cwd"
|
||
>
|
||
<FolderOpen class="size-2.5 shrink-0" />
|
||
<span class="truncate max-w-[80px]">{{ cwdTail(item.cwd) }}</span>
|
||
</span>
|
||
<span class="truncate max-w-[90px]">{{ item.hostName }}</span>
|
||
<span v-if="isFailed(item)" class="text-danger shrink-0">
|
||
退出码 {{ item.exitCode }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 行操作:悬停出现,避免列表视觉噪音 -->
|
||
<div
|
||
class="shrink-0 flex items-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||
>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
class="size-6 text-muted-foreground hover:text-foreground"
|
||
title="填入命令行(不执行)"
|
||
@click.stop="useCommand(item, false)"
|
||
>
|
||
<ArrowDownToLine class="size-3" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
class="size-6 text-muted-foreground hover:text-foreground"
|
||
title="填入并执行"
|
||
@click.stop="useCommand(item, true)"
|
||
>
|
||
<Play class="size-3" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
class="size-6 text-muted-foreground hover:text-foreground"
|
||
title="复制"
|
||
@click.stop="copyCommand(item)"
|
||
>
|
||
<Copy class="size-3" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
class="size-6 text-muted-foreground hover:text-danger"
|
||
title="删除这条"
|
||
@click.stop="removeItem(item)"
|
||
>
|
||
<Trash2 class="size-3" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</ScrollArea>
|
||
|
||
<!-- ===== 底部:操作提示 + 清空 ===== -->
|
||
<div
|
||
class="flex items-center gap-2 px-2.5 py-1.5 border-t border-border text-[10px] text-muted-foreground"
|
||
>
|
||
<span class="truncate">双击 / Enter 填入(不执行)</span>
|
||
<div class="flex-1" />
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
class="h-6 px-2 text-[11px] text-muted-foreground hover:text-destructive"
|
||
title="清空历史(保留收藏)"
|
||
@click="clearAll"
|
||
>
|
||
<Trash2 class="size-3" />清空
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom">命令历史(Ctrl+Shift+H)</TooltipContent>
|
||
</Tooltip>
|
||
</template> |