@agimon-ai/doompi-file-edit 0.0.1-alpha.40 → 0.0.1-alpha.43

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 CHANGED
@@ -15,7 +15,7 @@ moved is recorded however it was written.
15
15
  ## Requirements
16
16
 
17
17
  - Node.js 22.19.0 or newer
18
- - Pi 0.84.3 and Pi TUI 0.84.3
18
+ - Pi 0.84.4 and Pi TUI 0.84.4
19
19
  - Git for diffs against `HEAD`
20
20
 
21
21
  ## Install
@@ -1,2 +1,2 @@
1
- const e=require("../_virtual/_rolldown/runtime.cjs"),t=require("./FileEditPaths/FileEditPaths.cjs"),n=require("../services/fileChanges.cjs"),r=require("../types/webFiles.cjs");let i=require("node:path");i=e.__toESM(i,1);let a=require("node:fs");a=e.__toESM(a,1);function o(e){try{return a.default.statSync(e).isFile()}catch{return!1}}function s(e,t){let r;try{r=a.default.readFileSync(e,`utf8`)}catch{return[]}let s=n.parseTimeline(r);return n.foldEntries(s).filter(e=>o(e.path)).map(e=>({path:e.path,relPath:i.default.relative(t,e.path)||e.path,tool:e.tool,at:e.at,count:e.count,diffable:n.isDiffable(n.foldVersions(s,e.path))}))}function c(e,n,r={}){let o=new t.FileEditPaths,c;try{c=o.timelinePath(e.cwd,o.sessionKey(e.sessionId))}catch{return n([]),{close:()=>void 0}}let l=!1,u,d,f,p=()=>{if(l)return;let t=s(c,e.cwd),r=JSON.stringify(t);r!==f&&(f=r,n(t))},m=()=>{if(!(u||l))try{u=a.default.watch(i.default.dirname(c),()=>{d&&clearTimeout(d),d=setTimeout(p,r.debounceMs??120)}),u.on(`error`,()=>{u?.close(),u=void 0})}catch{}};m(),p();let h=setInterval(()=>{m(),p()},r.pollMs??2e3);return{close(){l=!0,d&&clearTimeout(d),clearInterval(h),u?.close(),u=void 0}}}function l(e=c){return{frameType:r.filesChannelType,start(t){let n=new Map,r=new Map;return{payloadFor(e){let t=n.get(e.sessionId);return t===void 0?void 0:{items:t}},sessionAdded(i){r.set(i.sessionId,e(i,e=>{n.set(i.sessionId,e),t.publish(i.sessionId,{items:e})}))},sessionRemoved(e){r.get(e)?.close(),r.delete(e),n.delete(e)},close(){for(let e of r.values())e.close();r.clear(),n.clear()}}}}}const u=[l()];exports.createFilesChannel=l,exports.readSessionFiles=s,exports.watchFiles=c,exports.webHubChannels=u;
1
+ const e=require("../_virtual/_rolldown/runtime.cjs"),t=require("./FileEditPaths/FileEditPaths.cjs"),n=require("../services/fileChanges.cjs"),r=require("../types/webFiles.cjs"),i=require("../services/doomIgnore.cjs");let a=require("node:path");a=e.__toESM(a,1);let o=require("node:fs");o=e.__toESM(o,1);function s(e){try{return o.default.statSync(e).isFile()}catch{return!1}}function c(e,t){let r;try{r=o.default.readFileSync(e,`utf8`)}catch{return[]}let c=n.parseTimeline(r),l=n.foldEntries(c).filter(e=>s(e.path)).map(e=>({path:e.path,relPath:a.default.relative(t,e.path)||e.path,tool:e.tool,at:e.at,count:e.count,diffable:n.isDiffable(n.foldVersions(c,e.path))}));try{let e=o.default.readFileSync(a.default.join(t,`.doomignore`),`utf8`);return i.filterDoomIgnoredFiles(l,e)}catch{return l}}function l(e,n,r={}){let i=new t.FileEditPaths,s;try{s=i.timelinePath(e.cwd,i.sessionKey(e.sessionId))}catch{return n([]),{close:()=>void 0}}let l=!1,u,d,f,p=()=>{if(l)return;let t=c(s,e.cwd),r=JSON.stringify(t);r!==f&&(f=r,n(t))},m=()=>{if(!(u||l))try{u=o.default.watch(a.default.dirname(s),()=>{d&&clearTimeout(d),d=setTimeout(p,r.debounceMs??120)}),u.on(`error`,()=>{u?.close(),u=void 0})}catch{}};m(),p();let h=setInterval(()=>{m(),p()},r.pollMs??2e3);return{close(){l=!0,d&&clearTimeout(d),clearInterval(h),u?.close(),u=void 0}}}function u(e=l){return{frameType:r.filesChannelType,start(t){let n=new Map,r=new Map;return{payloadFor(e){let t=n.get(e.sessionId);return t===void 0?void 0:{items:t}},sessionAdded(i){r.set(i.sessionId,e(i,e=>{n.set(i.sessionId,e),t.publish(i.sessionId,{items:e})}))},sessionRemoved(e){r.get(e)?.close(),r.delete(e),n.delete(e)},close(){for(let e of r.values())e.close();r.clear(),n.clear()}}}}}const d=[u()];exports.createFilesChannel=u,exports.readSessionFiles=c,exports.watchFiles=l,exports.webHubChannels=d;
2
2
  //# sourceMappingURL=webFilesChannel.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"webFilesChannel.cjs","names":["fs","parseTimeline","foldEntries","path","isDiffable","foldVersions","FileEditPaths","filesChannelType"],"sources":["../../src/adapters/webFilesChannel.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\nimport type { HubChannelSource, HubSessionScope, WebHubChannel } from '@agimon-ai/doompi-web-contracts';\nimport { foldEntries, foldVersions, isDiffable, parseTimeline } from '../services/fileChanges.ts';\nimport { filesChannelType, type FilesItemView } from '../types/webFiles.ts';\nimport { FileEditPaths } from './FileEditPaths/FileEditPaths.ts';\n\n/**\n * The changed-files channel: one watcher per managed session over the timeline\n * that session's extension appends to.\n *\n * The hub runs in its own process, so it reads what the extension wrote rather\n * than talking to it. Both halves derive the same path from the same session id\n * and working directory, which is what lets them meet on disk without an\n * agreement to keep in sync.\n *\n * Same reliability posture as this repository's other watchers: the poll is the\n * source of truth and fs.watch is only an accelerator, so a missed inotify\n * event costs latency rather than correctness, and a timeline that does not\n * exist yet (a session that has changed nothing) is a normal state.\n */\n\nconst POLL_MS = 2000;\nconst DEBOUNCE_MS = 120;\n\nexport interface FilesSource {\n close(): void;\n}\n\nexport interface WatchFilesOptions {\n pollMs?: number;\n debounceMs?: number;\n}\n\n/** Whether the file is still there to be opened. */\nfunction stillExists(filePath: string): boolean {\n try {\n return fs.statSync(filePath).isFile();\n } catch {\n return false;\n }\n}\n\n/**\n * Reads one session's timeline and presents it as the rows the dock lists.\n *\n * A file the session changed and then removed is left out. The dock is a list\n * of things to open, and a row that can only ever answer \"this is gone\" is a\n * dead end; the timeline still holds the change, so a tab already open on the\n * file keeps working and says so.\n */\nexport function readSessionFiles(timelinePath: string, cwd: string): FilesItemView[] {\n let content: string;\n try {\n content = fs.readFileSync(timelinePath, 'utf8');\n } catch {\n return [];\n }\n const events = parseTimeline(content);\n return foldEntries(events)\n .filter((entry) => stillExists(entry.path))\n .map((entry) => ({\n path: entry.path,\n relPath: path.relative(cwd, entry.path) || entry.path,\n tool: entry.tool,\n at: entry.at,\n count: entry.count,\n diffable: isDiffable(foldVersions(events, entry.path)),\n }));\n}\n\n/**\n * Watches one session's timeline and reports every change to its file list.\n * Announcements are deduped by fingerprint, so an unchanged list is silent.\n */\nexport function watchFiles(\n scope: HubSessionScope,\n onChange: (items: FilesItemView[]) => void,\n options: WatchFilesOptions = {},\n): FilesSource {\n const paths = new FileEditPaths();\n let timelinePath: string;\n try {\n timelinePath = paths.timelinePath(scope.cwd, paths.sessionKey(scope.sessionId));\n } catch {\n // A working directory that cannot be resolved has no timeline to read; the\n // session simply reports nothing rather than taking the hub down with it.\n onChange([]);\n return { close: () => undefined };\n }\n\n let closed = false;\n let watcher: fs.FSWatcher | undefined;\n let debounce: NodeJS.Timeout | undefined;\n let lastEmitted: string | undefined;\n\n const emit = (): void => {\n if (closed) return;\n const items = readSessionFiles(timelinePath, scope.cwd);\n const fingerprint = JSON.stringify(items);\n if (fingerprint === lastEmitted) return;\n lastEmitted = fingerprint;\n onChange(items);\n };\n\n // The timeline file is created on the first change, so the watch is placed on\n // the directory that holds it and re-checked by the poll until it appears.\n const ensureWatcher = (): void => {\n if (watcher || closed) return;\n try {\n watcher = fs.watch(path.dirname(timelinePath), () => {\n if (debounce) clearTimeout(debounce);\n debounce = setTimeout(emit, options.debounceMs ?? DEBOUNCE_MS);\n });\n watcher.on('error', () => {\n watcher?.close();\n watcher = undefined; // The poll re-establishes it once the directory is back.\n });\n } catch {\n // The directory does not exist yet; the poll keeps trying.\n }\n };\n\n ensureWatcher();\n emit();\n const poll = setInterval(() => {\n ensureWatcher();\n emit();\n }, options.pollMs ?? POLL_MS);\n\n return {\n close() {\n closed = true;\n if (debounce) clearTimeout(debounce);\n clearInterval(poll);\n watcher?.close();\n watcher = undefined;\n },\n };\n}\n\n/**\n * The files data channel: one watcher per managed session, published as\n * { items } payloads under the 'file_edits' frame type.\n */\nexport function createFilesChannel(watch: typeof watchFiles = watchFiles): WebHubChannel {\n return {\n frameType: filesChannelType,\n start(host) {\n const latest = new Map<string, FilesItemView[]>();\n const sources = new Map<string, FilesSource>();\n const channelSource: HubChannelSource = {\n payloadFor(scope) {\n const items = latest.get(scope.sessionId);\n return items === undefined ? undefined : { items };\n },\n sessionAdded(scope) {\n sources.set(\n scope.sessionId,\n watch(scope, (items) => {\n latest.set(scope.sessionId, items);\n host.publish(scope.sessionId, { items });\n }),\n );\n },\n sessionRemoved(sessionId) {\n sources.get(sessionId)?.close();\n sources.delete(sessionId);\n latest.delete(sessionId);\n },\n close() {\n for (const source of sources.values()) source.close();\n sources.clear();\n latest.clear();\n },\n };\n return channelSource;\n },\n };\n}\n\n/** The named export the hub imports from the built entry the doompiWeb block names. */\nexport const webHubChannels: readonly WebHubChannel[] = [createFilesChannel()];\n"],"mappings":"sQAmCA,SAAS,EAAY,EAA2B,CAC9C,GAAI,CACF,OAAOA,EAAAA,QAAG,SAAS,CAAQ,CAAC,CAAC,OAAO,CACtC,MAAQ,CACN,MAAO,EACT,CACF,CAUA,SAAgB,EAAiB,EAAsB,EAA8B,CACnF,IAAI,EACJ,GAAI,CACF,EAAUA,EAAAA,QAAG,aAAa,EAAc,MAAM,CAChD,MAAQ,CACN,MAAO,CAAC,CACV,CACA,IAAM,EAASC,EAAAA,cAAc,CAAO,EACpC,OAAOC,EAAAA,YAAY,CAAM,CAAC,CACvB,OAAQ,GAAU,EAAY,EAAM,IAAI,CAAC,CAAC,CAC1C,IAAK,IAAW,CACf,KAAM,EAAM,KACZ,QAASC,EAAAA,QAAK,SAAS,EAAK,EAAM,IAAI,GAAK,EAAM,KACjD,KAAM,EAAM,KACZ,GAAI,EAAM,GACV,MAAO,EAAM,MACb,SAAUC,EAAAA,WAAWC,EAAAA,aAAa,EAAQ,EAAM,IAAI,CAAC,CACvD,EAAE,CACN,CAMA,SAAgB,EACd,EACA,EACA,EAA6B,CAAC,EACjB,CACb,IAAM,EAAQ,IAAIC,EAAAA,cACd,EACJ,GAAI,CACF,EAAe,EAAM,aAAa,EAAM,IAAK,EAAM,WAAW,EAAM,SAAS,CAAC,CAChF,MAAQ,CAIN,OADA,EAAS,CAAC,CAAC,EACJ,CAAE,UAAa,IAAA,EAAU,CAClC,CAEA,IAAI,EAAS,GACT,EACA,EACA,EAEE,MAAmB,CACvB,GAAI,EAAQ,OACZ,IAAM,EAAQ,EAAiB,EAAc,EAAM,GAAG,EAChD,EAAc,KAAK,UAAU,CAAK,EACpC,IAAgB,IACpB,EAAc,EACd,EAAS,CAAK,EAChB,EAIM,MAA4B,CAC5B,QAAW,GACf,GAAI,CACF,EAAUN,EAAAA,QAAG,MAAMG,EAAAA,QAAK,QAAQ,CAAY,MAAS,CAC/C,GAAU,aAAa,CAAQ,EACnC,EAAW,WAAW,EAAM,EAAQ,YAAc,GAAW,CAC/D,CAAC,EACD,EAAQ,GAAG,YAAe,CACxB,GAAS,MAAM,EACf,EAAU,IAAA,EACZ,CAAC,CACH,MAAQ,CAER,CACF,EAEA,EAAc,EACd,EAAK,EACL,IAAM,EAAO,gBAAkB,CAC7B,EAAc,EACd,EAAK,CACP,EAAG,EAAQ,QAAU,GAAO,EAE5B,MAAO,CACL,OAAQ,CACN,EAAS,GACL,GAAU,aAAa,CAAQ,EACnC,cAAc,CAAI,EAClB,GAAS,MAAM,EACf,EAAU,IAAA,EACZ,CACF,CACF,CAMA,SAAgB,EAAmB,EAA2B,EAA2B,CACvF,MAAO,CACL,UAAWI,EAAAA,iBACX,MAAM,EAAM,CACV,IAAM,EAAS,IAAI,IACb,EAAU,IAAI,IA0BpB,MAAO,CAxBL,WAAW,EAAO,CAChB,IAAM,EAAQ,EAAO,IAAI,EAAM,SAAS,EACxC,OAAO,IAAU,IAAA,GAAY,IAAA,GAAY,CAAE,OAAM,CACnD,EACA,aAAa,EAAO,CAClB,EAAQ,IACN,EAAM,UACN,EAAM,EAAQ,GAAU,CACtB,EAAO,IAAI,EAAM,UAAW,CAAK,EACjC,EAAK,QAAQ,EAAM,UAAW,CAAE,OAAM,CAAC,CACzC,CAAC,CACH,CACF,EACA,eAAe,EAAW,CACxB,EAAQ,IAAI,CAAS,CAAC,EAAE,MAAM,EAC9B,EAAQ,OAAO,CAAS,EACxB,EAAO,OAAO,CAAS,CACzB,EACA,OAAQ,CACN,IAAK,IAAM,KAAU,EAAQ,OAAO,EAAG,EAAO,MAAM,EACpD,EAAQ,MAAM,EACd,EAAO,MAAM,CACf,CAEiB,CACrB,CACF,CACF,CAGA,MAAa,EAA2C,CAAC,EAAmB,CAAC"}
1
+ {"version":3,"file":"webFilesChannel.cjs","names":["fs","parseTimeline","foldEntries","path","isDiffable","foldVersions","filterDoomIgnoredFiles","FileEditPaths","filesChannelType"],"sources":["../../src/adapters/webFilesChannel.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\nimport type { HubChannelSource, HubSessionScope, WebHubChannel } from '@agimon-ai/doompi-web-contracts';\nimport { filterDoomIgnoredFiles } from '../services/doomIgnore.ts';\nimport { foldEntries, foldVersions, isDiffable, parseTimeline } from '../services/fileChanges.ts';\nimport { filesChannelType, type FilesItemView } from '../types/webFiles.ts';\nimport { FileEditPaths } from './FileEditPaths/FileEditPaths.ts';\n\n/**\n * The changed-files channel: one watcher per managed session over the timeline\n * that session's extension appends to.\n *\n * The hub runs in its own process, so it reads what the extension wrote rather\n * than talking to it. Both halves derive the same path from the same session id\n * and working directory, which is what lets them meet on disk without an\n * agreement to keep in sync.\n *\n * Same reliability posture as this repository's other watchers: the poll is the\n * source of truth and fs.watch is only an accelerator, so a missed inotify\n * event costs latency rather than correctness, and a timeline that does not\n * exist yet (a session that has changed nothing) is a normal state.\n */\n\nconst POLL_MS = 2000;\nconst DEBOUNCE_MS = 120;\n\nexport interface FilesSource {\n close(): void;\n}\n\nexport interface WatchFilesOptions {\n pollMs?: number;\n debounceMs?: number;\n}\n\n/** Whether the file is still there to be opened. */\nfunction stillExists(filePath: string): boolean {\n try {\n return fs.statSync(filePath).isFile();\n } catch {\n return false;\n }\n}\n\n/**\n * Reads one session's timeline and presents it as the rows the dock lists.\n *\n * A file the session changed and then removed is left out. The dock is a list\n * of things to open, and a row that can only ever answer \"this is gone\" is a\n * dead end; the timeline still holds the change, so a tab already open on the\n * file keeps working and says so.\n */\nexport function readSessionFiles(timelinePath: string, cwd: string): FilesItemView[] {\n let content: string;\n try {\n content = fs.readFileSync(timelinePath, 'utf8');\n } catch {\n return [];\n }\n const events = parseTimeline(content);\n const items = foldEntries(events)\n .filter((entry) => stillExists(entry.path))\n .map((entry) => ({\n path: entry.path,\n relPath: path.relative(cwd, entry.path) || entry.path,\n tool: entry.tool,\n at: entry.at,\n count: entry.count,\n diffable: isDiffable(foldVersions(events, entry.path)),\n }));\n\n try {\n const doomIgnore = fs.readFileSync(path.join(cwd, '.doomignore'), 'utf8');\n return filterDoomIgnoredFiles(items, doomIgnore);\n } catch {\n return items;\n }\n}\n\n/**\n * Watches one session's timeline and reports every change to its file list.\n * Announcements are deduped by fingerprint, so an unchanged list is silent.\n */\nexport function watchFiles(\n scope: HubSessionScope,\n onChange: (items: FilesItemView[]) => void,\n options: WatchFilesOptions = {},\n): FilesSource {\n const paths = new FileEditPaths();\n let timelinePath: string;\n try {\n timelinePath = paths.timelinePath(scope.cwd, paths.sessionKey(scope.sessionId));\n } catch {\n // A working directory that cannot be resolved has no timeline to read; the\n // session simply reports nothing rather than taking the hub down with it.\n onChange([]);\n return { close: () => undefined };\n }\n\n let closed = false;\n let watcher: fs.FSWatcher | undefined;\n let debounce: NodeJS.Timeout | undefined;\n let lastEmitted: string | undefined;\n\n const emit = (): void => {\n if (closed) return;\n const items = readSessionFiles(timelinePath, scope.cwd);\n const fingerprint = JSON.stringify(items);\n if (fingerprint === lastEmitted) return;\n lastEmitted = fingerprint;\n onChange(items);\n };\n\n // The timeline file is created on the first change, so the watch is placed on\n // the directory that holds it and re-checked by the poll until it appears.\n const ensureWatcher = (): void => {\n if (watcher || closed) return;\n try {\n watcher = fs.watch(path.dirname(timelinePath), () => {\n if (debounce) clearTimeout(debounce);\n debounce = setTimeout(emit, options.debounceMs ?? DEBOUNCE_MS);\n });\n watcher.on('error', () => {\n watcher?.close();\n watcher = undefined; // The poll re-establishes it once the directory is back.\n });\n } catch {\n // The directory does not exist yet; the poll keeps trying.\n }\n };\n\n ensureWatcher();\n emit();\n const poll = setInterval(() => {\n ensureWatcher();\n emit();\n }, options.pollMs ?? POLL_MS);\n\n return {\n close() {\n closed = true;\n if (debounce) clearTimeout(debounce);\n clearInterval(poll);\n watcher?.close();\n watcher = undefined;\n },\n };\n}\n\n/**\n * The files data channel: one watcher per managed session, published as\n * { items } payloads under the 'file_edits' frame type.\n */\nexport function createFilesChannel(watch: typeof watchFiles = watchFiles): WebHubChannel {\n return {\n frameType: filesChannelType,\n start(host) {\n const latest = new Map<string, FilesItemView[]>();\n const sources = new Map<string, FilesSource>();\n const channelSource: HubChannelSource = {\n payloadFor(scope) {\n const items = latest.get(scope.sessionId);\n return items === undefined ? undefined : { items };\n },\n sessionAdded(scope) {\n sources.set(\n scope.sessionId,\n watch(scope, (items) => {\n latest.set(scope.sessionId, items);\n host.publish(scope.sessionId, { items });\n }),\n );\n },\n sessionRemoved(sessionId) {\n sources.get(sessionId)?.close();\n sources.delete(sessionId);\n latest.delete(sessionId);\n },\n close() {\n for (const source of sources.values()) source.close();\n sources.clear();\n latest.clear();\n },\n };\n return channelSource;\n },\n };\n}\n\n/** The named export the hub imports from the built entry the doompiWeb block names. */\nexport const webHubChannels: readonly WebHubChannel[] = [createFilesChannel()];\n"],"mappings":"8SAoCA,SAAS,EAAY,EAA2B,CAC9C,GAAI,CACF,OAAOA,EAAAA,QAAG,SAAS,CAAQ,CAAC,CAAC,OAAO,CACtC,MAAQ,CACN,MAAO,EACT,CACF,CAUA,SAAgB,EAAiB,EAAsB,EAA8B,CACnF,IAAI,EACJ,GAAI,CACF,EAAUA,EAAAA,QAAG,aAAa,EAAc,MAAM,CAChD,MAAQ,CACN,MAAO,CAAC,CACV,CACA,IAAM,EAASC,EAAAA,cAAc,CAAO,EAC9B,EAAQC,EAAAA,YAAY,CAAM,CAAC,CAC9B,OAAQ,GAAU,EAAY,EAAM,IAAI,CAAC,CAAC,CAC1C,IAAK,IAAW,CACf,KAAM,EAAM,KACZ,QAASC,EAAAA,QAAK,SAAS,EAAK,EAAM,IAAI,GAAK,EAAM,KACjD,KAAM,EAAM,KACZ,GAAI,EAAM,GACV,MAAO,EAAM,MACb,SAAUC,EAAAA,WAAWC,EAAAA,aAAa,EAAQ,EAAM,IAAI,CAAC,CACvD,EAAE,EAEJ,GAAI,CACF,IAAM,EAAaL,EAAAA,QAAG,aAAaG,EAAAA,QAAK,KAAK,EAAK,aAAa,EAAG,MAAM,EACxE,OAAOG,EAAAA,uBAAuB,EAAO,CAAU,CACjD,MAAQ,CACN,OAAO,CACT,CACF,CAMA,SAAgB,EACd,EACA,EACA,EAA6B,CAAC,EACjB,CACb,IAAM,EAAQ,IAAIC,EAAAA,cACd,EACJ,GAAI,CACF,EAAe,EAAM,aAAa,EAAM,IAAK,EAAM,WAAW,EAAM,SAAS,CAAC,CAChF,MAAQ,CAIN,OADA,EAAS,CAAC,CAAC,EACJ,CAAE,UAAa,IAAA,EAAU,CAClC,CAEA,IAAI,EAAS,GACT,EACA,EACA,EAEE,MAAmB,CACvB,GAAI,EAAQ,OACZ,IAAM,EAAQ,EAAiB,EAAc,EAAM,GAAG,EAChD,EAAc,KAAK,UAAU,CAAK,EACpC,IAAgB,IACpB,EAAc,EACd,EAAS,CAAK,EAChB,EAIM,MAA4B,CAC5B,QAAW,GACf,GAAI,CACF,EAAUP,EAAAA,QAAG,MAAMG,EAAAA,QAAK,QAAQ,CAAY,MAAS,CAC/C,GAAU,aAAa,CAAQ,EACnC,EAAW,WAAW,EAAM,EAAQ,YAAc,GAAW,CAC/D,CAAC,EACD,EAAQ,GAAG,YAAe,CACxB,GAAS,MAAM,EACf,EAAU,IAAA,EACZ,CAAC,CACH,MAAQ,CAER,CACF,EAEA,EAAc,EACd,EAAK,EACL,IAAM,EAAO,gBAAkB,CAC7B,EAAc,EACd,EAAK,CACP,EAAG,EAAQ,QAAU,GAAO,EAE5B,MAAO,CACL,OAAQ,CACN,EAAS,GACL,GAAU,aAAa,CAAQ,EACnC,cAAc,CAAI,EAClB,GAAS,MAAM,EACf,EAAU,IAAA,EACZ,CACF,CACF,CAMA,SAAgB,EAAmB,EAA2B,EAA2B,CACvF,MAAO,CACL,UAAWK,EAAAA,iBACX,MAAM,EAAM,CACV,IAAM,EAAS,IAAI,IACb,EAAU,IAAI,IA0BpB,MAAO,CAxBL,WAAW,EAAO,CAChB,IAAM,EAAQ,EAAO,IAAI,EAAM,SAAS,EACxC,OAAO,IAAU,IAAA,GAAY,IAAA,GAAY,CAAE,OAAM,CACnD,EACA,aAAa,EAAO,CAClB,EAAQ,IACN,EAAM,UACN,EAAM,EAAQ,GAAU,CACtB,EAAO,IAAI,EAAM,UAAW,CAAK,EACjC,EAAK,QAAQ,EAAM,UAAW,CAAE,OAAM,CAAC,CACzC,CAAC,CACH,CACF,EACA,eAAe,EAAW,CACxB,EAAQ,IAAI,CAAS,CAAC,EAAE,MAAM,EAC9B,EAAQ,OAAO,CAAS,EACxB,EAAO,OAAO,CAAS,CACzB,EACA,OAAQ,CACN,IAAK,IAAM,KAAU,EAAQ,OAAO,EAAG,EAAO,MAAM,EACpD,EAAQ,MAAM,EACd,EAAO,MAAM,CACf,CAEiB,CACrB,CACF,CACF,CAGA,MAAa,EAA2C,CAAC,EAAmB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"webFilesChannel.d.cts","names":[],"sources":["../../src/adapters/webFilesChannel.ts"],"mappings":";;;UAyBiB;EACf;;UAGe;EACf;EACA;;;;;;iBA4Cc,WACd,OAAO,iBACP,WAAW,OAAO,0BAClB,UAAS,oBACR;;;;;iBAkEa,mBAAmB,eAAc,aAA0B;;cAqC9D,yBAAyB"}
1
+ {"version":3,"file":"webFilesChannel.d.cts","names":[],"sources":["../../src/adapters/webFilesChannel.ts"],"mappings":";;;UA0BiB;EACf;;UAGe;EACf;EACA;;;;;;iBAmDc,WACd,OAAO,iBACP,WAAW,OAAO,0BAClB,UAAS,oBACR;;;;;iBAkEa,mBAAmB,eAAc,aAA0B;;cAqC9D,yBAAyB"}
@@ -1 +1 @@
1
- {"version":3,"file":"webFilesChannel.d.mts","names":[],"sources":["../../src/adapters/webFilesChannel.ts"],"mappings":";;;UAyBiB;EACf;;UAGe;EACf;EACA;;;;;;iBA4Cc,WACd,OAAO,iBACP,WAAW,OAAO,0BAClB,UAAS,oBACR;;;;;iBAkEa,mBAAmB,eAAc,aAA0B;;cAqC9D,yBAAyB"}
1
+ {"version":3,"file":"webFilesChannel.d.mts","names":[],"sources":["../../src/adapters/webFilesChannel.ts"],"mappings":";;;UA0BiB;EACf;;UAGe;EACf;EACA;;;;;;iBAmDc,WACd,OAAO,iBACP,WAAW,OAAO,0BAClB,UAAS,oBACR;;;;;iBAkEa,mBAAmB,eAAc,aAA0B;;cAqC9D,yBAAyB"}
@@ -1,2 +1,2 @@
1
- import{FileEditPaths as e}from"./FileEditPaths/FileEditPaths.mjs";import{foldEntries as t,foldVersions as n,isDiffable as r,parseTimeline as i}from"../services/fileChanges.mjs";import{filesChannelType as a}from"../types/webFiles.mjs";import o from"node:path";import s from"node:fs";function c(e){try{return s.statSync(e).isFile()}catch{return!1}}function l(e,a){let l;try{l=s.readFileSync(e,`utf8`)}catch{return[]}let u=i(l);return t(u).filter(e=>c(e.path)).map(e=>({path:e.path,relPath:o.relative(a,e.path)||e.path,tool:e.tool,at:e.at,count:e.count,diffable:r(n(u,e.path))}))}function u(t,n,r={}){let i=new e,a;try{a=i.timelinePath(t.cwd,i.sessionKey(t.sessionId))}catch{return n([]),{close:()=>void 0}}let c=!1,u,d,f,p=()=>{if(c)return;let e=l(a,t.cwd),r=JSON.stringify(e);r!==f&&(f=r,n(e))},m=()=>{if(!(u||c))try{u=s.watch(o.dirname(a),()=>{d&&clearTimeout(d),d=setTimeout(p,r.debounceMs??120)}),u.on(`error`,()=>{u?.close(),u=void 0})}catch{}};m(),p();let h=setInterval(()=>{m(),p()},r.pollMs??2e3);return{close(){c=!0,d&&clearTimeout(d),clearInterval(h),u?.close(),u=void 0}}}function d(e=u){return{frameType:a,start(t){let n=new Map,r=new Map;return{payloadFor(e){let t=n.get(e.sessionId);return t===void 0?void 0:{items:t}},sessionAdded(i){r.set(i.sessionId,e(i,e=>{n.set(i.sessionId,e),t.publish(i.sessionId,{items:e})}))},sessionRemoved(e){r.get(e)?.close(),r.delete(e),n.delete(e)},close(){for(let e of r.values())e.close();r.clear(),n.clear()}}}}}const f=[d()];export{d as createFilesChannel,l as readSessionFiles,u as watchFiles,f as webHubChannels};
1
+ import{FileEditPaths as e}from"./FileEditPaths/FileEditPaths.mjs";import{foldEntries as t,foldVersions as n,isDiffable as r,parseTimeline as i}from"../services/fileChanges.mjs";import{filesChannelType as a}from"../types/webFiles.mjs";import{filterDoomIgnoredFiles as o}from"../services/doomIgnore.mjs";import s from"node:path";import c from"node:fs";function l(e){try{return c.statSync(e).isFile()}catch{return!1}}function u(e,a){let u;try{u=c.readFileSync(e,`utf8`)}catch{return[]}let d=i(u),f=t(d).filter(e=>l(e.path)).map(e=>({path:e.path,relPath:s.relative(a,e.path)||e.path,tool:e.tool,at:e.at,count:e.count,diffable:r(n(d,e.path))}));try{let e=c.readFileSync(s.join(a,`.doomignore`),`utf8`);return o(f,e)}catch{return f}}function d(t,n,r={}){let i=new e,a;try{a=i.timelinePath(t.cwd,i.sessionKey(t.sessionId))}catch{return n([]),{close:()=>void 0}}let o=!1,l,d,f,p=()=>{if(o)return;let e=u(a,t.cwd),r=JSON.stringify(e);r!==f&&(f=r,n(e))},m=()=>{if(!(l||o))try{l=c.watch(s.dirname(a),()=>{d&&clearTimeout(d),d=setTimeout(p,r.debounceMs??120)}),l.on(`error`,()=>{l?.close(),l=void 0})}catch{}};m(),p();let h=setInterval(()=>{m(),p()},r.pollMs??2e3);return{close(){o=!0,d&&clearTimeout(d),clearInterval(h),l?.close(),l=void 0}}}function f(e=d){return{frameType:a,start(t){let n=new Map,r=new Map;return{payloadFor(e){let t=n.get(e.sessionId);return t===void 0?void 0:{items:t}},sessionAdded(i){r.set(i.sessionId,e(i,e=>{n.set(i.sessionId,e),t.publish(i.sessionId,{items:e})}))},sessionRemoved(e){r.get(e)?.close(),r.delete(e),n.delete(e)},close(){for(let e of r.values())e.close();r.clear(),n.clear()}}}}}const p=[f()];export{f as createFilesChannel,u as readSessionFiles,d as watchFiles,p as webHubChannels};
2
2
  //# sourceMappingURL=webFilesChannel.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"webFilesChannel.mjs","names":[],"sources":["../../src/adapters/webFilesChannel.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\nimport type { HubChannelSource, HubSessionScope, WebHubChannel } from '@agimon-ai/doompi-web-contracts';\nimport { foldEntries, foldVersions, isDiffable, parseTimeline } from '../services/fileChanges.ts';\nimport { filesChannelType, type FilesItemView } from '../types/webFiles.ts';\nimport { FileEditPaths } from './FileEditPaths/FileEditPaths.ts';\n\n/**\n * The changed-files channel: one watcher per managed session over the timeline\n * that session's extension appends to.\n *\n * The hub runs in its own process, so it reads what the extension wrote rather\n * than talking to it. Both halves derive the same path from the same session id\n * and working directory, which is what lets them meet on disk without an\n * agreement to keep in sync.\n *\n * Same reliability posture as this repository's other watchers: the poll is the\n * source of truth and fs.watch is only an accelerator, so a missed inotify\n * event costs latency rather than correctness, and a timeline that does not\n * exist yet (a session that has changed nothing) is a normal state.\n */\n\nconst POLL_MS = 2000;\nconst DEBOUNCE_MS = 120;\n\nexport interface FilesSource {\n close(): void;\n}\n\nexport interface WatchFilesOptions {\n pollMs?: number;\n debounceMs?: number;\n}\n\n/** Whether the file is still there to be opened. */\nfunction stillExists(filePath: string): boolean {\n try {\n return fs.statSync(filePath).isFile();\n } catch {\n return false;\n }\n}\n\n/**\n * Reads one session's timeline and presents it as the rows the dock lists.\n *\n * A file the session changed and then removed is left out. The dock is a list\n * of things to open, and a row that can only ever answer \"this is gone\" is a\n * dead end; the timeline still holds the change, so a tab already open on the\n * file keeps working and says so.\n */\nexport function readSessionFiles(timelinePath: string, cwd: string): FilesItemView[] {\n let content: string;\n try {\n content = fs.readFileSync(timelinePath, 'utf8');\n } catch {\n return [];\n }\n const events = parseTimeline(content);\n return foldEntries(events)\n .filter((entry) => stillExists(entry.path))\n .map((entry) => ({\n path: entry.path,\n relPath: path.relative(cwd, entry.path) || entry.path,\n tool: entry.tool,\n at: entry.at,\n count: entry.count,\n diffable: isDiffable(foldVersions(events, entry.path)),\n }));\n}\n\n/**\n * Watches one session's timeline and reports every change to its file list.\n * Announcements are deduped by fingerprint, so an unchanged list is silent.\n */\nexport function watchFiles(\n scope: HubSessionScope,\n onChange: (items: FilesItemView[]) => void,\n options: WatchFilesOptions = {},\n): FilesSource {\n const paths = new FileEditPaths();\n let timelinePath: string;\n try {\n timelinePath = paths.timelinePath(scope.cwd, paths.sessionKey(scope.sessionId));\n } catch {\n // A working directory that cannot be resolved has no timeline to read; the\n // session simply reports nothing rather than taking the hub down with it.\n onChange([]);\n return { close: () => undefined };\n }\n\n let closed = false;\n let watcher: fs.FSWatcher | undefined;\n let debounce: NodeJS.Timeout | undefined;\n let lastEmitted: string | undefined;\n\n const emit = (): void => {\n if (closed) return;\n const items = readSessionFiles(timelinePath, scope.cwd);\n const fingerprint = JSON.stringify(items);\n if (fingerprint === lastEmitted) return;\n lastEmitted = fingerprint;\n onChange(items);\n };\n\n // The timeline file is created on the first change, so the watch is placed on\n // the directory that holds it and re-checked by the poll until it appears.\n const ensureWatcher = (): void => {\n if (watcher || closed) return;\n try {\n watcher = fs.watch(path.dirname(timelinePath), () => {\n if (debounce) clearTimeout(debounce);\n debounce = setTimeout(emit, options.debounceMs ?? DEBOUNCE_MS);\n });\n watcher.on('error', () => {\n watcher?.close();\n watcher = undefined; // The poll re-establishes it once the directory is back.\n });\n } catch {\n // The directory does not exist yet; the poll keeps trying.\n }\n };\n\n ensureWatcher();\n emit();\n const poll = setInterval(() => {\n ensureWatcher();\n emit();\n }, options.pollMs ?? POLL_MS);\n\n return {\n close() {\n closed = true;\n if (debounce) clearTimeout(debounce);\n clearInterval(poll);\n watcher?.close();\n watcher = undefined;\n },\n };\n}\n\n/**\n * The files data channel: one watcher per managed session, published as\n * { items } payloads under the 'file_edits' frame type.\n */\nexport function createFilesChannel(watch: typeof watchFiles = watchFiles): WebHubChannel {\n return {\n frameType: filesChannelType,\n start(host) {\n const latest = new Map<string, FilesItemView[]>();\n const sources = new Map<string, FilesSource>();\n const channelSource: HubChannelSource = {\n payloadFor(scope) {\n const items = latest.get(scope.sessionId);\n return items === undefined ? undefined : { items };\n },\n sessionAdded(scope) {\n sources.set(\n scope.sessionId,\n watch(scope, (items) => {\n latest.set(scope.sessionId, items);\n host.publish(scope.sessionId, { items });\n }),\n );\n },\n sessionRemoved(sessionId) {\n sources.get(sessionId)?.close();\n sources.delete(sessionId);\n latest.delete(sessionId);\n },\n close() {\n for (const source of sources.values()) source.close();\n sources.clear();\n latest.clear();\n },\n };\n return channelSource;\n },\n };\n}\n\n/** The named export the hub imports from the built entry the doompiWeb block names. */\nexport const webHubChannels: readonly WebHubChannel[] = [createFilesChannel()];\n"],"mappings":"0RAmCA,SAAS,EAAY,EAA2B,CAC9C,GAAI,CACF,OAAO,EAAG,SAAS,CAAQ,CAAC,CAAC,OAAO,CACtC,MAAQ,CACN,MAAO,EACT,CACF,CAUA,SAAgB,EAAiB,EAAsB,EAA8B,CACnF,IAAI,EACJ,GAAI,CACF,EAAU,EAAG,aAAa,EAAc,MAAM,CAChD,MAAQ,CACN,MAAO,CAAC,CACV,CACA,IAAM,EAAS,EAAc,CAAO,EACpC,OAAO,EAAY,CAAM,CAAC,CACvB,OAAQ,GAAU,EAAY,EAAM,IAAI,CAAC,CAAC,CAC1C,IAAK,IAAW,CACf,KAAM,EAAM,KACZ,QAAS,EAAK,SAAS,EAAK,EAAM,IAAI,GAAK,EAAM,KACjD,KAAM,EAAM,KACZ,GAAI,EAAM,GACV,MAAO,EAAM,MACb,SAAU,EAAW,EAAa,EAAQ,EAAM,IAAI,CAAC,CACvD,EAAE,CACN,CAMA,SAAgB,EACd,EACA,EACA,EAA6B,CAAC,EACjB,CACb,IAAM,EAAQ,IAAI,EACd,EACJ,GAAI,CACF,EAAe,EAAM,aAAa,EAAM,IAAK,EAAM,WAAW,EAAM,SAAS,CAAC,CAChF,MAAQ,CAIN,OADA,EAAS,CAAC,CAAC,EACJ,CAAE,UAAa,IAAA,EAAU,CAClC,CAEA,IAAI,EAAS,GACT,EACA,EACA,EAEE,MAAmB,CACvB,GAAI,EAAQ,OACZ,IAAM,EAAQ,EAAiB,EAAc,EAAM,GAAG,EAChD,EAAc,KAAK,UAAU,CAAK,EACpC,IAAgB,IACpB,EAAc,EACd,EAAS,CAAK,EAChB,EAIM,MAA4B,CAC5B,QAAW,GACf,GAAI,CACF,EAAU,EAAG,MAAM,EAAK,QAAQ,CAAY,MAAS,CAC/C,GAAU,aAAa,CAAQ,EACnC,EAAW,WAAW,EAAM,EAAQ,YAAc,GAAW,CAC/D,CAAC,EACD,EAAQ,GAAG,YAAe,CACxB,GAAS,MAAM,EACf,EAAU,IAAA,EACZ,CAAC,CACH,MAAQ,CAER,CACF,EAEA,EAAc,EACd,EAAK,EACL,IAAM,EAAO,gBAAkB,CAC7B,EAAc,EACd,EAAK,CACP,EAAG,EAAQ,QAAU,GAAO,EAE5B,MAAO,CACL,OAAQ,CACN,EAAS,GACL,GAAU,aAAa,CAAQ,EACnC,cAAc,CAAI,EAClB,GAAS,MAAM,EACf,EAAU,IAAA,EACZ,CACF,CACF,CAMA,SAAgB,EAAmB,EAA2B,EAA2B,CACvF,MAAO,CACL,UAAW,EACX,MAAM,EAAM,CACV,IAAM,EAAS,IAAI,IACb,EAAU,IAAI,IA0BpB,MAAO,CAxBL,WAAW,EAAO,CAChB,IAAM,EAAQ,EAAO,IAAI,EAAM,SAAS,EACxC,OAAO,IAAU,IAAA,GAAY,IAAA,GAAY,CAAE,OAAM,CACnD,EACA,aAAa,EAAO,CAClB,EAAQ,IACN,EAAM,UACN,EAAM,EAAQ,GAAU,CACtB,EAAO,IAAI,EAAM,UAAW,CAAK,EACjC,EAAK,QAAQ,EAAM,UAAW,CAAE,OAAM,CAAC,CACzC,CAAC,CACH,CACF,EACA,eAAe,EAAW,CACxB,EAAQ,IAAI,CAAS,CAAC,EAAE,MAAM,EAC9B,EAAQ,OAAO,CAAS,EACxB,EAAO,OAAO,CAAS,CACzB,EACA,OAAQ,CACN,IAAK,IAAM,KAAU,EAAQ,OAAO,EAAG,EAAO,MAAM,EACpD,EAAQ,MAAM,EACd,EAAO,MAAM,CACf,CAEiB,CACrB,CACF,CACF,CAGA,MAAa,EAA2C,CAAC,EAAmB,CAAC"}
1
+ {"version":3,"file":"webFilesChannel.mjs","names":[],"sources":["../../src/adapters/webFilesChannel.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\nimport type { HubChannelSource, HubSessionScope, WebHubChannel } from '@agimon-ai/doompi-web-contracts';\nimport { filterDoomIgnoredFiles } from '../services/doomIgnore.ts';\nimport { foldEntries, foldVersions, isDiffable, parseTimeline } from '../services/fileChanges.ts';\nimport { filesChannelType, type FilesItemView } from '../types/webFiles.ts';\nimport { FileEditPaths } from './FileEditPaths/FileEditPaths.ts';\n\n/**\n * The changed-files channel: one watcher per managed session over the timeline\n * that session's extension appends to.\n *\n * The hub runs in its own process, so it reads what the extension wrote rather\n * than talking to it. Both halves derive the same path from the same session id\n * and working directory, which is what lets them meet on disk without an\n * agreement to keep in sync.\n *\n * Same reliability posture as this repository's other watchers: the poll is the\n * source of truth and fs.watch is only an accelerator, so a missed inotify\n * event costs latency rather than correctness, and a timeline that does not\n * exist yet (a session that has changed nothing) is a normal state.\n */\n\nconst POLL_MS = 2000;\nconst DEBOUNCE_MS = 120;\n\nexport interface FilesSource {\n close(): void;\n}\n\nexport interface WatchFilesOptions {\n pollMs?: number;\n debounceMs?: number;\n}\n\n/** Whether the file is still there to be opened. */\nfunction stillExists(filePath: string): boolean {\n try {\n return fs.statSync(filePath).isFile();\n } catch {\n return false;\n }\n}\n\n/**\n * Reads one session's timeline and presents it as the rows the dock lists.\n *\n * A file the session changed and then removed is left out. The dock is a list\n * of things to open, and a row that can only ever answer \"this is gone\" is a\n * dead end; the timeline still holds the change, so a tab already open on the\n * file keeps working and says so.\n */\nexport function readSessionFiles(timelinePath: string, cwd: string): FilesItemView[] {\n let content: string;\n try {\n content = fs.readFileSync(timelinePath, 'utf8');\n } catch {\n return [];\n }\n const events = parseTimeline(content);\n const items = foldEntries(events)\n .filter((entry) => stillExists(entry.path))\n .map((entry) => ({\n path: entry.path,\n relPath: path.relative(cwd, entry.path) || entry.path,\n tool: entry.tool,\n at: entry.at,\n count: entry.count,\n diffable: isDiffable(foldVersions(events, entry.path)),\n }));\n\n try {\n const doomIgnore = fs.readFileSync(path.join(cwd, '.doomignore'), 'utf8');\n return filterDoomIgnoredFiles(items, doomIgnore);\n } catch {\n return items;\n }\n}\n\n/**\n * Watches one session's timeline and reports every change to its file list.\n * Announcements are deduped by fingerprint, so an unchanged list is silent.\n */\nexport function watchFiles(\n scope: HubSessionScope,\n onChange: (items: FilesItemView[]) => void,\n options: WatchFilesOptions = {},\n): FilesSource {\n const paths = new FileEditPaths();\n let timelinePath: string;\n try {\n timelinePath = paths.timelinePath(scope.cwd, paths.sessionKey(scope.sessionId));\n } catch {\n // A working directory that cannot be resolved has no timeline to read; the\n // session simply reports nothing rather than taking the hub down with it.\n onChange([]);\n return { close: () => undefined };\n }\n\n let closed = false;\n let watcher: fs.FSWatcher | undefined;\n let debounce: NodeJS.Timeout | undefined;\n let lastEmitted: string | undefined;\n\n const emit = (): void => {\n if (closed) return;\n const items = readSessionFiles(timelinePath, scope.cwd);\n const fingerprint = JSON.stringify(items);\n if (fingerprint === lastEmitted) return;\n lastEmitted = fingerprint;\n onChange(items);\n };\n\n // The timeline file is created on the first change, so the watch is placed on\n // the directory that holds it and re-checked by the poll until it appears.\n const ensureWatcher = (): void => {\n if (watcher || closed) return;\n try {\n watcher = fs.watch(path.dirname(timelinePath), () => {\n if (debounce) clearTimeout(debounce);\n debounce = setTimeout(emit, options.debounceMs ?? DEBOUNCE_MS);\n });\n watcher.on('error', () => {\n watcher?.close();\n watcher = undefined; // The poll re-establishes it once the directory is back.\n });\n } catch {\n // The directory does not exist yet; the poll keeps trying.\n }\n };\n\n ensureWatcher();\n emit();\n const poll = setInterval(() => {\n ensureWatcher();\n emit();\n }, options.pollMs ?? POLL_MS);\n\n return {\n close() {\n closed = true;\n if (debounce) clearTimeout(debounce);\n clearInterval(poll);\n watcher?.close();\n watcher = undefined;\n },\n };\n}\n\n/**\n * The files data channel: one watcher per managed session, published as\n * { items } payloads under the 'file_edits' frame type.\n */\nexport function createFilesChannel(watch: typeof watchFiles = watchFiles): WebHubChannel {\n return {\n frameType: filesChannelType,\n start(host) {\n const latest = new Map<string, FilesItemView[]>();\n const sources = new Map<string, FilesSource>();\n const channelSource: HubChannelSource = {\n payloadFor(scope) {\n const items = latest.get(scope.sessionId);\n return items === undefined ? undefined : { items };\n },\n sessionAdded(scope) {\n sources.set(\n scope.sessionId,\n watch(scope, (items) => {\n latest.set(scope.sessionId, items);\n host.publish(scope.sessionId, { items });\n }),\n );\n },\n sessionRemoved(sessionId) {\n sources.get(sessionId)?.close();\n sources.delete(sessionId);\n latest.delete(sessionId);\n },\n close() {\n for (const source of sources.values()) source.close();\n sources.clear();\n latest.clear();\n },\n };\n return channelSource;\n },\n };\n}\n\n/** The named export the hub imports from the built entry the doompiWeb block names. */\nexport const webHubChannels: readonly WebHubChannel[] = [createFilesChannel()];\n"],"mappings":"8VAoCA,SAAS,EAAY,EAA2B,CAC9C,GAAI,CACF,OAAO,EAAG,SAAS,CAAQ,CAAC,CAAC,OAAO,CACtC,MAAQ,CACN,MAAO,EACT,CACF,CAUA,SAAgB,EAAiB,EAAsB,EAA8B,CACnF,IAAI,EACJ,GAAI,CACF,EAAU,EAAG,aAAa,EAAc,MAAM,CAChD,MAAQ,CACN,MAAO,CAAC,CACV,CACA,IAAM,EAAS,EAAc,CAAO,EAC9B,EAAQ,EAAY,CAAM,CAAC,CAC9B,OAAQ,GAAU,EAAY,EAAM,IAAI,CAAC,CAAC,CAC1C,IAAK,IAAW,CACf,KAAM,EAAM,KACZ,QAAS,EAAK,SAAS,EAAK,EAAM,IAAI,GAAK,EAAM,KACjD,KAAM,EAAM,KACZ,GAAI,EAAM,GACV,MAAO,EAAM,MACb,SAAU,EAAW,EAAa,EAAQ,EAAM,IAAI,CAAC,CACvD,EAAE,EAEJ,GAAI,CACF,IAAM,EAAa,EAAG,aAAa,EAAK,KAAK,EAAK,aAAa,EAAG,MAAM,EACxE,OAAO,EAAuB,EAAO,CAAU,CACjD,MAAQ,CACN,OAAO,CACT,CACF,CAMA,SAAgB,EACd,EACA,EACA,EAA6B,CAAC,EACjB,CACb,IAAM,EAAQ,IAAI,EACd,EACJ,GAAI,CACF,EAAe,EAAM,aAAa,EAAM,IAAK,EAAM,WAAW,EAAM,SAAS,CAAC,CAChF,MAAQ,CAIN,OADA,EAAS,CAAC,CAAC,EACJ,CAAE,UAAa,IAAA,EAAU,CAClC,CAEA,IAAI,EAAS,GACT,EACA,EACA,EAEE,MAAmB,CACvB,GAAI,EAAQ,OACZ,IAAM,EAAQ,EAAiB,EAAc,EAAM,GAAG,EAChD,EAAc,KAAK,UAAU,CAAK,EACpC,IAAgB,IACpB,EAAc,EACd,EAAS,CAAK,EAChB,EAIM,MAA4B,CAC5B,QAAW,GACf,GAAI,CACF,EAAU,EAAG,MAAM,EAAK,QAAQ,CAAY,MAAS,CAC/C,GAAU,aAAa,CAAQ,EACnC,EAAW,WAAW,EAAM,EAAQ,YAAc,GAAW,CAC/D,CAAC,EACD,EAAQ,GAAG,YAAe,CACxB,GAAS,MAAM,EACf,EAAU,IAAA,EACZ,CAAC,CACH,MAAQ,CAER,CACF,EAEA,EAAc,EACd,EAAK,EACL,IAAM,EAAO,gBAAkB,CAC7B,EAAc,EACd,EAAK,CACP,EAAG,EAAQ,QAAU,GAAO,EAE5B,MAAO,CACL,OAAQ,CACN,EAAS,GACL,GAAU,aAAa,CAAQ,EACnC,cAAc,CAAI,EAClB,GAAS,MAAM,EACf,EAAU,IAAA,EACZ,CACF,CACF,CAMA,SAAgB,EAAmB,EAA2B,EAA2B,CACvF,MAAO,CACL,UAAW,EACX,MAAM,EAAM,CACV,IAAM,EAAS,IAAI,IACb,EAAU,IAAI,IA0BpB,MAAO,CAxBL,WAAW,EAAO,CAChB,IAAM,EAAQ,EAAO,IAAI,EAAM,SAAS,EACxC,OAAO,IAAU,IAAA,GAAY,IAAA,GAAY,CAAE,OAAM,CACnD,EACA,aAAa,EAAO,CAClB,EAAQ,IACN,EAAM,UACN,EAAM,EAAQ,GAAU,CACtB,EAAO,IAAI,EAAM,UAAW,CAAK,EACjC,EAAK,QAAQ,EAAM,UAAW,CAAE,OAAM,CAAC,CACzC,CAAC,CACH,CACF,EACA,eAAe,EAAW,CACxB,EAAQ,IAAI,CAAS,CAAC,EAAE,MAAM,EAC9B,EAAQ,OAAO,CAAS,EACxB,EAAO,OAAO,CAAS,CACzB,EACA,OAAQ,CACN,IAAK,IAAM,KAAU,EAAQ,OAAO,EAAG,EAAO,MAAM,EACpD,EAAQ,MAAM,EACd,EAAO,MAAM,CACf,CAEiB,CACrB,CACF,CACF,CAGA,MAAa,EAA2C,CAAC,EAAmB,CAAC"}
@@ -0,0 +1,2 @@
1
+ const e=require("../_virtual/_rolldown/runtime.cjs");let t=require("ignore");t=e.__toESM(t,1);function n(e){let t=e.replace(/\\/gu,`/`);if(!(t.length===0||t.includes(`\0`)||t===`..`||t.startsWith(`../`)||t.startsWith(`/`)||/^[A-Za-z]:\//u.test(t)))return t}function r(e,r){try{if(r.trim().length===0)return e;let i=(0,t.default)().add(r);return e.filter(e=>{let t=n(e.relPath);return t===void 0||!i.ignores(t)})}catch{return e}}exports.filterDoomIgnoredFiles=r;
2
+ //# sourceMappingURL=doomIgnore.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"doomIgnore.cjs","names":[],"sources":["../../src/services/doomIgnore.ts"],"sourcesContent":["import ignore from 'ignore';\nimport type { FilesItemView } from '../types/webFiles.ts';\n\nfunction matchablePath(relPath: string): string | undefined {\n const normalized = relPath.replace(/\\\\/gu, '/');\n if (\n normalized.length === 0 ||\n normalized.includes('\\0') ||\n normalized === '..' ||\n normalized.startsWith('../') ||\n normalized.startsWith('/') ||\n /^[A-Za-z]:\\//u.test(normalized)\n ) {\n return undefined;\n }\n return normalized;\n}\n\n/** Applies project-local gitignore rules while keeping unsafe candidates visible. */\nexport function filterDoomIgnoredFiles(items: FilesItemView[], content: string): FilesItemView[] {\n try {\n if (content.trim().length === 0) return items;\n const matcher = ignore().add(content);\n return items.filter((item) => {\n const candidate = matchablePath(item.relPath);\n return candidate === undefined || !matcher.ignores(candidate);\n });\n } catch {\n return items;\n }\n}\n"],"mappings":"8FAGA,SAAS,EAAc,EAAqC,CAC1D,IAAM,EAAa,EAAQ,QAAQ,OAAQ,GAAG,EAE5C,OAAW,SAAW,GACtB,EAAW,SAAS,IAAI,GACxB,IAAe,MACf,EAAW,WAAW,KAAK,GAC3B,EAAW,WAAW,GAAG,GACzB,gBAAgB,KAAK,CAAU,GAIjC,OAAO,CACT,CAGA,SAAgB,EAAuB,EAAwB,EAAkC,CAC/F,GAAI,CACF,GAAI,EAAQ,KAAK,CAAC,CAAC,SAAW,EAAG,OAAO,EACxC,IAAM,GAAA,EAAU,EAAA,QAAA,CAAO,CAAC,CAAC,IAAI,CAAO,EACpC,OAAO,EAAM,OAAQ,GAAS,CAC5B,IAAM,EAAY,EAAc,EAAK,OAAO,EAC5C,OAAO,IAAc,IAAA,IAAa,CAAC,EAAQ,QAAQ,CAAS,CAC9D,CAAC,CACH,MAAQ,CACN,OAAO,CACT,CACF"}
@@ -0,0 +1,2 @@
1
+ import e from"ignore";function t(e){let t=e.replace(/\\/gu,`/`);if(!(t.length===0||t.includes(`\0`)||t===`..`||t.startsWith(`../`)||t.startsWith(`/`)||/^[A-Za-z]:\//u.test(t)))return t}function n(n,r){try{if(r.trim().length===0)return n;let i=e().add(r);return n.filter(e=>{let n=t(e.relPath);return n===void 0||!i.ignores(n)})}catch{return n}}export{n as filterDoomIgnoredFiles};
2
+ //# sourceMappingURL=doomIgnore.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"doomIgnore.mjs","names":[],"sources":["../../src/services/doomIgnore.ts"],"sourcesContent":["import ignore from 'ignore';\nimport type { FilesItemView } from '../types/webFiles.ts';\n\nfunction matchablePath(relPath: string): string | undefined {\n const normalized = relPath.replace(/\\\\/gu, '/');\n if (\n normalized.length === 0 ||\n normalized.includes('\\0') ||\n normalized === '..' ||\n normalized.startsWith('../') ||\n normalized.startsWith('/') ||\n /^[A-Za-z]:\\//u.test(normalized)\n ) {\n return undefined;\n }\n return normalized;\n}\n\n/** Applies project-local gitignore rules while keeping unsafe candidates visible. */\nexport function filterDoomIgnoredFiles(items: FilesItemView[], content: string): FilesItemView[] {\n try {\n if (content.trim().length === 0) return items;\n const matcher = ignore().add(content);\n return items.filter((item) => {\n const candidate = matchablePath(item.relPath);\n return candidate === undefined || !matcher.ignores(candidate);\n });\n } catch {\n return items;\n }\n}\n"],"mappings":"sBAGA,SAAS,EAAc,EAAqC,CAC1D,IAAM,EAAa,EAAQ,QAAQ,OAAQ,GAAG,EAE5C,OAAW,SAAW,GACtB,EAAW,SAAS,IAAI,GACxB,IAAe,MACf,EAAW,WAAW,KAAK,GAC3B,EAAW,WAAW,GAAG,GACzB,gBAAgB,KAAK,CAAU,GAIjC,OAAO,CACT,CAGA,SAAgB,EAAuB,EAAwB,EAAkC,CAC/F,GAAI,CACF,GAAI,EAAQ,KAAK,CAAC,CAAC,SAAW,EAAG,OAAO,EACxC,IAAM,EAAU,EAAO,CAAC,CAAC,IAAI,CAAO,EACpC,OAAO,EAAM,OAAQ,GAAS,CAC5B,IAAM,EAAY,EAAc,EAAK,OAAO,EAC5C,OAAO,IAAc,IAAA,IAAa,CAAC,EAAQ,QAAQ,CAAS,CAC9D,CAAC,CACH,MAAQ,CACN,OAAO,CACT,CACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agimon-ai/doompi-file-edit",
3
- "version": "0.0.1-alpha.40",
3
+ "version": "0.0.1-alpha.43",
4
4
  "description": "Pi extension for session-scoped file-change timelines and external editor workflows.",
5
5
  "keywords": [
6
6
  "ai",
@@ -68,17 +68,18 @@
68
68
  "@deepseek-ai/cordis": "4.0.1",
69
69
  "cross-spawn": "7.0.6",
70
70
  "hono": "4.13.3",
71
- "@agimon-ai/doompi-config": "0.0.1-alpha.40",
72
- "@agimon-ai/doompi-ui": "0.0.1-alpha.40",
73
- "@agimon-ai/doompi-web-components": "0.0.1-alpha.1",
74
- "@agimon-ai/doompi-telemetry": "0.0.1-alpha.40",
75
- "@agimon-ai/doompi-web-contracts": "0.0.1-alpha.3",
76
- "@agimon-ai/doompi-web-security": "0.0.1-alpha.2",
77
- "@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.40"
71
+ "ignore": "7.0.6",
72
+ "@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.43",
73
+ "@agimon-ai/doompi-telemetry": "0.0.1-alpha.43",
74
+ "@agimon-ai/doompi-ui": "0.0.1-alpha.43",
75
+ "@agimon-ai/doompi-web-contracts": "0.0.1-alpha.6",
76
+ "@agimon-ai/doompi-web-components": "0.0.1-alpha.4",
77
+ "@agimon-ai/doompi-web-security": "0.0.1-alpha.5",
78
+ "@agimon-ai/doompi-config": "0.0.1-alpha.43"
78
79
  },
79
80
  "devDependencies": {
80
- "@earendil-works/pi-coding-agent": "0.84.3",
81
- "@earendil-works/pi-tui": "0.84.3",
81
+ "@earendil-works/pi-coding-agent": "0.84.4",
82
+ "@earendil-works/pi-tui": "0.84.4",
82
83
  "@tanstack/react-store": "0.11.1",
83
84
  "@tanstack/store": "0.11.1",
84
85
  "@types/cross-spawn": "6.0.6",
@@ -93,8 +94,8 @@
93
94
  "vitest": "4.1.11"
94
95
  },
95
96
  "peerDependencies": {
96
- "@earendil-works/pi-coding-agent": "0.84.3",
97
- "@earendil-works/pi-tui": "0.84.3"
97
+ "@earendil-works/pi-coding-agent": "0.84.4",
98
+ "@earendil-works/pi-tui": "0.84.4"
98
99
  },
99
100
  "peerDependenciesMeta": {
100
101
  "@earendil-works/pi-coding-agent": {
@@ -1,10 +1,11 @@
1
1
  import { Button } from '@agimon-ai/doompi-web-components';
2
2
  import type { WebPluginSlotProps } from '@agimon-ai/doompi-web-contracts';
3
3
  import { useStore } from '@tanstack/react-store';
4
+ import { useState } from 'react';
4
5
  import type { FilesItemView } from '../src/types/webFiles.ts';
5
6
  import { fileTab } from './FilePanel.tsx';
7
+ import { FileActivityRow, FilesBrowser } from './FilesBrowser.tsx';
6
8
  import { files } from './filesStore.ts';
7
- import { TOOL_LABEL } from './fileView.ts';
8
9
 
9
10
  /**
10
11
  * The files group's body in the activity dock: what this session has changed.
@@ -16,11 +17,17 @@ import { TOOL_LABEL } from './fileView.ts';
16
17
  */
17
18
 
18
19
  /** The most recent changes first, which is the order the timeline already folds to. */
19
- const VISIBLE_ROWS = 12;
20
+ const VISIBLE_ROWS = 5;
20
21
 
21
22
  export function FilesActivitySection({ sessionId, openTransientTab }: WebPluginSlotProps) {
22
23
  const { items } = useStore(files.store, (state) => files.select(state, sessionId));
24
+ const [browserOpen, setBrowserOpen] = useState(false);
23
25
 
26
+ const openFile = (item: FilesItemView): void => {
27
+ if (sessionId === null) return;
28
+ openTransientTab(fileTab(item.path, item.relPath));
29
+ setBrowserOpen(false);
30
+ };
24
31
  if (items.length === 0) {
25
32
  return (
26
33
  <p data-testid="activity-summary-files" className="px-1 text-[10px] text-doom-faint">
@@ -34,33 +41,21 @@ export function FilesActivitySection({ sessionId, openTransientTab }: WebPluginS
34
41
  return (
35
42
  <div data-testid="activity-file-edits" className="flex flex-col gap-0.5">
36
43
  {visible.map((item: FilesItemView) => (
44
+ <FileActivityRow key={item.path} item={item} onOpen={() => openFile(item)} />
45
+ ))}
46
+ {items.length > visible.length ? (
37
47
  <Button
38
- key={item.path}
39
- variant="ghost"
40
- size="card"
41
- data-testid={`activity-file-${item.relPath}`}
42
- data-file-diffable={item.diffable}
43
- title={item.diffable ? item.path : `${item.path} (changed by a command, so no diff was captured)`}
44
- onClick={() => {
45
- if (sessionId === null) return;
46
- openTransientTab(fileTab(item.path, item.relPath));
47
- }}
48
- className="min-w-0 gap-0.5 rounded-[5px] px-1 py-1 hover:bg-doom-panel"
48
+ variant="link"
49
+ size="xs"
50
+ data-testid="activity-files-show-all"
51
+ aria-label={`show all ${items.length} changed files`}
52
+ className="self-start px-1 pt-0.5 text-[9px] text-doom-faint"
53
+ onClick={() => setBrowserOpen(true)}
49
54
  >
50
- <span className="flex min-w-0 w-full items-center gap-1.5">
51
- <span className="min-w-0 flex-1 truncate text-left text-[10px] font-bold text-doom-hi">{item.relPath}</span>
52
- {item.count > 1 ? <span className="shrink-0 text-[9px] text-doom-faint">{item.count}×</span> : null}
53
- <span className={`shrink-0 text-[9px] ${item.diffable ? 'text-doom-faint' : 'text-doom-yellow'}`}>
54
- {TOOL_LABEL[item.tool] ?? item.tool}
55
- </span>
56
- </span>
55
+ show all {items.length} files
57
56
  </Button>
58
- ))}
59
- {items.length > visible.length ? (
60
- <p data-testid="activity-file-overflow" className="px-1 pt-0.5 text-[9px] text-doom-faint">
61
- and {items.length - visible.length} more
62
- </p>
63
57
  ) : null}
58
+ {browserOpen ? <FilesBrowser items={items} onClose={() => setBrowserOpen(false)} onOpen={openFile} /> : null}
64
59
  </div>
65
60
  );
66
61
  }
@@ -0,0 +1,196 @@
1
+ import { Button, CloseIcon, Input } from '@agimon-ai/doompi-web-components';
2
+ import type { ChangeEvent, KeyboardEvent as ReactKeyboardEvent } from 'react';
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import type { FilesItemView } from '../src/types/webFiles.ts';
5
+ import { filterFileItems, TOOL_LABEL } from './fileView.ts';
6
+
7
+ function FileMetadata({ item }: { item: FilesItemView }) {
8
+ return (
9
+ <span className="flex min-w-0 w-full items-center gap-1.5">
10
+ <span className="min-w-0 flex-1 truncate text-left text-[10px] font-bold text-doom-hi">{item.relPath}</span>
11
+ {item.count > 1 ? <span className="shrink-0 text-[9px] text-doom-faint">{item.count}×</span> : null}
12
+ <span className={`shrink-0 text-[9px] ${item.diffable ? 'text-doom-faint' : 'text-doom-yellow'}`}>
13
+ {TOOL_LABEL[item.tool] ?? item.tool}
14
+ </span>
15
+ </span>
16
+ );
17
+ }
18
+
19
+ export function FileActivityRow({ item, onOpen }: { item: FilesItemView; onOpen: () => void }) {
20
+ return (
21
+ <Button
22
+ variant="ghost"
23
+ size="card"
24
+ data-testid={`activity-file-${item.relPath}`}
25
+ data-file-diffable={item.diffable}
26
+ title={item.diffable ? item.path : `${item.path} (changed by a command, so no diff was captured)`}
27
+ onClick={onOpen}
28
+ className="min-w-0 gap-0.5 rounded-[5px] px-1 py-1 hover:bg-doom-panel"
29
+ >
30
+ <FileMetadata item={item} />
31
+ </Button>
32
+ );
33
+ }
34
+
35
+ function BrowserFileRow({
36
+ item,
37
+ selected,
38
+ onSelect,
39
+ onOpen,
40
+ }: {
41
+ item: FilesItemView;
42
+ selected: boolean;
43
+ onSelect: () => void;
44
+ onOpen: () => void;
45
+ }) {
46
+ return (
47
+ <div
48
+ role="option"
49
+ aria-selected={selected}
50
+ tabIndex={-1}
51
+ data-testid={`files-browser-file-${item.relPath}`}
52
+ data-file-diffable={item.diffable}
53
+ data-selected={selected}
54
+ title={item.diffable ? item.path : `${item.path} (changed by a command, so no diff was captured)`}
55
+ onMouseEnter={onSelect}
56
+ onClick={onOpen}
57
+ className={`flex min-w-0 cursor-pointer border-l-2 px-4 py-2 ${
58
+ selected ? 'border-doom-blue bg-doom-panel' : 'border-transparent hover:bg-doom-panel/60'
59
+ }`}
60
+ >
61
+ <FileMetadata item={item} />
62
+ </div>
63
+ );
64
+ }
65
+
66
+ /** The complete session file picker, ordered exactly as the file timeline supplies it. */
67
+ export function FilesBrowser({
68
+ items,
69
+ onClose,
70
+ onOpen,
71
+ }: {
72
+ items: readonly FilesItemView[];
73
+ onClose: () => void;
74
+ onOpen: (item: FilesItemView) => void;
75
+ }) {
76
+ const [query, setQuery] = useState('');
77
+ const [selectedIndex, setSelectedIndex] = useState(0);
78
+ const inputRef = useRef<HTMLInputElement>(null);
79
+ const listRef = useRef<HTMLDivElement>(null);
80
+ const shown = filterFileItems(items, query);
81
+ const selected = shown[selectedIndex];
82
+
83
+ useEffect(() => {
84
+ setSelectedIndex((current) => Math.min(current, Math.max(0, shown.length - 1)));
85
+ }, [shown.length]);
86
+
87
+ useEffect(() => {
88
+ listRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' });
89
+ }, [selectedIndex]);
90
+
91
+ const move = (delta: number): void => {
92
+ if (shown.length === 0) return;
93
+ setSelectedIndex((current) => (current + delta + shown.length) % shown.length);
94
+ };
95
+
96
+ const onKeyDown = (event: ReactKeyboardEvent<HTMLElement>): void => {
97
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
98
+ event.preventDefault();
99
+ move(event.key === 'ArrowDown' ? 1 : -1);
100
+ return;
101
+ }
102
+ if (event.key === 'Escape') {
103
+ event.preventDefault();
104
+ onClose();
105
+ return;
106
+ }
107
+ if (
108
+ event.key === 'Enter' &&
109
+ selected !== undefined &&
110
+ (!(event.target instanceof Element) || event.target.closest('button') === null)
111
+ ) {
112
+ event.preventDefault();
113
+ onOpen(selected);
114
+ }
115
+ };
116
+
117
+ return (
118
+ <aside
119
+ data-testid="files-browser"
120
+ aria-label="changed files"
121
+ tabIndex={-1}
122
+ onKeyDown={onKeyDown}
123
+ className="fixed inset-y-0 right-0 z-50 flex w-[min(440px,calc(100vw-24px))] flex-col overflow-hidden border-l border-doom-border bg-doom-rail outline-none"
124
+ >
125
+ <div className="flex h-11 shrink-0 items-center gap-2.5 border-b border-doom-border px-4">
126
+ <span className="text-[13px] font-bold text-doom-hi"># files</span>
127
+ <span data-testid="files-browser-total" className="text-[9px] text-doom-faint">
128
+ {items.length} changed
129
+ </span>
130
+ <span className="min-w-0 flex-1" />
131
+ <Button
132
+ variant="ghost"
133
+ size="icon"
134
+ data-testid="files-browser-close"
135
+ aria-label="close changed files"
136
+ title="close changed files"
137
+ onClick={onClose}
138
+ >
139
+ <CloseIcon className="h-3 w-3" />
140
+ </Button>
141
+ </div>
142
+ <div className="flex items-center gap-2 px-4 pt-2.5 pb-1.5">
143
+ <Input
144
+ ref={inputRef}
145
+ data-testid="files-browser-search"
146
+ aria-label="search changed file paths"
147
+ value={query}
148
+ placeholder="search paths…"
149
+ autoFocus
150
+ onChange={(event: ChangeEvent<HTMLInputElement>) => {
151
+ setQuery(event.target.value);
152
+ setSelectedIndex(0);
153
+ }}
154
+ />
155
+ {query.length > 0 ? (
156
+ <Button
157
+ variant="ghost"
158
+ size="xs"
159
+ data-testid="files-browser-clear"
160
+ aria-label="clear path search"
161
+ onClick={() => {
162
+ setQuery('');
163
+ setSelectedIndex(0);
164
+ inputRef.current?.focus();
165
+ }}
166
+ >
167
+ clear
168
+ </Button>
169
+ ) : null}
170
+ </div>
171
+ <p data-testid="files-browser-matches" className="shrink-0 px-4 pb-2 text-[9px] text-doom-faint">
172
+ {shown.length} matches · newest change first
173
+ </p>
174
+ <div
175
+ ref={listRef}
176
+ role="listbox"
177
+ aria-label="changed files"
178
+ className="flex min-h-0 flex-1 flex-col overflow-y-auto pb-2"
179
+ >
180
+ {shown.length === 0 ? <p className="px-4 py-3 text-[10px] text-doom-faint">nothing matches the path</p> : null}
181
+ {shown.map((item, index) => (
182
+ <BrowserFileRow
183
+ key={item.path}
184
+ item={item}
185
+ selected={index === selectedIndex}
186
+ onSelect={() => setSelectedIndex(index)}
187
+ onOpen={() => onOpen(item)}
188
+ />
189
+ ))}
190
+ </div>
191
+ <div className="flex h-8 shrink-0 items-center border-t border-doom-border-soft bg-doom-deep px-4">
192
+ <span className="text-[9px] text-doom-faint">↑↓ choose · enter open · esc close</span>
193
+ </div>
194
+ </aside>
195
+ );
196
+ }
package/web/fileView.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { grammarKeyOf, mediaKindOf } from '@agimon-ai/doompi-web-components';
2
2
  import type { FileEditTool } from '../src/types/domain.ts';
3
3
  import type { FileEditsDiffHunk } from '../src/types/fileEditsApi.ts';
4
+ import type { FilesItemView } from '../src/types/webFiles.ts';
4
5
  import type { FileComment } from './filesStore.ts';
5
6
 
6
7
  /**
@@ -20,6 +21,13 @@ export const TOOL_LABEL: Readonly<Record<FileEditTool, string>> = {
20
21
  user: 'you',
21
22
  };
22
23
 
24
+ /** Filters visible relative paths without changing the timeline's newest-first order. */
25
+ export function filterFileItems(items: readonly FilesItemView[], query: string): FilesItemView[] {
26
+ const normalized = query.trim().toLocaleLowerCase();
27
+ if (normalized.length === 0) return [...items];
28
+ return items.filter((item) => item.relPath.toLocaleLowerCase().includes(normalized));
29
+ }
30
+
23
31
  /**
24
32
  * How the preview shows a file: as the thing it is, wherever the browser can.
25
33
  *
package/web/index.ts CHANGED
@@ -22,8 +22,18 @@ export const webPlugin = defineWebPlugin({
22
22
  id: 'files',
23
23
  channels: [filesChannel],
24
24
  // hideWhenEmpty: a session that has changed nothing has no files to list,
25
- // and the group returns as soon as it changes one.
26
- activityGroups: [{ name: 'files', keys: 'e f', statusKey: filesStatusKey, hideWhenEmpty: true, order: 15 }],
25
+ // and the group returns as soon as it changes one. Changed files are session
26
+ // history, not background work, so their presence never marks the session busy.
27
+ activityGroups: [
28
+ {
29
+ name: 'files',
30
+ keys: 'e f',
31
+ statusKey: filesStatusKey,
32
+ hideWhenEmpty: true,
33
+ marksBackgroundWork: false,
34
+ order: 15,
35
+ },
36
+ ],
27
37
  // Same name as the group: the dock renders this inside it, in place of the
28
38
  // session's one-line summary.
29
39
  activitySections: [{ id: 'files', component: FilesActivitySection }],