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
+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}`)