fix: 回应独立 CR 的 P2/P3 意见(0×P1)
- P2-1 onOpen 收到真正落地的 tab:url 新建场景从 landed 态按 id 反查
(补丁后对象),回调不再拿到未打 url 的 tab
- P2-2 插件设置行显式取值源:FeatureSettingsRows 新增 valueSource prop,
插件行读自己的 pluginSettings blob——插件键与宿主 pref 同名时不再
静默读宿主值(原投影合并有读宿主写插件的读写不对称);prop 命名避开
Object.prototype.valueOf 冲突
- P2-3 consumer 类型检查接入工程:package.json 新增 check:consumer-types
脚本 + CI 在 build 后执行;脚本补 lib/types 存在性前置检查(防假绿)
- P3-1 url seed 只在新建时应用:dedupe 聚焦的 tab 不再被覆写 path
- P3-2 closeTab/activateTab 未知 id 严格 no-op:store.reduce 对同引用
结果短路(不 notify、不重写 localStorage),根因修复 + 回归测试
- P3-3 closeTab/activateTab 增加可选 scope(cwd 随回调传递),Sidebar
双树激活/关闭路径传入 { sessionId, cwd }
- P3-4 定向打开不再自动展开不可见 session 的面板
- P3-5 Sidebar 终端 pty 释放查找覆盖双树(底部面板终端不再漏掉 HTTP
兜底)
- P3-6/P3-7 与 Nits:AGENTS.md/指南文档化(dedupeKey 纯函数、meta 用
null 清除、回调 scope 的 cwd 语义);consumer fixture 补常量导出覆盖
测试 +6(url 落地保真 / url 不覆写聚焦 / 未知 id 无 notify / 定向打开
不展开 / scope 随回调 / valueSource 键隔离),464 全绿;typecheck、
build、consumer 校验全部通过。
This commit is contained in:
@@ -36,3 +36,8 @@ jobs:
|
|||||||
run: pnpm test
|
run: pnpm test
|
||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm 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
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ interface TabDescriptor {
|
|||||||
* 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。
|
* 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。
|
||||||
* 内置策略:explorer/git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.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)。
|
* 自定义 tab 创建(minting SidebarTab + 状态 patch)。
|
||||||
* 返回 null 拒绝创建。terminal 用它生成 terminal:<n> id 并递增 nextTerminal。
|
* 返回 null 拒绝创建。terminal 用它生成 terminal:<n> id 并递增 nextTerminal。
|
||||||
@@ -198,7 +198,10 @@ interface TabDescriptor {
|
|||||||
* - onClose:closeTab 关闭 tab 后。
|
* - onClose:closeTab 关闭 tab 后。
|
||||||
* 内置专属流程(diff 拆分放置、agent 终端 reconcile)直接改 state,不触发回调——
|
* 内置专属流程(diff 拆分放置、agent 终端 reconcile)直接改 state,不触发回调——
|
||||||
* 但它们只作用于内置类型(diff/terminal),外部插件的 tab 永远走 service 路径。
|
* 但它们只作用于内置类型(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
|
onOpen?: (tab: SidebarTab, scope: SessionScope) => void
|
||||||
onActivate?: (tab: SidebarTab, scope: SessionScope) => void
|
onActivate?: (tab: SidebarTab, scope: SessionScope) => void
|
||||||
@@ -436,19 +439,22 @@ interface BetterSidebarService {
|
|||||||
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
|
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
|
||||||
* title 可选:给出时优先于 descriptor.title(editor 显示文件名);
|
* title 可选:给出时优先于 descriptor.title(editor 显示文件名);
|
||||||
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
|
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
|
||||||
* url 可选:落地后把 tab 的 path 预填为 URL(侧边栏浏览器导航种子,
|
* url 可选:把**新建** tab 的 path 预填为 URL(侧边栏浏览器导航种子,
|
||||||
* 通常配合 hostname title;对 createTab 铸造的 tab 同样生效)。
|
* 通常配合 hostname title;对 createTab 铸造的 tab 同样生效)。聚焦既有
|
||||||
|
* tab 时 url 不会覆写其 path。
|
||||||
* 被设置禁用的类型是 no-op(console.warn 提示)。
|
* 被设置禁用的类型是 no-op(console.warn 提示)。
|
||||||
* scope(v0.12.0+)定向到指定 session:给出且非当前 session 时,打开落在
|
* scope(v0.12.0+)定向到指定 session:给出且非当前 session 时,打开落在
|
||||||
* 该 session 的侧边栏状态里(没有则按 prefs 新建),不切换 UI 的激活 session;
|
* 该 session 的侧边栏状态里(没有则按 prefs 新建),不切换 UI 的激活 session;
|
||||||
|
* 定向打开**不自动展开**目标 session 的面板(用户看不见,展开无意义);
|
||||||
* 缺省或指向当前 session 时行为与之前完全一致。注意:available 不拦截 openTab。
|
* 缺省或指向当前 session 时行为与之前完全一致。注意:available 不拦截 openTab。
|
||||||
* 内容型打开(带 path/url seed)必须落在视野内:承载落点 pane 的面板
|
* 内容型打开(带 path/url seed)必须落在视野内:承载落点 pane 的面板
|
||||||
* 折叠时自动展开(右侧面板;落点 pane 在底部树则展开底部面板;窄视口
|
* 折叠时自动展开(右侧面板;落点 pane 在底部树则展开底部面板;窄视口
|
||||||
* 展开合并抽屉);类型型打开(+ 菜单、agent 终端自动补 tab)不展开。
|
* 展开合并抽屉);类型型打开(+ 菜单、agent 终端自动补 tab)不展开。
|
||||||
*/
|
*/
|
||||||
openTab(seed: OpenTabSeed, scope?: SessionScope): void
|
openTab(seed: OpenTabSeed, scope?: SessionScope): void
|
||||||
/** 关闭一个 tab */
|
/** 关闭一个 tab(未知 id 严格 no-op,无状态搅动);scope(v0.12.0+)
|
||||||
closeTab(tabId: string): void
|
* 随回调传递(含可选 cwd),缺省为 { sessionId: 当前 } */
|
||||||
|
closeTab(tabId: string, scope?: SessionScope): void
|
||||||
/** 订阅注册表变化(register/dispose 时触发) */
|
/** 订阅注册表变化(register/dispose 时触发) */
|
||||||
subscribe(listener: () => void): () => void
|
subscribe(listener: () => void): () => void
|
||||||
// ── v0.12.0+ ──────────────────────────────────────────────────────────
|
// ── v0.12.0+ ──────────────────────────────────────────────────────────
|
||||||
@@ -465,8 +471,9 @@ interface BetterSidebarService {
|
|||||||
subscribeState(listener: () => void): () => void
|
subscribeState(listener: () => void): () => void
|
||||||
/** 更新一个已打开 tab 的显示字段(title/path/meta);tab 不存在时 no-op */
|
/** 更新一个已打开 tab 的显示字段(title/path/meta);tab 不存在时 no-op */
|
||||||
updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void
|
updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void
|
||||||
/** 激活一个已打开的 tab(tab 栏点击路径;触发 descriptor.onActivate) */
|
/** 激活一个已打开的 tab(tab 栏点击路径;触发 descriptor.onActivate;
|
||||||
activateTab(tabId: string): void
|
* 未知 id 严格 no-op);scope(v0.12.0+)随回调传递,同 closeTab */
|
||||||
|
activateTab(tabId: string, scope?: SessionScope): void
|
||||||
/** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名;
|
/** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名;
|
||||||
* id 按路径派生,与内置 open-path 拦截一致,不同文件可并排打开) */
|
* id 按路径派生,与内置 open-path 拦截一致,不同文件可并排打开) */
|
||||||
openFile(scope: SessionScope, path: string, title?: string): void
|
openFile(scope: SessionScope, path: string, title?: string): void
|
||||||
@@ -480,7 +487,8 @@ interface OpenTabSeed {
|
|||||||
diff?: SidebarTab['diff']
|
diff?: SidebarTab['diff']
|
||||||
id?: string
|
id?: string
|
||||||
url?: string
|
url?: string
|
||||||
/** JSON 可序列化的自定义状态,随 tab 持久化(刷新后原样恢复) */
|
/** JSON 可序列化的自定义状态,随 tab 持久化(刷新后原样恢复);
|
||||||
|
* updateTab/seed 传 undefined 表示「不改」,传 null 可显式清除 */
|
||||||
meta?: unknown
|
meta?: unknown
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -522,16 +522,19 @@ interface BetterSidebarService {
|
|||||||
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
|
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
|
||||||
* title 可选:给出时优先于 descriptor.title(editor 显示文件名);
|
* title 可选:给出时优先于 descriptor.title(editor 显示文件名);
|
||||||
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
|
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
|
||||||
* url 可选:落地后把 tab 的 path 预填为 URL(侧边栏浏览器导航种子)。
|
* url 可选:把**新建** tab 的 path 预填为 URL(侧边栏浏览器导航种子);
|
||||||
|
* 聚焦既有 tab 时 url 不会覆写其 path。
|
||||||
* 被设置禁用的类型是 no-op(console.warn 提示)。
|
* 被设置禁用的类型是 no-op(console.warn 提示)。
|
||||||
* scope(v0.12.0+)定向到指定 session:给出且非当前 session 时,打开落在
|
* scope(v0.12.0+)定向到指定 session:给出且非当前 session 时,打开落在
|
||||||
* 该 session 的侧边栏状态里(没有则按 prefs 新建),不切换 UI 的激活 session;
|
* 该 session 的侧边栏状态里(没有则按 prefs 新建),不切换 UI 的激活 session;
|
||||||
* 缺省或指向当前 session 时行为与之前完全一致。注意:available 不拦截 openTab。
|
* 定向打开不自动展开目标 session 的面板;缺省或指向当前 session 时行为
|
||||||
|
* 与之前完全一致。注意:available 不拦截 openTab。
|
||||||
* 内容型打开(带 path/url seed)自动展开承载面板,保证落点可见。
|
* 内容型打开(带 path/url seed)自动展开承载面板,保证落点可见。
|
||||||
*/
|
*/
|
||||||
openTab(seed: OpenTabSeed, scope?: SessionScope): void
|
openTab(seed: OpenTabSeed, scope?: SessionScope): void
|
||||||
/** 关闭一个 tab */
|
/** 关闭一个 tab(未知 id 严格 no-op,无状态搅动);scope(v0.12.0+)
|
||||||
closeTab(tabId: string): void
|
* 随回调传递(含可选 cwd),缺省为 { sessionId: 当前 } */
|
||||||
|
closeTab(tabId: string, scope?: SessionScope): void
|
||||||
/** 订阅注册表变化(register/dispose 时触发) */
|
/** 订阅注册表变化(register/dispose 时触发) */
|
||||||
subscribe(listener: () => void): () => void
|
subscribe(listener: () => void): () => void
|
||||||
// ── v0.12.0+ ──────────────────────────────────────────────────────────
|
// ── v0.12.0+ ──────────────────────────────────────────────────────────
|
||||||
@@ -548,8 +551,9 @@ interface BetterSidebarService {
|
|||||||
subscribeState(listener: () => void): () => void
|
subscribeState(listener: () => void): () => void
|
||||||
/** 更新一个已打开 tab 的显示字段(title/path/meta);tab 不存在时 no-op */
|
/** 更新一个已打开 tab 的显示字段(title/path/meta);tab 不存在时 no-op */
|
||||||
updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void
|
updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void
|
||||||
/** 激活一个已打开的 tab(tab 栏点击路径;触发 descriptor.onActivate) */
|
/** 激活一个已打开的 tab(tab 栏点击路径;触发 descriptor.onActivate;
|
||||||
activateTab(tabId: string): void
|
* 未知 id 严格 no-op);scope(v0.12.0+)随回调传递,同 closeTab */
|
||||||
|
activateTab(tabId: string, scope?: SessionScope): void
|
||||||
/** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名;
|
/** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名;
|
||||||
* id 按路径派生,与内置 open-path 拦截一致,不同文件可并排打开) */
|
* id 按路径派生,与内置 open-path 拦截一致,不同文件可并排打开) */
|
||||||
openFile(scope: SessionScope, path: string, title?: string): void
|
openFile(scope: SessionScope, path: string, title?: string): void
|
||||||
|
|||||||
+5
-2
@@ -79,7 +79,8 @@
|
|||||||
"bundle": "tsdown",
|
"bundle": "tsdown",
|
||||||
"watch": "tsdown --watch",
|
"watch": "tsdown --watch",
|
||||||
"prepare": "tsdown",
|
"prepare": "tsdown",
|
||||||
"test": "vitest run"
|
"test": "vitest run",
|
||||||
|
"check:consumer-types": "bash scripts/check-consumer-types.sh"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -104,7 +105,9 @@
|
|||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"cordis": { "optional": true }
|
"cordis": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aiden0z/pptx-renderer": "^1.2.4",
|
"@aiden0z/pptx-renderer": "^1.2.4",
|
||||||
|
|||||||
@@ -10,6 +10,13 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
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)"
|
WORK="$(mktemp -d)"
|
||||||
trap 'rm -rf "$WORK"' EXIT
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
|
|
||||||
@@ -17,6 +24,7 @@ mkdir -p "$WORK/node_modules"
|
|||||||
ln -s "$(pwd)" "$WORK/node_modules/dsh-better-sidebar"
|
ln -s "$(pwd)" "$WORK/node_modules/dsh-better-sidebar"
|
||||||
|
|
||||||
cat > "$WORK/check.ts" <<'EOF'
|
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 {} from 'dsh-better-sidebar/client/service'
|
||||||
import type {
|
import type {
|
||||||
BetterSidebarService, FileViewerDescriptor, OpenTabSeed, SidebarSettingsRenderProps,
|
BetterSidebarService, FileViewerDescriptor, OpenTabSeed, SidebarSettingsRenderProps,
|
||||||
@@ -56,6 +64,7 @@ const snap: SidebarSnapshot = ctx.betterSidebar.getSnapshot()
|
|||||||
const prefs: SidebarPrefs = snap.prefs
|
const prefs: SidebarPrefs = snap.prefs
|
||||||
const store: SidebarStore = null as unknown as SidebarStore
|
const store: SidebarStore = null as unknown as SidebarStore
|
||||||
void prefs; void store; void ctx.betterSidebar.version; void ctx.betterSidebar.features
|
void prefs; void store; void ctx.betterSidebar.version; void ctx.betterSidebar.features
|
||||||
|
void SIDEBAR_SERVICE_VERSION; void SIDEBAR_FEATURES
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
cat > "$WORK/tsconfig.json" <<'EOF'
|
cat > "$WORK/tsconfig.json" <<'EOF'
|
||||||
|
|||||||
@@ -108,11 +108,6 @@ function viewerOrder(a: FileViewerDescriptor, b: FileViewerDescriptor): number {
|
|||||||
return (b.priority ?? 0) - (a.priority ?? 0)
|
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<string, boolean>)[key] === true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether a feature declares any secondary settings (gear button shows). */
|
/** Whether a feature declares any secondary settings (gear button shows). */
|
||||||
function hasSettings(feature: TabDescriptor | FileViewerDescriptor): boolean {
|
function hasSettings(feature: TabDescriptor | FileViewerDescriptor): boolean {
|
||||||
const settings = feature.settings
|
const settings = feature.settings
|
||||||
@@ -210,8 +205,15 @@ export function FeatureSettingsRows(props: {
|
|||||||
* display (clamped for numbers, the current pref when the input is
|
* display (clamped for numbers, the current pref when the input is
|
||||||
* invalid). Optional: rows with no handler keep their draft. */
|
* invalid). Optional: rows with no handler keep their draft. */
|
||||||
onCommit?: (toggle: SidebarSettingToggle, raw: string) => string
|
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<string, unknown>)[key])
|
||||||
return (
|
return (
|
||||||
<div className={css.popupRows}>
|
<div className={css.popupRows}>
|
||||||
{toggles.map(toggle => {
|
{toggles.map(toggle => {
|
||||||
@@ -225,13 +227,13 @@ export function FeatureSettingsRows(props: {
|
|||||||
</span>
|
</span>
|
||||||
<Switch
|
<Switch
|
||||||
label={title}
|
label={title}
|
||||||
checked={prefBool(prefs, toggle.key)}
|
checked={read(toggle.key) === true}
|
||||||
onChange={(next) => { onToggle(toggle, next) }}
|
onChange={(next) => { onToggle(toggle, next) }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const value = String((prefs as unknown as Record<string, unknown>)[toggle.key] ?? '')
|
const value = String(read(toggle.key) ?? '')
|
||||||
// Keyed by the committed value: a failed commit reverts prefs, the
|
// Keyed by the committed value: a failed commit reverts prefs, the
|
||||||
// key changes, and the row remounts with the stored value (typing
|
// key changes, and the row remounts with the stored value (typing
|
||||||
// never changes the key, so mid-edit drafts survive re-renders).
|
// 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 toggles = feature.settings?.toggles ?? []
|
||||||
const pluginToggles = feature.settings?.pluginToggles ?? []
|
const pluginToggles = feature.settings?.pluginToggles ?? []
|
||||||
if (toggles.length === 0 && pluginToggles.length === 0) return null
|
if (toggles.length === 0 && pluginToggles.length === 0) return null
|
||||||
// Plugin rows read/write their values through a projected prefs face so
|
// Plugin rows read their values from the descriptor's OWN blob through
|
||||||
// the shared row renderer works unchanged (its rows read prefs[key]).
|
// an explicit value source — no projection onto the prefs face, so a
|
||||||
const effectivePrefs = { ...prefs, ...(prefs.pluginSettings[feature.id] ?? {}) } as SidebarPrefs
|
// plugin key can never collide with (or silently read) a host pref of
|
||||||
|
// the same name.
|
||||||
|
const pluginBlob = prefs.pluginSettings[feature.id] ?? {}
|
||||||
return (
|
return (
|
||||||
<div className={css.popupRows}>
|
<div className={css.popupRows}>
|
||||||
{toggles.length > 0 && (
|
{toggles.length > 0 && (
|
||||||
@@ -353,9 +357,10 @@ export function SettingsBody(props: {
|
|||||||
{pluginToggles.length > 0 && (
|
{pluginToggles.length > 0 && (
|
||||||
<FeatureSettingsRows
|
<FeatureSettingsRows
|
||||||
toggles={pluginToggles}
|
toggles={pluginToggles}
|
||||||
prefs={effectivePrefs}
|
prefs={prefs}
|
||||||
onToggle={onPluginToggle}
|
onToggle={onPluginToggle}
|
||||||
onCommit={onPluginCommit}
|
onCommit={onPluginCommit}
|
||||||
|
valueSource={(key) => pluginBlob[key]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+12
-5
@@ -553,11 +553,17 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|||||||
// TerminalView on unmount), and the agent-pty.close HTTP route is the
|
// TerminalView on unmount), and the agent-pty.close HTTP route is the
|
||||||
// fallback when the WS is down.
|
// fallback when the WS is down.
|
||||||
const current = store.getSnapshot().state
|
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)
|
const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
|
||||||
// Route through the service: the tab-bar close is the canonical close
|
// Route through the service: the tab-bar close is the canonical close
|
||||||
// path (finds the pane itself, fires descriptor.onClose).
|
// path (finds the pane itself, fires descriptor.onClose); the session
|
||||||
ctx.betterSidebar?.closeTab(tabId)
|
// scope (with its cwd) rides to the callback.
|
||||||
|
ctx.betterSidebar?.closeTab(tabId, sessionId === undefined ? undefined : { sessionId, cwd })
|
||||||
if (tab?.type === 'terminal') {
|
if (tab?.type === 'terminal') {
|
||||||
if (isAgentTabId(tabId)) {
|
if (isAgentTabId(tabId)) {
|
||||||
const uuid = agentUuidOf(tabId)
|
const uuid = agentUuidOf(tabId)
|
||||||
@@ -569,8 +575,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|||||||
},
|
},
|
||||||
activateTab: (paneId, tabId) => {
|
activateTab: (paneId, tabId) => {
|
||||||
// Route through the service: same reducer (finds the pane in EITHER
|
// Route through the service: same reducer (finds the pane in EITHER
|
||||||
// tree, sets the active pane) and fires descriptor.onActivate.
|
// tree, sets the active pane) and fires descriptor.onActivate; the
|
||||||
ctx.betterSidebar?.activateTab(tabId)
|
// 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 })) },
|
focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) },
|
||||||
moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => {
|
moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => {
|
||||||
|
|||||||
+55
-32
@@ -321,8 +321,13 @@ export interface BetterSidebarService {
|
|||||||
* refuse `openTab` (only the settings disable switch does).
|
* refuse `openTab` (only the settings disable switch does).
|
||||||
*/
|
*/
|
||||||
openTab(seed: OpenTabSeed, scope?: SessionScope): void
|
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 to registry changes (register/dispose). */
|
||||||
subscribe(listener: () => void): () => void
|
subscribe(listener: () => void): () => void
|
||||||
/** The plugin version this service instance was built from ('0.12.0'). */
|
/** The plugin version this service instance was built from ('0.12.0'). */
|
||||||
@@ -344,8 +349,12 @@ export interface BetterSidebarService {
|
|||||||
subscribeState(listener: () => void): () => void
|
subscribeState(listener: () => void): () => void
|
||||||
/** Update an open tab's display fields (title / path / meta); a missing tab id is a no-op. */
|
/** 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
|
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). */
|
/** 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
|
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
|
const targetSessionId = scope?.sessionId ?? store.getSnapshot().sessionId
|
||||||
if (targetSessionId === undefined) return
|
if (targetSessionId === undefined) return
|
||||||
const callbackScope: SessionScope = scope ?? { sessionId: targetSessionId }
|
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
|
// Lifecycle capture: `created` when the open minted a NEW tab (a
|
||||||
// dedupe/id-safety-net focus is an ACTIVATION, not an open).
|
// dedupe/id-safety-net focus is an ACTIVATION, not an open).
|
||||||
let created: SidebarTab | undefined
|
let created: SidebarTab | undefined
|
||||||
@@ -530,20 +544,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
|||||||
}
|
}
|
||||||
next = applyDedupe(state, tab, descriptor)
|
next = applyDedupe(state, tab, descriptor)
|
||||||
}
|
}
|
||||||
// A URL seed pre-fills the tab's path (the browser tab navigates to it
|
// Classify the landing against the INPUT state FIRST: a FOCUS fires
|
||||||
// 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
|
|
||||||
// onActivate with the tab that is active NOW; a real creation 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
|
// 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
|
// 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
|
const existedByKey = key !== undefined
|
||||||
&& inputTabs.some(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
|
&& inputTabs.some(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
|
||||||
const existedById = tabOpenIn(state, tab.id)
|
const existedById = tabOpenIn(state, tab.id)
|
||||||
if (!existedByKey && !existedById) {
|
const isCreation = !existedByKey && !existedById
|
||||||
created = tab
|
// 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 {
|
} else {
|
||||||
// A focus happened: resolve the tab that is actually active now and
|
// A focus happened: resolve the tab that is actually active now and
|
||||||
// report THAT to onActivate (never the caller's un-inserted seed).
|
// 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
|
// agent-terminal auto-tabs) never expand (the panel behavior is their
|
||||||
// caller's business). The check runs on the post-dedupe state, so a
|
// caller's business). The check runs on the post-dedupe state, so a
|
||||||
// content open that merely FOCUSES an existing tab expands the panel
|
// 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 (
|
if (
|
||||||
typeof window !== 'undefined'
|
!targetsInactiveSession
|
||||||
|
&& typeof window !== 'undefined'
|
||||||
&& (seed.path !== undefined || seed.url !== undefined)
|
&& (seed.path !== undefined || seed.url !== undefined)
|
||||||
) {
|
) {
|
||||||
if (isNarrowWidth(window.innerWidth)) {
|
if (isNarrowWidth(window.innerWidth)) {
|
||||||
@@ -597,8 +616,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
|||||||
// A scope targeting ANOTHER session lands the open there without
|
// A scope targeting ANOTHER session lands the open there without
|
||||||
// switching the UI; a scope naming the active session (or no scope)
|
// switching the UI; a scope naming the active session (or no scope)
|
||||||
// takes the regular reduce path so the UI notifies and re-renders.
|
// takes the regular reduce path so the UI notifies and re-renders.
|
||||||
const activeSessionId = store.getSnapshot().sessionId
|
if (targetsInactiveSession) {
|
||||||
if (scope !== undefined && scope.sessionId !== activeSessionId) {
|
|
||||||
store.reduceFor(scope.sessionId, reducer)
|
store.reduceFor(scope.sessionId, reducer)
|
||||||
} else {
|
} else {
|
||||||
store.reduce(reducer)
|
store.reduce(reducer)
|
||||||
@@ -607,20 +625,23 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
|||||||
else if (activated !== undefined) safeCall(() => descriptor.onActivate?.(activated!, callbackScope))
|
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
|
let closed: SidebarTab | undefined
|
||||||
store.reduce((state) => {
|
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)
|
const paneId = findPaneIdOf(state, tabId)
|
||||||
if (paneId === '') return state
|
|
||||||
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
|
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
|
||||||
closed = leaf?.tabs.find(tab => tab.id === tabId)
|
closed = leaf?.tabs.find(tab => tab.id === tabId)
|
||||||
return closeTabReducer(state, paneId, tabId)
|
return closeTabReducer(state, paneId, tabId)
|
||||||
})
|
})
|
||||||
if (closed !== undefined) {
|
if (closed !== undefined) {
|
||||||
const sessionId = store.getSnapshot().sessionId
|
const sessionId = scope?.sessionId ?? store.getSnapshot().sessionId
|
||||||
if (sessionId !== undefined) {
|
if (sessionId !== undefined) {
|
||||||
const descriptor = tabs.get(closed.type)
|
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). */
|
/** 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
|
let activated: SidebarTab | undefined
|
||||||
store.reduce((state) => {
|
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)
|
const paneId = findPaneIdOf(state, tabId)
|
||||||
if (paneId === '') return state
|
|
||||||
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
|
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
|
||||||
activated = leaf?.tabs.find(tab => tab.id === tabId)
|
activated = leaf?.tabs.find(tab => tab.id === tabId)
|
||||||
return activateTabReducer(state, paneId, tabId)
|
return activateTabReducer(state, paneId, tabId)
|
||||||
})
|
})
|
||||||
if (activated !== undefined) {
|
if (activated !== undefined) {
|
||||||
const sessionId = store.getSnapshot().sessionId
|
const sessionId = scope?.sessionId ?? store.getSnapshot().sessionId
|
||||||
if (sessionId !== undefined) {
|
if (sessionId !== undefined) {
|
||||||
const descriptor = tabs.get(activated.type)
|
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 }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -1025,6 +1025,11 @@ export class SidebarStore {
|
|||||||
const state = this.snapshot.state
|
const state = this.snapshot.state
|
||||||
if (sessionId === undefined || state === undefined) return
|
if (sessionId === undefined || state === undefined) return
|
||||||
const next = reducer(state)
|
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.bySession.set(sessionId, next)
|
||||||
this.snapshot = { sessionId, state: next, prefs: this.prefs }
|
this.snapshot = { sessionId, state: next, prefs: this.prefs }
|
||||||
this.schedulePersist(sessionId, next)
|
this.schedulePersist(sessionId, next)
|
||||||
@@ -1056,8 +1061,11 @@ export class SidebarStore {
|
|||||||
nextIdCounter = maxCounterId(state)
|
nextIdCounter = maxCounterId(state)
|
||||||
}
|
}
|
||||||
const next = reducer(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)
|
nextIdCounter = Math.max(nextIdCounter, counterBefore)
|
||||||
|
if (next === state) return
|
||||||
|
this.bySession.set(sessionId, next)
|
||||||
this.schedulePersist(sessionId, next)
|
this.schedulePersist(sessionId, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@
|
|||||||
* `dsh-better-sidebar/client/service` in the built package.
|
* `dsh-better-sidebar/client/service` in the built package.
|
||||||
*/
|
*/
|
||||||
import type {} from '../src/client/service.ts'
|
import type {} from '../src/client/service.ts'
|
||||||
|
import {
|
||||||
|
SIDEBAR_FEATURES,
|
||||||
|
SIDEBAR_SERVICE_VERSION,
|
||||||
|
} from '../src/client/service.ts'
|
||||||
import type {
|
import type {
|
||||||
BetterSidebarService,
|
BetterSidebarService,
|
||||||
FileFetchStrategy,
|
FileFetchStrategy,
|
||||||
@@ -129,5 +133,8 @@ const toggleType: SidebarSettingToggleType = 'number'
|
|||||||
const toggle: SidebarSettingToggle = { key: 'k', title: 'K', type: toggleType }
|
const toggle: SidebarSettingToggle = { key: 'k', title: 'K', type: toggleType }
|
||||||
const declaration: SidebarSettingsDeclaration = { toggles: [toggle], pluginToggles: [toggle] }
|
const declaration: SidebarSettingsDeclaration = { toggles: [toggle], pluginToggles: [toggle] }
|
||||||
const typeName: TabType = 'my-plugin:db'
|
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'
|
const strategy: FileFetchStrategy = 'mediaUrl'
|
||||||
void diff; void prefs; void store; void declaration; void typeName; void strategy
|
void diff; void prefs; void store; void declaration; void typeName; void strategy
|
||||||
|
|||||||
@@ -897,3 +897,89 @@ describe('lifecycle classification vs dedupe (codex review fixes)', () => {
|
|||||||
expect(events).toEqual(['open', 'activate'])
|
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' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -236,3 +236,34 @@ describe('mergePluginSetting (v0.12.0, codex review fix)', () => {
|
|||||||
expect(map['my-plugin:db']).toEqual({ pageSize: 50, theme: 'dark' })
|
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=""')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user