Files
Menghuan1918 5a1e75e46b fix(lint): 清零存量 lint 告警并接入 CI / Makefile 门禁
存量清理(首跑 141 errors / 21 warnings → 0 / 0):
- 删除孤儿 disable:SideChatView / Sidebar / selection-popup 三处 exhaustive-deps 豁免与 chunk-artifact 的 no-new-func 豁免(多行理由注释把 disable-next-line 顶离了实报行;selection-popup 改为提取 popupOpen 标志真修复)
- 死代码:未用导入(Sidebar×7、TextEditor、SideCardSection、config、fs-operations 及 6 个 spec)、sidechat-core 死累加器 total、chunk-loader.spec 死助手 registry、sidechat-seed-validation 死助手 toolResultMessage
- 真实小修:agent-opens 三处 throw 补 cause;lazy-chunk any → ComponentType<P>(P extends object);service.ts claimed 初值改定值赋值;chunk-loader task / spec 内 etag / pendingLoad / snapshot 改 const;terminal-links 去掉字符类内多余转义;SubagentView catalogs 空兜底 useMemo 稳定标识;editor-host.spec 组件提出大写 FakeViewer(rules-of-hooks);pty-helpers.spec 内联 require 提升为静态导入;panel-host-css 注释里的零宽空格改为可见写法
- 依赖数组豁免(均带理由):granular scope 字段(EditorHost / PdfView / ChangesTab / GitLens×2)、job.status 粒度(SubagentView×2)、清理期读 ref、mount-only 注册等;ctx(挂载期恒定)直接入 deps(Sidebar×3)、TextEditor previewScroll 补 scope(幂等)
- 刻意控制字符正则(conversation-draft / markdown-images)与宿主槽位 any 签名(context-types)以带理由豁免保留

接入:
- Makefile:lint 目标(typecheck 旁),check 聚合补 lint 保持「对齐 CI」语义
- ci.yml ci / ci-windows 两 job 与 release.yml 在 Typecheck 后加 Lint 步骤(release 注释同步顺序)
2026-09-03 20:00:08 +08:00

156 lines
6.2 KiB
TypeScript

/**
* The floating "add selection to conversation" popup shared by the text
* viewers (markdown preview + the catch-all code viewer): a viewport-anchored
* button portaled to `document.body`, kept alive across the selection gesture
* and committed on click.
*
* Dismissal contract (fixes upstream issue #425): the popup must never
* outlive its editor surface. The sidebar keeps every tab MOUNTED — switching
* tabs only flips the pane cell to `display:none` and collapsing the panel
* translates it off-screen — while the portaled `position:fixed` button stays
* pinned to its viewport anchor, ignoring both. The caller already hides on
* surface scrolls, selection collapse and mode flips; this hook adds the
* global dismissal that covers everything else:
*
* - any `mousedown` outside the button (tab bar, composer, another pane,
* the collapse toggle, …) closes it;
* - `Escape` closes it;
* - the document going hidden (`visibilitychange`) or the window losing
* focus closes it;
* - an `IntersectionObserver` on the editor surface closes it as soon as the
* surface leaves the viewport — the tab-switch (`display:none`) and
* panel-collapse (translated off-screen) paths have no DOM events of their
* own, so the geometry signal is the only reliable one.
*
* The button's own `mousedown` is never treated as an outside click: the
* caller preventDefaults it to keep the selection/caret alive until the
* click commits (the hook's capture-phase listener runs first and must not
* hide for it).
*/
import { useEffect, useRef, useState, type RefObject } from 'react'
/** The floating "add to conversation" action: payload + viewport anchor. */
export interface SelectionPopup {
insert: string
left: number
top: number
}
export interface SelectionPopupOptions {
/** Commit the payload into the composer draft (button click). */
onCommit(insert: string): void
/**
* The DOM surface that must stay on screen for the popup to live: the
* markdown preview container in preview mode, the CodeMirror host
* otherwise. Called lazily (refs are null until the content loads).
*/
getSurface(): HTMLElement | null
}
export interface SelectionPopupControls {
/** The current popup (null = hidden). */
popup: SelectionPopup | null
/** Attach to the portaled button element. */
buttonRef: RefObject<HTMLButtonElement>
/** Anchor the popup above a selection (viewport-clamped). */
show(insert: string, left: number, top: number): void
/** Hide the popup (idempotent). */
hide(): void
/** The button's click: commit the stored payload, then hide. */
commit(): void
}
export function useSelectionPopup(options: SelectionPopupOptions): SelectionPopupControls {
// Latest-callback refs: the dismissal listeners live for the mount's
// lifetime, so they must not capture stale closures across renders.
const onCommitRef = useRef(options.onCommit)
const getSurfaceRef = useRef(options.getSurface)
onCommitRef.current = options.onCommit
getSurfaceRef.current = options.getSurface
const [popup, setPopup] = useState<SelectionPopup | null>(null)
/** Live mirror for click/event-time reads (no re-render race). */
const popupRef = useRef<SelectionPopup | null>(null)
/** The portaled button itself (for the outside-click guard). */
const buttonRef = useRef<HTMLButtonElement>(null)
/** The surface visibility observer (created lazily on open). */
const observerRef = useRef<IntersectionObserver | null>(null)
const show = (insert: string, left: number, top: number): void => {
const next: SelectionPopup = {
insert,
left: Math.min(Math.max(left, 80), window.innerWidth - 80),
top,
}
popupRef.current = next
setPopup(next)
}
const hide = (): void => {
popupRef.current = null
setPopup(null)
}
const commit = (): void => {
const current = popupRef.current
if (current === null) return
onCommitRef.current(current.insert)
hide()
}
// Global dismissal listeners, registered once; every handler reads the
// live popup ref so it no-ops while no popup is open.
useEffect(() => {
const onMouseDown = (event: MouseEvent): void => {
if (popupRef.current === null) return
const button = buttonRef.current
if (button !== null && (button === event.target || button.contains(event.target as Node))) return
hide()
}
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape' && popupRef.current !== null) hide()
}
const onVisibilityChange = (): void => {
if (document.hidden && popupRef.current !== null) hide()
}
const onWindowBlur = (): void => {
if (popupRef.current !== null) hide()
}
document.addEventListener('mousedown', onMouseDown, true)
document.addEventListener('keydown', onKeyDown, true)
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('blur', onWindowBlur)
return () => {
document.removeEventListener('mousedown', onMouseDown, true)
document.removeEventListener('keydown', onKeyDown, true)
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('blur', onWindowBlur)
observerRef.current?.disconnect()
observerRef.current = null
}
}, [])
// Re-target the visibility observer whenever a popup opens: the surface
// refs may have been null at mount (content loads async, mode flips swap
// the preview container for the CodeMirror host), so the surface is only
// trustworthy at open time. The boolean flip is the only thing that must
// re-run this; an anchor move while open keeps the same surface.
const popupOpen = popup !== null
useEffect(() => {
if (!popupOpen) return
observerRef.current?.disconnect()
observerRef.current = null
if (typeof IntersectionObserver === 'undefined') return
const surface = getSurfaceRef.current()
if (surface === null) return
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) hide()
}
}, { threshold: 0 })
observerRef.current = observer
observer.observe(surface)
}, [popupOpen])
return { popup, buttonRef, show, hide, commit }
}