dsh-documents 1.0.1 → 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.
Files changed (4) hide show
  1. package/README.md +11 -5
  2. package/client.js +161 -15
  3. package/index.js +257 -58
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -31,7 +31,9 @@ DSH Desktop 插件:把**会话生成、编辑或交付过的文档**集中起
31
31
  - 侧边栏「文档」图标打开文档库。
32
32
  - **左侧始终保留文档列表**,右侧是查看器;点击列表中的另一个文档即可直接切换查看。
33
33
  - 列表按最近活动排序,显示创建会话、更新时间;可按类型 / 工作区筛选,按名称、文件夹、会话标题搜索。
34
- - **缓存与刷新**:列表和文档预览都会缓存在页面内,打开 / 切换文档不会重新请求;列表每 **60 秒**自动后台刷新一次(仅页面可见时),窗口重新聚焦时只有在缓存过期后才刷新。右上角「刷新」按钮可立即强制刷新并清空预览缓存。
34
+ - **渐进加载**:列表按会话日志分块返回(NDJSON 流),先扫完的文档先显示,其余在后台继续扫描,底部提示「正在继续扫描会话…已显示 N 个」,不必等全部会话日志读完。
35
+ - **增量刷新**:列表每 **60 秒**后台刷新一次(仅页面可见时),只回传新增 / 变化 / 删除的文档,没有变化时完全不重绘;窗口重新聚焦时只有在缓存过期后才刷新。右上角「刷新」按钮强制刷新并清空预览缓存。
36
+ - **缓存**:文档预览缓存在页面内,打开 / 切换文档不会重新请求。
35
37
  - 重命名后的文件会记录映射并在列表中保留(映射存放在 `~/.dsh/documents-renames.json`),不会因为会话日志里还是旧路径而消失。
36
38
  - 查看器:
37
39
  - Markdown 默认**阅读模式**(渲染标题、列表、表格、代码块、链接),可切换源码。
@@ -74,7 +76,7 @@ DSH Desktop 插件:把**会话生成、编辑或交付过的文档**集中起
74
76
  - Organization or user:`aerfaman`
75
77
  - Repository:`dsh-documents`
76
78
  - Workflow filename:`release.yml`(必须与 `.github/workflows/release.yml` 同名)
77
- - Environment:留空(除非 workflow 里也写了同名的 `environment:`)
79
+ - Environment:`release`(**必须**与 workflow 里的 `environment: release` 完全一致;两边不一致时 registry 的 OIDC 交换会返回 404,npm 随后静默退回传统认证并报 `ENEEDAUTH / npm login`)
78
80
  3. **不要**再配置 `NPM_TOKEN` / `NODE_AUTH_TOKEN`:npm 一旦发现 token 就会放弃 OIDC,退回 token 认证。
79
81
  4. 仓库 **Settings → Actions → General → Workflow permissions** 勾选 `Read and write`(创建 Release 需要 `contents: write`,workflow 内已声明 `permissions`)。
80
82
  5. workflow 里 `actions/setup-node` **不要**加 `registry-url`(它会写入 `${NODE_AUTH_TOKEN}` 的 `.npmrc`),registry 由 `package.json` 的 `publishConfig.registry` 决定。
@@ -90,8 +92,9 @@ git push origin main # 或者 git push --follow-tags,同时推 v* tag
90
92
 
91
93
  1. `npm run check` 校验包结构;
92
94
  2. 查 npm 上是否已有该版本:**已有 → 跳过发布**(所以只推代码、没改版本号不会失败);
93
- 3. 版本是新的 → `npm publish --provenance --access public`(OIDC 换短期凭证,provenance 自动生成);
94
- 4. 该 tag 还没有 GitHub Release → `npm pack` 并把 `.tgz` 附到 Release,同时创建 `v<version>` tag。
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。
95
98
 
96
99
  补充:
97
100
 
@@ -133,7 +136,9 @@ npm run pack:dry # 查看 npm 包内容
133
136
 
134
137
  ## 本地接口
135
138
 
136
- - `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 }`
137
142
  - `GET /dsh-documents/api/preview?path=...` — 文本预览
138
143
  - `GET /dsh-documents/api/raw?path=...` — 图片 / PDF 内嵌流(支持 Range)
139
144
  - `POST /dsh-documents/api/action` — `{ action: "open" | "reveal" | "rename" | "trash", path, name? }`
@@ -145,3 +150,4 @@ POST 需要 `x-dsh-documents: 1`;所有路径先做 realpath 校验,必须
145
150
  - 包名切换为 `dsh-documents` 后重新安装成功,插件行 active,`/list` 返回 31 个会话文档 / 32 个会话。
146
151
  - 客户端 bundle 以 `dsh-documents` 模块 id 正常下发。
147
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(0)
600
- const [loading, setLoading] = React.useState(true)
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
- const data = await requestJson('/list')
660
- if (!alive.current || ticket !== loadId.current) return
661
- const next = Array.isArray(data.documents) ? data.documents : []
662
- setDocuments(next)
663
- setSessionCount(typeof data.sessionCount === 'number' ? data.sessionCount : 0)
664
- setTruncated(data.truncated === true)
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) setRefreshing(false)
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
- async function sessionRecords(ctx, signal) {
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
- let titles = new Map()
367
+ const titles = new Map()
313
368
  try {
314
- const observed = await query.readTitleSnapshots(records.map((record) => record.header.id), signal)
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
- titles = new Map()
377
+ // Titles are decoration; a scan without them is still useful.
322
378
  }
323
- const out = []
324
- for (const record of records) {
325
- const header = record.header
326
- if (!header || typeof header.cwd !== 'string') continue
327
- const id = String(header.id)
328
- let revision = ''
329
- if (persistence && typeof persistence.stat === 'function') {
330
- try {
331
- const stat = await persistence.stat(header.id, { signal })
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
- async function collectDocuments(ctx, config, signal) {
368
- const roots = rootsFor(ctx, config)
369
- const renames = await loadRenames()
370
- const sessions = await sessionRecords(ctx, signal)
371
- const relevant = sessions.filter((session) => insideRoots(path.resolve(session.header.cwd), roots))
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 relevant) {
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
- let info
430
- try {
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.mtimeMs,
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
- docsCache = { key, at: now, value }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-documents",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Session-linked document library for DSH Desktop: in-app Markdown viewer, zoom, provenance and edit history.",
5
5
  "type": "module",
6
6
  "license": "MIT",