From 5a1e75e46b1cc3842a6cd4ad6489d8cdd222cef5 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Thu, 3 Sep 2026 17:07:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(lint):=20=E6=B8=85=E9=9B=B6=E5=AD=98?= =?UTF-8?q?=E9=87=8F=20lint=20=E5=91=8A=E8=AD=A6=E5=B9=B6=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=20CI=20/=20Makefile=20=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 存量清理(首跑 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 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 注释同步顺序) --- .github/workflows/ci.yml | 4 ++++ .github/workflows/release.yml | 8 +++++-- Makefile | 10 ++++---- src/agent-opens.ts | 6 ++--- src/client/EditorHost.tsx | 3 +++ src/client/PdfView.tsx | 3 +++ src/client/SideCardSection.tsx | 1 - src/client/SideChatView.tsx | 3 +-- src/client/Sidebar.tsx | 21 +++++++++-------- src/client/SubagentView.tsx | 12 +++++++++- src/client/TextEditor.tsx | 10 ++++++-- src/client/add-plugin-modal.tsx | 2 +- src/client/builtins/tabs.tsx | 2 +- src/client/changes/ChangesTab.tsx | 3 +++ src/client/changes/GitLens.tsx | 6 +++++ src/client/chunk-loader.ts | 3 +-- src/client/conversation-draft.ts | 1 + src/client/index.tsx | 2 +- src/client/lazy-chunk.tsx | 4 ++-- src/client/markdown-images.ts | 1 + src/client/selection-popup.ts | 11 ++++----- src/client/service.ts | 2 +- src/client/terminal-links.ts | 2 +- src/config.ts | 2 -- src/context-types.ts | 1 + src/fs-operations.ts | 1 - src/sidechat-core.ts | 3 --- tests/add-plugin-modal.spec.tsx | 3 +-- tests/agent-pty.spec.ts | 1 - tests/chunk-artifact.spec.ts | 1 - tests/chunk-loader.spec.ts | 10 ++------ tests/consumer-types.ts | 4 ++-- tests/editor-host.spec.tsx | 32 +++++++++++++++----------- tests/fs-operations.spec.ts | 2 +- tests/markdown-manual-refresh.spec.tsx | 2 ++ tests/panel-host-css.spec.ts | 2 +- tests/pty-helpers.spec.ts | 6 ++--- tests/sidechat-core.spec.ts | 1 - tests/sidechat-seed-validation.spec.ts | 9 -------- tests/sidechat-view.spec.tsx | 2 +- 40 files changed, 112 insertions(+), 90 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da30813..bcb900a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,8 @@ jobs: run: pnpm install --frozen-lockfile - name: Typecheck run: pnpm typecheck + - name: Lint + run: pnpm lint - name: Test run: pnpm test - name: Build @@ -88,6 +90,8 @@ jobs: run: pnpm install --frozen-lockfile - name: Typecheck run: pnpm typecheck + - name: Lint + run: pnpm lint - name: Test run: pnpm test - name: Build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 670dfa7..be1389c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,6 +88,9 @@ jobs: - name: Typecheck run: pnpm typecheck + - name: Lint + run: pnpm lint + - name: Test run: pnpm test @@ -95,8 +98,9 @@ jobs: run: pnpm build # The consumer-facing declaration surface guard — mirrors the ci.yml - # verification order (typecheck → test → build → check:consumer-types) - # so a release is gated on the same checks a PR runs. + # verification order (typecheck → lint → test → build → + # check:consumer-types) so a release is gated on the same checks a PR + # runs. - name: Check consumer type surface run: pnpm check:consumer-types diff --git a/Makefile b/Makefile index f703433..9bd14bd 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,6 @@ # dsh-better-sidebar 命令面薄封装:目标仅转发 package.json scripts(唯一事实源), # 新增/改名 script 不需要动这里;本文件只补目标发现(help)与 CI 门禁聚合 # (check / mount / mount-aggregate / registry)。 -# 注:lint 目标留给引入 ESLint 的后续 PR。 .DEFAULT_GOAL := help @@ -17,11 +16,14 @@ build: ## 构建(清 lib/ → tsc → tsdown) typecheck: ## 类型检查(tsc --noEmit) pnpm typecheck +lint: ## 代码检查(eslint .,flat config 最小规则集) + pnpm lint + test: ## 单元测试(vitest run) pnpm test -check: ## 聚合校验门禁:typecheck → build → test → check:consumer-types(对齐 CI) - pnpm typecheck && pnpm build && pnpm test && pnpm check:consumer-types +check: ## 聚合校验门禁:typecheck → lint → build → test → check:consumer-types(对齐 CI) + pnpm typecheck && pnpm lint && pnpm build && pnpm test && pnpm check:consumer-types clean: ## 清理构建产物与测试报告(lib/、*.tgz、playwright-report/、test-results/) rm -rf lib playwright-report test-results @@ -39,4 +41,4 @@ mount-aggregate: ## 聚合双挂载回归:build + pack → pnpm test:mount:agg registry: ## 组装 plugin-registry 暂存(registry/,不入库):build → node scripts/package-registry.mjs pnpm build && node scripts/package-registry.mjs -.PHONY: help install build typecheck test check clean pack mount mount-aggregate registry +.PHONY: help install build typecheck lint test check clean pack mount mount-aggregate registry diff --git a/src/agent-opens.ts b/src/agent-opens.ts index f0af56d..d102920 100644 --- a/src/agent-opens.ts +++ b/src/agent-opens.ts @@ -158,9 +158,9 @@ async function classifyTarget(raw: string, cwd: string): Promise<{ kind: AgentOp info = await stat(target) } catch (error) { const code = (error as NodeJS.ErrnoException).code - if (code === 'ENOENT') throw new Error(`"${raw}" does not exist (resolved to "${target}")`) - if (code === 'EACCES' || code === 'EPERM') throw new Error(`"${target}" is not readable`) - throw new Error(`cannot open "${target}": ${error instanceof Error ? error.message : String(error)}`) + if (code === 'ENOENT') throw new Error(`"${raw}" does not exist (resolved to "${target}")`, { cause: error }) + if (code === 'EACCES' || code === 'EPERM') throw new Error(`"${target}" is not readable`, { cause: error }) + throw new Error(`cannot open "${target}": ${error instanceof Error ? error.message : String(error)}`, { cause: error }) } const title = basenameOf(target) return { kind: info.isDirectory() ? 'folder' : 'file', target, title: title === '' ? raw : title } diff --git a/src/client/EditorHost.tsx b/src/client/EditorHost.tsx index e1459a9..4411f58 100644 --- a/src/client/EditorHost.tsx +++ b/src/client/EditorHost.tsx @@ -338,6 +338,9 @@ export function EditorHost(props: { } apply(planFirstMatch(ctx.get('betterSidebar')?.matchFileViewer(path), mediaUrlOf)) return () => { cancelled = true; controller.abort() } + // The deps are deliberately granular: the scope object's identity churns, + // only its sessionId / cwd fields gate the (re)fetch. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [scope.sessionId, scope.cwd, path, ctx, showEmpty, isDir, reloadSeq]) // Save-then-refresh in preview mode (issue #167 part C): the edge into diff --git a/src/client/PdfView.tsx b/src/client/PdfView.tsx index c59ccb9..8cb7ab3 100644 --- a/src/client/PdfView.tsx +++ b/src/client/PdfView.tsx @@ -40,6 +40,9 @@ export function PdfView(props: { scope: SessionScope; path: string; title: strin controller.abort() if (objectUrl !== undefined) URL.revokeObjectURL(objectUrl) } + // Granular scope fields: the scope object's identity churns, only its + // sessionId / cwd fields gate the fetch. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [scope.sessionId, scope.cwd, path]) useEffect(() => { diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx index 5a6e0a2..f0596f8 100644 --- a/src/client/SideCardSection.tsx +++ b/src/client/SideCardSection.tsx @@ -60,7 +60,6 @@ import { WIDTH_PERCENT_MAX, WIDTH_PERCENT_MIN, type SidebarPrefs, - type TitleBarScheme, } from '../prefs-shared.ts' import { api } from './api.ts' import { parsePrefs } from './prefs.ts' diff --git a/src/client/SideChatView.tsx b/src/client/SideChatView.tsx index f6ed825..e80abbd 100644 --- a/src/client/SideChatView.tsx +++ b/src/client/SideChatView.tsx @@ -574,7 +574,6 @@ export function SideChatView(props: { } } return items - // eslint-disable-next-line react-hooks/exhaustive-deps }, [threads]) const growComposer = (): void => { @@ -688,7 +687,7 @@ export function SideChatView(props: { )} items={menuItems} selectedId={threadId} - onSelect={(id) => { id === '$new' ? openNewThread() : openExistingThread(id) }} + onSelect={(id) => { if (id === '$new') openNewThread(); else openExistingThread(id) }} onClose={() => { setMenuOpen(false) }} align="end" portal diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 264591e..e2fbe0a 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -35,14 +35,13 @@ import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context, SidebarSessionList } from '../context-types.ts' import { appendToDraft, insertFileReference } from './conversation-draft.ts' import { - BOTTOM_MIN, PANEL_MIN, activateTab, agentUuidOf, allLeaves, closeFloatByTab, closeTab, dockFloat, firstLeaf, floatTab, - floatWithTab, isAgentTabId, leafWithTab, migrateBottomTabs, - moveFloat, moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, raiseFloat, reconcileAgentTerminals, + BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeFloatByTab, closeTab, dockFloat, firstLeaf, floatTab, + isAgentTabId, leafWithTab, migrateBottomTabs, + moveFloat, moveTab, moveTabToEdge, openDiffTab, raiseFloat, reconcileAgentTerminals, resizeFloat, resizeSplitIn, setBottomHeight, setTabPin, setWidth, toggleBottomPanel, toggleExpanded, togglePanel, - type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode, + type DropZone, type SidebarState, type SidebarStore, type SidebarTab, } from './state.ts' -import { collectPinnedTabs, createPinnedVirtualTab, getPinnedHomeScope, injectPinnedIntoTree, isPinnedVirtualId, isPinnedVirtualTab, parsePinnedVirtualId, type PinnedTabEntry } from './pinned.ts' -import { IconPinOutline16 } from './icons.tsx' +import { collectPinnedTabs, createPinnedVirtualTab, getPinnedHomeScope, injectPinnedIntoTree, isPinnedVirtualId, parsePinnedVirtualId, type PinnedTabEntry } from './pinned.ts' import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx' import { Workbench, type WorkbenchActions } from './split-pane.tsx' import { isNarrowWidth, useViewportSize } from './breakpoints.ts' @@ -418,7 +417,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // for the descriptors' available() callbacks. (The render's own guard // sits below every hook; this memo must handle the no-session case // itself.) - // eslint-disable-next-line react-hooks/exhaustive-deps [state, ctx, sessionId, cwd], ) @@ -476,7 +474,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { window.clearTimeout(retry) socket?.close() } - }, [sessionId, store]) + }, [sessionId, ctx, store]) /** * Agent opens push: subscribe to the host's `sidebar_open` requests for @@ -548,7 +546,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { window.clearTimeout(retry) socket?.close() } - }, [sessionId, store]) + }, [sessionId, ctx, store]) /** * Subagent auto-activation: the moment the current conversation spawns its @@ -679,6 +677,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { const pinnedEntries: readonly PinnedTabEntry[] = useMemo(() => { if (sessionId === undefined) return [] return collectPinnedTabs(store.getSessionStates(), { sessionId, cwd }) + // snapshot / pinnedRevision are deliberate cache-busters (see the comment + // above): they recompute this memo for changes that carry no dep of their own. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [store, sessionId, cwd, snapshot, pinnedRevision]) /** Virtual SidebarTab objects for the pinned entries (stable references @@ -1297,7 +1298,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { pinTab: (tabId, scope) => { store.reduce(s => setTabPin(s, tabId, scope === null ? null : { scope, homeCwd: cwd })) }, - }), [store, sessionId, cwd]) + }), [store, sessionId, cwd, ctx]) /** * Wrap the base actions to intercept pinned VIRTUAL tab ids (injected from diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx index 3f28979..739844f 100644 --- a/src/client/SubagentView.tsx +++ b/src/client/SubagentView.tsx @@ -415,6 +415,9 @@ function JobOutputPane(props: { if (!active || !isJobLive(job)) return const timer = window.setInterval(() => { void load() }, JOB_POLL_MS) return () => { window.clearInterval(timer) } + // isJobLive reads only job.status; whole-job identity churns on every + // catalog refresh and must not restart the poll interval. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [load, active, job.status]) useEffect(() => () => { controllerRef.current?.abort() }, []) @@ -425,6 +428,8 @@ function JobOutputPane(props: { if (!isJobLive(job) || typeof state !== 'object' || state.text.length === 0) return const pre = preRef.current if (pre !== null) pre.scrollTop = pre.scrollHeight + // Same as the poll effect above: only the status transition matters. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [state, job.status]) return ( @@ -656,7 +661,9 @@ export function SubagentView(props: { useCallback(() => sessions.list.getSnapshot(), [sessions]), ) const byId = list.byId - const catalogs = list.subagentsByParent ?? {} + // Memoized so the empty-catalog fallback keeps a stable identity — a fresh + // `{}` per render would invalidate every catalog-dependent memo/effect. + const catalogs = useMemo(() => list.subagentsByParent ?? {}, [list.subagentsByParent]) // The topology root: the main agent of the current session's tree. const rootId = useMemo(() => rootAncestor(byId, sessionId), [byId, sessionId]) @@ -680,6 +687,9 @@ export function SubagentView(props: { if (rootId === undefined || !active) return observe(rootId, true) return () => { + // The cleanup must release everything observed AT cleanup time (the set + // mutates as branches open), so reading the ref here is the point. + // eslint-disable-next-line react-hooks/exhaustive-deps for (const parentSessionId of observedRef.current) { sessions.setSubagentCatalogOpen?.(parentSessionId, false) } diff --git a/src/client/TextEditor.tsx b/src/client/TextEditor.tsx index 4ce8318..459eaf0 100644 --- a/src/client/TextEditor.tsx +++ b/src/client/TextEditor.tsx @@ -31,7 +31,6 @@ import { SandboxStatusBar } from './SandboxStatusBar.tsx' import { appendToDraft } from './conversation-draft.ts' import { useSelectionPopup } from './selection-popup.ts' import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts' -import { lazyChunkComponent } from './lazy-chunk.tsx' import { analyzeMarkdownHtml } from './markdown-html.ts' import { LazyMermaidMarkdown, MarkdownDocument, type MarkdownHtmlMedia } from './MarkdownHtml.tsx' import { MdToc } from './md-toc.tsx' @@ -117,13 +116,16 @@ export function TextEditor(props: FileViewerProps) { setDirty(false) setSaveState('idle') selectionPopup.hide() + // hide() reads a live ref; the reset must fire only on a content (file) + // swap, and the hook object's identity churns on every render. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [content]) // A different file switches the remembered preview scroll position to that // file's own entry (first open: none, so the preview starts at the top). useEffect(() => { previewScrollRef.current = previewScrollMemory.get(previewScrollKey(scope, path)) ?? 0 - }, [path]) + }, [scope, path]) // Create the CodeMirror editor once the content is loaded. The view owns // the document; React only tracks dirty state through the update listener @@ -220,6 +222,7 @@ export function TextEditor(props: FileViewerProps) { // The keymap's save() reads live refs; scope/path are stable for a // tab's lifetime, and the dark flip is handled by the reconfigure // effect below (recreating the view here would drop the draft). + // eslint-disable-next-line react-hooks/exhaustive-deps }, [content, path]) // Scheme flip: re-theme in place (the compartment holds only the @@ -276,6 +279,9 @@ export function TextEditor(props: FileViewerProps) { view.requestMeasure() }) }) + // The reveal reads the live document/view refs; only the flip into + // preview triggers it. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode]) // Snapshot the live document into the draft whenever the preview needs diff --git a/src/client/add-plugin-modal.tsx b/src/client/add-plugin-modal.tsx index fa6351a..8abb146 100644 --- a/src/client/add-plugin-modal.tsx +++ b/src/client/add-plugin-modal.tsx @@ -43,7 +43,7 @@ const COPIED_FEEDBACK_MS = 1500 /** The modal body: the GitHub topic button + the recommended plugin list * with per-entry jump/copy buttons (extracted for direct testing). */ export function PluginListBody(props: { service: BetterSidebarService; kind: PluginKind }) { - const { service, kind } = props + const { kind } = props // Which entry's copy button currently shows the "已复制" feedback. const [copiedId, setCopiedId] = useState(null) // Live catalog filter (name / id / description). A free-text search keeps diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx index a4a131c..1a73d66 100644 --- a/src/client/builtins/tabs.tsx +++ b/src/client/builtins/tabs.tsx @@ -180,7 +180,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read ], }], }, - component: ({ ctx, store, scope, tab, visible, onOpenFile, onOpenDiff }) => ( + component: ({ ctx, store, scope, tab, visible, onOpenDiff }) => ( { pollGen.current += 1 diff --git a/src/client/changes/GitLens.tsx b/src/client/changes/GitLens.tsx index 106d06e..f3be2b1 100644 --- a/src/client/changes/GitLens.tsx +++ b/src/client/changes/GitLens.tsx @@ -168,6 +168,9 @@ export function GitLens(props: GitLensProps) { } finally { if (options.loading && options.generation === refreshGeneration.current) setLoading(false) } + // Granular scope fields: the scope object's identity churns, only its + // sessionId / cwd fields gate the git target. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [scope.sessionId, scope.cwd, repoRoot]) const refresh = useCallback(async (silent = false): Promise => { @@ -231,6 +234,9 @@ export function GitLens(props: GitLensProps) { } finally { refreshInFlight.current = false } + // Granular scope fields: the scope object's identity churns, only its + // sessionId / cwd fields gate the refresh target. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [scope.sessionId, scope.cwd, refreshTarget]) useEffect(() => { diff --git a/src/client/chunk-loader.ts b/src/client/chunk-loader.ts index 169da7c..0a9e87a 100644 --- a/src/client/chunk-loader.ts +++ b/src/client/chunk-loader.ts @@ -244,8 +244,7 @@ export async function loadChunk(name: ChunkName): Promise { if (revalidation !== null) await revalidation const cached = cache.get(name) if (cached !== undefined) return cached - let task: Promise - task = (async (): Promise => { + const task: Promise = (async (): Promise => { const test = testLoaders.get(name) if (test !== undefined) return test() const modules = moduleSystem() diff --git a/src/client/conversation-draft.ts b/src/client/conversation-draft.ts index 3432523..9235059 100644 --- a/src/client/conversation-draft.ts +++ b/src/client/conversation-draft.ts @@ -203,6 +203,7 @@ export function appendToDraft(ctx: Context, sessionId: string, text: string): bo */ export function fileMention(relativePath: string): { mention: string; label: string } | undefined { const path = relativePath.replace(/[\\/]+$/, '') + // eslint-disable-next-line no-control-regex -- rejecting control characters is the point of this guard if (/[\u0000-\u001f\u007f-\u009f"]/u.test(path)) return undefined const mention = /\s/u.test(path) ? `@"${path}"` : `@${path}` const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) diff --git a/src/client/index.tsx b/src/client/index.tsx index 2915d42..32be1e3 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -297,7 +297,7 @@ export function apply(ctx: Context): void { // rides the same fetch, so one round trip covers both decisions). const decision = await Promise.race([ loadBootDecision(api), - new Promise(resolve => { const timer = window.setTimeout(() => resolve(null), 2000) }), + new Promise(resolve => { window.setTimeout(() => resolve(null), 2000) }), ]) if (disposed) return if (decision !== null) { diff --git a/src/client/lazy-chunk.tsx b/src/client/lazy-chunk.tsx index b8ce62a..a6aee3a 100644 --- a/src/client/lazy-chunk.tsx +++ b/src/client/lazy-chunk.tsx @@ -22,12 +22,12 @@ interface LazyChunkViewProps

{ props: P } -function LazyChunkView

({ chunk, pick, props }: LazyChunkViewProps

): ReactNode { +function LazyChunkView

({ chunk, pick, props }: LazyChunkViewProps

): ReactNode { const [attempt, setAttempt] = useState(0) const [state, setState] = useState< | { status: 'loading' } | { status: 'error'; message: string } - | { status: 'ready'; Comp: ComponentType } + | { status: 'ready'; Comp: ComponentType

} >({ status: 'loading' }) useEffect(() => { diff --git a/src/client/markdown-images.ts b/src/client/markdown-images.ts index cb223c9..c246b19 100644 --- a/src/client/markdown-images.ts +++ b/src/client/markdown-images.ts @@ -133,5 +133,6 @@ export function rewriteLocalImageUrls( return `${head}${resolve(dest.replace(/^<|>$/g, ''))}` }) + // eslint-disable-next-line no-control-regex -- NUL is the deliberate mask sentinel (cannot appear in source markdown) return refsRewritten.replace(/\u0000(\d+)\u0000/g, (_m, index: string) => masks[Number(index)] ?? '') } diff --git a/src/client/selection-popup.ts b/src/client/selection-popup.ts index 5386d9c..ae84427 100644 --- a/src/client/selection-popup.ts +++ b/src/client/selection-popup.ts @@ -133,9 +133,11 @@ export function useSelectionPopup(options: SelectionPopupOptions): SelectionPopu // 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. + // 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 (popup === null) return + if (!popupOpen) return observerRef.current?.disconnect() observerRef.current = null if (typeof IntersectionObserver === 'undefined') return @@ -148,10 +150,7 @@ export function useSelectionPopup(options: SelectionPopupOptions): SelectionPopu }, { threshold: 0 }) observerRef.current = observer observer.observe(surface) - // eslint-disable-next-line react-hooks/exhaustive-deps -- the boolean - // flip is the only thing that must re-run this; an anchor move while - // open keeps the same surface. - }, [popup !== null]) + }, [popupOpen]) return { popup, buttonRef, show, hide, commit } } \ No newline at end of file diff --git a/src/client/service.ts b/src/client/service.ts index 9663ac9..12fe6aa 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -449,7 +449,7 @@ function baseNameOf(path: string): string { export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDescriptor | undefined { for (const tab of tabs) { if (tab.urlTarget === undefined) continue - let claimed = false + let claimed: boolean try { claimed = tab.urlTarget(url) === true } catch (error) { diff --git a/src/client/terminal-links.ts b/src/client/terminal-links.ts index 37834b9..7e68a91 100644 --- a/src/client/terminal-links.ts +++ b/src/client/terminal-links.ts @@ -38,7 +38,7 @@ const OPENABLE_SCHEMES = new Set(['http:', 'https:']) * match on a line; callers must reset `lastIndex` before reuse (the * helper does this defensively). */ -export const TERMINAL_URL_REGEX = /\bhttps?:\/\/[^\s"'<>\[\]{}|\\^`]+/gi +export const TERMINAL_URL_REGEX = /\bhttps?:\/\/[^\s"'<>[\]{}|\\^`]+/gi /** A URL match in a terminal line: the text and its 0-based start offset. */ export interface TerminalUrlMatch { diff --git a/src/config.ts b/src/config.ts index 4f1b227..3d055cb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,8 +7,6 @@ import z from 'schemastery' import { - SIDEBAR_PREFS_DEFAULTS, - SIDEBAR_PREFS_NS, TERMINAL_FONT_SIZE_DEFAULT, TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN, diff --git a/src/context-types.ts b/src/context-types.ts index d17faa1..3730749 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -119,6 +119,7 @@ export interface SidebarSlotRegisterOptions { locale?: string registrant?: string /** Business-face factory; args depend on the slot scope. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mirrors the host slots signature, where inject args are untyped; unknown[] would reject concrete-typed implementations (contravariance) inject?: (...args: any[]) => Record children?: Record } diff --git a/src/fs-operations.ts b/src/fs-operations.ts index 919fe8a..fc6a48d 100644 --- a/src/fs-operations.ts +++ b/src/fs-operations.ts @@ -15,7 +15,6 @@ import { once } from 'node:events' import { createWriteStream } from 'node:fs' import { mkdir, rename, rm, stat } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' -import type { SidebarHttpRequest } from './context-types.ts' import { requireAbsolute } from './fs-tree.ts' import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts' import { SidebarError } from './wire.ts' diff --git a/src/sidechat-core.ts b/src/sidechat-core.ts index 51b2a4b..af18208 100644 --- a/src/sidechat-core.ts +++ b/src/sidechat-core.ts @@ -259,7 +259,6 @@ export function buildOpenTurnSnapshot(events: readonly SidechatLogEvent[]): stri let reasoning = '' const tools: string[] = [] const pendingCalls = new Map() - let total = 0 for (let index = boundary + 1; index < events.length; index++) { const event = events[index] if (event === undefined) continue @@ -299,13 +298,11 @@ export function buildOpenTurnSnapshot(events: readonly SidechatLogEvent[]): stri ...(result === '' ? [] : [` Result: ${result}`]), ].join('\n') tools.push(line) - total += line.length } } for (const [, call] of pendingCalls) { const line = `- \`${call.name}\` (executing) — arguments: \`${call.args}\`` tools.push(line) - total += line.length } const sections: string[] = [] if (text.trim() !== '') sections.push(`Assistant output so far:\n\n${text}`) diff --git a/tests/add-plugin-modal.spec.tsx b/tests/add-plugin-modal.spec.tsx index d508dd0..85720e9 100644 --- a/tests/add-plugin-modal.spec.tsx +++ b/tests/add-plugin-modal.spec.tsx @@ -18,8 +18,7 @@ import { renderToString } from 'react-dom/server' import { createRoot } from 'react-dom/client' import { act } from 'react-dom/test-utils' import * as primitives from '@deepseek-ai/dsh-client-ui-primitives' -import { PLUGIN_TOPIC_URL, type PluginEntry } from '../src/client/plugins-shared.ts' -import { builtinTabPlugins } from '../src/client/plugins-tabs.ts' +import { type PluginEntry } from '../src/client/plugins-shared.ts' import { builtinViewerPlugins } from '../src/client/plugins-viewers.ts' import { AddPluginModal, PluginListBody } from '../src/client/add-plugin-modal.tsx' import { createBetterSidebarService } from '../src/client/service.ts' diff --git a/tests/agent-pty.spec.ts b/tests/agent-pty.spec.ts index 88896c6..7b69a18 100644 --- a/tests/agent-pty.spec.ts +++ b/tests/agent-pty.spec.ts @@ -11,7 +11,6 @@ import { ALLOWED_SIGNALS, snapshotOf, tryResizePty, - type AgentTerminalSnapshot, } from '../src/agent-pty.ts' /** diff --git a/tests/chunk-artifact.spec.ts b/tests/chunk-artifact.spec.ts index ec39364..4a6ca54 100644 --- a/tests/chunk-artifact.spec.ts +++ b/tests/chunk-artifact.spec.ts @@ -36,7 +36,6 @@ describe.skipIf(!chunksBuilt)('built chunk artifacts', () => { if (typeof g.removeEventListener !== 'function') g.removeEventListener = () => {} for (const name of CHUNKS) { const code = readFileSync(`lib/client-${name}.js`, 'utf8') - // eslint-disable-next-line no-new-func expect(() => new Function(code)(), name).not.toThrow() const registry = g.__dshChunks__ as Record expect(typeof registry[name], name).toBe('function') diff --git a/tests/chunk-loader.spec.ts b/tests/chunk-loader.spec.ts index 5fb94d3..b94327b 100644 --- a/tests/chunk-loader.spec.ts +++ b/tests/chunk-loader.spec.ts @@ -41,11 +41,6 @@ function removeModuleSystem(): void { delete (globalThis as Record).__DSH_MODULES__ } -/** The global registry the chunk scripts populate (mirror of chunk-loader). */ -function registry(): Record { - return (globalThis as { __dshChunks__?: Record }).__dshChunks__ ?? {} -} - /** Simulate a chunk script executing: it assigns its factory to the registry. */ function simulateScript(name: string, factory: (require: (spec: string) => unknown) => ChunkExports): void { const g = globalThis as { __dshChunks__?: Record } @@ -314,8 +309,7 @@ describe('revalidateChunksOnReactivate (HMR re-activation keeps unchanged chunks const revalidating = revalidateChunksOnReactivate() // A lazy open DURING the pending revalidation must NOT get the old cache. let resolved = false - let pendingLoad: Promise | null = null - pendingLoad = loadChunk('editor').then((exports) => { resolved = true; return exports }) + const pendingLoad = loadChunk('editor').then((exports) => { resolved = true; return exports }) await new Promise((resolve) => setTimeout(resolve, 5)) expect(resolved, 'loadChunk must await the pending revalidation').toBe(false) // Release the HEAD; the barrier lifts and the load re-injects fresh exports. @@ -369,7 +363,7 @@ describe('revalidateChunksOnReactivate (HMR re-activation keeps unchanged chunks scriptCalls += 1 simulateScript('editor', () => ({ TextEditor: `editor-view:${scriptCalls}` })) }) - let etag = '"v1"' + const etag = '"v1"' stubBundleHead(() => etag) await loadChunk('editor') await settleEtag() diff --git a/tests/consumer-types.ts b/tests/consumer-types.ts index cd15cf2..19fe249 100644 --- a/tests/consumer-types.ts +++ b/tests/consumer-types.ts @@ -43,7 +43,7 @@ import type { const tab: TabDescriptor = { id: 'my-plugin:db', title: () => 'Database', - icon: (size: number) => null, + icon: (_size: number) => null, order: 50, hidden: false, available: (ctx, scope, state) => scope.sessionId !== '' && state.panelOpen && ctx !== null, @@ -116,7 +116,7 @@ service.subscribe(() => {}) const seed: OpenTabSeed = { type: 'my-plugin:db', title: 'DB', path: '/p', id: 'x', meta: { a: 1 } } service.openTab(seed) service.openTab(seed, { sessionId: 's1', cwd: '/p' }) -service.version +const _version: string = service.version service.features.includes('badge') const snapshot: SidebarSnapshot | undefined = service.getSnapshot() void snapshot diff --git a/tests/editor-host.spec.tsx b/tests/editor-host.spec.tsx index 5f99828..707c08e 100644 --- a/tests/editor-host.spec.tsx +++ b/tests/editor-host.spec.tsx @@ -10,12 +10,12 @@ */ // @vitest-environment jsdom import { describe, expect, it } from 'vitest' -import { createElement, useEffect } from 'react' +import { createElement, useEffect, type ReactNode } from 'react' import { createRoot } from 'react-dom/client' import { act } from 'react-dom/test-utils' import type { Context } from '../src/context-types.ts' import { EditorHost } from '../src/client/EditorHost.tsx' -import { createBetterSidebarService } from '../src/client/service.ts' +import { createBetterSidebarService, type FileViewerProps } from '../src/client/service.ts' import { allLeaves, createSidebarStore, type SidebarTab } from '../src/client/state.ts' // The act() environment flag (React 18.2 reads it before flushing effects). @@ -258,22 +258,26 @@ describe('EditorHost (files window)', () => { const service = ctx.betterSidebar const calls: string[] = [] // A viewer with a hoisted toolbar (the TextEditor contract): register - // commands and report the state once on mount. + // commands and report the state once on mount. Capitalized so the hooks + // rules recognize it as a component. + const FakeViewer = (viewerProps: FileViewerProps): ReactNode => { + useEffect(() => { + viewerProps.onToolbarControls?.({ + setMode: (next) => { calls.push(`mode:${next}`) }, + save: () => { calls.push('save') }, + }) + viewerProps.onToolbarState?.({ modes: true, mode: 'preview', dirty: true, editable: true, saveState: 'idle' }) + return () => { viewerProps.onToolbarControls?.(null) } + // Mount-only: re-running would re-fire the toolbar registration. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + return null + } service.registerFileViewer({ id: 'test:fake', exts: ['fake'], fetchStrategy: 'none', - component: (viewerProps) => { - useEffect(() => { - viewerProps.onToolbarControls?.({ - setMode: (next) => { calls.push(`mode:${next}`) }, - save: () => { calls.push('save') }, - }) - viewerProps.onToolbarState?.({ modes: true, mode: 'preview', dirty: true, editable: true, saveState: 'idle' }) - return () => { viewerProps.onToolbarControls?.(null) } - }, []) - return null - }, + component: FakeViewer, }) service.openTab({ type: 'editor', title: 'x.fake', path: '/tmp/x.fake', id: 'editor:/tmp/x.fake' }) const fileTab = (): SidebarTab => diff --git a/tests/fs-operations.spec.ts b/tests/fs-operations.spec.ts index e0ebefb..fb31399 100644 --- a/tests/fs-operations.spec.ts +++ b/tests/fs-operations.spec.ts @@ -1,5 +1,5 @@ import { afterAll, describe, expect, it } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync } from 'node:fs' +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { writeWorkspaceUpload } from '../src/fs-operations.ts' diff --git a/tests/markdown-manual-refresh.spec.tsx b/tests/markdown-manual-refresh.spec.tsx index 1f2df30..d2c0d18 100644 --- a/tests/markdown-manual-refresh.spec.tsx +++ b/tests/markdown-manual-refresh.spec.tsx @@ -37,6 +37,8 @@ function FakeMarkdownViewer(props: FileViewerProps) { markDirty = undefined props.onToolbarControls?.(null) } + // Only the callback identities must re-wire; content is read at render time. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.onToolbarControls, props.onToolbarState]) return createElement('div', { 'data-testid': 'markdown-content' }, props.content ?? '') } diff --git a/tests/panel-host-css.spec.ts b/tests/panel-host-css.spec.ts index 8b4e470..b3c6950 100644 --- a/tests/panel-host-css.spec.ts +++ b/tests/panel-host-css.spec.ts @@ -22,7 +22,7 @@ const css = readFileSync('src/client/sidebar.module.css', 'utf8') /** Body of the `:global([data-dsh-panel-host])` rule (up to the un-indented `}`). */ const hostRule = css.match(/:global\(\[data-dsh-panel-host\]\) \{([\s\S]*?)\n\}/)?.[1] -/** Strip /* ... *​/ comment blocks so prose mentions don't count as declarations. */ +/** Strip block comments (`/*` to `*`-`/`) so prose mentions don't count as declarations. */ const stripComments = (cssText: string): string => cssText.replace(/\/\*[\s\S]*?\*\//g, '') describe('panel host layer css', () => { diff --git a/tests/pty-helpers.spec.ts b/tests/pty-helpers.spec.ts index e1f4267..a02cefd 100644 --- a/tests/pty-helpers.spec.ts +++ b/tests/pty-helpers.spec.ts @@ -12,6 +12,9 @@ vi.mock('node:os', async (importOriginal) => { } }) +import { existsSync, statSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' import { resolveSidebarConfig } from '../src/config.ts' import { defaultShell, @@ -141,9 +144,6 @@ describe('pty helpers', () => { if (process.platform !== 'darwin') return ensureSpawnHelper() ensureSpawnHelper() - const { existsSync, statSync } = require('node:fs') as typeof import('node:fs') - const { dirname, join } = require('node:path') as typeof import('node:path') - const { createRequire } = require('node:module') as typeof import('node:module') const entry = createRequire(import.meta.url).resolve('node-pty') const root = dirname(dirname(entry)) // Prebuilt (tarball) or node-gyp-compiled (build/Release): mirror diff --git a/tests/sidechat-core.spec.ts b/tests/sidechat-core.spec.ts index 1516d2a..8c3a207 100644 --- a/tests/sidechat-core.spec.ts +++ b/tests/sidechat-core.spec.ts @@ -22,7 +22,6 @@ import { SIDE_INJECTION_PLUGIN, SIDE_LABEL_PREFIX, type SeedEvent, - type SidechatLogEvent, } from '../src/sidechat-core.ts' /** One log event fixture (structural, seq === index like the live contract). diff --git a/tests/sidechat-seed-validation.spec.ts b/tests/sidechat-seed-validation.spec.ts index 9407d1f..3f929d4 100644 --- a/tests/sidechat-seed-validation.spec.ts +++ b/tests/sidechat-seed-validation.spec.ts @@ -40,15 +40,6 @@ function assistantMessage(text: string): Record { } } -function toolResultMessage(callId: string, text: string): Record { - return { - id: `m-${callId}`, - role: 'user', - content: [{ type: 'tool-result', toolCallId: callId, content: [{ type: 'text', text }] }], - source: { kind: 'tool', callId }, - } -} - /** A parent log with a completed turn, a pending question, and an open * in-progress turn (the exact shape a mid-stream thread creation sees). */ function parentLog(): SidebarSessionEvent[] { diff --git a/tests/sidechat-view.spec.tsx b/tests/sidechat-view.spec.tsx index 2d52500..8d15951 100644 --- a/tests/sidechat-view.spec.tsx +++ b/tests/sidechat-view.spec.tsx @@ -64,7 +64,7 @@ const EVENTS = [ /** A subscribable sessions-list snapshot (mirror of the runtime list feed). */ function makeStore(initial: SidebarSessionList) { - let snapshot = initial + const snapshot = initial const listeners = new Set<() => void>() return { getSnapshot: (): SidebarSessionList => snapshot,