diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0570c6..48c6295 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,3 +36,8 @@ jobs: run: pnpm test - name: Build run: pnpm build + # The consumer-facing declaration surface guard: a browser-only plugin + # (no @types/node, skipLibCheck: false) must type-check against the + # shipped lib/types. Runs AFTER build (it reads the built output). + - name: Check consumer type surface + run: pnpm check:consumer-types diff --git a/AGENTS.md b/AGENTS.md index 8eb7446..9b0bdb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,7 +127,7 @@ interface TabDescriptor { * 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。 * 内置策略:explorer/git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.id。 */ - dedupeKey?: (tab: SidebarTab) => string | undefined + dedupeKey?: (tab: SidebarTab) => string | undefined // 必须保持纯函数:每次 open 会求值两次,抛错会向外传播 /** * 自定义 tab 创建(minting SidebarTab + 状态 patch)。 * 返回 null 拒绝创建。terminal 用它生成 terminal: id 并递增 nextTerminal。 @@ -198,7 +198,10 @@ interface TabDescriptor { * - onClose:closeTab 关闭 tab 后。 * 内置专属流程(diff 拆分放置、agent 终端 reconcile)直接改 state,不触发回调—— * 但它们只作用于内置类型(diff/terminal),外部插件的 tab 永远走 service 路径。 - * 回调抛错只 console.error,绝不打断打开/关闭流程。scope 携带 { sessionId, cwd? }。 + * 回调抛错只 console.error,绝不打断打开/关闭流程。openTab 的回调 scope + * 携带调用者传入的 { sessionId, cwd? }(+ 菜单路径带 cwd;无 scope 的自动 + * 打开路径只有 sessionId);closeTab/activateTab 的回调 scope 只在显式传入 + * scope 参数时带 cwd。 */ onOpen?: (tab: SidebarTab, scope: SessionScope) => void onActivate?: (tab: SidebarTab, scope: SessionScope) => void @@ -436,19 +439,22 @@ interface BetterSidebarService { * 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。 * title 可选:给出时优先于 descriptor.title(editor 显示文件名); * 有 createTab 的 descriptor(terminal)会忽略 title/path/id。 - * url 可选:落地后把 tab 的 path 预填为 URL(侧边栏浏览器导航种子, - * 通常配合 hostname title;对 createTab 铸造的 tab 同样生效)。 + * url 可选:把**新建** tab 的 path 预填为 URL(侧边栏浏览器导航种子, + * 通常配合 hostname title;对 createTab 铸造的 tab 同样生效)。聚焦既有 + * tab 时 url 不会覆写其 path。 * 被设置禁用的类型是 no-op(console.warn 提示)。 * scope(v0.12.0+)定向到指定 session:给出且非当前 session 时,打开落在 * 该 session 的侧边栏状态里(没有则按 prefs 新建),不切换 UI 的激活 session; + * 定向打开**不自动展开**目标 session 的面板(用户看不见,展开无意义); * 缺省或指向当前 session 时行为与之前完全一致。注意:available 不拦截 openTab。 * 内容型打开(带 path/url seed)必须落在视野内:承载落点 pane 的面板 * 折叠时自动展开(右侧面板;落点 pane 在底部树则展开底部面板;窄视口 * 展开合并抽屉);类型型打开(+ 菜单、agent 终端自动补 tab)不展开。 */ openTab(seed: OpenTabSeed, scope?: SessionScope): void - /** 关闭一个 tab */ - closeTab(tabId: string): void + /** 关闭一个 tab(未知 id 严格 no-op,无状态搅动);scope(v0.12.0+) + * 随回调传递(含可选 cwd),缺省为 { sessionId: 当前 } */ + closeTab(tabId: string, scope?: SessionScope): void /** 订阅注册表变化(register/dispose 时触发) */ subscribe(listener: () => void): () => void // ── v0.12.0+ ────────────────────────────────────────────────────────── @@ -465,8 +471,9 @@ interface BetterSidebarService { subscribeState(listener: () => void): () => void /** 更新一个已打开 tab 的显示字段(title/path/meta);tab 不存在时 no-op */ updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void - /** 激活一个已打开的 tab(tab 栏点击路径;触发 descriptor.onActivate) */ - activateTab(tabId: string): void + /** 激活一个已打开的 tab(tab 栏点击路径;触发 descriptor.onActivate; + * 未知 id 严格 no-op);scope(v0.12.0+)随回调传递,同 closeTab */ + activateTab(tabId: string, scope?: SessionScope): void /** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名; * id 按路径派生,与内置 open-path 拦截一致,不同文件可并排打开) */ openFile(scope: SessionScope, path: string, title?: string): void @@ -480,7 +487,8 @@ interface OpenTabSeed { diff?: SidebarTab['diff'] id?: string url?: string - /** JSON 可序列化的自定义状态,随 tab 持久化(刷新后原样恢复) */ + /** JSON 可序列化的自定义状态,随 tab 持久化(刷新后原样恢复); + * updateTab/seed 传 undefined 表示「不改」,传 null 可显式清除 */ meta?: unknown } ``` diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index 2c48255..38efa29 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -522,16 +522,19 @@ interface BetterSidebarService { * 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。 * title 可选:给出时优先于 descriptor.title(editor 显示文件名); * 有 createTab 的 descriptor(terminal)会忽略 title/path/id。 - * url 可选:落地后把 tab 的 path 预填为 URL(侧边栏浏览器导航种子)。 + * url 可选:把**新建** tab 的 path 预填为 URL(侧边栏浏览器导航种子); + * 聚焦既有 tab 时 url 不会覆写其 path。 * 被设置禁用的类型是 no-op(console.warn 提示)。 * scope(v0.12.0+)定向到指定 session:给出且非当前 session 时,打开落在 * 该 session 的侧边栏状态里(没有则按 prefs 新建),不切换 UI 的激活 session; - * 缺省或指向当前 session 时行为与之前完全一致。注意:available 不拦截 openTab。 + * 定向打开不自动展开目标 session 的面板;缺省或指向当前 session 时行为 + * 与之前完全一致。注意:available 不拦截 openTab。 * 内容型打开(带 path/url seed)自动展开承载面板,保证落点可见。 */ openTab(seed: OpenTabSeed, scope?: SessionScope): void - /** 关闭一个 tab */ - closeTab(tabId: string): void + /** 关闭一个 tab(未知 id 严格 no-op,无状态搅动);scope(v0.12.0+) + * 随回调传递(含可选 cwd),缺省为 { sessionId: 当前 } */ + closeTab(tabId: string, scope?: SessionScope): void /** 订阅注册表变化(register/dispose 时触发) */ subscribe(listener: () => void): () => void // ── v0.12.0+ ────────────────────────────────────────────────────────── @@ -548,8 +551,9 @@ interface BetterSidebarService { subscribeState(listener: () => void): () => void /** 更新一个已打开 tab 的显示字段(title/path/meta);tab 不存在时 no-op */ updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void - /** 激活一个已打开的 tab(tab 栏点击路径;触发 descriptor.onActivate) */ - activateTab(tabId: string): void + /** 激活一个已打开的 tab(tab 栏点击路径;触发 descriptor.onActivate; + * 未知 id 严格 no-op);scope(v0.12.0+)随回调传递,同 closeTab */ + activateTab(tabId: string, scope?: SessionScope): void /** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名; * id 按路径派生,与内置 open-path 拦截一致,不同文件可并排打开) */ openFile(scope: SessionScope, path: string, title?: string): void diff --git a/package.json b/package.json index 7333a3c..e4a66e8 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,8 @@ "bundle": "tsdown", "watch": "tsdown --watch", "prepare": "tsdown", - "test": "vitest run" + "test": "vitest run", + "check:consumer-types": "bash scripts/check-consumer-types.sh" }, "license": "MIT", "peerDependencies": { @@ -104,7 +105,9 @@ "react-dom": "^18.2.0" }, "peerDependenciesMeta": { - "cordis": { "optional": true } + "cordis": { + "optional": true + } }, "dependencies": { "@aiden0z/pptx-renderer": "^1.2.4", diff --git a/scripts/check-consumer-types.sh b/scripts/check-consumer-types.sh index f7765c9..300c88a 100755 --- a/scripts/check-consumer-types.sh +++ b/scripts/check-consumer-types.sh @@ -10,6 +10,13 @@ set -euo pipefail cd "$(dirname "$0")/.." +# The check reads the BUILT declaration surface (lib/types) — fail loudly +# instead of silently passing when the build output is missing. +if [ ! -f lib/types/client/service.d.ts ]; then + echo "[check-consumer-types] FAIL: lib/types/client/service.d.ts not found — run 'pnpm build' first." >&2 + exit 1 +fi + WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT @@ -17,6 +24,7 @@ mkdir -p "$WORK/node_modules" ln -s "$(pwd)" "$WORK/node_modules/dsh-better-sidebar" cat > "$WORK/check.ts" <<'EOF' +import { SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from 'dsh-better-sidebar/client/service' import type {} from 'dsh-better-sidebar/client/service' import type { BetterSidebarService, FileViewerDescriptor, OpenTabSeed, SidebarSettingsRenderProps, @@ -56,6 +64,7 @@ const snap: SidebarSnapshot = ctx.betterSidebar.getSnapshot() const prefs: SidebarPrefs = snap.prefs const store: SidebarStore = null as unknown as SidebarStore void prefs; void store; void ctx.betterSidebar.version; void ctx.betterSidebar.features +void SIDEBAR_SERVICE_VERSION; void SIDEBAR_FEATURES EOF cat > "$WORK/tsconfig.json" <<'EOF' diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx index 9481cf6..45876ce 100644 --- a/src/client/SideCardSection.tsx +++ b/src/client/SideCardSection.tsx @@ -108,11 +108,6 @@ function viewerOrder(a: FileViewerDescriptor, b: FileViewerDescriptor): number { return (b.priority ?? 0) - (a.priority ?? 0) } -/** Read one boolean pref by declarative key (missing = false). */ -function prefBool(prefs: SidebarPrefs, key: string): boolean { - return (prefs as unknown as Record)[key] === true -} - /** Whether a feature declares any secondary settings (gear button shows). */ function hasSettings(feature: TabDescriptor | FileViewerDescriptor): boolean { const settings = feature.settings @@ -210,8 +205,15 @@ export function FeatureSettingsRows(props: { * display (clamped for numbers, the current pref when the input is * invalid). Optional: rows with no handler keep their draft. */ onCommit?: (toggle: SidebarSettingToggle, raw: string) => string + /** Explicit value source (v0.12.0+): when given, rows read their values + * from it instead of the `prefs` face — plugin-owned rows read their + * own blob, so a plugin key can never collide with (or silently read) + * a host pref of the same name. (Named `valueSource`, not `valueOf`: + * the latter collides with the inherited Object.prototype.valueOf.) */ + valueSource?: (key: string) => unknown }) { - const { toggles, prefs, onToggle, onCommit } = props + const { toggles, prefs, onToggle, onCommit, valueSource } = props + const read = valueSource ?? ((key: string): unknown => (prefs as unknown as Record)[key]) return (
{toggles.map(toggle => { @@ -225,13 +227,13 @@ export function FeatureSettingsRows(props: { { onToggle(toggle, next) }} />
) } - const value = String((prefs as unknown as Record)[toggle.key] ?? '') + const value = String(read(toggle.key) ?? '') // Keyed by the committed value: a failed commit reverts prefs, the // key changes, and the row remounts with the stored value (typing // never changes the key, so mid-edit drafts survive re-renders). @@ -337,9 +339,11 @@ export function SettingsBody(props: { const toggles = feature.settings?.toggles ?? [] const pluginToggles = feature.settings?.pluginToggles ?? [] if (toggles.length === 0 && pluginToggles.length === 0) return null - // Plugin rows read/write their values through a projected prefs face so - // the shared row renderer works unchanged (its rows read prefs[key]). - const effectivePrefs = { ...prefs, ...(prefs.pluginSettings[feature.id] ?? {}) } as SidebarPrefs + // Plugin rows read their values from the descriptor's OWN blob through + // an explicit value source — no projection onto the prefs face, so a + // plugin key can never collide with (or silently read) a host pref of + // the same name. + const pluginBlob = prefs.pluginSettings[feature.id] ?? {} return (
{toggles.length > 0 && ( @@ -353,9 +357,10 @@ export function SettingsBody(props: { {pluginToggles.length > 0 && ( pluginBlob[key]} /> )}
diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 20ca9de..8d4cafc 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -553,11 +553,17 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // TerminalView on unmount), and the agent-pty.close HTTP route is the // fallback when the WS is down. const current = store.getSnapshot().state - const leaf = current === undefined ? undefined : leafWithTab(current.splits, tabId) + // Terminal tabs may live in EITHER tree (the bottom panel hosts them + // too) — the pty-release lookup covers both, or the HTTP fallback is + // skipped for a bottom-panel terminal whose WS frame never arrived. + const leaf = current === undefined + ? undefined + : leafWithTab(current.splits, tabId) ?? leafWithTab(current.bottomSplits, tabId) const tab = leaf?.tabs.find(candidate => candidate.id === tabId) // Route through the service: the tab-bar close is the canonical close - // path (finds the pane itself, fires descriptor.onClose). - ctx.betterSidebar?.closeTab(tabId) + // path (finds the pane itself, fires descriptor.onClose); the session + // scope (with its cwd) rides to the callback. + ctx.betterSidebar?.closeTab(tabId, sessionId === undefined ? undefined : { sessionId, cwd }) if (tab?.type === 'terminal') { if (isAgentTabId(tabId)) { const uuid = agentUuidOf(tabId) @@ -569,8 +575,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { }, activateTab: (paneId, tabId) => { // Route through the service: same reducer (finds the pane in EITHER - // tree, sets the active pane) and fires descriptor.onActivate. - ctx.betterSidebar?.activateTab(tabId) + // tree, sets the active pane) and fires descriptor.onActivate; the + // session scope (with its cwd) rides to the callback. + ctx.betterSidebar?.activateTab(tabId, sessionId === undefined ? undefined : { sessionId, cwd }) }, focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) }, moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => { diff --git a/src/client/service.ts b/src/client/service.ts index 2ec9c8e..8ec2a43 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -321,8 +321,13 @@ export interface BetterSidebarService { * refuse `openTab` (only the settings disable switch does). */ openTab(seed: OpenTabSeed, scope?: SessionScope): void - /** Close a tab by id. */ - closeTab(tabId: string): void + /** + * Close a tab by id (fires descriptor.onClose). An unknown tab id is a + * strict no-op (no state churn, no callbacks). `scope` (v0.12.0+) rides + * to the callback (its optional cwd included); absent, the callback gets + * `{ sessionId }` of the active session. + */ + closeTab(tabId: string, scope?: SessionScope): void /** Subscribe to registry changes (register/dispose). */ subscribe(listener: () => void): () => void /** The plugin version this service instance was built from ('0.12.0'). */ @@ -344,8 +349,12 @@ export interface BetterSidebarService { subscribeState(listener: () => void): () => void /** Update an open tab's display fields (title / path / meta); a missing tab id is a no-op. */ updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void - /** Activate an open tab (the tab-bar activation path; fires descriptor.onActivate). */ - activateTab(tabId: string): void + /** + * Activate an open tab (the tab-bar activation path; fires + * descriptor.onActivate). An unknown tab id is a strict no-op. `scope` + * (v0.12.0+) rides to the callback like `closeTab`'s. + */ + activateTab(tabId: string, scope?: SessionScope): void /** Open a file in the sidebar editor of `scope`'s session (title defaults to the file name). */ openFile(scope: SessionScope, path: string, title?: string): void } @@ -503,6 +512,11 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe const targetSessionId = scope?.sessionId ?? store.getSnapshot().sessionId if (targetSessionId === undefined) return const callbackScope: SessionScope = scope ?? { sessionId: targetSessionId } + // Whether this open targets a session that is NOT the one on screen: a + // targeted open must not auto-expand panels the user cannot see (the + // expansion is about landing "in sight" for the CURRENT viewer). + const activeSessionId = store.getSnapshot().sessionId + const targetsInactiveSession = scope !== undefined && scope.sessionId !== activeSessionId // Lifecycle capture: `created` when the open minted a NEW tab (a // dedupe/id-safety-net focus is an ACTIVATION, not an open). let created: SidebarTab | undefined @@ -530,20 +544,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe } next = applyDedupe(state, tab, descriptor) } - // A URL seed pre-fills the tab's path (the browser tab navigates to it - // on mount). An explicit seed.title also wins over a createTab-minted - // default title (e.g. the sidebar-browser's hostname title). - let landed: SidebarState - if (seed.url !== undefined) { - landed = patchTab(next, tab.id, { - path: seed.url, - ...(seed.title !== undefined ? { title: seed.title } : {}), - }) - } else { - landed = next - } - // Lifecycle capture (before the auto-expand block, which early-returns). - // Classify the landing against the INPUT state: a FOCUS fires + // Classify the landing against the INPUT state FIRST: a FOCUS fires // onActivate with the tab that is active NOW; a real creation fires // onOpen with the minted tab. Both the dedupeKey match AND the id // match count as a focus — a descriptor deduping by key (e.g. editor @@ -556,8 +557,24 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe const existedByKey = key !== undefined && inputTabs.some(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key) const existedById = tabOpenIn(state, tab.id) - if (!existedByKey && !existedById) { - created = tab + const isCreation = !existedByKey && !existedById + // A URL seed pre-fills a NEWLY CREATED tab's path (the browser tab + // navigates to it on mount); a FOCUS must never have its path + // overwritten. An explicit seed.title still wins over a createTab- + // minted default title (e.g. the sidebar-browser's hostname title). + let landed: SidebarState = next + if (seed.url !== undefined && isCreation) { + landed = patchTab(next, tab.id, { + path: seed.url, + ...(seed.title !== undefined ? { title: seed.title } : {}), + }) + } + // Lifecycle capture (before the auto-expand block, which early-returns). + if (isCreation) { + // Resolve the ACTUAL landed tab — the url patch mints a new object, + // so the callback must see the tab that was really inserted. + const landedTabs = allLeaves(landed.splits).concat(allLeaves(landed.bottomSplits)).flatMap(leaf => leaf.tabs) + created = landedTabs.find(candidate => candidate.id === tab.id) ?? tab } else { // A focus happened: resolve the tab that is actually active now and // report THAT to onActivate (never the caller's un-inserted seed). @@ -576,9 +593,11 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe // agent-terminal auto-tabs) never expand (the panel behavior is their // caller's business). The check runs on the post-dedupe state, so a // content open that merely FOCUSES an existing tab expands the panel - // too — the open must never land out of sight. + // too — the open must never land out of sight. Opens targeted at an + // INACTIVE session never expand (nothing is in sight for the user). if ( - typeof window !== 'undefined' + !targetsInactiveSession + && typeof window !== 'undefined' && (seed.path !== undefined || seed.url !== undefined) ) { if (isNarrowWidth(window.innerWidth)) { @@ -597,8 +616,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe // A scope targeting ANOTHER session lands the open there without // switching the UI; a scope naming the active session (or no scope) // takes the regular reduce path so the UI notifies and re-renders. - const activeSessionId = store.getSnapshot().sessionId - if (scope !== undefined && scope.sessionId !== activeSessionId) { + if (targetsInactiveSession) { store.reduceFor(scope.sessionId, reducer) } else { store.reduce(reducer) @@ -607,20 +625,23 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe else if (activated !== undefined) safeCall(() => descriptor.onActivate?.(activated!, callbackScope)) } - const closeTab = (tabId: string): void => { + const closeTab = (tabId: string, scope?: SessionScope): void => { let closed: SidebarTab | undefined store.reduce((state) => { + // Unknown tab ids are a strict no-op: no state churn, no notify, no + // pointless localStorage rewrite (mirrors updateTab's short-circuit). + if (!tabOpenIn(state, tabId)) return state const paneId = findPaneIdOf(state, tabId) - if (paneId === '') return state const leaf = leafWithTab(state[treeOf(state, paneId)], tabId) closed = leaf?.tabs.find(tab => tab.id === tabId) return closeTabReducer(state, paneId, tabId) }) if (closed !== undefined) { - const sessionId = store.getSnapshot().sessionId + const sessionId = scope?.sessionId ?? store.getSnapshot().sessionId if (sessionId !== undefined) { const descriptor = tabs.get(closed.type) - safeCall(() => descriptor?.onClose?.(closed!, { sessionId })) + // An explicit scope (with its optional cwd) rides to the callback. + safeCall(() => descriptor?.onClose?.(closed!, scope ?? { sessionId })) } } } @@ -641,20 +662,22 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe } /** Activate an open tab (the tab-bar activation path; fires onActivate). */ - const activateTab = (tabId: string): void => { + const activateTab = (tabId: string, scope?: SessionScope): void => { let activated: SidebarTab | undefined store.reduce((state) => { + // Unknown tab ids are a strict no-op (no state churn / notify). + if (!tabOpenIn(state, tabId)) return state const paneId = findPaneIdOf(state, tabId) - if (paneId === '') return state const leaf = leafWithTab(state[treeOf(state, paneId)], tabId) activated = leaf?.tabs.find(tab => tab.id === tabId) return activateTabReducer(state, paneId, tabId) }) if (activated !== undefined) { - const sessionId = store.getSnapshot().sessionId + const sessionId = scope?.sessionId ?? store.getSnapshot().sessionId if (sessionId !== undefined) { const descriptor = tabs.get(activated.type) - safeCall(() => descriptor?.onActivate?.(activated!, { sessionId })) + // An explicit scope (with its optional cwd) rides to the callback. + safeCall(() => descriptor?.onActivate?.(activated!, scope ?? { sessionId })) } } } diff --git a/src/client/state.ts b/src/client/state.ts index 6908ddd..8656ddc 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -1025,6 +1025,11 @@ export class SidebarStore { const state = this.snapshot.state if (sessionId === undefined || state === undefined) return const next = reducer(state) + // A reducer returning the SAME reference means "no change": skip the + // persist + notify entirely — strict no-op paths (unknown tab ids, + // patchTab on a missing tab) must not churn the state or rewrite + // localStorage. + if (next === state) return this.bySession.set(sessionId, next) this.snapshot = { sessionId, state: next, prefs: this.prefs } this.schedulePersist(sessionId, next) @@ -1056,8 +1061,11 @@ export class SidebarStore { nextIdCounter = maxCounterId(state) } const next = reducer(state) - this.bySession.set(sessionId, next) + // Same-reference result = no change: keep the counter restore (it may + // have been seeded down) but skip the write. nextIdCounter = Math.max(nextIdCounter, counterBefore) + if (next === state) return + this.bySession.set(sessionId, next) this.schedulePersist(sessionId, next) } diff --git a/tests/consumer-types.ts b/tests/consumer-types.ts index a0d7854..cd15cf2 100644 --- a/tests/consumer-types.ts +++ b/tests/consumer-types.ts @@ -11,6 +11,10 @@ * `dsh-better-sidebar/client/service` in the built package. */ import type {} from '../src/client/service.ts' +import { + SIDEBAR_FEATURES, + SIDEBAR_SERVICE_VERSION, +} from '../src/client/service.ts' import type { BetterSidebarService, FileFetchStrategy, @@ -129,5 +133,8 @@ const toggleType: SidebarSettingToggleType = 'number' const toggle: SidebarSettingToggle = { key: 'k', title: 'K', type: toggleType } const declaration: SidebarSettingsDeclaration = { toggles: [toggle], pluginToggles: [toggle] } const typeName: TabType = 'my-plugin:db' +const version: string = SIDEBAR_SERVICE_VERSION +const features: readonly string[] = SIDEBAR_FEATURES +void version; void features const strategy: FileFetchStrategy = 'mediaUrl' void diff; void prefs; void store; void declaration; void typeName; void strategy diff --git a/tests/service.spec.ts b/tests/service.spec.ts index 88b213d..b4487a4 100644 --- a/tests/service.spec.ts +++ b/tests/service.spec.ts @@ -897,3 +897,89 @@ describe('lifecycle classification vs dedupe (codex review fixes)', () => { expect(events).toEqual(['open', 'activate']) }) }) + +describe('independent CR follow-up fixes', () => { + it('onOpen for a url-created tab receives the LANDED tab (path = url)', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + let seenPath: string | undefined + service.registerTab({ + id: 'web', + title: 'Web', + onOpen: (tab) => { seenPath = tab.path }, + component: () => null, + }) + store.setSession('s1') + service.openTab({ type: 'web', title: 'example.com', url: 'https://example.com/x' }) + expect(seenPath).toBe('https://example.com/x') + }) + + it('a url seed NEVER overwrites the path of a dedupe-focused tab', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ + id: 'web', + title: 'Web', + dedupeKey: () => 'web', + component: () => null, + }) + store.setSession('s1') + service.openTab({ type: 'web', title: 'first', url: 'https://a.example' }) + service.openTab({ type: 'web', title: 'second', url: 'https://b.example' }) + const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'web') + expect(tabs).toHaveLength(1) + // The focused tab keeps its ORIGINAL url — the second open must not + // repoint it. + expect(tabs[0]!.path).toBe('https://a.example') + }) + + it('closing / activating an unknown tab id is a strict no-op (no notify)', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ id: 'x', title: 'X', component: () => null }) + store.setSession('s1') + let calls = 0 + store.subscribe(() => { calls++ }) + service.closeTab('does-not-exist') + service.activateTab('does-not-exist') + expect(calls).toBe(0) + }) + + it('a targeted open into an INACTIVE session never auto-expands its panels', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + service.registerTab({ id: 'editor', title: 'Editor', component: () => null }) + store.setSession('s1') + // The target session starts collapsed. + store.reduceFor('s2', s => ({ ...s, panelOpen: false, bottomOpen: false })) + service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' }, { sessionId: 's2' }) + // Nothing is in sight for the user — the open must not expand s2. + store.setSession('s2') + expect(store.getSnapshot().state?.panelOpen).toBe(false) + expect(store.getSnapshot().state?.bottomOpen).toBe(false) + expect(allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).some(t => t.type === 'editor')).toBe(true) + }) + + it('closeTab/activateTab accept an optional scope that rides to the callback', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + const seen: Array<{ kind: string; cwd?: string }> = [] + service.registerTab({ + id: 'life', + title: 'Life', + single: true, + onActivate: (_tab, scope) => { seen.push({ kind: 'activate', cwd: scope.cwd }) }, + onClose: (_tab, scope) => { seen.push({ kind: 'close', cwd: scope.cwd }) }, + component: () => null, + }) + store.setSession('s1') + service.openTab({ type: 'life', title: 'Life' }) + const tab = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).find(t => t.type === 'life')! + service.activateTab(tab.id, { sessionId: 's1', cwd: '/work' }) + service.closeTab(tab.id, { sessionId: 's1', cwd: '/work' }) + expect(seen).toEqual([ + { kind: 'activate', cwd: '/work' }, + { kind: 'close', cwd: '/work' }, + ]) + }) +}) diff --git a/tests/side-card-section.spec.tsx b/tests/side-card-section.spec.tsx index 57efc39..a6a279d 100644 --- a/tests/side-card-section.spec.tsx +++ b/tests/side-card-section.spec.tsx @@ -236,3 +236,34 @@ describe('mergePluginSetting (v0.12.0, codex review fix)', () => { expect(map['my-plugin:db']).toEqual({ pageSize: 50, theme: 'dark' }) }) }) + +describe('FeatureSettingsRows valueSource (v0.12.0, independent CR fix)', () => { + it('plugin rows read from their OWN value source — a plugin key colliding with a host pref never reads the host value', () => { + const prefs = { ...SIDEBAR_PREFS_DEFAULTS, openByDefault: true } + const toggle = { key: 'openByDefault', title: 'My flag' } + // valueOf returns undefined (the plugin never wrote this key): the row + // must render UNCHECKED even though the host pref openByDefault is true. + let html = renderToString(createElement(FeatureSettingsRows, { + toggles: [toggle], + prefs, + onToggle: () => {}, + valueSource: () => undefined, + })) + expect(html).not.toContain('checked=""') + // The plugin wrote `true` into its own blob: the row is checked. + html = renderToString(createElement(FeatureSettingsRows, { + toggles: [toggle], + prefs, + onToggle: () => {}, + valueSource: () => true, + })) + expect(html).toContain('checked=""') + // Without valueOf the row falls back to the prefs face (host semantics). + html = renderToString(createElement(FeatureSettingsRows, { + toggles: [toggle], + prefs, + onToggle: () => {}, + })) + expect(html).toContain('checked=""') + }) +})