Merge pull request #536 from omdsh-dev/feat/eslint

feat(lint): 引入 ESLint flat config 并接入 CI 与 Makefile
This commit is contained in:
Atlas Zeng
2026-09-03 20:18:10 +08:00
committed by GitHub
45 changed files with 1527 additions and 92 deletions
+4
View File
@@ -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
+6 -2
View File
@@ -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
+6 -4
View File
@@ -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
+1
View File
@@ -524,6 +524,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
```sh
pnpm install # @deepseek-ai/* devDependencies 已发布(基线 0.1.2-rc.1,next dist-tag),直接解析、无需令牌
pnpm typecheck # tsc --noEmit
pnpm lint # eslint .(flat config:js + typescript-eslint + react-hooks recommended)
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
pnpm test # vitest(含 manifest 一致性守卫,需先 build)
pnpm watch # tsdown --watch
+1
View File
@@ -519,6 +519,7 @@ The dashed cards at the end of the "Sidebar content" / "File viewers" grids in t
```sh
pnpm install # @deepseek-ai/* devDependencies resolve (baseline 0.1.2-rc.1, next dist-tag) — no token needed
pnpm typecheck # tsc --noEmit
pnpm lint # eslint . (flat config: js + typescript-eslint + react-hooks recommended)
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
pnpm test # vitest (includes manifest consistency guard; build first)
pnpm watch # tsdown --watch
+88
View File
@@ -0,0 +1,88 @@
// ESLint flat config(最小规则集):
// - 基础:@eslint/js recommended + typescript-eslint recommended(非 type-checked 档,
// 全量 type-checked 需要项目服务且更慢,当前收益不足以引入);
// - react-hooks recommended:client 侧是 React 重镇,依赖数组豁免注释
// (react-hooks/exhaustive-deps)由此规则消费;
// - globals 按运行域分治:client bundle → browser,host/脚本 → node,tests → 混合。
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import tseslint from 'typescript-eslint'
export default tseslint.config(
// 构建产物 / 工具目录不参与 lint(node_modules 与点开头目录默认也不进)。
{
ignores: [
'lib/',
'dist/',
'coverage/',
'registry/',
'playwright-report/',
'test-results/',
'node_modules/',
'.worktrees/',
],
},
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
// react-hooks v7 的 recommended 捆绑了 React Compiler 语义规则(purity /
// immutability / refs / set-state-in-effect 等):本仓库对 CodeMirror / xterm
// 等命令式集成大量依赖 render 期读 ref、effect 内同步 setState 的既有模式,
// 57 处存量报错均为噪音级误报,先整体关闭该档,只保留经典双规则
// (rules-of-hooks / exhaustive-deps——后者消费代码里带理由的豁免注释)。
{
rules: Object.fromEntries(
[
'config',
'error-boundaries',
'gating',
'globals',
'immutability',
'incompatible-library',
'preserve-manual-memoization',
'purity',
'refs',
'set-state-in-effect',
'static-components',
'unsupported-syntax',
'use-memo',
].map(rule => [`react-hooks/${rule}`, 'off']),
),
},
// 孤儿 eslint-disable 注释(规则未实际触发)直接报错,防止豁免残留。
{ linterOptions: { reportUnusedDisableDirectives: 'error' } },
// no-unused-vars 对齐仓库既有约定:下划线前缀 = 刻意不用的占位(mock 桩等)。
{
rules: {
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
}],
},
},
// client bundle 运行在浏览器(portal 侧边栏 / 各视图 / 拦截层)。
{
files: ['src/client/**/*.{js,mjs,cjs,ts,tsx,jsx}'],
languageOptions: { globals: globals.browser },
},
// host 侧插件入口与构建/运维脚本跑在 Node。eslint.config.js 自身不 ignore:
// 它就是一个普通 ESM 脚本,走 js recommended + node globals 正常通过。
{
files: ['src/*.{js,mjs,cjs,ts}', 'scripts/**/*.{js,mjs,cjs,ts}', '*.config.ts', 'eslint.config.js'],
languageOptions: { globals: globals.node },
},
// tests:Node 里跑(vitest / fs fixtures),jsdom 组件测试又摸浏览器全局。
{
files: ['tests/**/*.{js,mjs,cjs,ts,tsx,jsx}'],
languageOptions: { globals: { ...globals.node, ...globals.browser } },
},
)
+8 -2
View File
@@ -82,7 +82,8 @@
"test:mount": "bash scripts/e2e-mount.sh",
"test:mount:aggregate": "bash scripts/e2e-aggregate-mount.sh",
"clean": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\"",
"check:consumer-types": "bash scripts/check-consumer-types.sh"
"check:consumer-types": "bash scripts/check-consumer-types.sh",
"lint": "eslint ."
},
"license": "MIT",
"peerDependencies": {
@@ -179,6 +180,11 @@
"tsdown": "^0.22.2",
"typescript": "^5.6.0",
"unrun": "^0.2.39",
"vitest": "^4.1.8"
"vitest": "^4.1.8",
"@eslint/js": "^10.0.1",
"eslint": "^10.9.1",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.12.0",
"typescript-eslint": "^8.69.0"
}
}
+1317
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -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 }
+3
View File
@@ -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
+3
View File
@@ -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(() => {
-1
View File
@@ -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'
+1 -2
View File
@@ -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
+11 -10
View File
@@ -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
+11 -1
View File
@@ -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)
}
+8 -2
View File
@@ -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
+1 -1
View File
@@ -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<string | null>(null)
// Live catalog filter (name / id / description). A free-text search keeps
+1 -1
View File
@@ -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 }) => (
<ChangesTab
ctx={ctx}
store={store}
+3
View File
@@ -93,6 +93,9 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
// only while nothing has ever loaded.
if (generation === pollGen.current) setOpsError(true)
}
// Granular scope fields: the scope object's identity churns, only its
// sessionId / cwd fields gate the poll target.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scope.sessionId, scope.cwd])
useEffect(() => {
pollGen.current += 1
+6
View File
@@ -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<void> => {
@@ -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(() => {
+1 -2
View File
@@ -244,8 +244,7 @@ export async function loadChunk(name: ChunkName): Promise<ChunkExports> {
if (revalidation !== null) await revalidation
const cached = cache.get(name)
if (cached !== undefined) return cached
let task: Promise<ChunkExports>
task = (async (): Promise<ChunkExports> => {
const task: Promise<ChunkExports> = (async (): Promise<ChunkExports> => {
const test = testLoaders.get(name)
if (test !== undefined) return test()
const modules = moduleSystem()
+1
View File
@@ -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('\\'))
+1 -1
View File
@@ -304,7 +304,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<null>(resolve => { const timer = window.setTimeout(() => resolve(null), 2000) }),
new Promise<null>(resolve => { window.setTimeout(() => resolve(null), 2000) }),
])
if (disposed) return
if (decision !== null) {
+2 -2
View File
@@ -22,12 +22,12 @@ interface LazyChunkViewProps<P> {
props: P
}
function LazyChunkView<P>({ chunk, pick, props }: LazyChunkViewProps<P>): ReactNode {
function LazyChunkView<P extends object>({ chunk, pick, props }: LazyChunkViewProps<P>): ReactNode {
const [attempt, setAttempt] = useState(0)
const [state, setState] = useState<
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; Comp: ComponentType<any> }
| { status: 'ready'; Comp: ComponentType<P> }
>({ status: 'loading' })
useEffect(() => {
+1
View File
@@ -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)] ?? '')
}
+5 -6
View File
@@ -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 }
}
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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 {
-2
View File
@@ -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,
+1
View File
@@ -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<string, unknown>
children?: Record<string, unknown>
}
-1
View File
@@ -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'
-3
View File
@@ -259,7 +259,6 @@ export function buildOpenTurnSnapshot(events: readonly SidechatLogEvent[]): stri
let reasoning = ''
const tools: string[] = []
const pendingCalls = new Map<string, { name: string; args: string }>()
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}`)
+1 -2
View File
@@ -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'
-1
View File
@@ -11,7 +11,6 @@ import {
ALLOWED_SIGNALS,
snapshotOf,
tryResizePty,
type AgentTerminalSnapshot,
} from '../src/agent-pty.ts'
/**
-1
View File
@@ -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<string, unknown>
expect(typeof registry[name], name).toBe('function')
+2 -8
View File
@@ -41,11 +41,6 @@ function removeModuleSystem(): void {
delete (globalThis as Record<string, unknown>).__DSH_MODULES__
}
/** The global registry the chunk scripts populate (mirror of chunk-loader). */
function registry(): Record<string, unknown> {
return (globalThis as { __dshChunks__?: Record<string, unknown> }).__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<string, unknown> }
@@ -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<ChunkExports> | 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()
+2 -2
View File
@@ -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
+18 -14
View File
@@ -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 =>
+1 -1
View File
@@ -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'
+2
View File
@@ -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 ?? '')
}
+1 -1
View File
@@ -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', () => {
+3 -3
View File
@@ -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
-1
View File
@@ -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).
-9
View File
@@ -40,15 +40,6 @@ function assistantMessage(text: string): Record<string, unknown> {
}
}
function toolResultMessage(callId: string, text: string): Record<string, unknown> {
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[] {
+1 -1
View File
@@ -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,