dsh-better-workspace 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-better-workspace",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "main": "./src/index.js",
5
5
  "description": "侧边栏工作区树:按官方磁盘目录嵌套(官方 workspace-tree 语义),可选按名称中的 / 分组(默认关闭,设置里开启);新会话页的工作区选择器同树。Workspace tree for the DSH sidebar workspace list, nested by official disk folders with optional \"/\"-name grouping; the New Session workspace picker shows the same tree.",
6
6
  "engines": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-better-workspace",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "侧边栏工作区树:按官方磁盘目录嵌套(官方 workspace-tree 语义),可选按名称中的 / 分组(默认关闭,设置里开启);新会话页的工作区选择器同树(同一份视图选项即时同步);添加工作区走官方目录流。Workspace tree for the DSH sidebar: disk-path nesting (official workspace-tree semantics), with optional \"/\"-name grouping (off by default), and the same tree in the New Session workspace picker; add-workspace rides the official directory flow.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/client.js CHANGED
@@ -3905,6 +3905,148 @@ window.__ModuleLoader__.load({
3905
3905
  return String(r.n) + (FALLBACK_UNITS[r.unit] || '')
3906
3906
  }
3907
3907
 
3908
+ // Port of ui-workspace rows/AnimatedRows.tsx — the package entry exports
3909
+ // only apply/inject, so the component cannot be required (same discipline
3910
+ // as the row-style port). FLIP glide + entry/exit fades for keyed rows:
3911
+ // armed by the first pointer/key input, skipped when membership, order,
3912
+ // readiness or the reset key did not change. Deltas from upstream: the
3913
+ // plugin tree rolls its own indent var (--bw-indent, not
3914
+ // --dsh-workspace-indent), the exit overlay is viewport-FIXED because no
3915
+ // positioned ancestor is guaranteed here (clone math subtracts the
3916
+ // overlay rect either way), and the list div takes an attrs passthrough
3917
+ // (data-bw-density / data-bw-count) plus innerRef (autoStyle samples it).
3918
+ const ROW_FADE_MS = 100
3919
+ const ROW_GLIDE_MS = 200
3920
+ const sameRowKeys = (previous, next) => previous.length === next.length
3921
+ && previous.every((key, index) => key === next[index])
3922
+ const intersectsViewport = (rect, viewport) => rect.bottom > viewport.top && rect.top < viewport.bottom
3923
+ && rect.right > viewport.left && rect.left < viewport.right
3924
+ class AnimatedRows extends React.Component {
3925
+ constructor(props) {
3926
+ super(props)
3927
+ this.armed = false
3928
+ this.list = { current: null }
3929
+ this.overlay = React.createRef()
3930
+ this.movements = new Map()
3931
+ this.exits = new Map()
3932
+ }
3933
+ getSnapshotBeforeUpdate(previous) {
3934
+ const list = this.list.current
3935
+ if (!this.armed || sameRowKeys(previous.rowKeys, this.props.rowKeys) || previous.resetKey !== this.props.resetKey
3936
+ || !previous.ready || !this.props.ready || list === null
3937
+ || typeof list.animate !== 'function'
3938
+ || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return null
3939
+ const viewport = list.getBoundingClientRect()
3940
+ const positions = this.readPositions()
3941
+ const nextKeys = new Set(this.props.rowKeys)
3942
+ const removed = new Map()
3943
+ for (const [key, row] of positions) {
3944
+ if (nextKeys.has(key) || !intersectsViewport(row.rect, viewport)) continue
3945
+ const clone = row.element.cloneNode(true)
3946
+ clone.removeAttribute('data-row-key')
3947
+ clone.inert = true
3948
+ clone.style.setProperty('--bw-indent', getComputedStyle(row.element).getPropertyValue('--bw-indent'))
3949
+ removed.set(key, Object.assign({}, row, { element: clone }))
3950
+ }
3951
+ return { positions, removed }
3952
+ }
3953
+ componentDidUpdate(previous, _state, snapshot) {
3954
+ if (snapshot === null) {
3955
+ if (!sameRowKeys(previous.rowKeys, this.props.rowKeys) || previous.resetKey !== this.props.resetKey
3956
+ || previous.ready !== this.props.ready) this.clear()
3957
+ return
3958
+ }
3959
+ this.cancelMovements()
3960
+ const list = this.list.current
3961
+ const overlay = this.overlay.current
3962
+ const viewport = list.getBoundingClientRect()
3963
+ const origin = overlay.getBoundingClientRect()
3964
+ const positions = this.readPositions()
3965
+ for (const [key, row] of positions) {
3966
+ this.removeExit(key)
3967
+ const previousRow = snapshot.positions.get(key)
3968
+ if (!intersectsViewport(row.rect, viewport)
3969
+ && (previousRow === undefined || !intersectsViewport(previousRow.rect, viewport))) continue
3970
+ if (previousRow === undefined) {
3971
+ this.move(row.element, [{ opacity: 0 }, { opacity: 1 }], ROW_FADE_MS)
3972
+ continue
3973
+ }
3974
+ const dx = previousRow.rect.left - row.rect.left
3975
+ const dy = previousRow.rect.top - row.rect.top
3976
+ if (dx === 0 && dy === 0 && previousRow.opacity === 1) continue
3977
+ this.move(row.element, [
3978
+ { transform: 'translate(' + String(dx) + 'px, ' + String(dy) + 'px)', opacity: previousRow.opacity },
3979
+ { transform: 'translate(0, 0)', opacity: 1 },
3980
+ ], ROW_GLIDE_MS)
3981
+ }
3982
+ for (const [key, row] of snapshot.removed) {
3983
+ const { element } = row
3984
+ this.removeExit(key)
3985
+ Object.assign(element.style, {
3986
+ position: 'absolute', margin: '0', transform: 'none', boxSizing: 'border-box',
3987
+ left: String(row.rect.left - origin.left) + 'px',
3988
+ top: String(row.rect.top - origin.top) + 'px',
3989
+ width: String(row.rect.width) + 'px', height: String(row.rect.height) + 'px',
3990
+ })
3991
+ overlay.append(element)
3992
+ const animation = element.animate([{ opacity: row.opacity }, { opacity: 0 }], {
3993
+ duration: ROW_FADE_MS, easing: 'ease-out', fill: 'forwards',
3994
+ })
3995
+ this.exits.set(key, { element, animation })
3996
+ animation.onfinish = () => { this.removeExit(key) }
3997
+ }
3998
+ }
3999
+ componentWillUnmount() { this.clear() }
4000
+ readPositions() {
4001
+ const list = this.list.current
4002
+ const rows = list.querySelectorAll('[data-row-key]')
4003
+ return new Map(Array.from(rows, (element) => [element.dataset.rowKey, {
4004
+ element,
4005
+ rect: element.getBoundingClientRect(),
4006
+ opacity: this.movements.has(element) ? Number(getComputedStyle(element).opacity) : 1,
4007
+ }]))
4008
+ }
4009
+ move(element, keyframes, duration) {
4010
+ const animation = element.animate(keyframes, { duration, easing: 'ease-out' })
4011
+ this.movements.set(element, animation)
4012
+ animation.onfinish = () => { this.movements.delete(element); animation.cancel() }
4013
+ }
4014
+ cancelMovements() {
4015
+ for (const animation of this.movements.values()) {
4016
+ animation.onfinish = null
4017
+ animation.cancel()
4018
+ }
4019
+ this.movements.clear()
4020
+ }
4021
+ removeExit(key) {
4022
+ const exit = this.exits.get(key)
4023
+ if (exit === undefined) return
4024
+ exit.animation.onfinish = null
4025
+ exit.animation.cancel()
4026
+ exit.element.remove()
4027
+ this.exits.delete(key)
4028
+ }
4029
+ clear() {
4030
+ this.cancelMovements()
4031
+ for (const key of Array.from(this.exits.keys())) this.removeExit(key)
4032
+ }
4033
+ render() {
4034
+ const attrs = this.props.attrs || {}
4035
+ return [
4036
+ E('div', Object.assign({
4037
+ key: 'list',
4038
+ ref: (el) => { this.list.current = el; if (this.props.innerRef) this.props.innerRef.current = el },
4039
+ className: this.props.className,
4040
+ role: 'tree',
4041
+ 'aria-label': this.props.label,
4042
+ onPointerDownCapture: () => { this.armed = true },
4043
+ onKeyDownCapture: () => { this.armed = true },
4044
+ }, attrs), this.props.children),
4045
+ E('div', { key: 'exits', ref: this.overlay, className: 'bw-rows-exits', 'aria-hidden': 'true' }),
4046
+ ]
4047
+ }
4048
+ }
4049
+
3908
4050
  const pendingKindOf = (pending, id) => {
3909
4051
  if (!pending) return undefined
3910
4052
  const p = typeof pending.get === 'function' ? pending.get(id) : pending[id]
@@ -4570,6 +4712,7 @@ window.__ModuleLoader__.load({
4570
4712
  '.bw-menu-block{display:block;width:100%}',
4571
4713
  '.bw-menu-inline{display:inline-block;width:auto;flex:none}',
4572
4714
  '.bw-tree{--bw-row-h:34px;--bw-session-h:32px;flex:1;overflow-y:auto;overflow-x:hidden;padding:2px 6px 12px;min-height:0}',
4715
+ '.bw-rows-exits{position:fixed;inset:0;contain:strict;overflow:clip;pointer-events:none}',
4573
4716
  '.bw-tree[data-bw-density="compact"]{--bw-row-h:28px;--bw-session-h:26px}',
4574
4717
  '.bw-tree[data-bw-density="roomy"]{--bw-row-h:40px;--bw-session-h:38px}',
4575
4718
  '.bw-row{display:flex;align-items:center;gap:6px;height:var(--bw-row-h,34px);padding:0 8px;padding-inline-start:calc(8px + var(--bw-indent,0px));border-radius:var(--dsw-radius-md,8px);cursor:pointer;user-select:none;font-size:14px;line-height:20px;color:var(--dsw-alias-label-primary,#e6e6e6);position:relative}',
@@ -4651,7 +4794,11 @@ window.__ModuleLoader__.load({
4651
4794
  // cancels it, so text still starts and truncates where it did.
4652
4795
  '.bw-row-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:3px;margin:-3px}',
4653
4796
  '.bw-session-row .bw-row-label{padding:0;margin:0 6px 0 4px}',
4654
- '.bw-session-row .bw-row-label[data-scrolled]{mask-image:linear-gradient(to right,transparent,#000 12px);-webkit-mask-image:linear-gradient(to right,transparent,#000 12px)}',
4797
+ /* Marquee: Chromium keeps painting the ellipsis at the right edge of a
4798
+ scrolling ellipsis element (engine behavior; official rows share it).
4799
+ Switch to clip while scrolling — the fade masks carry both edges and
4800
+ the full heading reads cleanly; the resting state keeps ellipsis. */
4801
+ '.bw-session-row .bw-row-label[data-scrolled]{text-overflow:clip;mask-image:linear-gradient(to right,transparent,#000 12px);-webkit-mask-image:linear-gradient(to right,transparent,#000 12px)}',
4655
4802
  '.bw-session-row .bw-row-label[data-clipped]{mask-image:linear-gradient(to left,transparent,#000 12px);-webkit-mask-image:linear-gradient(to left,transparent,#000 12px)}',
4656
4803
  '.bw-session-row .bw-row-label[data-scrolled][data-clipped]{mask-image:linear-gradient(to right,transparent,#000 12px,#000 calc(100% - 12px),transparent);-webkit-mask-image:linear-gradient(to right,transparent,#000 12px,#000 calc(100% - 12px),transparent)}',
4657
4804
  '.bw-row-count{flex:none;display:none;font-size:12px;color:var(--dsw-alias-label-quaternary,#8a8a8a)}',
@@ -5872,7 +6019,7 @@ window.__ModuleLoader__.load({
5872
6019
  }
5873
6020
  }
5874
6021
 
5875
- function FolderRow({ node, depth, expanded, onToggle, onContextMenu, dropInto, dragEvents, custStyle, iconMode, pulse, active, t }) {
6022
+ function FolderRow({ node, depth, expanded, onToggle, onContextMenu, dropInto, dragEvents, custStyle, iconMode, pulse, active, dataRowKey, t }) {
5876
6023
  const total = countWorkspaces(node)
5877
6024
  const iconEl = iconOf(iconMode, expanded)
5878
6025
  // Hidden status dots breathe on the icon; with the icon hidden (mode
@@ -5882,6 +6029,7 @@ window.__ModuleLoader__.load({
5882
6029
  : iconEl
5883
6030
  return E('div', {
5884
6031
  className: cls('bw-row', 'bw-container-row', dropInto && 'bw-drop-into', dropInto && 'bw-drop-into-strong'),
6032
+ 'data-row-key': dataRowKey,
5885
6033
  style: { '--bw-indent': (depth * 12) + 'px', ...(custStyle || {}), ...(pulse ? { '--bw-pulse-color': PULSE_COLORS[pulse] || PULSE_COLORS.ongoing } : null) },
5886
6034
  onClick: onToggle,
5887
6035
  onContextMenu: onContextMenu,
@@ -5900,13 +6048,14 @@ window.__ModuleLoader__.load({
5900
6048
  )
5901
6049
  }
5902
6050
 
5903
- function WorkspaceRow({ workspace, depth, count, sessionsOpen, onToggle, onStart, onMenu, onContextMenu, currentInside, menuOpen, dropHalf, dropInto, dragEvents, custStyle, iconMode, pulse, newSessionLabel, newSessionAria, menuAria, hoverBody, t }) {
6051
+ function WorkspaceRow({ workspace, depth, count, sessionsOpen, onToggle, onStart, onMenu, onContextMenu, currentInside, menuOpen, dropHalf, dropInto, dragEvents, custStyle, iconMode, pulse, newSessionLabel, newSessionAria, menuAria, hoverBody, dataRowKey, t }) {
5904
6052
  const iconEl = iconOf(iconMode, sessionsOpen)
5905
6053
  const iconChild = pulse
5906
6054
  ? E(PulseGlow, { state: pulse }, iconEl || (typeof ui.StateDot === 'function' ? E(ui.StateDot, { state: pulse, size: 10 }) : null))
5907
6055
  : iconEl
5908
6056
  let row = E('div', {
5909
6057
  className: cls('bw-row', 'bw-container-row', currentInside && 'bw-row-current', menuOpen && 'menu-open', dropHalf === 'before' && 'bw-drop-before', dropHalf === 'after' && 'bw-drop-after', dropInto && 'bw-drop-into', dropInto && 'bw-drop-into-strong'),
6058
+ 'data-row-key': dataRowKey,
5910
6059
  style: { '--bw-indent': (depth * 12) + 'px', ...(custStyle || {}), ...(pulse ? { '--bw-pulse-color': PULSE_COLORS[pulse] || PULSE_COLORS.ongoing } : null) },
5911
6060
  onClick: onToggle,
5912
6061
  onContextMenu: onContextMenu,
@@ -5937,7 +6086,7 @@ window.__ModuleLoader__.load({
5937
6086
  'aria-keyshortcuts': newSessionAria,
5938
6087
  title: newSessionLabel || t('session.new'),
5939
6088
  onClick: (e) => { e.stopPropagation(); onStart() },
5940
- }, icon('IconPlusOutline16')),
6089
+ }, icon('IconNewChatOutline16')),
5941
6090
  ),
5942
6091
  )
5943
6092
  // Official workspace hover card (Rows.tsx WorkspaceHoverContent): the
@@ -6041,7 +6190,7 @@ window.__ModuleLoader__.load({
6041
6190
  return { titleRef, onPointerEnter, onPointerLeave }
6042
6191
  }
6043
6192
 
6044
- function SessionRow({ node, depth, current, onOpen, onContextMenu, now, dropHalf, dragEvents, custStyle, breathing, sessionActions, scheduleSeat, t }) {
6193
+ function SessionRow({ node, depth, current, onOpen, onContextMenu, now, dropHalf, dragEvents, custStyle, breathing, sessionActions, scheduleSeat, dataRowKey, t }) {
6045
6194
  const state = sessionStateOf(node)
6046
6195
  const status = state === null ? null : {
6047
6196
  state,
@@ -6081,58 +6230,28 @@ window.__ModuleLoader__.load({
6081
6230
  }
6082
6231
  const archivedTitle = official('row.archived')
6083
6232
  const pinnedTitle = official('row.pinned')
6084
- // In-row "..." menu (official Rows.tsx): the ellipsis button anchors a
6085
- // primitives Menu whose entries are the SAME shared list the
6086
- // right-click menu builds (sessionActions.menuItems) and whose trailing
6087
- // children are the shipped menu.item seat occupants; picks dispatch
6088
- // through the SAME handler (sessionActions.pick), so the two surfaces
6089
- // cannot drift. Hosts without the primitives Menu keep the old
6090
- // open-context-menu trigger button.
6091
- const inlineMenu = !node.blank && sessionActions && typeof ui.Menu === 'function'
6092
- && typeof sessionActions.menuItems === 'function' && typeof sessionActions.pick === 'function'
6093
- const [menuOpen, setMenuOpen] = React.useState(false)
6094
- const menuOpenPair = React.useMemo(() => [menuOpen, setMenuOpen], [menuOpen])
6095
- const menuSeatChildren = inlineMenu && Array.isArray(sessionActions.menuSeat)
6096
- ? sessionActions.menuSeat.map((seat, index) => E(ShippedSeatOccupant, {
6097
- key: 'menu-seat-' + index,
6098
- seat,
6099
- ownerProps: { sessionId: node.id, displayTitle: node.leaf || node.title },
6100
- t: seat.t,
6101
- // The menu.item hook-context factory (ui-slots) hands occupants
6102
- // the open-state pair; reproduced here for the in-row menu.
6103
- useMenuOpenState: () => menuOpenPair,
6104
- }))
6105
- : null
6106
- // The shipped row.action seat renders the OFFICIAL pin/archive quick
6107
- // buttons; our own fallback buttons step aside so the row never shows
6108
- // two sets (same rule as the schedule mark's leading cell).
6233
+ // Row-trailing quick actions: the shipped row.action seat renders the
6234
+ // OFFICIAL pin/archive buttons; our own fallback buttons render only
6235
+ // where the seat has no entries, so the row never shows two sets. The
6236
+ // in-row "..." menu (added v0.34.0) is RETIRED (issue #12): the context
6237
+ // menu already carries every entry, and the menu.item occupants depend
6238
+ // on slot-level inject hooks (useShortcuts on 0.2.1-rc.2) that an
6239
+ // in-row reproduction cannot supply — one missing hook took the whole
6240
+ // sidebar down. Each seat occupant sits in a QuietBoundary so a broken
6241
+ // seat degrades to a missing button, never a blank panel.
6109
6242
  const quickSeatUsed = Array.isArray(sessionActions.rowActionSeat) && sessionActions.rowActionSeat.length > 0
6110
- const strip = !node.blank && sessionActions && (inlineMenu || sessionActions.trigger || (sessionActions.quick && !quickSeatUsed))
6243
+ const strip = !node.blank && sessionActions && sessionActions.quick && (quickSeatUsed || sessionActions.pin || sessionActions.archive)
6111
6244
  ? E('span', { className: 'bw-row-actions', onClick: (e) => e.stopPropagation() },
6112
- inlineMenu ? E(ui.Menu, {
6113
- open: menuOpen,
6114
- onClose: () => setMenuOpen(false),
6115
- portal: true,
6116
- closeOnPointerLeave: true,
6117
- dense: true,
6118
- anchor: E('button', {
6119
- type: 'button',
6120
- className: 'bw-row-btn',
6121
- 'aria-label': official('actions.session.aria', { name: node.leaf || node.title }) || '...',
6122
- title: sessionActions.triggerTitle,
6123
- onClick: () => setMenuOpen((open) => !open),
6124
- }, icon('IconEllipsisOutline16', 15)),
6125
- items: sessionActions.menuItems(node),
6126
- onSelect: (id) => { setMenuOpen(false); sessionActions.pick(node, id) },
6127
- }, menuSeatChildren)
6128
- : sessionActions.trigger ? E('button', {
6129
- type: 'button',
6130
- className: 'bw-icon-btn',
6131
- 'aria-label': official('actions.session.aria', { name: node.leaf || node.title }) || '...',
6132
- title: sessionActions.triggerTitle,
6133
- onClick: (e) => sessionActions.openMenu(node, e),
6134
- }, icon('IconEllipsisOutline16', 15)) : null,
6135
- !quickSeatUsed && sessionActions.quick && sessionActions.pin && !node.archived ? E('button', {
6245
+ quickSeatUsed
6246
+ ? sessionActions.rowActionSeat.map((seat, index) => E(QuietBoundary, { key: 'row-action-seat-' + index },
6247
+ E(ShippedSeatOccupant, {
6248
+ key: 'row-action-seat-' + index,
6249
+ seat,
6250
+ ownerProps: { sessionId: node.id, displayTitle: node.leaf || node.title },
6251
+ t: seat.t,
6252
+ })))
6253
+ : null,
6254
+ !quickSeatUsed && sessionActions.pin && !node.archived ? E('button', {
6136
6255
  type: 'button',
6137
6256
  className: 'bw-icon-btn',
6138
6257
  'aria-label': official(node.pinned ? 'menu.unpinSession' : 'menu.pinSession') || '',
@@ -6157,8 +6276,8 @@ window.__ModuleLoader__.load({
6157
6276
  let row = E('div', {
6158
6277
  className: cls('bw-row', 'bw-session-row', current && 'bw-row-current',
6159
6278
  node.archived && 'bw-row-archived',
6160
- menuOpen && 'menu-open',
6161
6279
  dropHalf === 'before' && 'bw-drop-before', dropHalf === 'after' && 'bw-drop-after'),
6280
+ 'data-row-key': dataRowKey,
6162
6281
  style: { '--bw-indent': (depth * 12) + 'px', ...(custStyle || {}) },
6163
6282
  onClick: () => {
6164
6283
  if (node.archived) {
@@ -6215,7 +6334,6 @@ window.__ModuleLoader__.load({
6215
6334
  content: sessionActions.hoverBody(node, status, state),
6216
6335
  openDelayMs: 800,
6217
6336
  grouped: true,
6218
- disabled: menuOpen,
6219
6337
  // copyText present REQUIRES both labels (primitives HoverCard
6220
6338
  // contract): copy only renders where the official copy survives.
6221
6339
  }, copyText && copyLabel && copiedLabel ? { copyText, copyLabel, copiedLabel } : {}))
@@ -6228,9 +6346,10 @@ window.__ModuleLoader__.load({
6228
6346
  * titles). Deliberately NOT styled like a workspace folder — no folder
6229
6347
  * icon, tertiary color — so a session level never reads as a workspace.
6230
6348
  */
6231
- function SessionGroupRow({ name, depth, expanded, count, onToggle, onContextMenu, dropInto, dragEvents, custStyle, pulse, t }) {
6349
+ function SessionGroupRow({ name, depth, expanded, count, onToggle, onContextMenu, dropInto, dragEvents, custStyle, pulse, dataRowKey, t }) {
6232
6350
  return E('div', {
6233
6351
  className: cls('bw-row', 'bw-sgroup-row', dropInto && 'bw-drop-into', dropInto && 'bw-drop-into-strong'),
6352
+ 'data-row-key': dataRowKey,
6234
6353
  style: { '--bw-indent': (depth * 12) + 'px', ...(custStyle || {}) },
6235
6354
  onClick: onToggle,
6236
6355
  onContextMenu: onContextMenu,
@@ -7099,7 +7218,14 @@ window.__ModuleLoader__.load({
7099
7218
  /* --------- settings → plug-ins card (accordion like official cards) --------- */
7100
7219
 
7101
7220
  function BetterWorkspacePluginCard(props) {
7102
- const { useStore, actions, t } = props
7221
+ const { useStore: injectedUseStore, actions: injectedActions, localStore: localStoreProp, t } = props
7222
+ // Issue #13: same private-store switch as the browser seat — the card
7223
+ // keeps working no matter what a shadowing occupant does to the props.
7224
+ const localStoreFace = typeof localStoreProp === 'function' ? localStoreProp() : null
7225
+ const useStore = localStoreFace && typeof localStoreFace.useStore === 'function'
7226
+ ? localStoreFace.useStore
7227
+ : injectedUseStore
7228
+ const actions = localStoreFace && localStoreFace.actions ? localStoreFace.actions : injectedActions
7103
7229
  // The bundle page (plugins.bundle.config, dsh 0.1.6-alpha.2+) renders
7104
7230
  // this card inline with view: 'page' — no accordion chrome there, the
7105
7231
  // settings body sits directly under a one-line title.
@@ -7402,7 +7528,8 @@ window.__ModuleLoader__.load({
7402
7528
  const {
7403
7529
  wide, expandSidebar,
7404
7530
  useSessions, useSessionPendingInteraction, useSessionStatus, useWorkspaces,
7405
- useStore, actions,
7531
+ useStore: injectedUseStore, actions: injectedActions,
7532
+ localStore: localStoreProp,
7406
7533
  startSession, open, renameSession, forkSession, renameWorkspace, deleteWorkspace,
7407
7534
  archiveSession, createWorkspace, pinSession, unpinSession, unarchiveSession,
7408
7535
  insertWorkspaceBefore, insertSessionBefore,
@@ -7414,6 +7541,17 @@ window.__ModuleLoader__.load({
7414
7541
  presetFaceOf,
7415
7542
  t,
7416
7543
  } = props
7544
+ // Issue #13: local reads and writes go through OUR store instance, never
7545
+ // the renderer-synthesized props — a shadowing occupant can swap those
7546
+ // props for its own store (issue #13). Host projections (useSessions /
7547
+ // useWorkspaces) stay on the injected props; only the plugin's OWN view
7548
+ // state switches. Names keep their original spelling so the component
7549
+ // body needs no other change.
7550
+ const localStoreFace = typeof localStoreProp === 'function' ? localStoreProp() : null
7551
+ const useStore = localStoreFace && typeof localStoreFace.useStore === 'function'
7552
+ ? localStoreFace.useStore
7553
+ : injectedUseStore
7554
+ const actions = localStoreFace && localStoreFace.actions ? localStoreFace.actions : injectedActions
7417
7555
  // v0.32.2: resolve the stable getter ONCE per render (see
7418
7556
  // browserInjected for why this is a function and not a value prop).
7419
7557
  // Every consumer below keeps reading this local, so a late-arriving
@@ -7627,13 +7765,14 @@ window.__ModuleLoader__.load({
7627
7765
  // The shipped schedule mark's seat occupant (see scheduleSeatOf); absent
7628
7766
  // whenever the Schedule overlay is off, which is the shipped default.
7629
7767
  const scheduleSeat = typeof scheduleSeatReader === 'function' ? scheduleSeatReader() : null
7630
- // The shipped session-row seats (official Rows.tsx): menu.item
7631
- // occupants become the in-row menu's trailing children, row.action
7632
- // occupants become the official pin/archive quick buttons (our own
7768
+ // The shipped session-row seats (official Rows.tsx): row.action
7769
+ // occupants render the official pin/archive quick buttons (our own
7633
7770
  // fallback buttons step aside), session.row.hover decorates the hover
7634
- // card. All read off the ledger like the schedule mark.
7771
+ // card. The menu.item seat is deliberately NOT read anymore: its
7772
+ // occupants need slot-level inject hooks (useShortcuts on 0.2.1-rc.2)
7773
+ // and the in-row menu they anchored was retired in issue #12. All read
7774
+ // off the ledger like the schedule mark.
7635
7775
  const seatEntriesOf = typeof seatEntriesReader === 'function' ? seatEntriesReader : null
7636
- const rowMenuSeat = seatEntriesOf ? seatEntriesOf('sidebar.workspaces.session.menu.item') : []
7637
7776
  const rowActionSeat = seatEntriesOf ? seatEntriesOf('sidebar.workspaces.session.row.action') : []
7638
7777
  const hoverSeat = seatEntriesOf ? seatEntriesOf('sidebar.session.row.hover') : []
7639
7778
  /** Effective catalog row of one shipped command (current keybinding, label). */
@@ -8284,22 +8423,13 @@ window.__ModuleLoader__.load({
8284
8423
  }
8285
8424
  : undefined,
8286
8425
  openMenu: (node, e) => openCtx('session', node, e),
8287
- // In-row "..." menu (official Rows.tsx): the SAME shared entry list
8288
- // (sessionMenuEntriesFor) and the SAME pick dispatch the right-click
8289
- // menu uses, so the two surfaces cannot drift. menuSeat carries the
8290
- // shipped menu.item occupants (rendered as Menu children);
8291
- // rowActionSeat the official quick buttons (our own buttons step
8292
- // aside while it has entries). An id the shared dispatcher does not
8293
- // own (bw extensions: move-group / customize) falls back to the
8294
- // ctx surface's handler through a transient payload.
8295
- menuItems: (node) => (typeof sessionMenuEntriesFor === 'function' ? sessionMenuEntriesFor(node) : []),
8296
- menuSeat: rowMenuSeat,
8426
+ // Row-trailing quick actions: the shipped row.action seat carries the
8427
+ // official pin/archive buttons; our own buttons render only where the
8428
+ // seat is empty. The in-row "..." menu is RETIRED (issue #12 — its
8429
+ // menu.item occupants need slot-level inject hooks an in-row
8430
+ // reproduction cannot supply); the context menu keeps every entry
8431
+ // through sessionMenuEntriesFor + handleCtxPick.
8297
8432
  rowActionSeat,
8298
- pick: (node, id) => {
8299
- if (typeof pickSessionAction === 'function' && pickSessionAction(node, id)) return
8300
- setCtx({ kind: 'session', payload: node, x: 0, y: 0 })
8301
- if (typeof handleCtxPick === 'function') handleCtxPick(id)
8302
- },
8303
8433
  rename: (node) => setDialog({ kind: 'sess-rename', session: node }),
8304
8434
  hoverBody: (node, status) => sessionHoverBody(node, status),
8305
8435
  explainArchived: () => {
@@ -9274,6 +9404,17 @@ window.__ModuleLoader__.load({
9274
9404
  )]
9275
9405
  : [E('div', { key: 'empty', className: 'bw-empty' }, searching ? t('empty.search') : (phase === 'pending' ? '…' : t('empty')))]
9276
9406
  }
9407
+ // Keyed-row handoff for AnimatedRows: every body row carries a stable
9408
+ // data-row-key (the element's React key when present, array index
9409
+ // otherwise) and the key list feeds the component's sameRows guard.
9410
+ bodyRows = bodyRows.filter((el) => el !== null && el !== false && el !== undefined)
9411
+ const bodyRowKeys = []
9412
+ bodyRows = bodyRows.map((el, index) => {
9413
+ if (!el || typeof el !== 'object') return el
9414
+ const rowKey = el.key != null ? String(el.key) : 'row-' + index
9415
+ bodyRowKeys.push(rowKey)
9416
+ return React.cloneElement(el, { 'data-row-key': rowKey, dataRowKey: rowKey })
9417
+ })
9277
9418
 
9278
9419
  /* ------------------------- context menu ------------------------ */
9279
9420
 
@@ -9886,13 +10027,17 @@ window.__ModuleLoader__.load({
9886
10027
  disabled: flowBusy,
9887
10028
  }, icon('IconProjectAddOutline16')) : null,
9888
10029
  ),
9889
- E('div', {
9890
- ref: treeRef,
10030
+ E(AnimatedRows, {
9891
10031
  className: 'bw-tree',
9892
- role: 'tree',
9893
- 'aria-label': t('title'),
9894
- 'data-bw-density': density,
9895
- ...(showCount ? { 'data-bw-count': '' } : {}),
10032
+ label: t('title'),
10033
+ rowKeys: bodyRowKeys,
10034
+ // Drag reorders rows itself; animating under it fights the gesture.
10035
+ ready: drag === null,
10036
+ // View-replacing switches (search, filter, density, count) settle
10037
+ // instead of gliding — official resetKey semantics.
10038
+ resetKey: JSON.stringify([density, showCount, searching, archivedOnly, normalizedQuery]),
10039
+ innerRef: treeRef,
10040
+ attrs: { 'data-bw-density': density, ...(showCount ? { 'data-bw-count': '' } : {}) },
9896
10041
  }, bodyRows),
9897
10042
  dialogElement,
9898
10043
  (ctx !== null && typeof ui.Menu === 'function') ? E(ui.Menu, {
@@ -10159,6 +10304,10 @@ window.__ModuleLoader__.load({
10159
10304
  // closure live, so every render and every menu build re-asks; hosts
10160
10305
  // without the service keep returning null, which simply hides the row.
10161
10306
  presetFaceOf: () => presetFace,
10307
+ // Issue #13: the PRIVATE store face — a stable getter like presetFaceOf
10308
+ // (the slot renderer caches inject results; keep the same discipline
10309
+ // even though this face never changes).
10310
+ localStore: () => ({ useStore: useViewStore, actions: viewInstance.actions }),
10162
10311
  // The shipped sidebar's OWN face (dsh 0.1.7-rc.2): the shortcut request
10163
10312
  // channel and the catalog of effective keybindings. Both belong to the
10164
10313
  // entry this seat shadows, so they are read off the ledger and consumed
@@ -10273,6 +10422,22 @@ window.__ModuleLoader__.load({
10273
10422
  // One shared store handle: the browser and the settings page must see the
10274
10423
  // same persisted state (expansion, folder list, prefs, styling).
10275
10424
  const viewStore = createViewStore()
10425
+ // Issue #13 hardening: our OWN store instance. The renderer synthesizes
10426
+ // props.useStore/actions from the seat's store handle — and a third-party
10427
+ // occupant wrapper (im-connect, issue #13) that shadows the workspace seat
10428
+ // replaces those props with ITS channel store, silently severing every
10429
+ // local read and write. The bundle therefore keeps one PRIVATE instance,
10430
+ // handed to the components through the inject face: local state no longer
10431
+ // depends on what the renderer (or anything shadowing it) put into props.
10432
+ // Same persist key, so on a healthy host both instances hydrate the same
10433
+ // snapshot; the renderer-side instance simply never writes again (no
10434
+ // consumer dispatches it) and ours becomes the only live one.
10435
+ const viewInstance = viewStore.create()
10436
+ const useViewStore = typeof React.useSyncExternalStore === 'function'
10437
+ ? (selector) => React.useSyncExternalStore(
10438
+ viewInstance.subscribe,
10439
+ () => { try { return selector(viewInstance.getSnapshot()) } catch (error) { return undefined } })
10440
+ : null
10276
10441
 
10277
10442
  // Cold-restart title fallback (see the title-cache block). Hydration
10278
10443
  // reads localStorage synchronously here, before the first render, so a
@@ -10348,6 +10513,9 @@ window.__ModuleLoader__.load({
10348
10513
  key: 'dsh-better-workspace',
10349
10514
  locale: NS,
10350
10515
  store: viewStore,
10516
+ // Issue #13: the settings card reads/writes through our PRIVATE store
10517
+ // instance too (same rationale as the browser seat).
10518
+ inject: () => ({ localStore: () => ({ useStore: useViewStore, actions: viewInstance.actions }) }),
10351
10519
  },
10352
10520
  BetterWorkspacePluginCard,
10353
10521
  ))