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 注释同步顺序)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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('\\'))
|
||||
|
||||
@@ -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<null>(resolve => { const timer = window.setTimeout(() => resolve(null), 2000) }),
|
||||
new Promise<null>(resolve => { window.setTimeout(() => resolve(null), 2000) }),
|
||||
])
|
||||
if (disposed) return
|
||||
if (decision !== null) {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)] ?? '')
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
ALLOWED_SIGNALS,
|
||||
snapshotOf,
|
||||
tryResizePty,
|
||||
type AgentTerminalSnapshot,
|
||||
} from '../src/agent-pty.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<string, unknown>
|
||||
expect(typeof registry[name], name).toBe('function')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,12 +258,9 @@ 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.
|
||||
service.registerFileViewer({
|
||||
id: 'test:fake',
|
||||
exts: ['fake'],
|
||||
fetchStrategy: 'none',
|
||||
component: (viewerProps) => {
|
||||
// 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}`) },
|
||||
@@ -271,9 +268,16 @@ describe('EditorHost (files window)', () => {
|
||||
})
|
||||
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: FakeViewer,
|
||||
})
|
||||
service.openTab({ type: 'editor', title: 'x.fake', path: '/tmp/x.fake', id: 'editor:/tmp/x.fake' })
|
||||
const fileTab = (): SidebarTab =>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 ?? '')
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user