dsh-folder-sidebar 0.17.1 → 0.18.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/CHANGELOG.md CHANGED
@@ -1,36 +1,45 @@
1
- # Changelog
2
-
3
- ## 0.17.0 — first public release
4
-
5
- 首次公开发布。把 DSH 侧栏的「工作区 / 会话」列表换成分文件夹的进入式导航,并尽量与内置侧栏(`ui-workspace`)的语义和视觉对齐。
6
-
7
- ### 导航
8
-
9
- - 一级只列工作区(文件夹),点进去才显示该工作区的会话列表,顶栏下方有「‹ 文件夹名 +」面包屑行(整行返回,`+` 新开会话)。
10
- - 默认停在当前会话所在的工作区;返回后停在文件夹列表。
11
- - 「未分组」伪文件夹承载不属于任何工作区的会话(subagent 来源的会话始终隐藏)。
12
-
13
- ### 状态位
14
-
15
- - 会话行首固定 18px 状态位:转圈(运行中)、绿 `✓`(未读完成)、黄 `?`(等你回答/审批)、红 `!`(失败或中断)、空心灰(你主动停止)、淡灰点(空闲/已读)。
16
- - 完成态跟随客户端的 `completionUnread` 规则(打开即清除);失败态来自本插件的 Host 投影 `folder-sidebar/turn`(`stateVersion = 3`),不因查看而清除。
17
- - 文件夹行图标即状态位:优先级 等待 > 运行 > 失败 > 未读完成 > 停止,同级取状态起始时间最早者。
18
- - 悬浮卡列出会话/文件夹的全部状态。
19
-
20
- ### 操作
21
-
22
- - 会话 `…` 菜单(含右键):置顶/取消置顶、重命名(行内)、分叉、复制 ID、归档(运行中会二次确认)。
23
- - 文件夹 `…` 菜单:重命名、在此新建会话、上移/下移、删除工作区。
24
- - 文件夹拖拽排序,落点有插入指示线,通过 Host 的 workspace 排序接口持久化。
25
- - 归档:视图选项三选一(隐藏 / 全部 / 仅归档)、归档行不可打开、提示条可撤销;失败操作有可见提示。
26
-
27
- ### 顶栏
28
-
29
- - 固定的标题栏 + 搜索 / 视图选项 / 添加工作区按钮;搜索点击后淡入并占据顶栏(约 150–180ms,尊重 `prefers-reduced-motion`)。
30
- - 视图选项菜单同时包含排序(手动/名称/最近 + 正序/倒序,两级各自独立并持久化)与归档显示筛选。
31
-
32
- ### 工程
33
-
34
- - 纯逻辑(排序比较器、状态优先级、文件夹聚合、归档筛选、时间格式化)放在 `client.js` 顶层并在 Node 环境导出,`test/logic.test.mjs` 直接测试发布源码。
35
- - 渲染错误由错误边界兜底:组件异常时在侧栏显示原因,而不是把槽位让回内置实现。
36
- - 图标按 `ui-primitives` 的真实几何复刻(不 import 宿主包),详见 README 的「图标」小节。
1
+ # Changelog
2
+
3
+ ## 0.18.0
4
+
5
+ - 长名称不再只能看省略号:悬停行(会话、文件夹、未分组、搜索结果、面包屑文件夹名)时,放不下的标题会缓慢横向滚到末尾再回来;不溢出的标题完全不动,`prefers-reduced-motion` 下不滚动。
6
+ - 跑马灯位移与时长由纯函数 `marqueeShiftOf` / `marqueeDurationOf` 计算,已加入 `test/logic.test.mjs` 覆盖(阈值、上下限、单调性)。
7
+
8
+ ## 0.17.1
9
+
10
+ - 重新发布以完成 npm 的暂存审批(内容与 0.17.0 一致)。0.17.0 在 npm 上归档为未批准的暂存版本。
11
+
12
+ ## 0.17.0 — first public release
13
+
14
+ 首次公开发布。把 DSH 侧栏的「工作区 / 会话」列表换成分文件夹的进入式导航,并尽量与内置侧栏(`ui-workspace`)的语义和视觉对齐。
15
+
16
+ ### 导航
17
+
18
+ - 一级只列工作区(文件夹),点进去才显示该工作区的会话列表,顶栏下方有「‹ 文件夹名 +」面包屑行(整行返回,`+` 新开会话)。
19
+ - 默认停在当前会话所在的工作区;返回后停在文件夹列表。
20
+ - 「未分组」伪文件夹承载不属于任何工作区的会话(subagent 来源的会话始终隐藏)。
21
+
22
+ ### 状态位
23
+
24
+ - 会话行首固定 18px 状态位:转圈(运行中)、绿 `✓`(未读完成)、黄 `?`(等你回答/审批)、红 `!`(失败或中断)、空心灰(你主动停止)、淡灰点(空闲/已读)。
25
+ - 完成态跟随客户端的 `completionUnread` 规则(打开即清除);失败态来自本插件的 Host 投影 `folder-sidebar/turn`(`stateVersion = 3`),不因查看而清除。
26
+ - 文件夹行图标即状态位:优先级 等待 > 运行 > 失败 > 未读完成 > 停止,同级取状态起始时间最早者。
27
+ - 悬浮卡列出会话/文件夹的全部状态。
28
+
29
+ ### 操作
30
+
31
+ - 会话 `…` 菜单(含右键):置顶/取消置顶、重命名(行内)、分叉、复制 ID、归档(运行中会二次确认)。
32
+ - 文件夹 `…` 菜单:重命名、在此新建会话、上移/下移、删除工作区。
33
+ - 文件夹拖拽排序,落点有插入指示线,通过 Host 的 workspace 排序接口持久化。
34
+ - 归档:视图选项三选一(隐藏 / 全部 / 仅归档)、归档行不可打开、提示条可撤销;失败操作有可见提示。
35
+
36
+ ### 顶栏
37
+
38
+ - 固定的标题栏 + 搜索 / 视图选项 / 添加工作区按钮;搜索点击后淡入并占据顶栏(约 150–180ms,尊重 `prefers-reduced-motion`)。
39
+ - 视图选项菜单同时包含排序(手动/名称/最近 + 正序/倒序,两级各自独立并持久化)与归档显示筛选。
40
+
41
+ ### 工程
42
+
43
+ - 纯逻辑(排序比较器、状态优先级、文件夹聚合、归档筛选、时间格式化)放在 `client.js` 顶层并在 Node 环境导出,`test/logic.test.mjs` 直接测试发布源码。
44
+ - 渲染错误由错误边界兜底:组件异常时在侧栏显示原因,而不是把槽位让回内置实现。
45
+ - 图标按 `ui-primitives` 的真实几何复刻(不 import 宿主包),详见 README 的「图标」小节。
package/README.md CHANGED
@@ -23,6 +23,7 @@
23
23
  点这一行任意位置 = 返回上一级(文件夹列表);点右侧 `+` = 在当前工作区新开会话(不触发返回)。下面才是该工作区的会话列表。
24
24
  - **默认停在当前会话所在的工作区**;返回后停在文件夹列表,不会被自动拉回。
25
25
  - **搜索**:标题匹配即时本地过滤;内容匹配 250 ms 防抖后走 Host 的会话检索,结果带所属工作区与命中片段,点击直接打开。
26
+ - **长名称**:放不下的标题在悬停该行时横向滚动(会话行、文件夹行、未分组、搜索结果、面包屑文件名都生效),滚到末尾再回滚;能放下的标题静止不动,`prefers-reduced-motion` 下不滚动。
26
27
  - 侧栏收起成窄栏时只渲染一个文件夹图标,点击展开。
27
28
 
28
29
  ## 状态位
package/client.js CHANGED
@@ -201,6 +201,21 @@ function baseName(path) {
201
201
  return parts.length > 0 ? parts[parts.length - 1] : path
202
202
  }
203
203
 
204
+ /**
205
+ * How far a title must travel to show its tail, or 0 when it already fits.
206
+ * A couple of pixels of rounding should not start a marquee.
207
+ */
208
+ function marqueeShiftOf(scrollWidth, clientWidth) {
209
+ if (!Number.isFinite(scrollWidth) || !Number.isFinite(clientWidth)) return 0
210
+ const shift = Math.round(scrollWidth - clientWidth)
211
+ return shift > 2 ? shift : 0
212
+ }
213
+
214
+ /** Longer titles scroll slower, so the reading speed stays roughly even. */
215
+ function marqueeDurationOf(shift) {
216
+ return Math.min(14000, Math.max(2200, Math.round(shift * 26)))
217
+ }
218
+
204
219
  /**
205
220
  * Web Client half of dsh-folder-sidebar.
206
221
  *
@@ -413,6 +428,12 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ !== undefined) {
413
428
  .dfs-row.is-current{background:var(--dsw-alias-bg-layer-1)}
414
429
  .dfs-row.is-current .dfs-title{font-weight:600}
415
430
  .dfs-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
431
+ .dfs-titleText{display:inline-block;white-space:nowrap;will-change:transform}
432
+ .dfs-title.is-marquee{text-overflow:clip}
433
+ .dfs-title.is-marquee .dfs-titleText,
434
+ .dfs-crumbName.is-marquee .dfs-titleText{animation:dfs-title-marquee var(--dfs-marquee-duration,4s) ease-in-out infinite alternate}
435
+ @keyframes dfs-title-marquee{0%,10%{transform:translateX(0)}90%,100%{transform:translateX(var(--dfs-marquee-shift,0))}}
436
+ @media (prefers-reduced-motion: reduce){.dfs-title.is-marquee .dfs-titleText,.dfs-crumbName.is-marquee .dfs-titleText{animation:none}}
416
437
  .dfs-snippet{flex:1 1 100%;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;color:var(--dsw-alias-label-secondary)}
417
438
  .dfs-time{flex:none;font-size:11px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary)}
418
439
  .dfs-status{display:inline-flex;align-items:center;justify-content:center;flex:none;width:18px;height:18px}
@@ -621,6 +642,17 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ !== undefined) {
621
642
  ], { size: 14 })
622
643
  }
623
644
 
645
+ /**
646
+ * One title line: the outer span clips, the inner span scrolls on hover.
647
+ * The marquee only starts for text that does not fit, so short names stay
648
+ * still and keep their ellipsis when narrowed.
649
+ */
650
+ function Title(props) {
651
+ const className = props.className ?? 'dfs-title'
652
+ return h('span', { className, title: props.title ?? props.text },
653
+ h('span', { className: 'dfs-titleText' }, props.text))
654
+ }
655
+
624
656
  class RenderBoundary extends React.Component {
625
657
  constructor(props) {
626
658
  super(props)
@@ -1104,6 +1136,32 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ !== undefined) {
1104
1136
  if (hoverTimer.current !== undefined) clearTimeout(hoverTimer.current)
1105
1137
  }, [])
1106
1138
 
1139
+ /** Reveal a clipped title by scrolling it while the row is hovered. */
1140
+ const startMarquee = (event) => {
1141
+ const row = event.currentTarget
1142
+ if (row === null || typeof row.querySelector !== 'function') return
1143
+ const text = row.querySelector('.dfs-titleText')
1144
+ const box = text === null ? null : text.parentElement
1145
+ if (text === null || box === null) return
1146
+ const shift = marqueeShiftOf(text.scrollWidth, box.clientWidth)
1147
+ if (shift === 0) return
1148
+ text.style.setProperty('--dfs-marquee-shift', `-${shift}px`)
1149
+ text.style.setProperty('--dfs-marquee-duration', `${marqueeDurationOf(shift)}ms`)
1150
+ box.classList.add('is-marquee')
1151
+ }
1152
+
1153
+ const stopMarquee = (event) => {
1154
+ const row = event.currentTarget
1155
+ if (row === null || typeof row.querySelector !== 'function') return
1156
+ const text = row.querySelector('.dfs-titleText')
1157
+ const box = text === null ? null : text.parentElement
1158
+ if (text === null || box === null) return
1159
+ box.classList.remove('is-marquee')
1160
+ text.style.removeProperty('--dfs-marquee-shift')
1161
+ text.style.removeProperty('--dfs-marquee-duration')
1162
+ }
1163
+
1164
+
1107
1165
  React.useEffect(() => {
1108
1166
  if (hoverFor === null) return undefined
1109
1167
  const onScroll = () => setHoverFor(null)
@@ -1374,10 +1432,12 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ !== undefined) {
1374
1432
  const resultRows = hits.slice(0, 30).map((hit) => h('div', { key: hit.sessionId, className: 'dfs-rowWrap' },
1375
1433
  h('div', {
1376
1434
  className: 'dfs-row', role: 'button', tabIndex: 0,
1435
+ onMouseEnter: startMarquee,
1436
+ onMouseLeave: stopMarquee,
1377
1437
  onClick: () => openSession(hit.sessionId),
1378
1438
  onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openSession(hit.sessionId) } },
1379
1439
  },
1380
- h('span', { className: 'dfs-title' }, hit.title),
1440
+ h(Title, { text: hit.title }),
1381
1441
  h('span', { className: 'dfs-time' }, baseName(hit.workspace.path) || t('group.ungrouped')),
1382
1442
  hit.snippet === undefined ? null : h('span', { className: 'dfs-snippet' }, `${t('search.content')}: ${hit.snippet}`))))
1383
1443
  body.push(topBar())
@@ -1598,14 +1658,17 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ !== undefined) {
1598
1658
  title: typeof workspace.path === 'string' && workspace.path !== '' ? workspace.path : name,
1599
1659
  onClick: () => { if (!renaming) setOpenId(workspace.workspaceId) },
1600
1660
  onContextMenu: contextMenuHandler(menuKey, folderMenu),
1601
- onMouseEnter: (event) => scheduleHover(menuKey, folderStatusRows(workspace), event),
1602
- onMouseLeave: cancelHover,
1661
+ onMouseEnter: (event) => {
1662
+ startMarquee(event)
1663
+ scheduleHover(menuKey, folderStatusRows(workspace), event)
1664
+ },
1665
+ onMouseLeave: (event) => { stopMarquee(event); cancelHover() },
1603
1666
  onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setOpenId(workspace.workspaceId) } },
1604
1667
  },
1605
1668
  h(FolderGlyph, { kind: folderKind, label: folderKind === undefined ? undefined : labels[folderKind] }),
1606
1669
  renaming
1607
1670
  ? renameInput(rename)
1608
- : h('span', { className: 'dfs-title' }, name === '' ? t('section.title') : name),
1671
+ : h(Title, { text: name === '' ? t('section.title') : name }),
1609
1672
  h('span', { className: `dfs-rowActions${menuFor !== null && menuFor.key === menuKey ? ' is-open' : ''}` },
1610
1673
  menuTrigger(menuKey, folderMenu)),
1611
1674
  rows.length > 0 ? h('span', { className: 'dfs-badge' }, String(rows.length)) : null,
@@ -1627,12 +1690,15 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ !== undefined) {
1627
1690
  tabIndex: 0,
1628
1691
  title: t('group.ungrouped'),
1629
1692
  onClick: () => setOpenId(UNGROUPED),
1630
- onMouseEnter: (event) => scheduleHover(menuKey, folderStatusRows(ungroupedWorkspace), event),
1631
- onMouseLeave: cancelHover,
1693
+ onMouseEnter: (event) => {
1694
+ startMarquee(event)
1695
+ scheduleHover(menuKey, folderStatusRows(ungroupedWorkspace), event)
1696
+ },
1697
+ onMouseLeave: (event) => { stopMarquee(event); cancelHover() },
1632
1698
  onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setOpenId(UNGROUPED) } },
1633
1699
  },
1634
1700
  h(FolderGlyph, { kind: folderKindOf(ungroupedWorkspace), label: undefined }),
1635
- h('span', { className: 'dfs-title' }, t('group.ungrouped')),
1701
+ h(Title, { text: t('group.ungrouped') }),
1636
1702
  h('span', { className: 'dfs-badge' }, String(ungroupedRows.length)),
1637
1703
  h(ChevronIcon, null))))
1638
1704
  }
@@ -1662,6 +1728,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ !== undefined) {
1662
1728
  role: 'button',
1663
1729
  tabIndex: 0,
1664
1730
  title: t('action.back'),
1731
+ onMouseEnter: startMarquee,
1732
+ onMouseLeave: stopMarquee,
1665
1733
  onClick: () => { setOpenId(null); setQuery('') },
1666
1734
  onKeyDown: (event) => {
1667
1735
  if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setOpenId(null); setQuery('') }
@@ -1671,7 +1739,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ !== undefined) {
1671
1739
  h(FolderIcon, null),
1672
1740
  workspaceRenaming
1673
1741
  ? renameInput(rename)
1674
- : h('span', { className: 'dfs-crumbName', title: name }, name),
1742
+ : h(Title, { className: 'dfs-crumbName', text: name }),
1675
1743
  isUngrouped ? null : h('button', {
1676
1744
  key: 'new',
1677
1745
  type: 'button', className: 'dfs-iconBtn', title: t('action.newSession'),
@@ -1715,12 +1783,15 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ !== undefined) {
1715
1783
  title: isArchived ? t('notice.archivedNotOpenable') : (kind === undefined ? titleOf(summary) : `${titleOf(summary)} — ${label}`),
1716
1784
  onClick: () => { if (!renaming) openRow() },
1717
1785
  onContextMenu: contextMenuHandler(menuKey, sessionMenu),
1718
- onMouseEnter: (event) => scheduleHover(menuKey, statusRowsOf(status, projectionOf(summary.id), summary.updatedAt), event),
1719
- onMouseLeave: cancelHover,
1786
+ onMouseEnter: (event) => {
1787
+ startMarquee(event)
1788
+ scheduleHover(menuKey, statusRowsOf(status, projectionOf(summary.id), summary.updatedAt), event)
1789
+ },
1790
+ onMouseLeave: (event) => { stopMarquee(event); cancelHover() },
1720
1791
  onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openRow() } },
1721
1792
  },
1722
1793
  h(Indicator, { kind, title: label }),
1723
- renaming ? renameInput(rename) : h('span', { className: 'dfs-title' }, titleOf(summary)),
1794
+ renaming ? renameInput(rename) : h(Title, { text: titleOf(summary) }),
1724
1795
  isPinned ? h('span', { className: 'dfs-pin', title: t('action.pin'), 'aria-hidden': true }, h(PinIcon)) : null,
1725
1796
  h('span', { className: `dfs-rowActions${menuFor !== null && menuFor.key === menuKey ? ' is-open' : ''}` },
1726
1797
  menuTrigger(menuKey, sessionMenu)),
@@ -1761,6 +1832,6 @@ if (typeof module === 'object' && module !== null && module.exports !== undefine
1761
1832
  directionSign, normalizeSort, readSortState, writeSortState,
1762
1833
  readArchivedFilter, writeArchivedFilter,
1763
1834
  sortWorkspaces, sortSessions, pickFolderKind, indicatorOf,
1764
- relativeTime, baseName,
1835
+ relativeTime, baseName, marqueeShiftOf, marqueeDurationOf,
1765
1836
  }
1766
1837
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-folder-sidebar",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "description": "Folder-style sidebar for DeepSeek Harness: workspaces read as folders, and clicking one enters its session list instead of scrolling every workspace at once. · DSH 文件夹侧栏:工作区即文件夹,点进去只看该工作区的会话。",
6
6
  "license": "MIT",
@@ -32,11 +32,6 @@
32
32
  "publishConfig": {
33
33
  "access": "public"
34
34
  },
35
- "scripts": {
36
- "test": "node test/smoke.test.mjs && node test/projection.test.mjs && node test/logic.test.mjs",
37
- "check": "node --check index.js && node --check client.js",
38
- "pack:check": "npm pack --dry-run"
39
- },
40
35
  "exports": {
41
36
  ".": "./index.js",
42
37
  "./client": "./client.js",
@@ -65,5 +60,10 @@
65
60
  "@deepseek-ai/dsh-client-ui-conversation"
66
61
  ]
67
62
  }
63
+ },
64
+ "scripts": {
65
+ "test": "node test/smoke.test.mjs && node test/projection.test.mjs && node test/logic.test.mjs",
66
+ "check": "node --check index.js && node --check client.js",
67
+ "pack:check": "npm pack --dry-run"
68
68
  }
69
- }
69
+ }