Files
Thing/src/modules/terminal/components/HistoryPopover.vue
T
2026-09-22 19:10:16 +08:00

471 lines
19 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">
/**
* 命令历史 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>