dsh-documents 1.0.0 → 1.0.2
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/README.md +32 -23
- package/client.js +161 -15
- package/index.js +257 -58
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,7 +31,9 @@ DSH Desktop 插件:把**会话生成、编辑或交付过的文档**集中起
|
|
|
31
31
|
- 侧边栏「文档」图标打开文档库。
|
|
32
32
|
- **左侧始终保留文档列表**,右侧是查看器;点击列表中的另一个文档即可直接切换查看。
|
|
33
33
|
- 列表按最近活动排序,显示创建会话、更新时间;可按类型 / 工作区筛选,按名称、文件夹、会话标题搜索。
|
|
34
|
-
-
|
|
34
|
+
- **渐进加载**:列表按会话日志分块返回(NDJSON 流),先扫完的文档先显示,其余在后台继续扫描,底部提示「正在继续扫描会话…已显示 N 个」,不必等全部会话日志读完。
|
|
35
|
+
- **增量刷新**:列表每 **60 秒**后台刷新一次(仅页面可见时),只回传新增 / 变化 / 删除的文档,没有变化时完全不重绘;窗口重新聚焦时只有在缓存过期后才刷新。右上角「刷新」按钮强制刷新并清空预览缓存。
|
|
36
|
+
- **缓存**:文档预览缓存在页面内,打开 / 切换文档不会重新请求。
|
|
35
37
|
- 重命名后的文件会记录映射并在列表中保留(映射存放在 `~/.dsh/documents-renames.json`),不会因为会话日志里还是旧路径而消失。
|
|
36
38
|
- 查看器:
|
|
37
39
|
- Markdown 默认**阅读模式**(渲染标题、列表、表格、代码块、链接),可切换源码。
|
|
@@ -67,35 +69,39 @@ DSH Desktop 插件:把**会话生成、编辑或交付过的文档**集中起
|
|
|
67
69
|
|
|
68
70
|
### 一次性准备
|
|
69
71
|
|
|
70
|
-
|
|
71
|
-
2. 在 GitHub 创建仓库 `dsh-documents`(公开),然后:
|
|
72
|
+
使用 **npm trusted publishing(OIDC)**:GitHub 里不存任何 npm token,凭据由 Actions 的 `id-token` 现换现用,provenance 自动生成。
|
|
72
73
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
3. 仓库 **Settings → Secrets and variables → Actions → New repository secret**,添加 `NPM_TOKEN`(npm Automation token)。
|
|
74
|
+
1. 包已存在于 npm(trusted publishing 不能创建新包,首次发布需手动;`dsh-documents@1.0.0` 已发布)。
|
|
75
|
+
2. npmjs.com → 该包 → **Settings → Trusted publishing** → 添加 **GitHub Actions**:
|
|
76
|
+
- Organization or user:`aerfaman`
|
|
77
|
+
- Repository:`dsh-documents`
|
|
78
|
+
- Workflow filename:`release.yml`(必须与 `.github/workflows/release.yml` 同名)
|
|
79
|
+
- Environment:`release`(**必须**与 workflow 里的 `environment: release` 完全一致;两边不一致时 registry 的 OIDC 交换会返回 404,npm 随后静默退回传统认证并报 `ENEEDAUTH / npm login`)
|
|
80
|
+
3. **不要**再配置 `NPM_TOKEN` / `NODE_AUTH_TOKEN`:npm 一旦发现 token 就会放弃 OIDC,退回 token 认证。
|
|
81
|
+
4. 仓库 **Settings → Actions → General → Workflow permissions** 勾选 `Read and write`(创建 Release 需要 `contents: write`,workflow 内已声明 `permissions`)。
|
|
82
|
+
5. workflow 里 `actions/setup-node` **不要**加 `registry-url`(它会写入 `${NODE_AUTH_TOKEN}` 的 `.npmrc`),registry 由 `package.json` 的 `publishConfig.registry` 决定。
|
|
83
83
|
|
|
84
84
|
### 发布新版本
|
|
85
85
|
|
|
86
|
+
推 main 即自动发布,`release.yml` 做这些事:
|
|
87
|
+
|
|
86
88
|
```bash
|
|
87
|
-
npm version patch
|
|
88
|
-
git push --follow-tags
|
|
89
|
+
npm version patch # 或 minor / major:只改 package.json 版本号
|
|
90
|
+
git push origin main # 或者 git push --follow-tags,同时推 v* tag
|
|
89
91
|
```
|
|
90
92
|
|
|
91
|
-
`
|
|
93
|
+
1. `npm run check` 校验包结构;
|
|
94
|
+
2. 查 npm 上是否已有该版本:**已有 → 跳过发布**(所以只推代码、没改版本号不会失败);
|
|
95
|
+
3. preflight:用 GitHub OIDC token 走一遍 npm 的 token 交换,失败时打出 registry 的真实状态码和原因,而不是 npm 那句含糊的 `ENEEDAUTH`;
|
|
96
|
+
4. 版本是新的 → `npm publish --provenance --access public`(OIDC 换短期凭证,provenance 自动生成);
|
|
97
|
+
5. 该 tag 还没有 GitHub Release → `npm pack` 并把 `.tgz` 附到 Release,同时创建 `v<version>` tag。
|
|
92
98
|
|
|
93
|
-
|
|
94
|
-
2. `npm run check`;
|
|
95
|
-
3. `npm publish --provenance --access public`(发布到 registry.npmjs.org);
|
|
96
|
-
4. `npm pack` 并把 `.tgz` 附到 GitHub Release。
|
|
99
|
+
补充:
|
|
97
100
|
|
|
98
|
-
|
|
101
|
+
- 打 `v*` tag 触发时会额外校验 tag 与 `package.json` 版本一致。
|
|
102
|
+
- 手动重跑:Actions → Release → **Run workflow**(`workflow_dispatch`)。
|
|
103
|
+
- 运行环境需要 npm ≥ 11.5.1,workflow 里已 `npm install -g npm@latest` 并校验。
|
|
104
|
+
- 也可以在本机手动发布(npm ≥ 11.5.1,注意本机 npm 默认 registry 可能是 npmmirror,必须显式指定官方源):
|
|
99
105
|
|
|
100
106
|
```bash
|
|
101
107
|
npm login --registry https://registry.npmjs.org
|
|
@@ -126,11 +132,13 @@ npm run pack:dry # 查看 npm 包内容
|
|
|
126
132
|
| `icon.svg` | 插件图标 |
|
|
127
133
|
| `scripts/verify.mjs` | 包名 / 客户端 id / patch 行名一致性校验 |
|
|
128
134
|
| `.github/workflows/ci.yml` | 推送与 PR 时校验包结构 |
|
|
129
|
-
| `.github/workflows/release.yml` | tag
|
|
135
|
+
| `.github/workflows/release.yml` | 推 main / `v*` tag 时发布到 npm 并创建 GitHub Release |
|
|
130
136
|
|
|
131
137
|
## 本地接口
|
|
132
138
|
|
|
133
|
-
- `GET /dsh-documents/api/list` — `{ documents, sessionCount, truncated }`
|
|
139
|
+
- `GET /dsh-documents/api/list` — `{ documents, sessionCount, truncated, rev }`
|
|
140
|
+
- `GET /dsh-documents/api/list?stream=1` — NDJSON:每扫完一块会话输出一行 `{ t: "docs", documents, scanned, sessions, sessionCount }`,结束为 `{ t: "done", rev, total, limit, truncated }`
|
|
141
|
+
- `GET /dsh-documents/api/list?since=<rev>` — 增量:`{ rev, added, updated, removed }`;无变化返回 `{ unchanged: true }`;`rev` 不在保留的历史里(宿主重启或落后太多)返回 `{ full: true, documents }`
|
|
134
142
|
- `GET /dsh-documents/api/preview?path=...` — 文本预览
|
|
135
143
|
- `GET /dsh-documents/api/raw?path=...` — 图片 / PDF 内嵌流(支持 Range)
|
|
136
144
|
- `POST /dsh-documents/api/action` — `{ action: "open" | "reveal" | "rename" | "trash", path, name? }`
|
|
@@ -142,3 +150,4 @@ POST 需要 `x-dsh-documents: 1`;所有路径先做 realpath 校验,必须
|
|
|
142
150
|
- 包名切换为 `dsh-documents` 后重新安装成功,插件行 active,`/list` 返回 31 个会话文档 / 32 个会话。
|
|
143
151
|
- 客户端 bundle 以 `dsh-documents` 模块 id 正常下发。
|
|
144
152
|
- `npm run check` 与 `npm pack --dry-run` 通过,tarball 包含全部运行文件(9 个)。
|
|
153
|
+
- 列表改为「分块流式 + 增量」:用桩 ctx 验证 8 个会话分 2 批、40 个文档(首批 20 个先上屏);增量接口正确返回 `added` / `updated` / `removed`,无变化返回 `unchanged`,过期 revision 回退 `full`;同一套逻辑经真实 HTTP 流式读回一致。
|
package/client.js
CHANGED
|
@@ -13,7 +13,7 @@ window.__ModuleLoader__.load({
|
|
|
13
13
|
'count': 'documents',
|
|
14
14
|
'sessionCount': 'sessions',
|
|
15
15
|
'refresh': 'Refresh',
|
|
16
|
-
'refresh.auto': 'Auto-refresh every 60s',
|
|
16
|
+
'refresh.auto': 'Auto-refresh every 60s · changes only',
|
|
17
17
|
'search': 'Search document, folder, or session',
|
|
18
18
|
'filter.all': 'All',
|
|
19
19
|
'sort.recent': 'Recently active',
|
|
@@ -21,6 +21,7 @@ window.__ModuleLoader__.load({
|
|
|
21
21
|
'sort.size': 'Largest first',
|
|
22
22
|
'workspace.all': 'All workspaces',
|
|
23
23
|
'loading': 'Loading documents…',
|
|
24
|
+
'loading.partial': 'Still scanning sessions… {count} shown',
|
|
24
25
|
'empty.title': 'No session documents yet',
|
|
25
26
|
'empty.body': 'When a DSH session writes, edits, or presents a document in a workspace, it appears here with its originating session.',
|
|
26
27
|
'empty.filtered': 'No documents match this filter.',
|
|
@@ -91,7 +92,7 @@ window.__ModuleLoader__.load({
|
|
|
91
92
|
'count': '个文档',
|
|
92
93
|
'sessionCount': '个会话',
|
|
93
94
|
'refresh': '刷新',
|
|
94
|
-
'refresh.auto': '每 60
|
|
95
|
+
'refresh.auto': '每 60 秒增量刷新',
|
|
95
96
|
'search': '搜索文档、文件夹或会话',
|
|
96
97
|
'filter.all': '全部',
|
|
97
98
|
'sort.recent': '最近活动',
|
|
@@ -99,6 +100,7 @@ window.__ModuleLoader__.load({
|
|
|
99
100
|
'sort.size': '从大到小',
|
|
100
101
|
'workspace.all': '全部工作区',
|
|
101
102
|
'loading': '正在加载文档…',
|
|
103
|
+
'loading.partial': '正在继续扫描会话…已显示 {count} 个',
|
|
102
104
|
'empty.title': '还没有会话文档',
|
|
103
105
|
'empty.body': 'DSH 会话在工作区写入、编辑或交付文档后,会连同来源会话一起显示在这里。',
|
|
104
106
|
'empty.filtered': '没有符合当前筛选的文档。',
|
|
@@ -172,6 +174,9 @@ window.__ModuleLoader__.load({
|
|
|
172
174
|
text: '#5b6472',
|
|
173
175
|
}
|
|
174
176
|
const REFRESH_MS = 60 * 1000
|
|
177
|
+
// Survives a remount (switching panels and coming back): the list paints from
|
|
178
|
+
// the last known state and a delta refresh runs behind it.
|
|
179
|
+
const panelState = { documents: [], sessionCount: 0, revision: '' }
|
|
175
180
|
|
|
176
181
|
const CSS = `
|
|
177
182
|
.dshdoc-page {
|
|
@@ -595,9 +600,9 @@ window.__ModuleLoader__.load({
|
|
|
595
600
|
const t = typeof props.t === 'function' ? props.t : translateWith(EN)
|
|
596
601
|
const jumpToSession = props.jumpToSession
|
|
597
602
|
|
|
598
|
-
const [documents, setDocuments] = React.useState(
|
|
599
|
-
const [sessionCount, setSessionCount] = React.useState(
|
|
600
|
-
const [loading, setLoading] = React.useState(
|
|
603
|
+
const [documents, setDocuments] = React.useState(() => panelState.documents)
|
|
604
|
+
const [sessionCount, setSessionCount] = React.useState(() => panelState.sessionCount)
|
|
605
|
+
const [loading, setLoading] = React.useState(() => panelState.documents.length === 0)
|
|
601
606
|
const [error, setError] = React.useState(null)
|
|
602
607
|
const [truncated, setTruncated] = React.useState(false)
|
|
603
608
|
const [query, setQuery] = React.useState('')
|
|
@@ -612,6 +617,7 @@ window.__ModuleLoader__.load({
|
|
|
612
617
|
const [preview, setPreview] = React.useState(null)
|
|
613
618
|
const [previewEpoch, setPreviewEpoch] = React.useState(0)
|
|
614
619
|
const [refreshing, setRefreshing] = React.useState(false)
|
|
620
|
+
const [streaming, setStreaming] = React.useState(false)
|
|
615
621
|
const [busy, setBusy] = React.useState(false)
|
|
616
622
|
const [toast, setToast] = React.useState(null)
|
|
617
623
|
const [dialog, setDialog] = React.useState(null)
|
|
@@ -624,6 +630,8 @@ window.__ModuleLoader__.load({
|
|
|
624
630
|
const inFlight = React.useRef(false)
|
|
625
631
|
const previewCache = React.useRef(new Map())
|
|
626
632
|
const reloadTimer = React.useRef(null)
|
|
633
|
+
/** Server revision of the list currently on screen; '' forces a full reload. */
|
|
634
|
+
const revision = React.useRef(panelState.revision)
|
|
627
635
|
|
|
628
636
|
React.useEffect(() => {
|
|
629
637
|
alive.current = true
|
|
@@ -634,6 +642,12 @@ window.__ModuleLoader__.load({
|
|
|
634
642
|
}
|
|
635
643
|
}, [])
|
|
636
644
|
|
|
645
|
+
React.useEffect(() => {
|
|
646
|
+
panelState.documents = documents
|
|
647
|
+
panelState.sessionCount = sessionCount
|
|
648
|
+
panelState.revision = revision.current
|
|
649
|
+
}, [documents, sessionCount])
|
|
650
|
+
|
|
637
651
|
const showToast = React.useCallback((text, isError) => {
|
|
638
652
|
setToast({ text, isError: isError === true })
|
|
639
653
|
if (toastTimer.current !== null) window.clearTimeout(toastTimer.current)
|
|
@@ -642,6 +656,103 @@ window.__ModuleLoader__.load({
|
|
|
642
656
|
}, 4200)
|
|
643
657
|
}, [])
|
|
644
658
|
|
|
659
|
+
/**
|
|
660
|
+
* Read the whole library as a stream of NDJSON batches.
|
|
661
|
+
*
|
|
662
|
+
* The host scans session logs in chunks and pushes one `docs` event per
|
|
663
|
+
* chunk, so rows land on screen while the remaining logs are still being
|
|
664
|
+
* read. Returning early (unmount, newer load) cancels the reader.
|
|
665
|
+
*/
|
|
666
|
+
const loadStream = React.useCallback(async (ticket) => {
|
|
667
|
+
const response = await fetch(API + '/list?stream=1', {
|
|
668
|
+
cache: 'no-store',
|
|
669
|
+
headers: { 'x-dsh-documents': '1' },
|
|
670
|
+
})
|
|
671
|
+
if (!response.ok || response.body === null) throw new Error(`HTTP ${response.status}`)
|
|
672
|
+
const reader = response.body.getReader()
|
|
673
|
+
const decoder = new TextDecoder()
|
|
674
|
+
const collected = new Map()
|
|
675
|
+
let buffer = ''
|
|
676
|
+
let painted = false
|
|
677
|
+
let final = null
|
|
678
|
+
const paint = () => {
|
|
679
|
+
if (!alive.current || ticket !== loadId.current) return false
|
|
680
|
+
setDocuments([...collected.values()])
|
|
681
|
+
if (!painted) {
|
|
682
|
+
painted = true
|
|
683
|
+
setLoading(false)
|
|
684
|
+
}
|
|
685
|
+
return true
|
|
686
|
+
}
|
|
687
|
+
try {
|
|
688
|
+
for (;;) {
|
|
689
|
+
const step = await reader.read()
|
|
690
|
+
if (step.done) break
|
|
691
|
+
buffer += decoder.decode(step.value, { stream: true })
|
|
692
|
+
let newline = buffer.indexOf('\n')
|
|
693
|
+
while (newline !== -1) {
|
|
694
|
+
const line = buffer.slice(0, newline).trim()
|
|
695
|
+
buffer = buffer.slice(newline + 1)
|
|
696
|
+
newline = buffer.indexOf('\n')
|
|
697
|
+
if (line === '') continue
|
|
698
|
+
let event
|
|
699
|
+
try {
|
|
700
|
+
event = JSON.parse(line)
|
|
701
|
+
} catch {
|
|
702
|
+
continue
|
|
703
|
+
}
|
|
704
|
+
if (event.t === 'docs') {
|
|
705
|
+
if (!Array.isArray(event.documents)) continue
|
|
706
|
+
for (const doc of event.documents) collected.set(doc.path.toLowerCase(), doc)
|
|
707
|
+
// Paint only once there is something to show: an empty first chunk
|
|
708
|
+
// must not flash the "no documents" state.
|
|
709
|
+
if (event.documents.length > 0 && !paint()) return
|
|
710
|
+
if (typeof event.sessionCount === 'number') setSessionCount(event.sessionCount)
|
|
711
|
+
} else if (event.t === 'done') {
|
|
712
|
+
final = event
|
|
713
|
+
} else if (event.t === 'error') {
|
|
714
|
+
throw new Error(String(event.error || 'list failed'))
|
|
715
|
+
} else if (event.ok === true && Array.isArray(event.documents)) {
|
|
716
|
+
// Host without streaming support answered with the plain JSON shape.
|
|
717
|
+
for (const doc of event.documents) collected.set(doc.path.toLowerCase(), doc)
|
|
718
|
+
if (!paint()) return
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
} finally {
|
|
723
|
+
reader.cancel().catch(() => {})
|
|
724
|
+
}
|
|
725
|
+
if (!alive.current || ticket !== loadId.current) return
|
|
726
|
+
if (final !== null) {
|
|
727
|
+
revision.current = typeof final.rev === 'string' ? final.rev : ''
|
|
728
|
+
panelState.revision = revision.current
|
|
729
|
+
setSessionCount(typeof final.sessionCount === 'number' ? final.sessionCount : collected.size)
|
|
730
|
+
setTruncated(final.truncated === true)
|
|
731
|
+
const limit = typeof final.limit === 'number' ? final.limit : 0
|
|
732
|
+
if (limit > 0 && collected.size > limit) {
|
|
733
|
+
setDocuments([...collected.values()].slice(0, limit))
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
if (!painted) setDocuments([...collected.values()])
|
|
737
|
+
setLoading(false)
|
|
738
|
+
}, [])
|
|
739
|
+
|
|
740
|
+
/** Apply an incremental refresh: only rows that appeared, changed, or vanished. */
|
|
741
|
+
const mergeDelta = React.useCallback((data) => {
|
|
742
|
+
const added = Array.isArray(data.added) ? data.added : []
|
|
743
|
+
const updated = Array.isArray(data.updated) ? data.updated : []
|
|
744
|
+
const removed = Array.isArray(data.removed) ? data.removed : []
|
|
745
|
+
if (added.length === 0 && updated.length === 0 && removed.length === 0) return false
|
|
746
|
+
setDocuments((current) => {
|
|
747
|
+
const map = new Map(current.map((doc) => [doc.path.toLowerCase(), doc]))
|
|
748
|
+
for (const doc of added) map.set(doc.path.toLowerCase(), doc)
|
|
749
|
+
for (const doc of updated) map.set(doc.path.toLowerCase(), doc)
|
|
750
|
+
for (const key of removed) map.delete(String(key).toLowerCase())
|
|
751
|
+
return [...map.values()]
|
|
752
|
+
})
|
|
753
|
+
return true
|
|
754
|
+
}, [])
|
|
755
|
+
|
|
645
756
|
const load = React.useCallback(async (options) => {
|
|
646
757
|
const force = options === true || (options !== undefined && options.force === true)
|
|
647
758
|
const silent = options !== undefined && options.silent === true
|
|
@@ -653,27 +764,48 @@ window.__ModuleLoader__.load({
|
|
|
653
764
|
if (!force && lastLoaded.current !== 0 && now - lastLoaded.current < REFRESH_MS) return
|
|
654
765
|
const ticket = ++loadId.current
|
|
655
766
|
inFlight.current = true
|
|
767
|
+
const full = revision.current === ''
|
|
768
|
+
if (full) setStreaming(true)
|
|
656
769
|
if (!silent) setLoading(true)
|
|
657
770
|
setError(null)
|
|
658
771
|
try {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
772
|
+
if (full) {
|
|
773
|
+
await loadStream(ticket)
|
|
774
|
+
} else {
|
|
775
|
+
const data = await requestJson('/list?since=' + encodeURIComponent(revision.current))
|
|
776
|
+
if (!alive.current || ticket !== loadId.current) return
|
|
777
|
+
if (data.full === true) {
|
|
778
|
+
revision.current = typeof data.rev === 'string' ? data.rev : ''
|
|
779
|
+
panelState.revision = revision.current
|
|
780
|
+
setDocuments(Array.isArray(data.documents) ? data.documents : [])
|
|
781
|
+
} else {
|
|
782
|
+
if (typeof data.rev === 'string' && data.rev !== '') {
|
|
783
|
+
revision.current = data.rev
|
|
784
|
+
panelState.revision = data.rev
|
|
785
|
+
}
|
|
786
|
+
if (data.unchanged !== true) mergeDelta(data)
|
|
787
|
+
}
|
|
788
|
+
if (typeof data.sessionCount === 'number') setSessionCount(data.sessionCount)
|
|
789
|
+
setTruncated(data.truncated === true)
|
|
790
|
+
setLoading(false)
|
|
791
|
+
}
|
|
665
792
|
lastLoaded.current = Date.now()
|
|
666
|
-
setLoading(false)
|
|
667
|
-
setViewerPath((current) => (current !== null && next.some((doc) => doc.path === current) ? current : null))
|
|
668
793
|
} catch (loadError) {
|
|
669
794
|
if (!alive.current || ticket !== loadId.current) return
|
|
670
795
|
setError(String((loadError && loadError.message) || loadError))
|
|
671
796
|
setLoading(false)
|
|
797
|
+
if (full) {
|
|
798
|
+
revision.current = ''
|
|
799
|
+
panelState.revision = ''
|
|
800
|
+
}
|
|
672
801
|
} finally {
|
|
673
802
|
inFlight.current = false
|
|
674
|
-
if (alive.current)
|
|
803
|
+
if (alive.current) {
|
|
804
|
+
setRefreshing(false)
|
|
805
|
+
setStreaming(false)
|
|
806
|
+
}
|
|
675
807
|
}
|
|
676
|
-
}, [])
|
|
808
|
+
}, [loadStream, mergeDelta])
|
|
677
809
|
|
|
678
810
|
const refreshNow = React.useCallback(() => {
|
|
679
811
|
previewCache.current.clear()
|
|
@@ -709,6 +841,13 @@ window.__ModuleLoader__.load({
|
|
|
709
841
|
return () => window.clearInterval(timer)
|
|
710
842
|
}, [load])
|
|
711
843
|
|
|
844
|
+
// A delta refresh can drop the document that was open (rename, trash, delete).
|
|
845
|
+
React.useEffect(() => {
|
|
846
|
+
if (loading || streaming) return
|
|
847
|
+
if (viewerPath === null) return
|
|
848
|
+
if (!documents.some((doc) => doc.path === viewerPath)) setViewerPath(null)
|
|
849
|
+
}, [documents, viewerPath, loading, streaming])
|
|
850
|
+
|
|
712
851
|
const selected = React.useMemo(
|
|
713
852
|
() => documents.find((doc) => doc.path === viewerPath) || null,
|
|
714
853
|
[documents, viewerPath],
|
|
@@ -1177,6 +1316,13 @@ window.__ModuleLoader__.load({
|
|
|
1177
1316
|
}
|
|
1178
1317
|
}
|
|
1179
1318
|
|
|
1319
|
+
if (streaming && documents.length > 0) {
|
|
1320
|
+
listContent = h(React.Fragment, null, listContent,
|
|
1321
|
+
h('div', { className: 'dshdoc-empty-body', style: { padding: '12px 12px 0' } },
|
|
1322
|
+
String(t('loading.partial') || '').replace('{count}', String(documents.length))),
|
|
1323
|
+
)
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1180
1326
|
const sidePanel = h('div', { className: 'dshdoc-side', 'data-open': String(listOpen) },
|
|
1181
1327
|
h('div', { className: 'dshdoc-side-head' },
|
|
1182
1328
|
h('label', { className: 'dshdoc-search' },
|
package/index.js
CHANGED
|
@@ -19,6 +19,10 @@ const RENAME_FILE = path.join(os.homedir(), '.dsh', 'documents-renames.json')
|
|
|
19
19
|
const PREVIEW_LIMIT = 400 * 1024
|
|
20
20
|
const MAX_DOCUMENTS = 800
|
|
21
21
|
const DOCS_CACHE_MS = 15000
|
|
22
|
+
/** Session logs read in parallel per chunk; the scan streams one batch per chunk. */
|
|
23
|
+
const SESSION_CHUNK = 4
|
|
24
|
+
/** How many list revisions stay addressable, so a client can still ask for a delta. */
|
|
25
|
+
const SNAPSHOT_HISTORY = 4
|
|
22
26
|
|
|
23
27
|
const KINDS = [
|
|
24
28
|
{ id: 'word', color: '#2b579a', exts: ['doc', 'docx', 'docm', 'rtf', 'odt', 'pages'] },
|
|
@@ -53,9 +57,11 @@ const WRITE_TOOLS = new Set([
|
|
|
53
57
|
const PATH_ARG_KEYS = ['path', 'file_path', 'filePath', 'target_file', 'file']
|
|
54
58
|
|
|
55
59
|
|
|
56
|
-
let docsCache = { at: 0, key: '', value: null }
|
|
60
|
+
let docsCache = { at: 0, key: '', value: null, snapshot: null, rev: '' }
|
|
57
61
|
let renameMap = null
|
|
58
62
|
const sessionCache = new Map()
|
|
63
|
+
/** Recent list snapshots, keyed by revision, so a polling client can receive a delta. */
|
|
64
|
+
const snapshotHistory = new Map()
|
|
59
65
|
|
|
60
66
|
/**
|
|
61
67
|
* Renames performed through this plugin are remembered because session events
|
|
@@ -302,75 +308,143 @@ function extractSessionFiles(events, sessionCwd) {
|
|
|
302
308
|
return out
|
|
303
309
|
}
|
|
304
310
|
|
|
305
|
-
|
|
311
|
+
/**
|
|
312
|
+
* Read one session log, reusing the extraction cache while its revision is stable.
|
|
313
|
+
* Returns null for a session whose log cannot be read.
|
|
314
|
+
*/
|
|
315
|
+
async function readSessionRecord(query, persistence, record, titles, signal) {
|
|
316
|
+
const header = record?.header
|
|
317
|
+
if (!header || typeof header.cwd !== 'string') return null
|
|
318
|
+
const id = String(header.id)
|
|
319
|
+
let revision = ''
|
|
320
|
+
if (persistence && typeof persistence.stat === 'function') {
|
|
321
|
+
try {
|
|
322
|
+
const stat = await persistence.stat(header.id, { signal })
|
|
323
|
+
revision = String(stat?.revision ?? '')
|
|
324
|
+
} catch {
|
|
325
|
+
revision = ''
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
let cached = sessionCache.get(id)
|
|
329
|
+
if (cached === undefined || cached.revision !== revision) {
|
|
330
|
+
let snapshot
|
|
331
|
+
try {
|
|
332
|
+
snapshot = await query.readSession(header.id)
|
|
333
|
+
} catch {
|
|
334
|
+
return null
|
|
335
|
+
}
|
|
336
|
+
const events = Array.isArray(snapshot?.events) ? snapshot.events : []
|
|
337
|
+
cached = {
|
|
338
|
+
revision,
|
|
339
|
+
header,
|
|
340
|
+
files: extractSessionFiles(events, header.cwd),
|
|
341
|
+
}
|
|
342
|
+
sessionCache.set(id, cached)
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
id,
|
|
346
|
+
header,
|
|
347
|
+
title: titles.get(id) || '',
|
|
348
|
+
files: cached.files,
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Walk every session log in small parallel chunks.
|
|
354
|
+
*
|
|
355
|
+
* Reading a log is by far the expensive step (36 logs / ~24 MB here), so callers
|
|
356
|
+
* that paint progressively can yield a batch after each chunk instead of waiting
|
|
357
|
+
* for the whole scan. Cached sessions skip `readSession` entirely, which makes a
|
|
358
|
+
* refresh cheap once the library has been loaded once.
|
|
359
|
+
*/
|
|
360
|
+
async function* iterateSessionRecords(ctx, signal) {
|
|
306
361
|
const query = ctx.get('sessionQuery')
|
|
307
362
|
const persistence = ctx.get('sessionPersistence')
|
|
308
363
|
if (!query || typeof query.listSessions !== 'function' || typeof query.readSession !== 'function') {
|
|
309
364
|
throw new Error('会话查询服务不可用')
|
|
310
365
|
}
|
|
311
366
|
const records = await query.listSessions(signal)
|
|
312
|
-
|
|
367
|
+
const titles = new Map()
|
|
313
368
|
try {
|
|
314
|
-
const
|
|
369
|
+
const ids = records.map((record) => record?.header?.id).filter((id) => id !== undefined && id !== null)
|
|
370
|
+
const observed = await query.readTitleSnapshots(ids, signal)
|
|
315
371
|
for (const item of observed) {
|
|
316
372
|
if (item && item.status === 'fulfilled' && item.value && item.value.title) {
|
|
317
373
|
titles.set(String(item.sessionId), String(item.value.title.title ?? ''))
|
|
318
374
|
}
|
|
319
375
|
}
|
|
320
376
|
} catch {
|
|
321
|
-
|
|
377
|
+
// Titles are decoration; a scan without them is still useful.
|
|
322
378
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
revision = String(stat?.revision ?? '')
|
|
333
|
-
} catch {
|
|
334
|
-
revision = ''
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
let cached = sessionCache.get(id)
|
|
338
|
-
if (cached === undefined || cached.revision !== revision) {
|
|
339
|
-
let snapshot
|
|
340
|
-
try {
|
|
341
|
-
snapshot = await query.readSession(header.id)
|
|
342
|
-
} catch {
|
|
343
|
-
continue
|
|
344
|
-
}
|
|
345
|
-
const events = Array.isArray(snapshot?.events) ? snapshot.events : []
|
|
346
|
-
cached = {
|
|
347
|
-
revision,
|
|
348
|
-
header,
|
|
349
|
-
files: extractSessionFiles(events, header.cwd),
|
|
350
|
-
}
|
|
351
|
-
sessionCache.set(id, cached)
|
|
379
|
+
for (let index = 0; index < records.length; index += SESSION_CHUNK) {
|
|
380
|
+
const slice = records.slice(index, index + SESSION_CHUNK)
|
|
381
|
+
const settled = await Promise.all(
|
|
382
|
+
slice.map((record) => readSessionRecord(query, persistence, record, titles, signal)),
|
|
383
|
+
)
|
|
384
|
+
yield {
|
|
385
|
+
sessions: settled.filter((entry) => entry !== null),
|
|
386
|
+
scanned: Math.min(index + slice.length, records.length),
|
|
387
|
+
total: records.length,
|
|
352
388
|
}
|
|
353
|
-
out.push({
|
|
354
|
-
id,
|
|
355
|
-
header,
|
|
356
|
-
title: titles.get(id) || '',
|
|
357
|
-
files: cached.files,
|
|
358
|
-
})
|
|
359
389
|
}
|
|
360
|
-
return out
|
|
361
390
|
}
|
|
362
391
|
|
|
363
392
|
function kindOf(ext) {
|
|
364
393
|
return KIND_BY_EXT.get(ext)
|
|
365
394
|
}
|
|
366
395
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
396
|
+
/**
|
|
397
|
+
* Identity of one document row. Any change here means the client must be told
|
|
398
|
+
* about the document again on the next incremental refresh.
|
|
399
|
+
*/
|
|
400
|
+
function fingerprintOf(doc) {
|
|
401
|
+
return `${doc.mtime}|${doc.bytes}|${doc.sessionCount}|${doc.editCount}|${doc.lastTime}`
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function snapshotOf(documents) {
|
|
405
|
+
const snapshot = new Map()
|
|
406
|
+
for (const doc of documents) snapshot.set(doc.path.toLowerCase(), fingerprintOf(doc))
|
|
407
|
+
return snapshot
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Stable hash of a document set, used as the delta cursor. Kept deterministic so
|
|
412
|
+
* a client revision stays meaningful after the host restarts.
|
|
413
|
+
*/
|
|
414
|
+
function revisionOf(snapshot) {
|
|
415
|
+
let hash = 0x811c9dc5
|
|
416
|
+
for (const key of [...snapshot.keys()].sort()) {
|
|
417
|
+
const line = `${key}=${snapshot.get(key)}\n`
|
|
418
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
419
|
+
hash ^= line.charCodeAt(index)
|
|
420
|
+
hash = Math.imul(hash, 0x01000193) >>> 0
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return hash.toString(16).padStart(8, '0')
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function statDocument(filePath, cache) {
|
|
427
|
+
if (cache.has(filePath)) return cache.get(filePath)
|
|
428
|
+
let info = null
|
|
429
|
+
try {
|
|
430
|
+
const stat = await fs.stat(filePath)
|
|
431
|
+
if (stat.isFile()) info = { size: stat.size, mtime: stat.mtimeMs }
|
|
432
|
+
} catch {
|
|
433
|
+
info = null
|
|
434
|
+
}
|
|
435
|
+
cache.set(filePath, info)
|
|
436
|
+
return info
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Turn session file events into the public document shape.
|
|
441
|
+
*
|
|
442
|
+
* `statCache` keeps the stat cost flat while a streaming caller re-runs this for
|
|
443
|
+
* every chunk, and an unreadable file simply drops out of the library.
|
|
444
|
+
*/
|
|
445
|
+
async function buildDocuments(sessions, roots, renames, statCache) {
|
|
372
446
|
const byPath = new Map()
|
|
373
|
-
for (const session of
|
|
447
|
+
for (const session of sessions) {
|
|
374
448
|
const rootInfo = rootForPath(path.resolve(session.header.cwd), roots)
|
|
375
449
|
for (const file of session.files) {
|
|
376
450
|
const sourcePath = path.resolve(file.path)
|
|
@@ -426,13 +500,8 @@ async function collectDocuments(ctx, config, signal) {
|
|
|
426
500
|
|
|
427
501
|
const documents = []
|
|
428
502
|
for (const doc of byPath.values()) {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
info = await fs.stat(doc.path)
|
|
432
|
-
} catch {
|
|
433
|
-
continue
|
|
434
|
-
}
|
|
435
|
-
if (!info.isFile()) continue
|
|
503
|
+
const info = await statDocument(doc.path, statCache)
|
|
504
|
+
if (info === null) continue
|
|
436
505
|
const entries = [...doc.sessions.values()]
|
|
437
506
|
for (const entry of entries) entry.events.sort((a, b) => a.seq - b.seq)
|
|
438
507
|
entries.sort((a, b) => {
|
|
@@ -451,7 +520,7 @@ async function collectDocuments(ctx, config, signal) {
|
|
|
451
520
|
rootTitle: doc.rootTitle,
|
|
452
521
|
rel: doc.rel,
|
|
453
522
|
bytes: info.size,
|
|
454
|
-
mtime: info.
|
|
523
|
+
mtime: info.mtime,
|
|
455
524
|
firstTime: doc.firstTime,
|
|
456
525
|
lastTime: doc.lastTime,
|
|
457
526
|
sessionCount: entries.length,
|
|
@@ -470,6 +539,20 @@ async function collectDocuments(ctx, config, signal) {
|
|
|
470
539
|
})
|
|
471
540
|
}
|
|
472
541
|
documents.sort((a, b) => b.lastTime - a.lastTime)
|
|
542
|
+
return documents
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Collect the whole library in one pass (agent tool, JSON endpoint). */
|
|
546
|
+
async function collectDocuments(ctx, config, signal) {
|
|
547
|
+
const roots = rootsFor(ctx, config)
|
|
548
|
+
const renames = await loadRenames()
|
|
549
|
+
const relevant = []
|
|
550
|
+
for await (const chunk of iterateSessionRecords(ctx, signal)) {
|
|
551
|
+
for (const session of chunk.sessions) {
|
|
552
|
+
if (insideRoots(path.resolve(session.header.cwd), roots)) relevant.push(session)
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
const documents = await buildDocuments(relevant, roots, renames, new Map())
|
|
473
556
|
return {
|
|
474
557
|
roots,
|
|
475
558
|
documents: documents.slice(0, MAX_DOCUMENTS),
|
|
@@ -486,12 +569,55 @@ async function listDocuments(ctx, config) {
|
|
|
486
569
|
return docsCache.value
|
|
487
570
|
}
|
|
488
571
|
const value = await collectDocuments(ctx, config)
|
|
489
|
-
|
|
572
|
+
const snapshot = snapshotOf(value.documents)
|
|
573
|
+
const rev = revisionOf(snapshot)
|
|
574
|
+
docsCache = { key, at: now, value, snapshot, rev }
|
|
575
|
+
rememberSnapshot(rev, snapshot)
|
|
490
576
|
return value
|
|
491
577
|
}
|
|
492
578
|
|
|
579
|
+
function rememberSnapshot(rev, snapshot) {
|
|
580
|
+
snapshotHistory.delete(rev)
|
|
581
|
+
snapshotHistory.set(rev, snapshot)
|
|
582
|
+
while (snapshotHistory.size > SNAPSHOT_HISTORY) {
|
|
583
|
+
const oldest = snapshotHistory.keys().next().value
|
|
584
|
+
if (oldest === undefined) break
|
|
585
|
+
snapshotHistory.delete(oldest)
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Answer an incremental refresh: only the documents that appeared, changed, or
|
|
591
|
+
* vanished since the caller's revision. When that revision has fallen out of the
|
|
592
|
+
* retained history (or the host restarted), the full list is resent once.
|
|
593
|
+
*/
|
|
594
|
+
async function listDelta(ctx, config, since) {
|
|
595
|
+
const value = await listDocuments(ctx, config)
|
|
596
|
+
const snapshot = docsCache.snapshot ?? snapshotOf(value.documents)
|
|
597
|
+
const rev = docsCache.rev || revisionOf(snapshot)
|
|
598
|
+
const base = { sessionCount: value.sessionCount, truncated: value.truncated }
|
|
599
|
+
if (since === rev) return { ok: true, unchanged: true, rev, ...base }
|
|
600
|
+
const previous = snapshotHistory.get(since)
|
|
601
|
+
if (previous === undefined) {
|
|
602
|
+
return { ok: true, full: true, rev, documents: value.documents, ...base }
|
|
603
|
+
}
|
|
604
|
+
const added = []
|
|
605
|
+
const updated = []
|
|
606
|
+
for (const doc of value.documents) {
|
|
607
|
+
const key = doc.path.toLowerCase()
|
|
608
|
+
const before = previous.get(key)
|
|
609
|
+
if (before === undefined) added.push(doc)
|
|
610
|
+
else if (before !== snapshot.get(key)) updated.push(doc)
|
|
611
|
+
}
|
|
612
|
+
const removed = []
|
|
613
|
+
for (const key of previous.keys()) {
|
|
614
|
+
if (!snapshot.has(key)) removed.push(key)
|
|
615
|
+
}
|
|
616
|
+
return { ok: true, rev, added, updated, removed, ...base }
|
|
617
|
+
}
|
|
618
|
+
|
|
493
619
|
function invalidateCache() {
|
|
494
|
-
docsCache = { at: 0, key: '', value: null }
|
|
620
|
+
docsCache = { at: 0, key: '', value: null, snapshot: null, rev: '' }
|
|
495
621
|
}
|
|
496
622
|
|
|
497
623
|
async function readPreview(target, roots) {
|
|
@@ -725,6 +851,70 @@ async function serveRaw(req, res, url, ctx, config) {
|
|
|
725
851
|
createReadStream(resolved, { start, end }).pipe(res)
|
|
726
852
|
}
|
|
727
853
|
|
|
854
|
+
/**
|
|
855
|
+
* Stream the library as NDJSON.
|
|
856
|
+
*
|
|
857
|
+
* One `docs` event per scanned chunk means the panel can paint the documents it
|
|
858
|
+
* already knows about while the remaining session logs are still being read,
|
|
859
|
+
* instead of staring at a skeleton for the whole scan.
|
|
860
|
+
*/
|
|
861
|
+
async function serveListStream(req, res, ctx, config) {
|
|
862
|
+
res.writeHead(200, {
|
|
863
|
+
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
864
|
+
'cache-control': 'no-store',
|
|
865
|
+
'x-accel-buffering': 'no',
|
|
866
|
+
})
|
|
867
|
+
const controller = new AbortController()
|
|
868
|
+
const abort = () => controller.abort()
|
|
869
|
+
res.on('close', abort)
|
|
870
|
+
const write = (event) => {
|
|
871
|
+
if (res.writableEnded === true) return
|
|
872
|
+
res.write(JSON.stringify(event) + '\n')
|
|
873
|
+
}
|
|
874
|
+
try {
|
|
875
|
+
const roots = rootsFor(ctx, config)
|
|
876
|
+
const renames = await loadRenames()
|
|
877
|
+
const statCache = new Map()
|
|
878
|
+
const relevant = []
|
|
879
|
+
const emitted = new Map()
|
|
880
|
+
let documents = []
|
|
881
|
+
let sessionCount = 0
|
|
882
|
+
for await (const chunk of iterateSessionRecords(ctx, controller.signal)) {
|
|
883
|
+
for (const session of chunk.sessions) {
|
|
884
|
+
if (insideRoots(path.resolve(session.header.cwd), roots)) relevant.push(session)
|
|
885
|
+
}
|
|
886
|
+
sessionCount = relevant.length
|
|
887
|
+
documents = await buildDocuments(relevant, roots, renames, statCache)
|
|
888
|
+
const changed = []
|
|
889
|
+
for (const doc of documents) {
|
|
890
|
+
const key = doc.path.toLowerCase()
|
|
891
|
+
const fingerprint = fingerprintOf(doc)
|
|
892
|
+
if (emitted.get(key) === fingerprint) continue
|
|
893
|
+
if (!emitted.has(key) && emitted.size >= MAX_DOCUMENTS) continue
|
|
894
|
+
emitted.set(key, fingerprint)
|
|
895
|
+
changed.push(doc)
|
|
896
|
+
}
|
|
897
|
+
write({ t: 'docs', documents: changed, scanned: chunk.scanned, sessions: chunk.total, sessionCount })
|
|
898
|
+
}
|
|
899
|
+
const rev = revisionOf(emitted)
|
|
900
|
+
const truncated = documents.length > MAX_DOCUMENTS
|
|
901
|
+
docsCache = {
|
|
902
|
+
key: roots.map((root) => root.path).join('|'),
|
|
903
|
+
at: Date.now(),
|
|
904
|
+
value: { roots, documents: documents.slice(0, MAX_DOCUMENTS), truncated, sessionCount },
|
|
905
|
+
snapshot: emitted,
|
|
906
|
+
rev,
|
|
907
|
+
}
|
|
908
|
+
rememberSnapshot(rev, emitted)
|
|
909
|
+
write({ t: 'done', rev, sessionCount, total: documents.length, limit: MAX_DOCUMENTS, truncated })
|
|
910
|
+
} catch (error) {
|
|
911
|
+
write({ t: 'error', error: messageOf(error) })
|
|
912
|
+
} finally {
|
|
913
|
+
res.off('close', abort)
|
|
914
|
+
if (res.writableEnded !== true) res.end()
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
728
918
|
function createHandler(ctx, config) {
|
|
729
919
|
return async function handleDocumentsRequest(req, res) {
|
|
730
920
|
try {
|
|
@@ -738,8 +928,17 @@ function createHandler(ctx, config) {
|
|
|
738
928
|
|
|
739
929
|
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
740
930
|
if (route === '/list') {
|
|
931
|
+
const since = url.searchParams.get('since')
|
|
932
|
+
if (since !== null && since !== '') {
|
|
933
|
+
sendJson(res, 200, await listDelta(ctx, config, since))
|
|
934
|
+
return
|
|
935
|
+
}
|
|
936
|
+
if (url.searchParams.get('stream') === '1' && req.method !== 'HEAD') {
|
|
937
|
+
await serveListStream(req, res, ctx, config)
|
|
938
|
+
return
|
|
939
|
+
}
|
|
741
940
|
const result = await listDocuments(ctx, config)
|
|
742
|
-
sendJson(res, 200, { ok: true, ...result })
|
|
941
|
+
sendJson(res, 200, { ok: true, rev: docsCache.rev, ...result })
|
|
743
942
|
return
|
|
744
943
|
}
|
|
745
944
|
if (route === '/preview') {
|
package/package.json
CHANGED