echoctl 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,91 @@
1
+ <template>
2
+ <div class="echo-global-controls">
3
+ <!-- [PROJECT_FILTER_DISABLED] 功能失灵,后期修复后取消注释
4
+ <select
5
+ class="echo-global-select"
6
+ :value="projectFilter.selectedProject.value"
7
+ @change="projectFilter.select(($event.target as HTMLSelectElement).value)"
8
+ >
9
+ <option value="__all__">全部项目</option>
10
+ <option v-for="p in projectFilter.allProjects.value" :key="p.id" :value="p.id">
11
+ {{ p.name }}
12
+ </option>
13
+ </select>
14
+ -->
15
+ <button
16
+ class="echo-global-btn"
17
+ :class="status?.captureEnabled ? 'echo-btn-on' : 'echo-btn-off'"
18
+ :disabled="state !== 'ready'"
19
+ :title="state === 'ready' ? '切换 Echo 收集状态' : 'Echo API 未运行'"
20
+ @click="toggleCapture"
21
+ >
22
+ 收集 {{ status?.captureEnabled ? '开' : '关' }}
23
+ </button>
24
+ <button
25
+ class="echo-global-btn"
26
+ :disabled="state !== 'ready'"
27
+ title="复制 Echo MCP 配置"
28
+ @click="showMcp"
29
+ >
30
+ MCP
31
+ </button>
32
+ </div>
33
+
34
+ <Teleport to="body">
35
+ <div v-if="mcpVisible" class="echo-modal" @click.self="mcpVisible = false">
36
+ <div class="echo-modal-content">
37
+ <h3>MCP 配置</h3>
38
+ <p>这是 AI 访问 Echo 的桥。将此 JSON 添加到 Claude/Codex MCP 配置中:</p>
39
+ <pre>{{ mcpConfigText }}</pre>
40
+ <div class="echo-modal-btns">
41
+ <button class="echo-btn" @click="copyMcp">复制</button>
42
+ <button class="echo-btn" @click="mcpVisible = false">关闭</button>
43
+ </div>
44
+ </div>
45
+ </div>
46
+ </Teleport>
47
+ </template>
48
+
49
+ <script setup lang="ts">
50
+ import { onMounted, ref } from 'vue'
51
+ import { useEchoStatus } from '../lib/useEchoStatus'
52
+ import { getMcpConfig, setCapture } from '../lib/echo-api'
53
+ // [PROJECT_FILTER_DISABLED] 后期恢复时取消注释
54
+ // import { useProjectFilter } from '../lib/useProjectFilter'
55
+
56
+ const articleId = ref<string | undefined>(undefined)
57
+ const { state, status } = useEchoStatus(articleId)
58
+ // const projectFilter = useProjectFilter()
59
+
60
+ onMounted(() => {
61
+ // projectFilter.load()
62
+ // projectFilter.restore()
63
+ })
64
+
65
+ const mcpVisible = ref(false)
66
+ const mcpConfigText = ref('')
67
+
68
+ async function toggleCapture() {
69
+ if (!status.value) return
70
+ try {
71
+ const r = await setCapture(!status.value.captureEnabled)
72
+ status.value = { ...status.value, captureEnabled: r.enabled }
73
+ } catch (_) {}
74
+ }
75
+
76
+ async function showMcp() {
77
+ try {
78
+ const cfg = await getMcpConfig()
79
+ mcpConfigText.value = JSON.stringify({
80
+ mcpServers: { echo: { command: cfg.canonical.command, args: cfg.canonical.args } }
81
+ }, null, 2)
82
+ mcpVisible.value = true
83
+ } catch (_) {}
84
+ }
85
+
86
+ async function copyMcp() {
87
+ try {
88
+ await navigator.clipboard.writeText(mcpConfigText.value)
89
+ } catch (_) {}
90
+ }
91
+ </script>
@@ -0,0 +1,178 @@
1
+ <template>
2
+ <div v-if="visible" class="echo-legacy-overlay" @click.self="dismiss">
3
+ <div class="echo-legacy-dialog">
4
+ <h2>Echo 发现遗留会话 / Echo found legacy sessions</h2>
5
+ <p class="echo-legacy-desc">
6
+ Echo 发现有一些会话记录之前进入了 legacy 区。它们看起来属于当前项目。<br/>
7
+ Echo found chat records in the legacy area. They appear to belong to the current project.
8
+ </p>
9
+ <p class="echo-legacy-question">
10
+ 是否迁移到当前项目?迁移后,它们会出现在当前项目的实时会话或文章列表中。<br/>
11
+ Move them into this project? After migration, they will appear in this project's live sessions or articles.
12
+ </p>
13
+
14
+ <div v-if="mode === 'list' && candidates.length > 0" class="echo-legacy-list">
15
+ <div v-for="c in candidates" :key="c.sessionId" class="echo-legacy-item">
16
+ <span class="echo-legacy-session">{{ c.fileName }}</span>
17
+ <span class="echo-legacy-meta">{{ c.turnCount }} turns, {{ c.confidence }} confidence</span>
18
+ </div>
19
+ </div>
20
+
21
+ <div v-if="error" class="echo-legacy-error">{{ error }}</div>
22
+ <div v-if="success" class="echo-legacy-success">{{ success }}</div>
23
+
24
+ <div class="echo-legacy-buttons">
25
+ <button class="echo-legacy-btn later" @click="dismiss" :disabled="migrating">
26
+ 稍后处理 / Later
27
+ </button>
28
+ <button v-if="mode === 'prompt'" class="echo-legacy-btn review" @click="mode = 'list'">
29
+ 查看候选 / Review
30
+ </button>
31
+ <button class="echo-legacy-btn migrate" @click="migrate" :disabled="migrating">
32
+ {{ migrating ? '迁移中... / Migrating...' : '迁移到当前项目 / Move to this project' }}
33
+ </button>
34
+ </div>
35
+ </div>
36
+ </div>
37
+ </template>
38
+
39
+ <script setup lang="ts">
40
+ import { ref, computed, onMounted } from 'vue'
41
+ import { useData } from 'vitepress'
42
+ import { getLegacyCandidates, migrateLegacyCandidates, getStatus } from '../lib/echo-api'
43
+
44
+ const props = defineProps<{ projectId?: string }>()
45
+
46
+ const { frontmatter } = useData()
47
+ const pageProjectId = computed(() => (frontmatter.value as any)?.echo?.projectId as string | null | undefined)
48
+
49
+ const visible = ref(false)
50
+ const mode = ref<'prompt' | 'list'>('prompt')
51
+ const candidates = ref<any[]>([])
52
+ const migrating = ref(false)
53
+ const error = ref('')
54
+ const success = ref('')
55
+ const resolvedProjectId = ref<string | null>(null)
56
+
57
+ onMounted(async () => {
58
+ let pid = pageProjectId.value || props.projectId || null
59
+ if (!pid) {
60
+ try { const s = await getStatus(); pid = s.projectId } catch (_) {}
61
+ }
62
+ if (!pid) return
63
+ resolvedProjectId.value = pid
64
+ try {
65
+ const result = await getLegacyCandidates(pid)
66
+ if (result.candidates.length > 0) {
67
+ candidates.value = result.candidates
68
+ visible.value = true
69
+ }
70
+ } catch (_) {}
71
+ })
72
+
73
+ function dismiss() {
74
+ visible.value = false
75
+ }
76
+
77
+ async function migrate() {
78
+ migrating.value = true
79
+ error.value = ''
80
+ try {
81
+ const ids = candidates.value.map((c) => c.sessionId)
82
+ const result = await migrateLegacyCandidates(resolvedProjectId.value!, ids)
83
+ if (result.ok) {
84
+ success.value = `已迁移 ${result.migrated} 个会话。页面即将刷新... / Migrated ${result.migrated} sessions. Refreshing...`
85
+ setTimeout(() => location.reload(), 1500)
86
+ }
87
+ } catch (err: any) {
88
+ error.value = err.message || '迁移失败 / Migration failed'
89
+ } finally {
90
+ migrating.value = false
91
+ }
92
+ }
93
+ </script>
94
+
95
+ <style scoped>
96
+ .echo-legacy-overlay {
97
+ position: fixed;
98
+ inset: 0;
99
+ background: rgba(0, 0, 0, 0.5);
100
+ display: flex;
101
+ align-items: center;
102
+ justify-content: center;
103
+ z-index: 1000;
104
+ }
105
+ .echo-legacy-dialog {
106
+ background: var(--vp-c-bg);
107
+ border: 1px solid var(--vp-c-divider);
108
+ border-radius: 12px;
109
+ padding: 24px;
110
+ max-width: 520px;
111
+ width: 90%;
112
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
113
+ }
114
+ .echo-legacy-dialog h2 {
115
+ margin: 0 0 12px;
116
+ font-size: 18px;
117
+ }
118
+ .echo-legacy-desc, .echo-legacy-question {
119
+ margin: 0 0 12px;
120
+ color: var(--vp-c-text-2);
121
+ font-size: 14px;
122
+ line-height: 1.6;
123
+ }
124
+ .echo-legacy-list {
125
+ max-height: 200px;
126
+ overflow-y: auto;
127
+ margin-bottom: 12px;
128
+ }
129
+ .echo-legacy-item {
130
+ display: flex;
131
+ justify-content: space-between;
132
+ padding: 6px 0;
133
+ border-bottom: 1px solid var(--vp-c-divider);
134
+ font-size: 13px;
135
+ }
136
+ .echo-legacy-session {
137
+ font-weight: 600;
138
+ }
139
+ .echo-legacy-meta {
140
+ color: var(--vp-c-text-3);
141
+ }
142
+ .echo-legacy-error {
143
+ color: var(--vp-c-danger);
144
+ margin-bottom: 12px;
145
+ font-size: 14px;
146
+ }
147
+ .echo-legacy-success {
148
+ color: var(--vp-c-brand);
149
+ margin-bottom: 12px;
150
+ font-size: 14px;
151
+ }
152
+ .echo-legacy-buttons {
153
+ display: flex;
154
+ gap: 8px;
155
+ justify-content: flex-end;
156
+ }
157
+ .echo-legacy-btn {
158
+ padding: 8px 16px;
159
+ border-radius: 6px;
160
+ border: 1px solid var(--vp-c-divider);
161
+ background: var(--vp-c-bg);
162
+ color: var(--vp-c-text-1);
163
+ cursor: pointer;
164
+ font-size: 14px;
165
+ }
166
+ .echo-legacy-btn.migrate {
167
+ background: var(--vp-c-brand);
168
+ color: #fff;
169
+ border-color: var(--vp-c-brand);
170
+ }
171
+ .echo-legacy-btn.migrate:hover {
172
+ background: var(--vp-c-brand-dark);
173
+ }
174
+ .echo-legacy-btn:disabled {
175
+ opacity: 0.5;
176
+ cursor: not-allowed;
177
+ }
178
+ </style>
@@ -0,0 +1,82 @@
1
+ <template>
2
+ <div class="echo-live-actions" v-if="!isPublished">
3
+ <button class="echo-publish-btn" @click="publish" :disabled="publishing">
4
+ {{ publishing ? '发布中...' : '发布为正式文章' }}
5
+ </button>
6
+ <span v-if="error" class="echo-publish-error">{{ error }}</span>
7
+ <span v-if="ok" class="echo-publish-ok">{{ ok }}</span>
8
+ </div>
9
+ <div class="echo-live-actions" v-else>
10
+ <a :href="`/articles/generated/${publishedSlug}`" class="echo-published-link">查看已发布文章</a>
11
+ </div>
12
+ </template>
13
+
14
+ <script setup lang="ts">
15
+ import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
16
+ import { postPublish, getLiveSessionState, EchoApiError } from '../lib/echo-api'
17
+ import { subscribeEchoHeartbeat } from '../lib/echo-heartbeat'
18
+
19
+ const props = defineProps<{
20
+ projectId: string
21
+ sessionId: string
22
+ published: string
23
+ publishedSlug: string
24
+ }>()
25
+
26
+ const publishing = ref(false)
27
+ const error = ref("")
28
+ const ok = ref("")
29
+ const isPublished = computed(() => props.published === "true")
30
+ let unsubscribeHeartbeat: (() => void) | null = null
31
+ let lastHash: string | null = null
32
+ let checking = false
33
+
34
+ onMounted(() => {
35
+ const livePath = window.location.pathname
36
+ const checkForChanges = async () => {
37
+ if (checking) return
38
+ if (document.visibilityState === "hidden") return
39
+ checking = true
40
+ try {
41
+ const state = await getLiveSessionState(props.projectId || null, props.sessionId)
42
+ if (!state.exists || !state.hash) return
43
+ if (lastHash && lastHash !== state.hash && window.location.pathname === livePath) {
44
+ window.location.reload()
45
+ return
46
+ }
47
+ lastHash = state.hash
48
+ } catch (_) {
49
+ // Live pages must remain readable when the local API is stopped.
50
+ } finally {
51
+ checking = false
52
+ }
53
+ }
54
+
55
+ void checkForChanges()
56
+ unsubscribeHeartbeat = subscribeEchoHeartbeat(checkForChanges)
57
+ })
58
+
59
+ onBeforeUnmount(() => {
60
+ if (unsubscribeHeartbeat) unsubscribeHeartbeat()
61
+ })
62
+
63
+ async function publish() {
64
+ publishing.value = true
65
+ error.value = ""
66
+ ok.value = ""
67
+ try {
68
+ const data = await postPublish({
69
+ projectId: props.projectId || null,
70
+ sessionId: props.sessionId,
71
+ })
72
+ ok.value = '发布成功!页面即将跳转...'
73
+ setTimeout(() => { window.location.href = `/articles/generated/${data.slug}` }, 1500)
74
+ } catch (e: any) {
75
+ error.value = e instanceof EchoApiError && e.status === 409
76
+ ? '已经是最新快照'
77
+ : e.message || '网络错误'
78
+ } finally {
79
+ publishing.value = false
80
+ }
81
+ }
82
+ </script>
@@ -0,0 +1,132 @@
1
+ <template>
2
+ <div class="echo-project-tabs" aria-label="项目筛选">
3
+ <a
4
+ v-for="tab in tabs"
5
+ :key="tab.key"
6
+ :href="`#${encodeURIComponent(tab.anchor)}`"
7
+ class="echo-project-tab"
8
+ :class="{ 'echo-project-tab-active': tab.key === selectedKey }"
9
+ @click.prevent="select(tab.key)"
10
+ >
11
+ {{ tab.label }}<span>{{ tab.articles.length }}</span>
12
+ </a>
13
+ </div>
14
+
15
+ <div class="echo-article-grid">
16
+ <a
17
+ v-for="article in selectedArticles"
18
+ :key="article.href"
19
+ class="echo-article-card"
20
+ :href="article.href"
21
+ >
22
+ <strong>{{ article.title }}</strong>
23
+ <small>{{ article.updated || '-' }}</small>
24
+ <p>{{ article.summary || '无摘要' }}</p>
25
+ <div v-if="article.tags.length" class="echo-tags">
26
+ <span v-for="tag in article.tags" :key="tag">{{ tag }}</span>
27
+ </div>
28
+ </a>
29
+ </div>
30
+ </template>
31
+
32
+ <script setup lang="ts">
33
+ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
34
+ import { useProjectFilter } from '../lib/useProjectFilter'
35
+
36
+ type ProjectArticle = {
37
+ href: string
38
+ summary?: string
39
+ tags: string[]
40
+ title: string
41
+ updated?: string
42
+ }
43
+
44
+ type ProjectGroup = {
45
+ anchor: string
46
+ articles: ProjectArticle[]
47
+ key: string
48
+ label: string
49
+ }
50
+
51
+ const props = defineProps<{ payload: string }>()
52
+
53
+ const projectFilter = useProjectFilter()
54
+
55
+ const projectGroups = computed<ProjectGroup[]>(() => {
56
+ try {
57
+ return JSON.parse(decodeURIComponent(props.payload)) as ProjectGroup[]
58
+ } catch {
59
+ return []
60
+ }
61
+ })
62
+
63
+ const allTab = computed<ProjectGroup>(() => ({
64
+ anchor: 'project-all',
65
+ articles: projectGroups.value.flatMap((group) => group.articles),
66
+ key: '__all__',
67
+ label: '全部',
68
+ }))
69
+
70
+ const emptyProjectTabs = computed<ProjectGroup[]>(() => {
71
+ const withArticles = new Set(projectGroups.value.map((group) => group.key))
72
+ return projectFilter.allProjects.value
73
+ .filter((project) => !withArticles.has(project.id))
74
+ .map((project) => ({
75
+ anchor: `project-${project.id}`,
76
+ articles: [],
77
+ key: project.id,
78
+ label: project.name,
79
+ }))
80
+ })
81
+
82
+ const tabs = computed(() => [allTab.value, ...projectGroups.value, ...emptyProjectTabs.value])
83
+ const selectedKey = ref('__all__')
84
+
85
+ const selectedArticles = computed(() => {
86
+ return tabs.value.find((tab) => tab.key === selectedKey.value)?.articles || []
87
+ })
88
+
89
+ function resolveTabKey(projectId: string): string {
90
+ if (projectId === '__all__') return '__all__'
91
+ const tab = tabs.value.find((t) => t.key === projectId)
92
+ return tab ? tab.key : '__all__'
93
+ }
94
+
95
+ function syncFromHash() {
96
+ const hash = decodeURIComponent(window.location.hash.slice(1) || '')
97
+ const tab = tabs.value.find((item) => item.anchor === hash)
98
+ selectedKey.value = tab?.key || '__all__'
99
+ }
100
+
101
+ function select(key: string) {
102
+ selectedKey.value = key
103
+ // Also update the global filter so nav dropdown syncs
104
+ projectFilter.select(key === '__all__' ? '__all__' : key)
105
+ }
106
+
107
+ function syncFromProjectFilter(projectId: string) {
108
+ selectedKey.value = resolveTabKey(projectId)
109
+ }
110
+
111
+ // Sync with global project filter (nav dropdown), including async project loads.
112
+ watch(
113
+ () => [projectFilter.selectedProject.value, tabs.value.map((tab) => tab.key).join('|')],
114
+ ([projectId]) => syncFromProjectFilter(projectId),
115
+ )
116
+
117
+ onMounted(() => {
118
+ projectFilter.load()
119
+ projectFilter.restore()
120
+ // If global filter is set, use it; otherwise fall back to hash
121
+ if (projectFilter.selectedProject.value !== '__all__') {
122
+ syncFromProjectFilter(projectFilter.selectedProject.value)
123
+ } else {
124
+ syncFromHash()
125
+ }
126
+ window.addEventListener('hashchange', syncFromHash)
127
+ })
128
+
129
+ onUnmounted(() => {
130
+ window.removeEventListener('hashchange', syncFromHash)
131
+ })
132
+ </script>
@@ -0,0 +1,215 @@
1
+ <template></template>
2
+
3
+ <script setup lang="ts">
4
+ import { nextTick, onMounted, onUnmounted, watch } from 'vue'
5
+ import { useRoute } from 'vitepress'
6
+
7
+ const route = useRoute()
8
+ const STORAGE_KEY = 'echo:lastSearchQuery'
9
+ const APPLIED_KEY = 'echo:lastSearchApplied'
10
+ const MAX_AGE_MS = 2 * 60 * 1000
11
+ let memorySearch: StoredSearch | null = null
12
+ let memoryApplied = ''
13
+
14
+ type StoredSearch = {
15
+ query: string
16
+ ts: number
17
+ }
18
+
19
+ function readStoredSearch(): StoredSearch | null {
20
+ if (memorySearch && Date.now() - memorySearch.ts <= MAX_AGE_MS) return memorySearch
21
+ try {
22
+ const raw = window.sessionStorage?.getItem(STORAGE_KEY)
23
+ if (!raw) return null
24
+ const parsed = JSON.parse(raw) as StoredSearch
25
+ if (!parsed.query || Date.now() - parsed.ts > MAX_AGE_MS) return null
26
+ return parsed
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ function writeSearchQuery(query: string) {
33
+ const value = query.trim()
34
+ if (!value) return
35
+ memorySearch = { query: value, ts: Date.now() }
36
+ try {
37
+ window.sessionStorage?.setItem(STORAGE_KEY, JSON.stringify(memorySearch))
38
+ } catch {}
39
+ }
40
+
41
+ function isSearchInput(input: HTMLInputElement): boolean {
42
+ const text = [
43
+ input.type,
44
+ input.placeholder,
45
+ input.ariaLabel,
46
+ input.className,
47
+ input.id,
48
+ input.closest('[class*="Search"], [class*="search"], [id*="Search"], [id*="search"]')?.className || '',
49
+ ].join(' ')
50
+ return /search|docsearch|搜索/i.test(text)
51
+ }
52
+
53
+ function currentSearchQuery(): string {
54
+ const active = document.activeElement
55
+ if (active instanceof HTMLInputElement && isSearchInput(active)) return active.value.trim()
56
+
57
+ const inputs = Array.from(document.querySelectorAll<HTMLInputElement>('input'))
58
+ const found = inputs.find((input) => isSearchInput(input) && input.value.trim())
59
+ return found?.value.trim() || ''
60
+ }
61
+
62
+ function rememberSearchQuery() {
63
+ const query = currentSearchQuery()
64
+ if (query) writeSearchQuery(query)
65
+ }
66
+
67
+ function handleInput(event: Event) {
68
+ const target = event.target
69
+ if (target instanceof HTMLInputElement && isSearchInput(target)) {
70
+ writeSearchQuery(target.value)
71
+ }
72
+ }
73
+
74
+ function handlePointerDown(event: Event) {
75
+ const target = event.target as HTMLElement | null
76
+ if (!target) return
77
+ if (target.closest('a, button, [role="option"], [role="link"], [role="button"]')) {
78
+ rememberSearchQuery()
79
+ scheduleSearchLanding()
80
+ }
81
+ }
82
+
83
+ function handleKeyDown(event: KeyboardEvent) {
84
+ if (event.key === 'Enter') {
85
+ rememberSearchQuery()
86
+ scheduleSearchLanding()
87
+ }
88
+ }
89
+
90
+ function unwrapPreviousHighlights(root: Element) {
91
+ const marks = Array.from(root.querySelectorAll('mark.echo-search-hit'))
92
+ for (const mark of marks) {
93
+ mark.replaceWith(document.createTextNode(mark.textContent || ''))
94
+ }
95
+ root.normalize()
96
+ }
97
+
98
+ function shouldSkipNode(node: Node): boolean {
99
+ const parent = node.parentElement
100
+ if (!parent) return true
101
+ return !!parent.closest('script, style, pre, code, textarea, input, select, button, nav, .echo-toolbar, .echo-modal, .echo-sel-popup, .echo-comment-box')
102
+ }
103
+
104
+ function highlightTextNode(node: Text, query: string): HTMLElement[] {
105
+ const text = node.nodeValue || ''
106
+ const lowerText = text.toLowerCase()
107
+ const lowerQuery = query.toLowerCase()
108
+ const hits: HTMLElement[] = []
109
+ let index = lowerText.indexOf(lowerQuery)
110
+ if (index === -1) return hits
111
+
112
+ const fragment = document.createDocumentFragment()
113
+ let cursor = 0
114
+ while (index !== -1) {
115
+ if (index > cursor) {
116
+ fragment.appendChild(document.createTextNode(text.slice(cursor, index)))
117
+ }
118
+ const mark = document.createElement('mark')
119
+ mark.className = 'echo-search-hit'
120
+ mark.textContent = text.slice(index, index + query.length)
121
+ fragment.appendChild(mark)
122
+ hits.push(mark)
123
+ cursor = index + query.length
124
+ index = lowerText.indexOf(lowerQuery, cursor)
125
+ }
126
+ if (cursor < text.length) {
127
+ fragment.appendChild(document.createTextNode(text.slice(cursor)))
128
+ }
129
+
130
+ node.replaceWith(fragment)
131
+ return hits
132
+ }
133
+
134
+ function highlightQuery(root: Element, query: string): HTMLElement[] {
135
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
136
+ acceptNode(node) {
137
+ if (shouldSkipNode(node)) return NodeFilter.FILTER_REJECT
138
+ return (node.nodeValue || '').toLowerCase().includes(query.toLowerCase())
139
+ ? NodeFilter.FILTER_ACCEPT
140
+ : NodeFilter.FILTER_REJECT
141
+ },
142
+ })
143
+
144
+ const nodes: Text[] = []
145
+ while (walker.nextNode()) nodes.push(walker.currentNode as Text)
146
+ return nodes.flatMap((node) => highlightTextNode(node, query))
147
+ }
148
+
149
+ function bestHit(hits: HTMLElement[]): HTMLElement | null {
150
+ if (hits.length === 0) return null
151
+
152
+ const hash = decodeURIComponent(location.hash.slice(1))
153
+ const hashTarget = hash ? document.getElementById(hash) : null
154
+ const anchorTop = hashTarget
155
+ ? hashTarget.getBoundingClientRect().top + window.scrollY
156
+ : window.scrollY + 120
157
+
158
+ return hits
159
+ .map((hit) => {
160
+ const rect = hit.getBoundingClientRect()
161
+ const top = rect.top + window.scrollY
162
+ return { hit, distance: Math.abs(top - anchorTop) }
163
+ })
164
+ .sort((a, b) => a.distance - b.distance)[0].hit
165
+ }
166
+
167
+ function applySearchLanding() {
168
+ const stored = readStoredSearch()
169
+ const root = document.querySelector('.vp-doc')
170
+ if (!stored || !root) return
171
+
172
+ const applyKey = `${location.pathname}${location.hash}:${stored.query}:${stored.ts}`
173
+ let applied = memoryApplied
174
+ try {
175
+ applied = window.sessionStorage?.getItem(APPLIED_KEY) || memoryApplied
176
+ } catch {}
177
+ if (applied === applyKey) return
178
+
179
+ unwrapPreviousHighlights(root)
180
+ const hits = highlightQuery(root, stored.query)
181
+ const target = bestHit(hits)
182
+ if (!target) return
183
+
184
+ memoryApplied = applyKey
185
+ try {
186
+ window.sessionStorage?.setItem(APPLIED_KEY, applyKey)
187
+ } catch {}
188
+ target.classList.add('echo-search-hit-active')
189
+ target.scrollIntoView({ behavior: 'smooth', block: 'center' })
190
+ }
191
+
192
+ function scheduleSearchLanding() {
193
+ window.setTimeout(() => {
194
+ nextTick(() => applySearchLanding())
195
+ }, 120)
196
+ }
197
+
198
+ onMounted(() => {
199
+ document.addEventListener('input', handleInput, true)
200
+ document.addEventListener('pointerdown', handlePointerDown, true)
201
+ document.addEventListener('keydown', handleKeyDown, true)
202
+ scheduleSearchLanding()
203
+ })
204
+
205
+ onUnmounted(() => {
206
+ document.removeEventListener('input', handleInput, true)
207
+ document.removeEventListener('pointerdown', handlePointerDown, true)
208
+ document.removeEventListener('keydown', handleKeyDown, true)
209
+ })
210
+
211
+ watch(
212
+ () => route.path,
213
+ () => scheduleSearchLanding(),
214
+ )
215
+ </script>