@agimon-ai/doompi-file-edit 0.0.1-alpha.46 → 0.0.1-alpha.49
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/dist/adapters/TimelineStore/TimelineStore.cjs +1 -1
- package/dist/adapters/TimelineStore/TimelineStore.cjs.map +1 -1
- package/dist/adapters/TimelineStore/TimelineStore.mjs +1 -1
- package/dist/adapters/TimelineStore/TimelineStore.mjs.map +1 -1
- package/dist/adapters/fileEditsApi.cjs +1 -1
- package/dist/adapters/fileEditsApi.cjs.map +1 -1
- package/dist/adapters/fileEditsApi.d.cts.map +1 -1
- package/dist/adapters/fileEditsApi.d.mts.map +1 -1
- package/dist/adapters/fileEditsApi.mjs +1 -1
- package/dist/adapters/fileEditsApi.mjs.map +1 -1
- package/dist/types/fileEditsApi.cjs.map +1 -1
- package/dist/types/fileEditsApi.mjs.map +1 -1
- package/package.json +8 -8
- package/src/types/fileEditsApi.ts +26 -3
- package/src/web/FilePreviewPanel.tsx +150 -0
- package/src/web/fileLinks.ts +13 -0
- package/src/web/filesApi.ts +18 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("../../_virtual/_rolldown/runtime.cjs"),t=require("../../services/fileChanges.cjs");let n=require("node:fs/promises");n=e.__toESM(n,1);function r(e,t){return e instanceof Error&&`code`in e&&e.code===t}var i=class{filePath;initialize(e){this.filePath=e}async append(e){await this.withLock(async()=>n.default.appendFile(this.requirePath(),`${JSON.stringify(e)}\n`,`utf8`))}async list(){return t.foldEntries(t.confirmedChanges(await this.events()))}async versions(e){return t.foldVersions(await this.events(),e)}async clear(){try{await n.default.unlink(this.requirePath())}catch(e){if(!r(e,`ENOENT`))throw e}}async events(){let e;try{e=await n.default.readFile(this.requirePath(),`utf8`)}catch(e){if(r(e,`ENOENT`))return[];throw e}return t.parseTimeline(e,e=>console.warn(`Ignoring malformed file edit timeline line: ${e.slice(0,200)}`))}requirePath(){if(!this.filePath)throw Error(`Timeline store is not initialized`);return this.filePath}async withLock(e){let t=`${this.requirePath()}.lock
|
|
1
|
+
const e=require("../../_virtual/_rolldown/runtime.cjs"),t=require("../../services/fileChanges.cjs");let n=require("node:fs/promises");n=e.__toESM(n,1);function r(e,t){return e instanceof Error&&`code`in e&&e.code===t}var i=class{filePath;initialize(e){this.filePath=e}async append(e){await this.withLock(async()=>n.default.appendFile(this.requirePath(),`${JSON.stringify(e)}\n`,`utf8`))}async list(){return t.foldEntries(t.confirmedChanges(await this.events()))}async versions(e){return t.foldVersions(await this.events(),e)}async clear(){try{await n.default.unlink(this.requirePath())}catch(e){if(!r(e,`ENOENT`))throw e}}async events(){let e;try{e=await n.default.readFile(this.requirePath(),`utf8`)}catch(e){if(r(e,`ENOENT`))return[];throw e}return t.parseTimeline(e,e=>console.warn(`Ignoring malformed file edit timeline line: ${e.slice(0,200)}`))}requirePath(){if(!this.filePath)throw Error(`Timeline store is not initialized`);return this.filePath}async withLock(e){let t=`${this.requirePath()}.lock`,n=await this.acquire(t);try{return await e()}finally{await n.close(),await this.release(t)}}async acquire(e){for(let t=0;t<400;t+=1)try{return await n.default.open(e,`wx`)}catch(t){if(!r(t,`EEXIST`))throw t;if(await this.breakStale(e))continue;await new Promise(e=>setTimeout(e,25))}throw Error(`Timed out acquiring timeline lock ${e}`)}async breakStale(e){try{let t=await n.default.stat(e);return Date.now()-t.mtimeMs<3e4?!1:(await n.default.unlink(e),!0)}catch(e){if(r(e,`ENOENT`))return!0;throw e}}async release(e){try{await n.default.unlink(e)}catch(e){r(e,`ENOENT`)||console.warn(`Could not remove timeline lock: ${String(e)}`)}}};exports.TimelineStore=i;
|
|
2
2
|
//# sourceMappingURL=TimelineStore.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TimelineStore.cjs","names":["fs","foldEntries","confirmedChanges","foldVersions","parseTimeline"],"sources":["../../../src/adapters/TimelineStore/TimelineStore.ts"],"sourcesContent":["import fs from 'node:fs/promises';\nimport {\n type AnyTimelineEvent,\n confirmedChanges,\n foldEntries,\n foldVersions,\n parseTimeline,\n} from '../../services/fileChanges.ts';\nimport type { FileEditEntry, FileEditVersion, TimelineEvent } from '../../types/domain';\nimport type { ITimelineStore } from '../../types/timelineStore';\n\nconst LOCK_RETRY_MS =
|
|
1
|
+
{"version":3,"file":"TimelineStore.cjs","names":["fs","foldEntries","confirmedChanges","foldVersions","parseTimeline"],"sources":["../../../src/adapters/TimelineStore/TimelineStore.ts"],"sourcesContent":["import fs, { type FileHandle } from 'node:fs/promises';\nimport {\n type AnyTimelineEvent,\n confirmedChanges,\n foldEntries,\n foldVersions,\n parseTimeline,\n} from '../../services/fileChanges.ts';\nimport type { FileEditEntry, FileEditVersion, TimelineEvent } from '../../types/domain';\nimport type { ITimelineStore } from '../../types/timelineStore';\n\nconst LOCK_RETRY_MS = 25;\nconst LOCK_RETRIES = 400;\n/**\n * How long a lock file may sit untouched before a waiter treats it as abandoned.\n *\n * An append writes one line, so a holder that has been there for half a minute\n * is a process that died without unlinking. Without this, that one file would\n * block every later append to the same timeline permanently.\n */\nconst LOCK_STALE_MS = 30_000;\n\nfunction hasCode(error: unknown, code: string): boolean {\n return error instanceof Error && 'code' in error && error.code === code;\n}\n\nexport class TimelineStore implements ITimelineStore {\n private filePath: string | undefined;\n\n initialize(filePath: string): void {\n this.filePath = filePath;\n }\n\n async append(event: TimelineEvent): Promise<void> {\n await this.withLock(async () => fs.appendFile(this.requirePath(), `${JSON.stringify(event)}\\n`, 'utf8'));\n }\n\n async list(): Promise<FileEditEntry[]> {\n // Listing shows what was edited, so a path a command only touched is left out.\n return foldEntries(confirmedChanges(await this.events()));\n }\n\n async versions(filePath: string): Promise<FileEditVersion[]> {\n return foldVersions(await this.events(), filePath);\n }\n\n async clear(): Promise<void> {\n try {\n await fs.unlink(this.requirePath());\n } catch (error) {\n if (!hasCode(error, 'ENOENT')) throw error;\n }\n }\n\n /** Every recorded change, or none at all when the session has not written yet. */\n private async events(): Promise<AnyTimelineEvent[]> {\n let content: string;\n try {\n content = await fs.readFile(this.requirePath(), 'utf8');\n } catch (error) {\n if (hasCode(error, 'ENOENT')) return [];\n throw error;\n }\n return parseTimeline(content, (line) =>\n console.warn(`Ignoring malformed file edit timeline line: ${line.slice(0, 200)}`),\n );\n }\n\n private requirePath(): string {\n if (!this.filePath) throw new Error('Timeline store is not initialized');\n return this.filePath;\n }\n\n private async withLock<T>(operation: () => Promise<T>): Promise<T> {\n const lockPath = `${this.requirePath()}.lock`;\n const lock = await this.acquire(lockPath);\n try {\n return await operation();\n } finally {\n await lock.close();\n await this.release(lockPath);\n }\n }\n\n /** Waits for the lock file, breaking one an earlier process left behind. */\n private async acquire(lockPath: string): Promise<FileHandle> {\n for (let attempt = 0; attempt < LOCK_RETRIES; attempt += 1) {\n try {\n return await fs.open(lockPath, 'wx');\n } catch (error) {\n if (!hasCode(error, 'EEXIST')) throw error;\n if (await this.breakStale(lockPath)) continue;\n await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));\n }\n }\n throw new Error(`Timed out acquiring timeline lock ${lockPath}`);\n }\n\n /** Whether the lock is now free to take, having removed it when its holder is gone. */\n private async breakStale(lockPath: string): Promise<boolean> {\n try {\n const stats = await fs.stat(lockPath);\n if (Date.now() - stats.mtimeMs < LOCK_STALE_MS) return false;\n await fs.unlink(lockPath);\n return true;\n } catch (error) {\n // Already gone, or another waiter broke it first: either way, try again.\n if (hasCode(error, 'ENOENT')) return true;\n throw error;\n }\n }\n\n private async release(lockPath: string): Promise<void> {\n try {\n await fs.unlink(lockPath);\n } catch (error) {\n if (!hasCode(error, 'ENOENT')) console.warn(`Could not remove timeline lock: ${String(error)}`);\n }\n }\n}\n"],"mappings":"uJAsBA,SAAS,EAAQ,EAAgB,EAAuB,CACtD,OAAO,aAAiB,OAAS,SAAU,GAAS,EAAM,OAAS,CACrE,CAEA,IAAa,EAAb,KAAqD,CACnD,SAEA,WAAW,EAAwB,CACjC,KAAK,SAAW,CAClB,CAEA,MAAM,OAAO,EAAqC,CAChD,MAAM,KAAK,SAAS,SAAYA,EAAAA,QAAG,WAAW,KAAK,YAAY,EAAG,GAAG,KAAK,UAAU,CAAK,EAAE,IAAK,MAAM,CAAC,CACzG,CAEA,MAAM,MAAiC,CAErC,OAAOC,EAAAA,YAAYC,EAAAA,iBAAiB,MAAM,KAAK,OAAO,CAAC,CAAC,CAC1D,CAEA,MAAM,SAAS,EAA8C,CAC3D,OAAOC,EAAAA,aAAa,MAAM,KAAK,OAAO,EAAG,CAAQ,CACnD,CAEA,MAAM,OAAuB,CAC3B,GAAI,CACF,MAAMH,EAAAA,QAAG,OAAO,KAAK,YAAY,CAAC,CACpC,OAAS,EAAO,CACd,GAAI,CAAC,EAAQ,EAAO,QAAQ,EAAG,MAAM,CACvC,CACF,CAGA,MAAc,QAAsC,CAClD,IAAI,EACJ,GAAI,CACF,EAAU,MAAMA,EAAAA,QAAG,SAAS,KAAK,YAAY,EAAG,MAAM,CACxD,OAAS,EAAO,CACd,GAAI,EAAQ,EAAO,QAAQ,EAAG,MAAO,CAAC,EACtC,MAAM,CACR,CACA,OAAOI,EAAAA,cAAc,EAAU,GAC7B,QAAQ,KAAK,+CAA+C,EAAK,MAAM,EAAG,GAAG,GAAG,CAClF,CACF,CAEA,aAA8B,CAC5B,GAAI,CAAC,KAAK,SAAU,MAAU,MAAM,mCAAmC,EACvE,OAAO,KAAK,QACd,CAEA,MAAc,SAAY,EAAyC,CACjE,IAAM,EAAW,GAAG,KAAK,YAAY,EAAE,OACjC,EAAO,MAAM,KAAK,QAAQ,CAAQ,EACxC,GAAI,CACF,OAAO,MAAM,EAAU,CACzB,QAAU,CACR,MAAM,EAAK,MAAM,EACjB,MAAM,KAAK,QAAQ,CAAQ,CAC7B,CACF,CAGA,MAAc,QAAQ,EAAuC,CAC3D,IAAK,IAAI,EAAU,EAAG,EAAU,IAAc,GAAW,EACvD,GAAI,CACF,OAAO,MAAMJ,EAAAA,QAAG,KAAK,EAAU,IAAI,CACrC,OAAS,EAAO,CACd,GAAI,CAAC,EAAQ,EAAO,QAAQ,EAAG,MAAM,EACrC,GAAI,MAAM,KAAK,WAAW,CAAQ,EAAG,SACrC,MAAM,IAAI,QAAS,GAAY,WAAW,EAAS,EAAa,CAAC,CACnE,CAEF,MAAU,MAAM,qCAAqC,GAAU,CACjE,CAGA,MAAc,WAAW,EAAoC,CAC3D,GAAI,CACF,IAAM,EAAQ,MAAMA,EAAAA,QAAG,KAAK,CAAQ,EAGpC,OAFI,KAAK,IAAI,EAAI,EAAM,QAAU,IAAsB,IACvD,MAAMA,EAAAA,QAAG,OAAO,CAAQ,EACjB,GACT,OAAS,EAAO,CAEd,GAAI,EAAQ,EAAO,QAAQ,EAAG,MAAO,GACrC,MAAM,CACR,CACF,CAEA,MAAc,QAAQ,EAAiC,CACrD,GAAI,CACF,MAAMA,EAAAA,QAAG,OAAO,CAAQ,CAC1B,OAAS,EAAO,CACT,EAAQ,EAAO,QAAQ,GAAG,QAAQ,KAAK,mCAAmC,OAAO,CAAK,GAAG,CAChG,CACF,CACF"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{confirmedChanges as e,foldEntries as t,foldVersions as n,parseTimeline as r}from"../../services/fileChanges.mjs";import i from"node:fs/promises";function a(e,t){return e instanceof Error&&`code`in e&&e.code===t}var o=class{filePath;initialize(e){this.filePath=e}async append(e){await this.withLock(async()=>i.appendFile(this.requirePath(),`${JSON.stringify(e)}\n`,`utf8`))}async list(){return t(e(await this.events()))}async versions(e){return n(await this.events(),e)}async clear(){try{await i.unlink(this.requirePath())}catch(e){if(!a(e,`ENOENT`))throw e}}async events(){let e;try{e=await i.readFile(this.requirePath(),`utf8`)}catch(e){if(a(e,`ENOENT`))return[];throw e}return r(e,e=>console.warn(`Ignoring malformed file edit timeline line: ${e.slice(0,200)}`))}requirePath(){if(!this.filePath)throw Error(`Timeline store is not initialized`);return this.filePath}async withLock(e){let t=`${this.requirePath()}.lock
|
|
1
|
+
import{confirmedChanges as e,foldEntries as t,foldVersions as n,parseTimeline as r}from"../../services/fileChanges.mjs";import i from"node:fs/promises";function a(e,t){return e instanceof Error&&`code`in e&&e.code===t}var o=class{filePath;initialize(e){this.filePath=e}async append(e){await this.withLock(async()=>i.appendFile(this.requirePath(),`${JSON.stringify(e)}\n`,`utf8`))}async list(){return t(e(await this.events()))}async versions(e){return n(await this.events(),e)}async clear(){try{await i.unlink(this.requirePath())}catch(e){if(!a(e,`ENOENT`))throw e}}async events(){let e;try{e=await i.readFile(this.requirePath(),`utf8`)}catch(e){if(a(e,`ENOENT`))return[];throw e}return r(e,e=>console.warn(`Ignoring malformed file edit timeline line: ${e.slice(0,200)}`))}requirePath(){if(!this.filePath)throw Error(`Timeline store is not initialized`);return this.filePath}async withLock(e){let t=`${this.requirePath()}.lock`,n=await this.acquire(t);try{return await e()}finally{await n.close(),await this.release(t)}}async acquire(e){for(let t=0;t<400;t+=1)try{return await i.open(e,`wx`)}catch(t){if(!a(t,`EEXIST`))throw t;if(await this.breakStale(e))continue;await new Promise(e=>setTimeout(e,25))}throw Error(`Timed out acquiring timeline lock ${e}`)}async breakStale(e){try{let t=await i.stat(e);return Date.now()-t.mtimeMs<3e4?!1:(await i.unlink(e),!0)}catch(e){if(a(e,`ENOENT`))return!0;throw e}}async release(e){try{await i.unlink(e)}catch(e){a(e,`ENOENT`)||console.warn(`Could not remove timeline lock: ${String(e)}`)}}};export{o as TimelineStore};
|
|
2
2
|
//# sourceMappingURL=TimelineStore.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TimelineStore.mjs","names":[],"sources":["../../../src/adapters/TimelineStore/TimelineStore.ts"],"sourcesContent":["import fs from 'node:fs/promises';\nimport {\n type AnyTimelineEvent,\n confirmedChanges,\n foldEntries,\n foldVersions,\n parseTimeline,\n} from '../../services/fileChanges.ts';\nimport type { FileEditEntry, FileEditVersion, TimelineEvent } from '../../types/domain';\nimport type { ITimelineStore } from '../../types/timelineStore';\n\nconst LOCK_RETRY_MS =
|
|
1
|
+
{"version":3,"file":"TimelineStore.mjs","names":[],"sources":["../../../src/adapters/TimelineStore/TimelineStore.ts"],"sourcesContent":["import fs, { type FileHandle } from 'node:fs/promises';\nimport {\n type AnyTimelineEvent,\n confirmedChanges,\n foldEntries,\n foldVersions,\n parseTimeline,\n} from '../../services/fileChanges.ts';\nimport type { FileEditEntry, FileEditVersion, TimelineEvent } from '../../types/domain';\nimport type { ITimelineStore } from '../../types/timelineStore';\n\nconst LOCK_RETRY_MS = 25;\nconst LOCK_RETRIES = 400;\n/**\n * How long a lock file may sit untouched before a waiter treats it as abandoned.\n *\n * An append writes one line, so a holder that has been there for half a minute\n * is a process that died without unlinking. Without this, that one file would\n * block every later append to the same timeline permanently.\n */\nconst LOCK_STALE_MS = 30_000;\n\nfunction hasCode(error: unknown, code: string): boolean {\n return error instanceof Error && 'code' in error && error.code === code;\n}\n\nexport class TimelineStore implements ITimelineStore {\n private filePath: string | undefined;\n\n initialize(filePath: string): void {\n this.filePath = filePath;\n }\n\n async append(event: TimelineEvent): Promise<void> {\n await this.withLock(async () => fs.appendFile(this.requirePath(), `${JSON.stringify(event)}\\n`, 'utf8'));\n }\n\n async list(): Promise<FileEditEntry[]> {\n // Listing shows what was edited, so a path a command only touched is left out.\n return foldEntries(confirmedChanges(await this.events()));\n }\n\n async versions(filePath: string): Promise<FileEditVersion[]> {\n return foldVersions(await this.events(), filePath);\n }\n\n async clear(): Promise<void> {\n try {\n await fs.unlink(this.requirePath());\n } catch (error) {\n if (!hasCode(error, 'ENOENT')) throw error;\n }\n }\n\n /** Every recorded change, or none at all when the session has not written yet. */\n private async events(): Promise<AnyTimelineEvent[]> {\n let content: string;\n try {\n content = await fs.readFile(this.requirePath(), 'utf8');\n } catch (error) {\n if (hasCode(error, 'ENOENT')) return [];\n throw error;\n }\n return parseTimeline(content, (line) =>\n console.warn(`Ignoring malformed file edit timeline line: ${line.slice(0, 200)}`),\n );\n }\n\n private requirePath(): string {\n if (!this.filePath) throw new Error('Timeline store is not initialized');\n return this.filePath;\n }\n\n private async withLock<T>(operation: () => Promise<T>): Promise<T> {\n const lockPath = `${this.requirePath()}.lock`;\n const lock = await this.acquire(lockPath);\n try {\n return await operation();\n } finally {\n await lock.close();\n await this.release(lockPath);\n }\n }\n\n /** Waits for the lock file, breaking one an earlier process left behind. */\n private async acquire(lockPath: string): Promise<FileHandle> {\n for (let attempt = 0; attempt < LOCK_RETRIES; attempt += 1) {\n try {\n return await fs.open(lockPath, 'wx');\n } catch (error) {\n if (!hasCode(error, 'EEXIST')) throw error;\n if (await this.breakStale(lockPath)) continue;\n await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));\n }\n }\n throw new Error(`Timed out acquiring timeline lock ${lockPath}`);\n }\n\n /** Whether the lock is now free to take, having removed it when its holder is gone. */\n private async breakStale(lockPath: string): Promise<boolean> {\n try {\n const stats = await fs.stat(lockPath);\n if (Date.now() - stats.mtimeMs < LOCK_STALE_MS) return false;\n await fs.unlink(lockPath);\n return true;\n } catch (error) {\n // Already gone, or another waiter broke it first: either way, try again.\n if (hasCode(error, 'ENOENT')) return true;\n throw error;\n }\n }\n\n private async release(lockPath: string): Promise<void> {\n try {\n await fs.unlink(lockPath);\n } catch (error) {\n if (!hasCode(error, 'ENOENT')) console.warn(`Could not remove timeline lock: ${String(error)}`);\n }\n }\n}\n"],"mappings":"wJAsBA,SAAS,EAAQ,EAAgB,EAAuB,CACtD,OAAO,aAAiB,OAAS,SAAU,GAAS,EAAM,OAAS,CACrE,CAEA,IAAa,EAAb,KAAqD,CACnD,SAEA,WAAW,EAAwB,CACjC,KAAK,SAAW,CAClB,CAEA,MAAM,OAAO,EAAqC,CAChD,MAAM,KAAK,SAAS,SAAY,EAAG,WAAW,KAAK,YAAY,EAAG,GAAG,KAAK,UAAU,CAAK,EAAE,IAAK,MAAM,CAAC,CACzG,CAEA,MAAM,MAAiC,CAErC,OAAO,EAAY,EAAiB,MAAM,KAAK,OAAO,CAAC,CAAC,CAC1D,CAEA,MAAM,SAAS,EAA8C,CAC3D,OAAO,EAAa,MAAM,KAAK,OAAO,EAAG,CAAQ,CACnD,CAEA,MAAM,OAAuB,CAC3B,GAAI,CACF,MAAM,EAAG,OAAO,KAAK,YAAY,CAAC,CACpC,OAAS,EAAO,CACd,GAAI,CAAC,EAAQ,EAAO,QAAQ,EAAG,MAAM,CACvC,CACF,CAGA,MAAc,QAAsC,CAClD,IAAI,EACJ,GAAI,CACF,EAAU,MAAM,EAAG,SAAS,KAAK,YAAY,EAAG,MAAM,CACxD,OAAS,EAAO,CACd,GAAI,EAAQ,EAAO,QAAQ,EAAG,MAAO,CAAC,EACtC,MAAM,CACR,CACA,OAAO,EAAc,EAAU,GAC7B,QAAQ,KAAK,+CAA+C,EAAK,MAAM,EAAG,GAAG,GAAG,CAClF,CACF,CAEA,aAA8B,CAC5B,GAAI,CAAC,KAAK,SAAU,MAAU,MAAM,mCAAmC,EACvE,OAAO,KAAK,QACd,CAEA,MAAc,SAAY,EAAyC,CACjE,IAAM,EAAW,GAAG,KAAK,YAAY,EAAE,OACjC,EAAO,MAAM,KAAK,QAAQ,CAAQ,EACxC,GAAI,CACF,OAAO,MAAM,EAAU,CACzB,QAAU,CACR,MAAM,EAAK,MAAM,EACjB,MAAM,KAAK,QAAQ,CAAQ,CAC7B,CACF,CAGA,MAAc,QAAQ,EAAuC,CAC3D,IAAK,IAAI,EAAU,EAAG,EAAU,IAAc,GAAW,EACvD,GAAI,CACF,OAAO,MAAM,EAAG,KAAK,EAAU,IAAI,CACrC,OAAS,EAAO,CACd,GAAI,CAAC,EAAQ,EAAO,QAAQ,EAAG,MAAM,EACrC,GAAI,MAAM,KAAK,WAAW,CAAQ,EAAG,SACrC,MAAM,IAAI,QAAS,GAAY,WAAW,EAAS,EAAa,CAAC,CACnE,CAEF,MAAU,MAAM,qCAAqC,GAAU,CACjE,CAGA,MAAc,WAAW,EAAoC,CAC3D,GAAI,CACF,IAAM,EAAQ,MAAM,EAAG,KAAK,CAAQ,EAGpC,OAFI,KAAK,IAAI,EAAI,EAAM,QAAU,IAAsB,IACvD,MAAM,EAAG,OAAO,CAAQ,EACjB,GACT,OAAS,EAAO,CAEd,GAAI,EAAQ,EAAO,QAAQ,EAAG,MAAO,GACrC,MAAM,CACR,CACF,CAEA,MAAc,QAAQ,EAAiC,CACrD,GAAI,CACF,MAAM,EAAG,OAAO,CAAQ,CAC1B,OAAS,EAAO,CACT,EAAQ,EAAO,QAAQ,GAAG,QAAQ,KAAK,mCAAmC,OAAO,CAAK,GAAG,CAChG,CACF,CACF"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("../_virtual/_rolldown/runtime.cjs"),t=require("../services/lineDiff.cjs"),n=require("./FileEditPaths/FileEditPaths.cjs"),r=require("./node/snapshotStore.cjs"),i=require("../services/fileChanges.cjs"),a=require("./TimelineStore/TimelineStore.cjs"),o=require("../types/fileEditsApi.cjs");let s=require("node:fs/promises");s=e.__toESM(s,1);let c=require("node:path");c=e.__toESM(c,1);let l=require("node:crypto"),u=require("hono");const d=1048576,f=`changed by a command, so no baseline was captured; showing the file as it stands`,p=`the changed region was too large to match line by line`;function m(e){return(0,l.createHash)(`sha256`).update(e,`utf8`).digest(`hex`)}function h(e,t){return e instanceof Error&&`code`in e&&e.code===t}function g(e){return e instanceof Error?e.message:String(e)}async function _(e){let t;try{t=await s.default.readFile(e)}catch{return{content:``,hash:``,unavailable:!0,reason:`the file no longer exists`}}if(t.byteLength>1048576)return{content:``,hash:``,unavailable:!0,reason:`the file is larger than ${d} bytes`};if(t.subarray(0,8192).includes(0))return{content:``,hash:``,unavailable:!0,reason:`the file is binary`};let n=t.toString(`utf8`);return{content:n,hash:m(n),unavailable:!1}}async function v(e,n){let r={index:e.index,tool:e.tool,at:e.at,origin:e.origin,additions:e.additions??0,removals:e.removals??0};if(e.before===void 0||e.after===void 0)return e.origin===`tool`?{...r,note:`this change could not be captured: the file is binary or past the size cap`}:{...r,note:e.tool===`bash`?f:`recorded before this session began capturing content, so there is no diff for it`};let[i,a]=await Promise.all([n.read(e.before),n.read(e.after)]);if(i===void 0||a===void 0)return{...r,note:`the content behind this change is no longer held`};let o=t.lineDiff(i,a);return{...r,additions:o.additions,removals:o.removals,hunks:o.hunks,...o.approximate?{note:p}:{}}}async function
|
|
1
|
+
const e=require("../_virtual/_rolldown/runtime.cjs"),t=require("../services/lineDiff.cjs"),n=require("./FileEditPaths/FileEditPaths.cjs"),r=require("./node/snapshotStore.cjs"),i=require("../services/fileChanges.cjs"),a=require("./TimelineStore/TimelineStore.cjs"),o=require("../types/fileEditsApi.cjs");let s=require("node:fs/promises");s=e.__toESM(s,1);let c=require("node:path");c=e.__toESM(c,1);let l=require("node:crypto"),u=require("hono");const d=1048576,f=`changed by a command, so no baseline was captured; showing the file as it stands`,p=`the changed region was too large to match line by line`;function m(e){return(0,l.createHash)(`sha256`).update(e,`utf8`).digest(`hex`)}function h(e,t){return e instanceof Error&&`code`in e&&e.code===t}function g(e){return e instanceof Error?e.message:String(e)}async function _(e){let t;try{t=await s.default.readFile(e)}catch{return{content:``,hash:``,unavailable:!0,reason:`the file no longer exists`}}if(t.byteLength>1048576)return{content:``,hash:``,unavailable:!0,reason:`the file is larger than ${d} bytes`};if(t.subarray(0,8192).includes(0))return{content:``,hash:``,unavailable:!0,reason:`the file is binary`};let n=t.toString(`utf8`);return{content:n,hash:m(n),unavailable:!1}}function v(e,t){return t===e||t.startsWith(`${e}${c.default.sep}`)}async function y(e,t){let n,r;try{n=await s.default.realpath(e),r=await s.default.realpath(t)}catch{return!0}return v(n,r)}async function b(e,n){let r={index:e.index,tool:e.tool,at:e.at,origin:e.origin,additions:e.additions??0,removals:e.removals??0};if(e.before===void 0||e.after===void 0)return e.origin===`tool`?{...r,note:`this change could not be captured: the file is binary or past the size cap`}:{...r,note:e.tool===`bash`?f:`recorded before this session began capturing content, so there is no diff for it`};let[i,a]=await Promise.all([n.read(e.before),n.read(e.after)]);if(i===void 0||a===void 0)return{...r,note:`the content behind this change is no longer held`};let o=t.lineDiff(i,a);return{...r,additions:o.additions,removals:o.removals,hunks:o.hunks,...o.approximate?{note:p}:{}}}async function x(e,n,r){let a=i.baselineOf(e);if(a===void 0)return{additions:0,removals:0,note:f};if(n.unavailable)return{additions:0,removals:0,note:n.reason??`the file cannot be read`};let o=await r.read(a);if(o===void 0)return{additions:0,removals:0,note:`the content this session started from is no longer held`};let s=t.lineDiff(o,n.content);return{additions:s.additions,removals:s.removals,hunks:s.hunks,...s.approximate?{note:p}:{}}}function S(e={}){let t=e.timeline??new a.TimelineStore,i=e.snapshots??new r.NodeSnapshotStoreAdapter,l=e.paths??new n.FileEditPaths,d=e.cwd??``,f=new u.Hono;if(e.sessionId!==void 0&&d!==``){let n=l.sessionKey(e.sessionId);e.timeline===void 0&&t.initialize(l.timelinePath(d,n)),e.snapshots===void 0&&i.initialize(l.snapshotsPath(d,n))}let p=async e=>{if(e===void 0||e===``)return;let n=await t.versions(c.default.resolve(d,e));return n.length===0?void 0:n};return f.get(`/detail`,async e=>{let t=e.req.query(o.PATH_QUERY_PARAM),n=await p(t);if(n===void 0||t===void 0)return e.json({error:`This session recorded no changes to that file.`},404);let r=c.default.resolve(d,t),a=await _(r),s={path:r,relPath:d===``?r:c.default.relative(d,r),versions:await Promise.all(n.map(e=>b(e,i))),cumulative:await x(n,a,i),working:a};return e.json(s)}),f.get(`/preview`,async e=>{let t=e.req.query(o.PATH_QUERY_PARAM);if(t===void 0||t===``||t.includes(`\0`))return e.json({error:`A preview names a path.`},400);if(d===``)return e.json({error:`This session has no working directory to read from.`},403);let n=c.default.resolve(d),r=c.default.resolve(n,t);if(!v(n,r)||!await y(n,r))return e.json({error:`The path leaves the session directory.`},403);let i={path:r,relPath:c.default.relative(n,r),working:await _(r)};return e.json(i)}),f.put(`/content`,async e=>{let t;try{t=await e.req.json()}catch{return e.json({error:`The save body is not JSON.`},400)}if(typeof t.path!=`string`||typeof t.content!=`string`)return e.json({error:`A save names a path and the content to write.`},400);if(await p(t.path)===void 0)return e.json({error:`This session recorded no changes to that file.`},404);let n=c.default.resolve(d,t.path),r=await _(n);if(r.unavailable)return e.json({error:r.reason??`The file cannot be read.`},409);if(r.hash!==t.expectedHash)return e.json({error:`The file changed since it was opened.`,hash:r.hash},409);await s.default.writeFile(n,t.content,`utf8`);let i={hash:m(t.content)};return e.json(i)}),f.delete(`/content`,async e=>{let t=e.req.query(o.PATH_QUERY_PARAM);if(await p(t)===void 0||t===void 0)return e.json({error:`This session recorded no changes to that file.`},404);let n=c.default.resolve(d,t);try{await s.default.unlink(n)}catch(t){if(!h(t,`ENOENT`))return e.json({error:`The file could not be deleted: ${g(t)}`},409)}return e.body(null,204)}),f}const C={basePath:o.API_BASE_PATH,start(e){let t=S({...e.sessionId===void 0?{}:{sessionId:e.sessionId},...e.cwd===void 0?{}:{cwd:e.cwd}});return{fetch:e=>t.fetch(e),close:()=>void 0}}};exports.MAX_WORKING_BYTES=d,exports.api=C,exports.createFileEditsApi=S;
|
|
2
2
|
//# sourceMappingURL=fileEditsApi.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fileEditsApi.cjs","names":["createHash","fs","lineDiff","baselineOf","TimelineStore","NodeSnapshotStoreAdapter","FileEditPaths","Hono","path","PATH_QUERY_PARAM","API_BASE_PATH"],"sources":["../../src/adapters/fileEditsApi.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\nimport type { DoomApi, DoomApiContext, DoomApiHandler } from '@agimon-ai/doompi-extension-contracts/package-api';\nimport { Hono } from 'hono';\nimport { baselineOf } from '../services/fileChanges.ts';\nimport { lineDiff } from '../services/lineDiff.ts';\nimport type { FileEditVersion } from '../types/domain.ts';\nimport {\n API_BASE_PATH,\n type FileEditsCumulativeView,\n type FileEditsDetailView,\n type FileEditsSaveRequest,\n type FileEditsSaveView,\n type FileEditsVersionView,\n type FileEditsWorkingView,\n PATH_QUERY_PARAM,\n} from '../types/fileEditsApi.ts';\nimport type { IFileEditPaths } from '../types/fileEditPaths.ts';\nimport type { SnapshotStorePort } from '../types/snapshotStore.ts';\nimport type { ITimelineStore } from '../types/timelineStore.ts';\nimport { FileEditPaths } from './FileEditPaths/FileEditPaths.ts';\nimport { NodeSnapshotStoreAdapter } from './node/snapshotStore.ts';\nimport { TimelineStore } from './TimelineStore/TimelineStore.ts';\n\n/**\n * This package's HTTP surface: one file's history, and the manual save.\n *\n * Routes are relative to the mount its host gives it, so nothing here repeats\n * where it was mounted. The host is one session's own server, which is what\n * makes the session id and working directory available without the page\n * supplying either: a page names a file, never a session's private paths.\n *\n * The detail route answers everything a reader needs to open a file, because\n * splitting it would make the page stitch three responses together for one\n * click. The save route is the only mutation, and it refuses unless the reader\n * proves they were looking at the content that is still on disk.\n */\n\n/** Past this, the working view reports the file rather than carrying it. */\nexport const MAX_WORKING_BYTES = 1024 * 1024;\n\nconst BINARY_SAMPLE_BYTES = 8192;\nconst NO_BASELINE_NOTE = 'changed by a command, so no baseline was captured; showing the file as it stands';\n/**\n * A change the tool layer named but never captured content for. That is what\n * every record written before this package learned to snapshot looks like, and\n * calling those \"changed by a command\" would be wrong: the tool is right there\n * in the record.\n */\nconst NO_SNAPSHOT_NOTE = 'recorded before this session began capturing content, so there is no diff for it';\nconst APPROXIMATE_NOTE = 'the changed region was too large to match line by line';\n\nexport interface FileEditsApiOptions {\n /** The session these routes answer for; absent when the host is the hub. */\n sessionId?: string;\n /** The session's working directory, which relative paths are shown against. */\n cwd?: string;\n timeline?: ITimelineStore;\n snapshots?: SnapshotStorePort;\n paths?: IFileEditPaths;\n}\n\nfunction hashOf(content: string): string {\n return createHash('sha256').update(content, 'utf8').digest('hex');\n}\n\nfunction hasCode(error: unknown, code: string): boolean {\n return error instanceof Error && 'code' in error && error.code === code;\n}\n\nfunction describe(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\n/** Reads the file as the source view will show it, or says why it cannot. */\nasync function readWorking(filePath: string): Promise<FileEditsWorkingView> {\n let raw: Buffer;\n try {\n raw = await fs.readFile(filePath);\n } catch {\n return { content: '', hash: '', unavailable: true, reason: 'the file no longer exists' };\n }\n if (raw.byteLength > MAX_WORKING_BYTES) {\n return { content: '', hash: '', unavailable: true, reason: `the file is larger than ${MAX_WORKING_BYTES} bytes` };\n }\n if (raw.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) {\n return { content: '', hash: '', unavailable: true, reason: 'the file is binary' };\n }\n const content = raw.toString('utf8');\n return { content, hash: hashOf(content), unavailable: false };\n}\n\n/**\n * One recorded change as the page renders it. A change with both sides captured\n * carries its own diff; one without says why, rather than showing nothing.\n */\nasync function toVersionView(version: FileEditVersion, snapshots: SnapshotStorePort): Promise<FileEditsVersionView> {\n const view: FileEditsVersionView = {\n index: version.index,\n tool: version.tool,\n at: version.at,\n origin: version.origin,\n additions: version.additions ?? 0,\n removals: version.removals ?? 0,\n };\n if (version.before === undefined || version.after === undefined) {\n // A scan found the path only after it changed, so no baseline exists. A\n // tool named the path but the content was not stored: either it did not\n // fit, or the record predates content capture, which the tool tells apart.\n if (version.origin === 'tool') {\n return { ...view, note: 'this change could not be captured: the file is binary or past the size cap' };\n }\n return { ...view, note: version.tool === 'bash' ? NO_BASELINE_NOTE : NO_SNAPSHOT_NOTE };\n }\n const [before, after] = await Promise.all([snapshots.read(version.before), snapshots.read(version.after)]);\n if (before === undefined || after === undefined) {\n return { ...view, note: 'the content behind this change is no longer held' };\n }\n const diff = lineDiff(before, after);\n return {\n ...view,\n additions: diff.additions,\n removals: diff.removals,\n hunks: diff.hunks,\n ...(diff.approximate ? { note: APPROXIMATE_NOTE } : {}),\n };\n}\n\n/** Everything the session did to the file: its oldest baseline against what is on disk now. */\nasync function toCumulativeView(\n versions: readonly FileEditVersion[],\n working: FileEditsWorkingView,\n snapshots: SnapshotStorePort,\n): Promise<FileEditsCumulativeView> {\n const baseline = baselineOf(versions);\n if (baseline === undefined) return { additions: 0, removals: 0, note: NO_BASELINE_NOTE };\n if (working.unavailable) {\n return { additions: 0, removals: 0, note: working.reason ?? 'the file cannot be read' };\n }\n const before = await snapshots.read(baseline);\n if (before === undefined) {\n return { additions: 0, removals: 0, note: 'the content this session started from is no longer held' };\n }\n const diff = lineDiff(before, working.content);\n return {\n additions: diff.additions,\n removals: diff.removals,\n hunks: diff.hunks,\n ...(diff.approximate ? { note: APPROXIMATE_NOTE } : {}),\n };\n}\n\nexport function createFileEditsApi(options: FileEditsApiOptions = {}): Hono {\n const timeline = options.timeline ?? new TimelineStore();\n const snapshots = options.snapshots ?? new NodeSnapshotStoreAdapter();\n const paths = options.paths ?? new FileEditPaths();\n const cwd = options.cwd ?? '';\n const app = new Hono();\n\n // The routes and the extension run in different processes, so this half\n // opens the same two locations from the same inputs rather than sharing an\n // instance with the half that writes them.\n //\n // Only what this function built gets pointed at them. A store handed in\n // through the options arrives already configured, and repointing it would\n // make the option a lie: the caller would be injecting a store the routes\n // then read somewhere else entirely.\n if (options.sessionId !== undefined && cwd !== '') {\n const sessionKey = paths.sessionKey(options.sessionId);\n if (options.timeline === undefined) timeline.initialize(paths.timelinePath(cwd, sessionKey));\n if (options.snapshots === undefined) snapshots.initialize(paths.snapshotsPath(cwd, sessionKey));\n }\n\n /**\n * The file a request names, rejected unless this session actually recorded a\n * change to it. That check is the authorization: these routes read and write\n * whatever path they are handed, so the timeline is what bounds them to files\n * the session already touched.\n */\n const recordedVersions = async (filePath: string | undefined): Promise<FileEditVersion[] | undefined> => {\n if (filePath === undefined || filePath === '') return undefined;\n const versions = await timeline.versions(path.resolve(cwd, filePath));\n return versions.length === 0 ? undefined : versions;\n };\n\n app.get('/detail', async (context) => {\n const requested = context.req.query(PATH_QUERY_PARAM);\n const versions = await recordedVersions(requested);\n if (versions === undefined || requested === undefined) {\n return context.json({ error: 'This session recorded no changes to that file.' }, 404);\n }\n const filePath = path.resolve(cwd, requested);\n const working = await readWorking(filePath);\n const body: FileEditsDetailView = {\n path: filePath,\n relPath: cwd === '' ? filePath : path.relative(cwd, filePath),\n versions: await Promise.all(versions.map((version) => toVersionView(version, snapshots))),\n cumulative: await toCumulativeView(versions, working, snapshots),\n working,\n };\n return context.json(body);\n });\n\n app.put('/content', async (context) => {\n let request: FileEditsSaveRequest;\n try {\n request = (await context.req.json()) as FileEditsSaveRequest;\n } catch {\n return context.json({ error: 'The save body is not JSON.' }, 400);\n }\n if (typeof request.path !== 'string' || typeof request.content !== 'string') {\n return context.json({ error: 'A save names a path and the content to write.' }, 400);\n }\n if ((await recordedVersions(request.path)) === undefined) {\n return context.json({ error: 'This session recorded no changes to that file.' }, 404);\n }\n const filePath = path.resolve(cwd, request.path);\n const current = await readWorking(filePath);\n if (current.unavailable) {\n return context.json({ error: current.reason ?? 'The file cannot be read.' }, 409);\n }\n // The agent runs in the same working directory, so it can rewrite the file\n // while a reader is still editing it. Refusing on a moved hash is what\n // stops the save from silently discarding whatever it did.\n if (current.hash !== request.expectedHash) {\n return context.json({ error: 'The file changed since it was opened.', hash: current.hash }, 409);\n }\n await fs.writeFile(filePath, request.content, 'utf8');\n const body: FileEditsSaveView = { hash: hashOf(request.content) };\n return context.json(body);\n });\n\n app.delete('/content', async (context) => {\n const requested = context.req.query(PATH_QUERY_PARAM);\n if ((await recordedVersions(requested)) === undefined || requested === undefined) {\n return context.json({ error: 'This session recorded no changes to that file.' }, 404);\n }\n const filePath = path.resolve(cwd, requested);\n try {\n await fs.unlink(filePath);\n } catch (error) {\n // Already gone is the outcome the caller wanted, so it is not a failure.\n if (!hasCode(error, 'ENOENT')) {\n return context.json({ error: `The file could not be deleted: ${describe(error)}` }, 409);\n }\n }\n return context.body(null, 204);\n });\n\n return app;\n}\n\n/** The named export a host imports from this package's built entry. */\nexport const api: DoomApi = {\n basePath: API_BASE_PATH,\n start(context: DoomApiContext): DoomApiHandler {\n const app = createFileEditsApi({\n ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }),\n ...(context.cwd === undefined ? {} : { cwd: context.cwd }),\n });\n return {\n fetch: (request) => app.fetch(request),\n // Nothing outlives a request here: every route reads, answers and closes.\n close: () => undefined,\n };\n },\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"fileEditsApi.cjs","names":["createHash","fs","path","lineDiff","baselineOf","TimelineStore","NodeSnapshotStoreAdapter","FileEditPaths","Hono","PATH_QUERY_PARAM","API_BASE_PATH"],"sources":["../../src/adapters/fileEditsApi.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\nimport type { DoomApi, DoomApiContext, DoomApiHandler } from '@agimon-ai/doompi-extension-contracts/package-api';\nimport { Hono } from 'hono';\nimport { baselineOf } from '../services/fileChanges.ts';\nimport { lineDiff } from '../services/lineDiff.ts';\nimport type { FileEditVersion } from '../types/domain.ts';\nimport {\n API_BASE_PATH,\n type FileEditsCumulativeView,\n type FileEditsDetailView,\n type FileEditsPreviewView,\n type FileEditsSaveRequest,\n type FileEditsSaveView,\n type FileEditsVersionView,\n type FileEditsWorkingView,\n PATH_QUERY_PARAM,\n} from '../types/fileEditsApi.ts';\nimport type { IFileEditPaths } from '../types/fileEditPaths.ts';\nimport type { SnapshotStorePort } from '../types/snapshotStore.ts';\nimport type { ITimelineStore } from '../types/timelineStore.ts';\nimport { FileEditPaths } from './FileEditPaths/FileEditPaths.ts';\nimport { NodeSnapshotStoreAdapter } from './node/snapshotStore.ts';\nimport { TimelineStore } from './TimelineStore/TimelineStore.ts';\n\n/**\n * This package's HTTP surface: one file's history, and the manual save.\n *\n * Routes are relative to the mount its host gives it, so nothing here repeats\n * where it was mounted. The host is one session's own server, which is what\n * makes the session id and working directory available without the page\n * supplying either: a page names a file, never a session's private paths.\n *\n * The detail route answers everything a reader needs to open a changed file,\n * because splitting it would make the page stitch three responses together for\n * one click. The save route is the only mutation, and it refuses unless the\n * reader proves they were looking at the content that is still on disk. The\n * preview route is the read-only way in to a file the session never changed,\n * bounded by the working directory instead of by the timeline.\n */\n\n/** Past this, the working view reports the file rather than carrying it. */\nexport const MAX_WORKING_BYTES = 1024 * 1024;\n\nconst BINARY_SAMPLE_BYTES = 8192;\nconst NO_BASELINE_NOTE = 'changed by a command, so no baseline was captured; showing the file as it stands';\n/**\n * A change the tool layer named but never captured content for. That is what\n * every record written before this package learned to snapshot looks like, and\n * calling those \"changed by a command\" would be wrong: the tool is right there\n * in the record.\n */\nconst NO_SNAPSHOT_NOTE = 'recorded before this session began capturing content, so there is no diff for it';\nconst APPROXIMATE_NOTE = 'the changed region was too large to match line by line';\n\nexport interface FileEditsApiOptions {\n /** The session these routes answer for; absent when the host is the hub. */\n sessionId?: string;\n /** The session's working directory, which relative paths are shown against. */\n cwd?: string;\n timeline?: ITimelineStore;\n snapshots?: SnapshotStorePort;\n paths?: IFileEditPaths;\n}\n\nfunction hashOf(content: string): string {\n return createHash('sha256').update(content, 'utf8').digest('hex');\n}\n\nfunction hasCode(error: unknown, code: string): boolean {\n return error instanceof Error && 'code' in error && error.code === code;\n}\n\nfunction describe(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\n/** Reads the file as the source view will show it, or says why it cannot. */\nasync function readWorking(filePath: string): Promise<FileEditsWorkingView> {\n let raw: Buffer;\n try {\n raw = await fs.readFile(filePath);\n } catch {\n return { content: '', hash: '', unavailable: true, reason: 'the file no longer exists' };\n }\n if (raw.byteLength > MAX_WORKING_BYTES) {\n return { content: '', hash: '', unavailable: true, reason: `the file is larger than ${MAX_WORKING_BYTES} bytes` };\n }\n if (raw.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) {\n return { content: '', hash: '', unavailable: true, reason: 'the file is binary' };\n }\n const content = raw.toString('utf8');\n return { content, hash: hashOf(content), unavailable: false };\n}\n\nfunction isInside(root: string, candidate: string): boolean {\n return candidate === root || candidate.startsWith(`${root}${path.sep}`);\n}\n\n/**\n * The same containment check after symlinks are followed. A path that does not\n * exist yet resolves to nothing, and there is nothing to hand out, so it\n * passes and the read below reports it missing.\n */\nasync function isRealPathInside(root: string, candidate: string): Promise<boolean> {\n let realRoot: string;\n let real: string;\n try {\n realRoot = await fs.realpath(root);\n real = await fs.realpath(candidate);\n } catch {\n return true;\n }\n return isInside(realRoot, real);\n}\n\n/**\n * One recorded change as the page renders it. A change with both sides captured\n * carries its own diff; one without says why, rather than showing nothing.\n */\nasync function toVersionView(version: FileEditVersion, snapshots: SnapshotStorePort): Promise<FileEditsVersionView> {\n const view: FileEditsVersionView = {\n index: version.index,\n tool: version.tool,\n at: version.at,\n origin: version.origin,\n additions: version.additions ?? 0,\n removals: version.removals ?? 0,\n };\n if (version.before === undefined || version.after === undefined) {\n // A scan found the path only after it changed, so no baseline exists. A\n // tool named the path but the content was not stored: either it did not\n // fit, or the record predates content capture, which the tool tells apart.\n if (version.origin === 'tool') {\n return { ...view, note: 'this change could not be captured: the file is binary or past the size cap' };\n }\n return { ...view, note: version.tool === 'bash' ? NO_BASELINE_NOTE : NO_SNAPSHOT_NOTE };\n }\n const [before, after] = await Promise.all([snapshots.read(version.before), snapshots.read(version.after)]);\n if (before === undefined || after === undefined) {\n return { ...view, note: 'the content behind this change is no longer held' };\n }\n const diff = lineDiff(before, after);\n return {\n ...view,\n additions: diff.additions,\n removals: diff.removals,\n hunks: diff.hunks,\n ...(diff.approximate ? { note: APPROXIMATE_NOTE } : {}),\n };\n}\n\n/** Everything the session did to the file: its oldest baseline against what is on disk now. */\nasync function toCumulativeView(\n versions: readonly FileEditVersion[],\n working: FileEditsWorkingView,\n snapshots: SnapshotStorePort,\n): Promise<FileEditsCumulativeView> {\n const baseline = baselineOf(versions);\n if (baseline === undefined) return { additions: 0, removals: 0, note: NO_BASELINE_NOTE };\n if (working.unavailable) {\n return { additions: 0, removals: 0, note: working.reason ?? 'the file cannot be read' };\n }\n const before = await snapshots.read(baseline);\n if (before === undefined) {\n return { additions: 0, removals: 0, note: 'the content this session started from is no longer held' };\n }\n const diff = lineDiff(before, working.content);\n return {\n additions: diff.additions,\n removals: diff.removals,\n hunks: diff.hunks,\n ...(diff.approximate ? { note: APPROXIMATE_NOTE } : {}),\n };\n}\n\nexport function createFileEditsApi(options: FileEditsApiOptions = {}): Hono {\n const timeline = options.timeline ?? new TimelineStore();\n const snapshots = options.snapshots ?? new NodeSnapshotStoreAdapter();\n const paths = options.paths ?? new FileEditPaths();\n const cwd = options.cwd ?? '';\n const app = new Hono();\n\n // The routes and the extension run in different processes, so this half\n // opens the same two locations from the same inputs rather than sharing an\n // instance with the half that writes them.\n //\n // Only what this function built gets pointed at them. A store handed in\n // through the options arrives already configured, and repointing it would\n // make the option a lie: the caller would be injecting a store the routes\n // then read somewhere else entirely.\n if (options.sessionId !== undefined && cwd !== '') {\n const sessionKey = paths.sessionKey(options.sessionId);\n if (options.timeline === undefined) timeline.initialize(paths.timelinePath(cwd, sessionKey));\n if (options.snapshots === undefined) snapshots.initialize(paths.snapshotsPath(cwd, sessionKey));\n }\n\n /**\n * The file a request names, rejected unless this session actually recorded a\n * change to it. That check is the authorization: these routes read and write\n * whatever path they are handed, so the timeline is what bounds them to files\n * the session already touched.\n */\n const recordedVersions = async (filePath: string | undefined): Promise<FileEditVersion[] | undefined> => {\n if (filePath === undefined || filePath === '') return undefined;\n const versions = await timeline.versions(path.resolve(cwd, filePath));\n return versions.length === 0 ? undefined : versions;\n };\n\n app.get('/detail', async (context) => {\n const requested = context.req.query(PATH_QUERY_PARAM);\n const versions = await recordedVersions(requested);\n if (versions === undefined || requested === undefined) {\n return context.json({ error: 'This session recorded no changes to that file.' }, 404);\n }\n const filePath = path.resolve(cwd, requested);\n const working = await readWorking(filePath);\n const body: FileEditsDetailView = {\n path: filePath,\n relPath: cwd === '' ? filePath : path.relative(cwd, filePath),\n versions: await Promise.all(versions.map((version) => toVersionView(version, snapshots))),\n cumulative: await toCumulativeView(versions, working, snapshots),\n working,\n };\n return context.json(body);\n });\n\n /**\n * The file a request names, read only, for a file the timeline knows nothing\n * about: a path the agent read but never changed is still a path the reader\n * clicked, and refusing it would leave the link dead.\n *\n * The working directory is the boundary here, since the timeline is not.\n * Containment is checked lexically and again on the real path, so a symlink\n * inside the tree cannot hand out something beyond it, and a host that gave\n * these routes no working directory has no boundary to enforce and so serves\n * nothing.\n */\n app.get('/preview', async (context) => {\n const requested = context.req.query(PATH_QUERY_PARAM);\n if (requested === undefined || requested === '' || requested.includes('\\0')) {\n return context.json({ error: 'A preview names a path.' }, 400);\n }\n if (cwd === '') return context.json({ error: 'This session has no working directory to read from.' }, 403);\n const root = path.resolve(cwd);\n const filePath = path.resolve(root, requested);\n if (!isInside(root, filePath) || !(await isRealPathInside(root, filePath))) {\n return context.json({ error: 'The path leaves the session directory.' }, 403);\n }\n const body: FileEditsPreviewView = {\n path: filePath,\n relPath: path.relative(root, filePath),\n working: await readWorking(filePath),\n };\n return context.json(body);\n });\n\n app.put('/content', async (context) => {\n let request: FileEditsSaveRequest;\n try {\n request = (await context.req.json()) as FileEditsSaveRequest;\n } catch {\n return context.json({ error: 'The save body is not JSON.' }, 400);\n }\n if (typeof request.path !== 'string' || typeof request.content !== 'string') {\n return context.json({ error: 'A save names a path and the content to write.' }, 400);\n }\n if ((await recordedVersions(request.path)) === undefined) {\n return context.json({ error: 'This session recorded no changes to that file.' }, 404);\n }\n const filePath = path.resolve(cwd, request.path);\n const current = await readWorking(filePath);\n if (current.unavailable) {\n return context.json({ error: current.reason ?? 'The file cannot be read.' }, 409);\n }\n // The agent runs in the same working directory, so it can rewrite the file\n // while a reader is still editing it. Refusing on a moved hash is what\n // stops the save from silently discarding whatever it did.\n if (current.hash !== request.expectedHash) {\n return context.json({ error: 'The file changed since it was opened.', hash: current.hash }, 409);\n }\n await fs.writeFile(filePath, request.content, 'utf8');\n const body: FileEditsSaveView = { hash: hashOf(request.content) };\n return context.json(body);\n });\n\n app.delete('/content', async (context) => {\n const requested = context.req.query(PATH_QUERY_PARAM);\n if ((await recordedVersions(requested)) === undefined || requested === undefined) {\n return context.json({ error: 'This session recorded no changes to that file.' }, 404);\n }\n const filePath = path.resolve(cwd, requested);\n try {\n await fs.unlink(filePath);\n } catch (error) {\n // Already gone is the outcome the caller wanted, so it is not a failure.\n if (!hasCode(error, 'ENOENT')) {\n return context.json({ error: `The file could not be deleted: ${describe(error)}` }, 409);\n }\n }\n return context.body(null, 204);\n });\n\n return app;\n}\n\n/** The named export a host imports from this package's built entry. */\nexport const api: DoomApi = {\n basePath: API_BASE_PATH,\n start(context: DoomApiContext): DoomApiHandler {\n const app = createFileEditsApi({\n ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }),\n ...(context.cwd === undefined ? {} : { cwd: context.cwd }),\n });\n return {\n fetch: (request) => app.fetch(request),\n // Nothing outlives a request here: every route reads, answers and closes.\n close: () => undefined,\n };\n },\n};\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fileEditsApi.d.cts","names":[],"sources":["../../src/adapters/fileEditsApi.ts"],"mappings":";;;;;;
|
|
1
|
+
{"version":3,"file":"fileEditsApi.d.cts","names":[],"sources":["../../src/adapters/fileEditsApi.ts"],"mappings":";;;;;;UAwDiB;;EAEf;;EAEA;EACA,WAAW;EACX,YAAY;EACZ,QAAQ;;iBAkHM,mBAAmB,UAAS,sBAA2B;;cAmI1D,KAAK"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fileEditsApi.d.mts","names":[],"sources":["../../src/adapters/fileEditsApi.ts"],"mappings":";;;;;;
|
|
1
|
+
{"version":3,"file":"fileEditsApi.d.mts","names":[],"sources":["../../src/adapters/fileEditsApi.ts"],"mappings":";;;;;;UAwDiB;;EAEf;;EAEA;EACA,WAAW;EACX,YAAY;EACZ,QAAQ;;iBAkHM,mBAAmB,UAAS,sBAA2B;;cAmI1D,KAAK"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{lineDiff as e}from"../services/lineDiff.mjs";import{FileEditPaths as t}from"./FileEditPaths/FileEditPaths.mjs";import{NodeSnapshotStoreAdapter as n}from"./node/snapshotStore.mjs";import{baselineOf as r}from"../services/fileChanges.mjs";import{TimelineStore as i}from"./TimelineStore/TimelineStore.mjs";import{API_BASE_PATH as a,PATH_QUERY_PARAM as o}from"../types/fileEditsApi.mjs";import s from"node:fs/promises";import c from"node:path";import{createHash as l}from"node:crypto";import{Hono as u}from"hono";const d=1048576,f=`changed by a command, so no baseline was captured; showing the file as it stands`,p=`the changed region was too large to match line by line`;function m(e){return l(`sha256`).update(e,`utf8`).digest(`hex`)}function h(e,t){return e instanceof Error&&`code`in e&&e.code===t}function g(e){return e instanceof Error?e.message:String(e)}async function _(e){let t;try{t=await s.readFile(e)}catch{return{content:``,hash:``,unavailable:!0,reason:`the file no longer exists`}}if(t.byteLength>1048576)return{content:``,hash:``,unavailable:!0,reason:`the file is larger than ${d} bytes`};if(t.subarray(0,8192).includes(0))return{content:``,hash:``,unavailable:!0,reason:`the file is binary`};let n=t.toString(`utf8`);return{content:n,hash:m(n),unavailable:!1}}async function v(t,n){let r={index:t.index,tool:t.tool,at:t.at,origin:t.origin,additions:t.additions??0,removals:t.removals??0};if(t.before===void 0||t.after===void 0)return t.origin===`tool`?{...r,note:`this change could not be captured: the file is binary or past the size cap`}:{...r,note:t.tool===`bash`?f:`recorded before this session began capturing content, so there is no diff for it`};let[i,a]=await Promise.all([n.read(t.before),n.read(t.after)]);if(i===void 0||a===void 0)return{...r,note:`the content behind this change is no longer held`};let o=e(i,a);return{...r,additions:o.additions,removals:o.removals,hunks:o.hunks,...o.approximate?{note:p}:{}}}async function
|
|
1
|
+
import{lineDiff as e}from"../services/lineDiff.mjs";import{FileEditPaths as t}from"./FileEditPaths/FileEditPaths.mjs";import{NodeSnapshotStoreAdapter as n}from"./node/snapshotStore.mjs";import{baselineOf as r}from"../services/fileChanges.mjs";import{TimelineStore as i}from"./TimelineStore/TimelineStore.mjs";import{API_BASE_PATH as a,PATH_QUERY_PARAM as o}from"../types/fileEditsApi.mjs";import s from"node:fs/promises";import c from"node:path";import{createHash as l}from"node:crypto";import{Hono as u}from"hono";const d=1048576,f=`changed by a command, so no baseline was captured; showing the file as it stands`,p=`the changed region was too large to match line by line`;function m(e){return l(`sha256`).update(e,`utf8`).digest(`hex`)}function h(e,t){return e instanceof Error&&`code`in e&&e.code===t}function g(e){return e instanceof Error?e.message:String(e)}async function _(e){let t;try{t=await s.readFile(e)}catch{return{content:``,hash:``,unavailable:!0,reason:`the file no longer exists`}}if(t.byteLength>1048576)return{content:``,hash:``,unavailable:!0,reason:`the file is larger than ${d} bytes`};if(t.subarray(0,8192).includes(0))return{content:``,hash:``,unavailable:!0,reason:`the file is binary`};let n=t.toString(`utf8`);return{content:n,hash:m(n),unavailable:!1}}function v(e,t){return t===e||t.startsWith(`${e}${c.sep}`)}async function y(e,t){let n,r;try{n=await s.realpath(e),r=await s.realpath(t)}catch{return!0}return v(n,r)}async function b(t,n){let r={index:t.index,tool:t.tool,at:t.at,origin:t.origin,additions:t.additions??0,removals:t.removals??0};if(t.before===void 0||t.after===void 0)return t.origin===`tool`?{...r,note:`this change could not be captured: the file is binary or past the size cap`}:{...r,note:t.tool===`bash`?f:`recorded before this session began capturing content, so there is no diff for it`};let[i,a]=await Promise.all([n.read(t.before),n.read(t.after)]);if(i===void 0||a===void 0)return{...r,note:`the content behind this change is no longer held`};let o=e(i,a);return{...r,additions:o.additions,removals:o.removals,hunks:o.hunks,...o.approximate?{note:p}:{}}}async function x(t,n,i){let a=r(t);if(a===void 0)return{additions:0,removals:0,note:f};if(n.unavailable)return{additions:0,removals:0,note:n.reason??`the file cannot be read`};let o=await i.read(a);if(o===void 0)return{additions:0,removals:0,note:`the content this session started from is no longer held`};let s=e(o,n.content);return{additions:s.additions,removals:s.removals,hunks:s.hunks,...s.approximate?{note:p}:{}}}function S(e={}){let r=e.timeline??new i,a=e.snapshots??new n,l=e.paths??new t,d=e.cwd??``,f=new u;if(e.sessionId!==void 0&&d!==``){let t=l.sessionKey(e.sessionId);e.timeline===void 0&&r.initialize(l.timelinePath(d,t)),e.snapshots===void 0&&a.initialize(l.snapshotsPath(d,t))}let p=async e=>{if(e===void 0||e===``)return;let t=await r.versions(c.resolve(d,e));return t.length===0?void 0:t};return f.get(`/detail`,async e=>{let t=e.req.query(o),n=await p(t);if(n===void 0||t===void 0)return e.json({error:`This session recorded no changes to that file.`},404);let r=c.resolve(d,t),i=await _(r),s={path:r,relPath:d===``?r:c.relative(d,r),versions:await Promise.all(n.map(e=>b(e,a))),cumulative:await x(n,i,a),working:i};return e.json(s)}),f.get(`/preview`,async e=>{let t=e.req.query(o);if(t===void 0||t===``||t.includes(`\0`))return e.json({error:`A preview names a path.`},400);if(d===``)return e.json({error:`This session has no working directory to read from.`},403);let n=c.resolve(d),r=c.resolve(n,t);if(!v(n,r)||!await y(n,r))return e.json({error:`The path leaves the session directory.`},403);let i={path:r,relPath:c.relative(n,r),working:await _(r)};return e.json(i)}),f.put(`/content`,async e=>{let t;try{t=await e.req.json()}catch{return e.json({error:`The save body is not JSON.`},400)}if(typeof t.path!=`string`||typeof t.content!=`string`)return e.json({error:`A save names a path and the content to write.`},400);if(await p(t.path)===void 0)return e.json({error:`This session recorded no changes to that file.`},404);let n=c.resolve(d,t.path),r=await _(n);if(r.unavailable)return e.json({error:r.reason??`The file cannot be read.`},409);if(r.hash!==t.expectedHash)return e.json({error:`The file changed since it was opened.`,hash:r.hash},409);await s.writeFile(n,t.content,`utf8`);let i={hash:m(t.content)};return e.json(i)}),f.delete(`/content`,async e=>{let t=e.req.query(o);if(await p(t)===void 0||t===void 0)return e.json({error:`This session recorded no changes to that file.`},404);let n=c.resolve(d,t);try{await s.unlink(n)}catch(t){if(!h(t,`ENOENT`))return e.json({error:`The file could not be deleted: ${g(t)}`},409)}return e.body(null,204)}),f}const C={basePath:a,start(e){let t=S({...e.sessionId===void 0?{}:{sessionId:e.sessionId},...e.cwd===void 0?{}:{cwd:e.cwd}});return{fetch:e=>t.fetch(e),close:()=>void 0}}};export{d as MAX_WORKING_BYTES,C as api,S as createFileEditsApi};
|
|
2
2
|
//# sourceMappingURL=fileEditsApi.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fileEditsApi.mjs","names":[],"sources":["../../src/adapters/fileEditsApi.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\nimport type { DoomApi, DoomApiContext, DoomApiHandler } from '@agimon-ai/doompi-extension-contracts/package-api';\nimport { Hono } from 'hono';\nimport { baselineOf } from '../services/fileChanges.ts';\nimport { lineDiff } from '../services/lineDiff.ts';\nimport type { FileEditVersion } from '../types/domain.ts';\nimport {\n API_BASE_PATH,\n type FileEditsCumulativeView,\n type FileEditsDetailView,\n type FileEditsSaveRequest,\n type FileEditsSaveView,\n type FileEditsVersionView,\n type FileEditsWorkingView,\n PATH_QUERY_PARAM,\n} from '../types/fileEditsApi.ts';\nimport type { IFileEditPaths } from '../types/fileEditPaths.ts';\nimport type { SnapshotStorePort } from '../types/snapshotStore.ts';\nimport type { ITimelineStore } from '../types/timelineStore.ts';\nimport { FileEditPaths } from './FileEditPaths/FileEditPaths.ts';\nimport { NodeSnapshotStoreAdapter } from './node/snapshotStore.ts';\nimport { TimelineStore } from './TimelineStore/TimelineStore.ts';\n\n/**\n * This package's HTTP surface: one file's history, and the manual save.\n *\n * Routes are relative to the mount its host gives it, so nothing here repeats\n * where it was mounted. The host is one session's own server, which is what\n * makes the session id and working directory available without the page\n * supplying either: a page names a file, never a session's private paths.\n *\n * The detail route answers everything a reader needs to open a file, because\n * splitting it would make the page stitch three responses together for one\n * click. The save route is the only mutation, and it refuses unless the reader\n * proves they were looking at the content that is still on disk.\n */\n\n/** Past this, the working view reports the file rather than carrying it. */\nexport const MAX_WORKING_BYTES = 1024 * 1024;\n\nconst BINARY_SAMPLE_BYTES = 8192;\nconst NO_BASELINE_NOTE = 'changed by a command, so no baseline was captured; showing the file as it stands';\n/**\n * A change the tool layer named but never captured content for. That is what\n * every record written before this package learned to snapshot looks like, and\n * calling those \"changed by a command\" would be wrong: the tool is right there\n * in the record.\n */\nconst NO_SNAPSHOT_NOTE = 'recorded before this session began capturing content, so there is no diff for it';\nconst APPROXIMATE_NOTE = 'the changed region was too large to match line by line';\n\nexport interface FileEditsApiOptions {\n /** The session these routes answer for; absent when the host is the hub. */\n sessionId?: string;\n /** The session's working directory, which relative paths are shown against. */\n cwd?: string;\n timeline?: ITimelineStore;\n snapshots?: SnapshotStorePort;\n paths?: IFileEditPaths;\n}\n\nfunction hashOf(content: string): string {\n return createHash('sha256').update(content, 'utf8').digest('hex');\n}\n\nfunction hasCode(error: unknown, code: string): boolean {\n return error instanceof Error && 'code' in error && error.code === code;\n}\n\nfunction describe(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\n/** Reads the file as the source view will show it, or says why it cannot. */\nasync function readWorking(filePath: string): Promise<FileEditsWorkingView> {\n let raw: Buffer;\n try {\n raw = await fs.readFile(filePath);\n } catch {\n return { content: '', hash: '', unavailable: true, reason: 'the file no longer exists' };\n }\n if (raw.byteLength > MAX_WORKING_BYTES) {\n return { content: '', hash: '', unavailable: true, reason: `the file is larger than ${MAX_WORKING_BYTES} bytes` };\n }\n if (raw.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) {\n return { content: '', hash: '', unavailable: true, reason: 'the file is binary' };\n }\n const content = raw.toString('utf8');\n return { content, hash: hashOf(content), unavailable: false };\n}\n\n/**\n * One recorded change as the page renders it. A change with both sides captured\n * carries its own diff; one without says why, rather than showing nothing.\n */\nasync function toVersionView(version: FileEditVersion, snapshots: SnapshotStorePort): Promise<FileEditsVersionView> {\n const view: FileEditsVersionView = {\n index: version.index,\n tool: version.tool,\n at: version.at,\n origin: version.origin,\n additions: version.additions ?? 0,\n removals: version.removals ?? 0,\n };\n if (version.before === undefined || version.after === undefined) {\n // A scan found the path only after it changed, so no baseline exists. A\n // tool named the path but the content was not stored: either it did not\n // fit, or the record predates content capture, which the tool tells apart.\n if (version.origin === 'tool') {\n return { ...view, note: 'this change could not be captured: the file is binary or past the size cap' };\n }\n return { ...view, note: version.tool === 'bash' ? NO_BASELINE_NOTE : NO_SNAPSHOT_NOTE };\n }\n const [before, after] = await Promise.all([snapshots.read(version.before), snapshots.read(version.after)]);\n if (before === undefined || after === undefined) {\n return { ...view, note: 'the content behind this change is no longer held' };\n }\n const diff = lineDiff(before, after);\n return {\n ...view,\n additions: diff.additions,\n removals: diff.removals,\n hunks: diff.hunks,\n ...(diff.approximate ? { note: APPROXIMATE_NOTE } : {}),\n };\n}\n\n/** Everything the session did to the file: its oldest baseline against what is on disk now. */\nasync function toCumulativeView(\n versions: readonly FileEditVersion[],\n working: FileEditsWorkingView,\n snapshots: SnapshotStorePort,\n): Promise<FileEditsCumulativeView> {\n const baseline = baselineOf(versions);\n if (baseline === undefined) return { additions: 0, removals: 0, note: NO_BASELINE_NOTE };\n if (working.unavailable) {\n return { additions: 0, removals: 0, note: working.reason ?? 'the file cannot be read' };\n }\n const before = await snapshots.read(baseline);\n if (before === undefined) {\n return { additions: 0, removals: 0, note: 'the content this session started from is no longer held' };\n }\n const diff = lineDiff(before, working.content);\n return {\n additions: diff.additions,\n removals: diff.removals,\n hunks: diff.hunks,\n ...(diff.approximate ? { note: APPROXIMATE_NOTE } : {}),\n };\n}\n\nexport function createFileEditsApi(options: FileEditsApiOptions = {}): Hono {\n const timeline = options.timeline ?? new TimelineStore();\n const snapshots = options.snapshots ?? new NodeSnapshotStoreAdapter();\n const paths = options.paths ?? new FileEditPaths();\n const cwd = options.cwd ?? '';\n const app = new Hono();\n\n // The routes and the extension run in different processes, so this half\n // opens the same two locations from the same inputs rather than sharing an\n // instance with the half that writes them.\n //\n // Only what this function built gets pointed at them. A store handed in\n // through the options arrives already configured, and repointing it would\n // make the option a lie: the caller would be injecting a store the routes\n // then read somewhere else entirely.\n if (options.sessionId !== undefined && cwd !== '') {\n const sessionKey = paths.sessionKey(options.sessionId);\n if (options.timeline === undefined) timeline.initialize(paths.timelinePath(cwd, sessionKey));\n if (options.snapshots === undefined) snapshots.initialize(paths.snapshotsPath(cwd, sessionKey));\n }\n\n /**\n * The file a request names, rejected unless this session actually recorded a\n * change to it. That check is the authorization: these routes read and write\n * whatever path they are handed, so the timeline is what bounds them to files\n * the session already touched.\n */\n const recordedVersions = async (filePath: string | undefined): Promise<FileEditVersion[] | undefined> => {\n if (filePath === undefined || filePath === '') return undefined;\n const versions = await timeline.versions(path.resolve(cwd, filePath));\n return versions.length === 0 ? undefined : versions;\n };\n\n app.get('/detail', async (context) => {\n const requested = context.req.query(PATH_QUERY_PARAM);\n const versions = await recordedVersions(requested);\n if (versions === undefined || requested === undefined) {\n return context.json({ error: 'This session recorded no changes to that file.' }, 404);\n }\n const filePath = path.resolve(cwd, requested);\n const working = await readWorking(filePath);\n const body: FileEditsDetailView = {\n path: filePath,\n relPath: cwd === '' ? filePath : path.relative(cwd, filePath),\n versions: await Promise.all(versions.map((version) => toVersionView(version, snapshots))),\n cumulative: await toCumulativeView(versions, working, snapshots),\n working,\n };\n return context.json(body);\n });\n\n app.put('/content', async (context) => {\n let request: FileEditsSaveRequest;\n try {\n request = (await context.req.json()) as FileEditsSaveRequest;\n } catch {\n return context.json({ error: 'The save body is not JSON.' }, 400);\n }\n if (typeof request.path !== 'string' || typeof request.content !== 'string') {\n return context.json({ error: 'A save names a path and the content to write.' }, 400);\n }\n if ((await recordedVersions(request.path)) === undefined) {\n return context.json({ error: 'This session recorded no changes to that file.' }, 404);\n }\n const filePath = path.resolve(cwd, request.path);\n const current = await readWorking(filePath);\n if (current.unavailable) {\n return context.json({ error: current.reason ?? 'The file cannot be read.' }, 409);\n }\n // The agent runs in the same working directory, so it can rewrite the file\n // while a reader is still editing it. Refusing on a moved hash is what\n // stops the save from silently discarding whatever it did.\n if (current.hash !== request.expectedHash) {\n return context.json({ error: 'The file changed since it was opened.', hash: current.hash }, 409);\n }\n await fs.writeFile(filePath, request.content, 'utf8');\n const body: FileEditsSaveView = { hash: hashOf(request.content) };\n return context.json(body);\n });\n\n app.delete('/content', async (context) => {\n const requested = context.req.query(PATH_QUERY_PARAM);\n if ((await recordedVersions(requested)) === undefined || requested === undefined) {\n return context.json({ error: 'This session recorded no changes to that file.' }, 404);\n }\n const filePath = path.resolve(cwd, requested);\n try {\n await fs.unlink(filePath);\n } catch (error) {\n // Already gone is the outcome the caller wanted, so it is not a failure.\n if (!hasCode(error, 'ENOENT')) {\n return context.json({ error: `The file could not be deleted: ${describe(error)}` }, 409);\n }\n }\n return context.body(null, 204);\n });\n\n return app;\n}\n\n/** The named export a host imports from this package's built entry. */\nexport const api: DoomApi = {\n basePath: API_BASE_PATH,\n start(context: DoomApiContext): DoomApiHandler {\n const app = createFileEditsApi({\n ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }),\n ...(context.cwd === undefined ? {} : { cwd: context.cwd }),\n });\n return {\n fetch: (request) => app.fetch(request),\n // Nothing outlives a request here: every route reads, answers and closes.\n close: () => undefined,\n };\n },\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"fileEditsApi.mjs","names":[],"sources":["../../src/adapters/fileEditsApi.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\nimport type { DoomApi, DoomApiContext, DoomApiHandler } from '@agimon-ai/doompi-extension-contracts/package-api';\nimport { Hono } from 'hono';\nimport { baselineOf } from '../services/fileChanges.ts';\nimport { lineDiff } from '../services/lineDiff.ts';\nimport type { FileEditVersion } from '../types/domain.ts';\nimport {\n API_BASE_PATH,\n type FileEditsCumulativeView,\n type FileEditsDetailView,\n type FileEditsPreviewView,\n type FileEditsSaveRequest,\n type FileEditsSaveView,\n type FileEditsVersionView,\n type FileEditsWorkingView,\n PATH_QUERY_PARAM,\n} from '../types/fileEditsApi.ts';\nimport type { IFileEditPaths } from '../types/fileEditPaths.ts';\nimport type { SnapshotStorePort } from '../types/snapshotStore.ts';\nimport type { ITimelineStore } from '../types/timelineStore.ts';\nimport { FileEditPaths } from './FileEditPaths/FileEditPaths.ts';\nimport { NodeSnapshotStoreAdapter } from './node/snapshotStore.ts';\nimport { TimelineStore } from './TimelineStore/TimelineStore.ts';\n\n/**\n * This package's HTTP surface: one file's history, and the manual save.\n *\n * Routes are relative to the mount its host gives it, so nothing here repeats\n * where it was mounted. The host is one session's own server, which is what\n * makes the session id and working directory available without the page\n * supplying either: a page names a file, never a session's private paths.\n *\n * The detail route answers everything a reader needs to open a changed file,\n * because splitting it would make the page stitch three responses together for\n * one click. The save route is the only mutation, and it refuses unless the\n * reader proves they were looking at the content that is still on disk. The\n * preview route is the read-only way in to a file the session never changed,\n * bounded by the working directory instead of by the timeline.\n */\n\n/** Past this, the working view reports the file rather than carrying it. */\nexport const MAX_WORKING_BYTES = 1024 * 1024;\n\nconst BINARY_SAMPLE_BYTES = 8192;\nconst NO_BASELINE_NOTE = 'changed by a command, so no baseline was captured; showing the file as it stands';\n/**\n * A change the tool layer named but never captured content for. That is what\n * every record written before this package learned to snapshot looks like, and\n * calling those \"changed by a command\" would be wrong: the tool is right there\n * in the record.\n */\nconst NO_SNAPSHOT_NOTE = 'recorded before this session began capturing content, so there is no diff for it';\nconst APPROXIMATE_NOTE = 'the changed region was too large to match line by line';\n\nexport interface FileEditsApiOptions {\n /** The session these routes answer for; absent when the host is the hub. */\n sessionId?: string;\n /** The session's working directory, which relative paths are shown against. */\n cwd?: string;\n timeline?: ITimelineStore;\n snapshots?: SnapshotStorePort;\n paths?: IFileEditPaths;\n}\n\nfunction hashOf(content: string): string {\n return createHash('sha256').update(content, 'utf8').digest('hex');\n}\n\nfunction hasCode(error: unknown, code: string): boolean {\n return error instanceof Error && 'code' in error && error.code === code;\n}\n\nfunction describe(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\n/** Reads the file as the source view will show it, or says why it cannot. */\nasync function readWorking(filePath: string): Promise<FileEditsWorkingView> {\n let raw: Buffer;\n try {\n raw = await fs.readFile(filePath);\n } catch {\n return { content: '', hash: '', unavailable: true, reason: 'the file no longer exists' };\n }\n if (raw.byteLength > MAX_WORKING_BYTES) {\n return { content: '', hash: '', unavailable: true, reason: `the file is larger than ${MAX_WORKING_BYTES} bytes` };\n }\n if (raw.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) {\n return { content: '', hash: '', unavailable: true, reason: 'the file is binary' };\n }\n const content = raw.toString('utf8');\n return { content, hash: hashOf(content), unavailable: false };\n}\n\nfunction isInside(root: string, candidate: string): boolean {\n return candidate === root || candidate.startsWith(`${root}${path.sep}`);\n}\n\n/**\n * The same containment check after symlinks are followed. A path that does not\n * exist yet resolves to nothing, and there is nothing to hand out, so it\n * passes and the read below reports it missing.\n */\nasync function isRealPathInside(root: string, candidate: string): Promise<boolean> {\n let realRoot: string;\n let real: string;\n try {\n realRoot = await fs.realpath(root);\n real = await fs.realpath(candidate);\n } catch {\n return true;\n }\n return isInside(realRoot, real);\n}\n\n/**\n * One recorded change as the page renders it. A change with both sides captured\n * carries its own diff; one without says why, rather than showing nothing.\n */\nasync function toVersionView(version: FileEditVersion, snapshots: SnapshotStorePort): Promise<FileEditsVersionView> {\n const view: FileEditsVersionView = {\n index: version.index,\n tool: version.tool,\n at: version.at,\n origin: version.origin,\n additions: version.additions ?? 0,\n removals: version.removals ?? 0,\n };\n if (version.before === undefined || version.after === undefined) {\n // A scan found the path only after it changed, so no baseline exists. A\n // tool named the path but the content was not stored: either it did not\n // fit, or the record predates content capture, which the tool tells apart.\n if (version.origin === 'tool') {\n return { ...view, note: 'this change could not be captured: the file is binary or past the size cap' };\n }\n return { ...view, note: version.tool === 'bash' ? NO_BASELINE_NOTE : NO_SNAPSHOT_NOTE };\n }\n const [before, after] = await Promise.all([snapshots.read(version.before), snapshots.read(version.after)]);\n if (before === undefined || after === undefined) {\n return { ...view, note: 'the content behind this change is no longer held' };\n }\n const diff = lineDiff(before, after);\n return {\n ...view,\n additions: diff.additions,\n removals: diff.removals,\n hunks: diff.hunks,\n ...(diff.approximate ? { note: APPROXIMATE_NOTE } : {}),\n };\n}\n\n/** Everything the session did to the file: its oldest baseline against what is on disk now. */\nasync function toCumulativeView(\n versions: readonly FileEditVersion[],\n working: FileEditsWorkingView,\n snapshots: SnapshotStorePort,\n): Promise<FileEditsCumulativeView> {\n const baseline = baselineOf(versions);\n if (baseline === undefined) return { additions: 0, removals: 0, note: NO_BASELINE_NOTE };\n if (working.unavailable) {\n return { additions: 0, removals: 0, note: working.reason ?? 'the file cannot be read' };\n }\n const before = await snapshots.read(baseline);\n if (before === undefined) {\n return { additions: 0, removals: 0, note: 'the content this session started from is no longer held' };\n }\n const diff = lineDiff(before, working.content);\n return {\n additions: diff.additions,\n removals: diff.removals,\n hunks: diff.hunks,\n ...(diff.approximate ? { note: APPROXIMATE_NOTE } : {}),\n };\n}\n\nexport function createFileEditsApi(options: FileEditsApiOptions = {}): Hono {\n const timeline = options.timeline ?? new TimelineStore();\n const snapshots = options.snapshots ?? new NodeSnapshotStoreAdapter();\n const paths = options.paths ?? new FileEditPaths();\n const cwd = options.cwd ?? '';\n const app = new Hono();\n\n // The routes and the extension run in different processes, so this half\n // opens the same two locations from the same inputs rather than sharing an\n // instance with the half that writes them.\n //\n // Only what this function built gets pointed at them. A store handed in\n // through the options arrives already configured, and repointing it would\n // make the option a lie: the caller would be injecting a store the routes\n // then read somewhere else entirely.\n if (options.sessionId !== undefined && cwd !== '') {\n const sessionKey = paths.sessionKey(options.sessionId);\n if (options.timeline === undefined) timeline.initialize(paths.timelinePath(cwd, sessionKey));\n if (options.snapshots === undefined) snapshots.initialize(paths.snapshotsPath(cwd, sessionKey));\n }\n\n /**\n * The file a request names, rejected unless this session actually recorded a\n * change to it. That check is the authorization: these routes read and write\n * whatever path they are handed, so the timeline is what bounds them to files\n * the session already touched.\n */\n const recordedVersions = async (filePath: string | undefined): Promise<FileEditVersion[] | undefined> => {\n if (filePath === undefined || filePath === '') return undefined;\n const versions = await timeline.versions(path.resolve(cwd, filePath));\n return versions.length === 0 ? undefined : versions;\n };\n\n app.get('/detail', async (context) => {\n const requested = context.req.query(PATH_QUERY_PARAM);\n const versions = await recordedVersions(requested);\n if (versions === undefined || requested === undefined) {\n return context.json({ error: 'This session recorded no changes to that file.' }, 404);\n }\n const filePath = path.resolve(cwd, requested);\n const working = await readWorking(filePath);\n const body: FileEditsDetailView = {\n path: filePath,\n relPath: cwd === '' ? filePath : path.relative(cwd, filePath),\n versions: await Promise.all(versions.map((version) => toVersionView(version, snapshots))),\n cumulative: await toCumulativeView(versions, working, snapshots),\n working,\n };\n return context.json(body);\n });\n\n /**\n * The file a request names, read only, for a file the timeline knows nothing\n * about: a path the agent read but never changed is still a path the reader\n * clicked, and refusing it would leave the link dead.\n *\n * The working directory is the boundary here, since the timeline is not.\n * Containment is checked lexically and again on the real path, so a symlink\n * inside the tree cannot hand out something beyond it, and a host that gave\n * these routes no working directory has no boundary to enforce and so serves\n * nothing.\n */\n app.get('/preview', async (context) => {\n const requested = context.req.query(PATH_QUERY_PARAM);\n if (requested === undefined || requested === '' || requested.includes('\\0')) {\n return context.json({ error: 'A preview names a path.' }, 400);\n }\n if (cwd === '') return context.json({ error: 'This session has no working directory to read from.' }, 403);\n const root = path.resolve(cwd);\n const filePath = path.resolve(root, requested);\n if (!isInside(root, filePath) || !(await isRealPathInside(root, filePath))) {\n return context.json({ error: 'The path leaves the session directory.' }, 403);\n }\n const body: FileEditsPreviewView = {\n path: filePath,\n relPath: path.relative(root, filePath),\n working: await readWorking(filePath),\n };\n return context.json(body);\n });\n\n app.put('/content', async (context) => {\n let request: FileEditsSaveRequest;\n try {\n request = (await context.req.json()) as FileEditsSaveRequest;\n } catch {\n return context.json({ error: 'The save body is not JSON.' }, 400);\n }\n if (typeof request.path !== 'string' || typeof request.content !== 'string') {\n return context.json({ error: 'A save names a path and the content to write.' }, 400);\n }\n if ((await recordedVersions(request.path)) === undefined) {\n return context.json({ error: 'This session recorded no changes to that file.' }, 404);\n }\n const filePath = path.resolve(cwd, request.path);\n const current = await readWorking(filePath);\n if (current.unavailable) {\n return context.json({ error: current.reason ?? 'The file cannot be read.' }, 409);\n }\n // The agent runs in the same working directory, so it can rewrite the file\n // while a reader is still editing it. Refusing on a moved hash is what\n // stops the save from silently discarding whatever it did.\n if (current.hash !== request.expectedHash) {\n return context.json({ error: 'The file changed since it was opened.', hash: current.hash }, 409);\n }\n await fs.writeFile(filePath, request.content, 'utf8');\n const body: FileEditsSaveView = { hash: hashOf(request.content) };\n return context.json(body);\n });\n\n app.delete('/content', async (context) => {\n const requested = context.req.query(PATH_QUERY_PARAM);\n if ((await recordedVersions(requested)) === undefined || requested === undefined) {\n return context.json({ error: 'This session recorded no changes to that file.' }, 404);\n }\n const filePath = path.resolve(cwd, requested);\n try {\n await fs.unlink(filePath);\n } catch (error) {\n // Already gone is the outcome the caller wanted, so it is not a failure.\n if (!hasCode(error, 'ENOENT')) {\n return context.json({ error: `The file could not be deleted: ${describe(error)}` }, 409);\n }\n }\n return context.body(null, 204);\n });\n\n return app;\n}\n\n/** The named export a host imports from this package's built entry. */\nexport const api: DoomApi = {\n basePath: API_BASE_PATH,\n start(context: DoomApiContext): DoomApiHandler {\n const app = createFileEditsApi({\n ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }),\n ...(context.cwd === undefined ? {} : { cwd: context.cwd }),\n });\n return {\n fetch: (request) => app.fetch(request),\n // Nothing outlives a request here: every route reads, answers and closes.\n close: () => undefined,\n };\n },\n};\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fileEditsApi.cjs","names":[],"sources":["../../src/types/fileEditsApi.ts"],"sourcesContent":["import type { FileEditOrigin, FileEditTool } from './domain.ts';\n\n/**\n * The wire vocabulary this package's API shares with whatever calls it.\n *\n * It lives under src/types because that is the one server root a browser\n * bundle may read: the cockpit plugin builds its URLs from these and the\n * routes answer them, so neither half can drift from the other.\n *\n *
|
|
1
|
+
{"version":3,"file":"fileEditsApi.cjs","names":[],"sources":["../../src/types/fileEditsApi.ts"],"sourcesContent":["import type { FileEditOrigin, FileEditTool } from './domain.ts';\n\n/**\n * The wire vocabulary this package's API shares with whatever calls it.\n *\n * It lives under src/types because that is the one server root a browser\n * bundle may read: the cockpit plugin builds its URLs from these and the\n * routes answer them, so neither half can drift from the other.\n *\n * Three routes. Opening a changed file wants its whole history at once, so\n * `detail` answers in one round trip rather than making the page stitch three\n * reads together; `content` takes the manual save back; `preview` reads a file\n * the session never changed, which has no history to answer with.\n */\n\n/** Where a host mounts this package's API; the segment after /api/plugin/. */\nexport const API_BASE_PATH = 'file-edits';\n\n/** Query parameter the cockpit hub reads to pick which session server to proxy to. */\nexport const SESSION_QUERY_PARAM = 'session';\n\n/** Query parameter naming the file a request is about. */\nexport const PATH_QUERY_PARAM = 'path';\n\n/** One file's whole detail, relative to the API's own mount. */\nexport function detailPath(): string {\n return '/detail';\n}\n\n/** The manual save, and the deletion, relative to the API's own mount. */\nexport function contentPath(): string {\n return '/content';\n}\n\n/** One unchanged file, read only, relative to the API's own mount. */\nexport function previewPath(): string {\n return '/preview';\n}\n\n/**\n * The absolute URL a page fetches, through the hub. The whole query is built\n * here, session parameter included, so a caller never appends a second '?'.\n */\nexport function detailUrl(sessionId: string, filePath: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });\n return `/api/plugin/${API_BASE_PATH}${detailPath()}?${search.toString()}`;\n}\n\n/** The absolute URL a page puts a manual save to. */\nexport function contentUrl(sessionId: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId });\n return `/api/plugin/${API_BASE_PATH}${contentPath()}?${search.toString()}`;\n}\n\n/** The absolute URL a page reads an unchanged file through. */\nexport function previewUrl(sessionId: string, filePath: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });\n return `/api/plugin/${API_BASE_PATH}${previewPath()}?${search.toString()}`;\n}\n\n/** The absolute URL a page deletes a file through; the path rides the query, not a body. */\nexport function deleteUrl(sessionId: string, filePath: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });\n return `/api/plugin/${API_BASE_PATH}${contentPath()}?${search.toString()}`;\n}\n\n/**\n * One line of a diff. The number is the new file's for context and additions\n * and the old file's for removals, so it always says where the line lives.\n */\nexport interface FileEditsDiffRow {\n marker: '+' | '-' | ' ';\n line: number;\n content: string;\n}\n\n/**\n * A run of changed lines with its surrounding context. Hunks are sent apart\n * rather than joined by an elision marker, so the page draws the gap between\n * them instead of parsing one out of a string.\n */\nexport interface FileEditsDiffHunk {\n /** The line number the hunk opens at. */\n start: number;\n rows: FileEditsDiffRow[];\n}\n\n/**\n * One change in a file's history, each rendered on its own so a file edited\n * four times shows four distinct diffs rather than one merged blur.\n */\nexport interface FileEditsVersionView {\n /** Position in the file's history, oldest first, starting at 1. */\n index: number;\n tool: FileEditTool;\n at: number;\n origin: FileEditOrigin;\n additions: number;\n removals: number;\n /** The changed lines of this one change; absent when no baseline was captured. */\n hunks?: FileEditsDiffHunk[];\n /** Why there is no diff, shown in its place. */\n note?: string;\n}\n\n/** Everything this session did to the file: its first baseline against what is on disk now. */\nexport interface FileEditsCumulativeView {\n additions: number;\n removals: number;\n hunks?: FileEditsDiffHunk[];\n note?: string;\n}\n\n/** The file as it stands on disk right now, which is what the source view edits. */\nexport interface FileEditsWorkingView {\n content: string;\n /** sha256 of the content; the token a save hands back to prove it is not stale. */\n hash: string;\n /** True when the file is binary, missing, or past the size cap, so `content` is empty. */\n unavailable: boolean;\n reason?: string;\n}\n\n/** What the detail route answers with. */\nexport interface FileEditsDetailView {\n path: string;\n relPath: string;\n versions: FileEditsVersionView[];\n cumulative: FileEditsCumulativeView;\n working: FileEditsWorkingView;\n}\n\n/**\n * What the preview route answers with: the file as it stands, and nothing\n * about how it got there. A file this session never touched has no history and\n * no baseline, so there is no diff to send and no save to take back.\n */\nexport interface FileEditsPreviewView {\n path: string;\n relPath: string;\n working: FileEditsWorkingView;\n}\n\n/** What the save route takes. */\nexport interface FileEditsSaveRequest {\n path: string;\n /** The hash the reader loaded; a mismatch means the file moved underneath them. */\n expectedHash: string;\n content: string;\n}\n\n/** What the save route answers with. */\nexport interface FileEditsSaveView {\n hash: string;\n}\n\n/** What a route reports when it refuses; the page shows `error` verbatim. */\nexport interface FileEditsErrorView {\n error: string;\n /** On a stale save, the hash the file actually has now. */\n hash?: string;\n}\n"],"mappings":""}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fileEditsApi.mjs","names":[],"sources":["../../src/types/fileEditsApi.ts"],"sourcesContent":["import type { FileEditOrigin, FileEditTool } from './domain.ts';\n\n/**\n * The wire vocabulary this package's API shares with whatever calls it.\n *\n * It lives under src/types because that is the one server root a browser\n * bundle may read: the cockpit plugin builds its URLs from these and the\n * routes answer them, so neither half can drift from the other.\n *\n *
|
|
1
|
+
{"version":3,"file":"fileEditsApi.mjs","names":[],"sources":["../../src/types/fileEditsApi.ts"],"sourcesContent":["import type { FileEditOrigin, FileEditTool } from './domain.ts';\n\n/**\n * The wire vocabulary this package's API shares with whatever calls it.\n *\n * It lives under src/types because that is the one server root a browser\n * bundle may read: the cockpit plugin builds its URLs from these and the\n * routes answer them, so neither half can drift from the other.\n *\n * Three routes. Opening a changed file wants its whole history at once, so\n * `detail` answers in one round trip rather than making the page stitch three\n * reads together; `content` takes the manual save back; `preview` reads a file\n * the session never changed, which has no history to answer with.\n */\n\n/** Where a host mounts this package's API; the segment after /api/plugin/. */\nexport const API_BASE_PATH = 'file-edits';\n\n/** Query parameter the cockpit hub reads to pick which session server to proxy to. */\nexport const SESSION_QUERY_PARAM = 'session';\n\n/** Query parameter naming the file a request is about. */\nexport const PATH_QUERY_PARAM = 'path';\n\n/** One file's whole detail, relative to the API's own mount. */\nexport function detailPath(): string {\n return '/detail';\n}\n\n/** The manual save, and the deletion, relative to the API's own mount. */\nexport function contentPath(): string {\n return '/content';\n}\n\n/** One unchanged file, read only, relative to the API's own mount. */\nexport function previewPath(): string {\n return '/preview';\n}\n\n/**\n * The absolute URL a page fetches, through the hub. The whole query is built\n * here, session parameter included, so a caller never appends a second '?'.\n */\nexport function detailUrl(sessionId: string, filePath: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });\n return `/api/plugin/${API_BASE_PATH}${detailPath()}?${search.toString()}`;\n}\n\n/** The absolute URL a page puts a manual save to. */\nexport function contentUrl(sessionId: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId });\n return `/api/plugin/${API_BASE_PATH}${contentPath()}?${search.toString()}`;\n}\n\n/** The absolute URL a page reads an unchanged file through. */\nexport function previewUrl(sessionId: string, filePath: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });\n return `/api/plugin/${API_BASE_PATH}${previewPath()}?${search.toString()}`;\n}\n\n/** The absolute URL a page deletes a file through; the path rides the query, not a body. */\nexport function deleteUrl(sessionId: string, filePath: string): string {\n const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });\n return `/api/plugin/${API_BASE_PATH}${contentPath()}?${search.toString()}`;\n}\n\n/**\n * One line of a diff. The number is the new file's for context and additions\n * and the old file's for removals, so it always says where the line lives.\n */\nexport interface FileEditsDiffRow {\n marker: '+' | '-' | ' ';\n line: number;\n content: string;\n}\n\n/**\n * A run of changed lines with its surrounding context. Hunks are sent apart\n * rather than joined by an elision marker, so the page draws the gap between\n * them instead of parsing one out of a string.\n */\nexport interface FileEditsDiffHunk {\n /** The line number the hunk opens at. */\n start: number;\n rows: FileEditsDiffRow[];\n}\n\n/**\n * One change in a file's history, each rendered on its own so a file edited\n * four times shows four distinct diffs rather than one merged blur.\n */\nexport interface FileEditsVersionView {\n /** Position in the file's history, oldest first, starting at 1. */\n index: number;\n tool: FileEditTool;\n at: number;\n origin: FileEditOrigin;\n additions: number;\n removals: number;\n /** The changed lines of this one change; absent when no baseline was captured. */\n hunks?: FileEditsDiffHunk[];\n /** Why there is no diff, shown in its place. */\n note?: string;\n}\n\n/** Everything this session did to the file: its first baseline against what is on disk now. */\nexport interface FileEditsCumulativeView {\n additions: number;\n removals: number;\n hunks?: FileEditsDiffHunk[];\n note?: string;\n}\n\n/** The file as it stands on disk right now, which is what the source view edits. */\nexport interface FileEditsWorkingView {\n content: string;\n /** sha256 of the content; the token a save hands back to prove it is not stale. */\n hash: string;\n /** True when the file is binary, missing, or past the size cap, so `content` is empty. */\n unavailable: boolean;\n reason?: string;\n}\n\n/** What the detail route answers with. */\nexport interface FileEditsDetailView {\n path: string;\n relPath: string;\n versions: FileEditsVersionView[];\n cumulative: FileEditsCumulativeView;\n working: FileEditsWorkingView;\n}\n\n/**\n * What the preview route answers with: the file as it stands, and nothing\n * about how it got there. A file this session never touched has no history and\n * no baseline, so there is no diff to send and no save to take back.\n */\nexport interface FileEditsPreviewView {\n path: string;\n relPath: string;\n working: FileEditsWorkingView;\n}\n\n/** What the save route takes. */\nexport interface FileEditsSaveRequest {\n path: string;\n /** The hash the reader loaded; a mismatch means the file moved underneath them. */\n expectedHash: string;\n content: string;\n}\n\n/** What the save route answers with. */\nexport interface FileEditsSaveView {\n hash: string;\n}\n\n/** What a route reports when it refuses; the page shows `error` verbatim. */\nexport interface FileEditsErrorView {\n error: string;\n /** On a stale save, the hash the file actually has now. */\n hash?: string;\n}\n"],"mappings":"AAgBA,MAAa,EAAgB,aAMhB,EAAmB"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agimon-ai/doompi-file-edit",
|
|
3
|
-
"version": "0.0.1-alpha.
|
|
3
|
+
"version": "0.0.1-alpha.49",
|
|
4
4
|
"description": "Pi extension for session-scoped file-change timelines and external editor workflows.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -70,13 +70,13 @@
|
|
|
70
70
|
"cross-spawn": "7.0.6",
|
|
71
71
|
"hono": "4.13.5",
|
|
72
72
|
"ignore": "7.0.6",
|
|
73
|
-
"@agimon-ai/doompi-config": "0.0.1-alpha.
|
|
74
|
-
"@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.
|
|
75
|
-
"@agimon-ai/doompi-
|
|
76
|
-
"@agimon-ai/doompi-ui": "0.0.1-alpha.
|
|
77
|
-
"@agimon-ai/doompi-
|
|
78
|
-
"@agimon-ai/doompi-web-security": "0.0.1-alpha.
|
|
79
|
-
"@agimon-ai/doompi-web-contracts": "0.0.1-alpha.
|
|
73
|
+
"@agimon-ai/doompi-config": "0.0.1-alpha.48",
|
|
74
|
+
"@agimon-ai/doompi-extension-contracts": "0.0.1-alpha.48",
|
|
75
|
+
"@agimon-ai/doompi-telemetry": "0.0.1-alpha.48",
|
|
76
|
+
"@agimon-ai/doompi-ui": "0.0.1-alpha.49",
|
|
77
|
+
"@agimon-ai/doompi-web-components": "0.0.1-alpha.9",
|
|
78
|
+
"@agimon-ai/doompi-web-security": "0.0.1-alpha.10",
|
|
79
|
+
"@agimon-ai/doompi-web-contracts": "0.0.1-alpha.12"
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
82
|
"@earendil-works/pi-coding-agent": "0.84.4",
|
|
@@ -7,9 +7,10 @@ import type { FileEditOrigin, FileEditTool } from './domain.ts';
|
|
|
7
7
|
* bundle may read: the cockpit plugin builds its URLs from these and the
|
|
8
8
|
* routes answer them, so neither half can drift from the other.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* answers in one round trip rather than making the page stitch three
|
|
12
|
-
* together; `content` takes the manual save back
|
|
10
|
+
* Three routes. Opening a changed file wants its whole history at once, so
|
|
11
|
+
* `detail` answers in one round trip rather than making the page stitch three
|
|
12
|
+
* reads together; `content` takes the manual save back; `preview` reads a file
|
|
13
|
+
* the session never changed, which has no history to answer with.
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
/** Where a host mounts this package's API; the segment after /api/plugin/. */
|
|
@@ -31,6 +32,11 @@ export function contentPath(): string {
|
|
|
31
32
|
return '/content';
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
/** One unchanged file, read only, relative to the API's own mount. */
|
|
36
|
+
export function previewPath(): string {
|
|
37
|
+
return '/preview';
|
|
38
|
+
}
|
|
39
|
+
|
|
34
40
|
/**
|
|
35
41
|
* The absolute URL a page fetches, through the hub. The whole query is built
|
|
36
42
|
* here, session parameter included, so a caller never appends a second '?'.
|
|
@@ -46,6 +52,12 @@ export function contentUrl(sessionId: string): string {
|
|
|
46
52
|
return `/api/plugin/${API_BASE_PATH}${contentPath()}?${search.toString()}`;
|
|
47
53
|
}
|
|
48
54
|
|
|
55
|
+
/** The absolute URL a page reads an unchanged file through. */
|
|
56
|
+
export function previewUrl(sessionId: string, filePath: string): string {
|
|
57
|
+
const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });
|
|
58
|
+
return `/api/plugin/${API_BASE_PATH}${previewPath()}?${search.toString()}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
49
61
|
/** The absolute URL a page deletes a file through; the path rides the query, not a body. */
|
|
50
62
|
export function deleteUrl(sessionId: string, filePath: string): string {
|
|
51
63
|
const search = new URLSearchParams({ [SESSION_QUERY_PARAM]: sessionId, [PATH_QUERY_PARAM]: filePath });
|
|
@@ -118,6 +130,17 @@ export interface FileEditsDetailView {
|
|
|
118
130
|
working: FileEditsWorkingView;
|
|
119
131
|
}
|
|
120
132
|
|
|
133
|
+
/**
|
|
134
|
+
* What the preview route answers with: the file as it stands, and nothing
|
|
135
|
+
* about how it got there. A file this session never touched has no history and
|
|
136
|
+
* no baseline, so there is no diff to send and no save to take back.
|
|
137
|
+
*/
|
|
138
|
+
export interface FileEditsPreviewView {
|
|
139
|
+
path: string;
|
|
140
|
+
relPath: string;
|
|
141
|
+
working: FileEditsWorkingView;
|
|
142
|
+
}
|
|
143
|
+
|
|
121
144
|
/** What the save route takes. */
|
|
122
145
|
export interface FileEditsSaveRequest {
|
|
123
146
|
path: string;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { Breadcrumb, Button, CodeEditor, Markdown, MediaPreview } from '@agimon-ai/doompi-web-components';
|
|
2
|
+
import type { TransientTab, WebPluginSlotProps } from '@agimon-ai/doompi-web-contracts';
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import type { FileEditsPreviewView } from '../types/fileEditsApi.ts';
|
|
5
|
+
import { fetchFilePreview, sessionFileUrl } from './filesApi.ts';
|
|
6
|
+
import { fileTabId, previewModeOf } from './fileView.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A file the session never changed, opened read-only.
|
|
10
|
+
*
|
|
11
|
+
* The tab a changed file opens is built around its history: a diff, a version
|
|
12
|
+
* list, an editor, a delete. None of that exists for a file the session only
|
|
13
|
+
* read, and offering an edit view over a file this package holds no baseline
|
|
14
|
+
* for would put a save behind a reader who cannot see what they are about to
|
|
15
|
+
* overwrite. So this is one view, the file as it stands, and nothing else.
|
|
16
|
+
*
|
|
17
|
+
* Its content comes from the preview route, which is bounded by the session's
|
|
18
|
+
* working directory rather than by the timeline.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
interface FilePreviewPanelProps extends WebPluginSlotProps {
|
|
22
|
+
filePath: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What one fetch settled on, tagged with the file it was about. */
|
|
26
|
+
interface Loaded {
|
|
27
|
+
path: string;
|
|
28
|
+
preview?: FileEditsPreviewView;
|
|
29
|
+
error?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function FilePreviewPanel({ filePath, sessionId, closeTransientTab }: FilePreviewPanelProps) {
|
|
33
|
+
// The answer carries the path it answered for, so switching files is a state
|
|
34
|
+
// that does not match rather than a reset the effect has to perform.
|
|
35
|
+
const [loaded, setLoaded] = useState<Loaded | undefined>(undefined);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (sessionId === null) return;
|
|
39
|
+
let cancelled = false;
|
|
40
|
+
void fetchFilePreview(sessionId, filePath).then((result) => {
|
|
41
|
+
if (cancelled) return;
|
|
42
|
+
setLoaded(result.ok ? { path: filePath, preview: result.preview } : { path: filePath, error: result.error });
|
|
43
|
+
});
|
|
44
|
+
return () => {
|
|
45
|
+
cancelled = true;
|
|
46
|
+
};
|
|
47
|
+
}, [sessionId, filePath]);
|
|
48
|
+
|
|
49
|
+
const current = loaded?.path === filePath ? loaded : undefined;
|
|
50
|
+
const preview = current?.preview;
|
|
51
|
+
const error = current?.error;
|
|
52
|
+
// Before the answer arrives the path is all there is to name the file by,
|
|
53
|
+
// and the route's own relative path replaces it once it does.
|
|
54
|
+
const relPath = preview?.relPath ?? filePath;
|
|
55
|
+
const working = preview?.working;
|
|
56
|
+
const content = working?.content ?? '';
|
|
57
|
+
const previewMode = previewModeOf(relPath, working?.unavailable === true);
|
|
58
|
+
const mediaSrc = sessionId === null ? '' : sessionFileUrl(sessionId, relPath);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div data-testid="files-preview-panel" className="flex min-h-0 flex-1 flex-col">
|
|
62
|
+
<header className="flex shrink-0 items-center gap-2 border-b border-doom-border px-4 py-2">
|
|
63
|
+
<Breadcrumb path={relPath} data-testid="files-preview-breadcrumb" className="min-w-0 flex-1" />
|
|
64
|
+
<span className="shrink-0 text-[9px] text-doom-faint">unchanged</span>
|
|
65
|
+
<Button
|
|
66
|
+
variant="ghost"
|
|
67
|
+
size="xs"
|
|
68
|
+
data-testid="files-preview-close"
|
|
69
|
+
onClick={() => closeTransientTab(filePreviewTabId(filePath))}
|
|
70
|
+
className="shrink-0 text-[9px]"
|
|
71
|
+
>
|
|
72
|
+
close
|
|
73
|
+
</Button>
|
|
74
|
+
</header>
|
|
75
|
+
|
|
76
|
+
{error === undefined ? null : (
|
|
77
|
+
<p data-testid="files-preview-error" className="px-4 py-3 text-[11px] text-doom-red">
|
|
78
|
+
{error}
|
|
79
|
+
</p>
|
|
80
|
+
)}
|
|
81
|
+
{preview === undefined && error === undefined ? (
|
|
82
|
+
<p data-testid="files-preview-loading" className="px-4 py-3 text-[11px] text-doom-faint">
|
|
83
|
+
reading this file…
|
|
84
|
+
</p>
|
|
85
|
+
) : null}
|
|
86
|
+
|
|
87
|
+
{preview === undefined ? null : (
|
|
88
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
89
|
+
<div
|
|
90
|
+
data-testid="files-preview"
|
|
91
|
+
data-mode={previewMode}
|
|
92
|
+
className="flex h-full flex-col gap-2 p-4 text-[12px] text-doom-text"
|
|
93
|
+
>
|
|
94
|
+
{previewMode === 'unavailable' ? (
|
|
95
|
+
<p data-testid="files-preview-unavailable" className="text-[11px] text-doom-faint">
|
|
96
|
+
{working?.reason ?? 'this file cannot be shown'}
|
|
97
|
+
</p>
|
|
98
|
+
) : null}
|
|
99
|
+
{previewMode === 'media' || previewMode === 'unavailable' ? (
|
|
100
|
+
<MediaPreview src={mediaSrc} path={relPath} data-testid="files-preview-media" />
|
|
101
|
+
) : previewMode === 'markdown' ? (
|
|
102
|
+
<Markdown text={content} />
|
|
103
|
+
) : previewMode === 'html' ? (
|
|
104
|
+
<iframe
|
|
105
|
+
data-testid="files-preview-html"
|
|
106
|
+
title={relPath}
|
|
107
|
+
sandbox=""
|
|
108
|
+
srcDoc={content}
|
|
109
|
+
className="h-[36rem] w-full rounded border border-doom-border bg-doom-deep"
|
|
110
|
+
/>
|
|
111
|
+
) : previewMode === 'code' ? (
|
|
112
|
+
<CodeEditor
|
|
113
|
+
data-testid="files-preview-code"
|
|
114
|
+
value={content}
|
|
115
|
+
path={relPath}
|
|
116
|
+
readOnly
|
|
117
|
+
className="min-h-[24rem] flex-1 rounded border border-doom-border"
|
|
118
|
+
/>
|
|
119
|
+
) : (
|
|
120
|
+
<pre
|
|
121
|
+
data-testid="files-preview-text"
|
|
122
|
+
className="whitespace-pre-wrap break-words font-mono text-[11px] leading-[1.5] text-doom-text"
|
|
123
|
+
>
|
|
124
|
+
{content}
|
|
125
|
+
</pre>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The id of the read-only tab, kept apart from the changed-file tab's own id:
|
|
136
|
+
* the same file can be read now and changed later, and the two tabs are not
|
|
137
|
+
* the same view of it.
|
|
138
|
+
*/
|
|
139
|
+
export function filePreviewTabId(filePath: string): string {
|
|
140
|
+
return `${fileTabId(filePath)}-preview`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The transient tab an unchanged file opens in; the same file reopens rather than duplicating. */
|
|
144
|
+
export function filePreviewTab(filePath: string): TransientTab {
|
|
145
|
+
return {
|
|
146
|
+
id: filePreviewTabId(filePath),
|
|
147
|
+
label: filePath.split('/').at(-1) ?? filePath,
|
|
148
|
+
panel: (props) => <FilePreviewPanel {...props} filePath={filePath} />,
|
|
149
|
+
};
|
|
150
|
+
}
|
package/src/web/fileLinks.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { FileLinkSource, TransientTab } from '@agimon-ai/doompi-web-contracts';
|
|
2
2
|
import type { FilesItemView } from '../types/webFiles.ts';
|
|
3
3
|
import { fileTab } from './FilePanel.tsx';
|
|
4
|
+
import { filePreviewTab } from './FilePreviewPanel.tsx';
|
|
4
5
|
import { files } from './filesStore.ts';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -42,4 +43,16 @@ export const fileLinks: FileLinkSource = {
|
|
|
42
43
|
const item = match(sessionId, path);
|
|
43
44
|
return item === undefined ? undefined : fileTab(item.path, item.relPath);
|
|
44
45
|
},
|
|
46
|
+
// A tool argument is a path on purpose, so nothing here has to be guessed.
|
|
47
|
+
// A file the session changed opens on its history; one it only read opens
|
|
48
|
+
// read-only, and the route behind that tab is what decides whether a path
|
|
49
|
+
// outside the working directory may be shown at all.
|
|
50
|
+
openPath: (sessionId, path): TransientTab | undefined => {
|
|
51
|
+
const item = match(sessionId, path);
|
|
52
|
+
if (item !== undefined) return fileTab(item.path, item.relPath);
|
|
53
|
+
// A call with no path argument at all hands this an empty string, and
|
|
54
|
+
// there is no file to open behind it.
|
|
55
|
+
const candidate = path.trim();
|
|
56
|
+
return candidate === '' ? undefined : filePreviewTab(candidate);
|
|
57
|
+
},
|
|
45
58
|
};
|
package/src/web/filesApi.ts
CHANGED
|
@@ -3,7 +3,9 @@ import {
|
|
|
3
3
|
deleteUrl,
|
|
4
4
|
detailUrl,
|
|
5
5
|
type FileEditsDetailView,
|
|
6
|
+
type FileEditsPreviewView,
|
|
6
7
|
type FileEditsSaveView,
|
|
8
|
+
previewUrl,
|
|
7
9
|
} from '../types/fileEditsApi.ts';
|
|
8
10
|
import { sealedTransport } from '@agimon-ai/doompi-web-security/browser';
|
|
9
11
|
|
|
@@ -71,6 +73,22 @@ export async function fetchFileDetail(sessionId: string, filePath: string): Prom
|
|
|
71
73
|
return { ok: true, detail: body as unknown as FileEditsDetailView };
|
|
72
74
|
}
|
|
73
75
|
|
|
76
|
+
export type FetchPreviewResult = { ok: true; preview: FileEditsPreviewView } | { ok: false; error: string };
|
|
77
|
+
|
|
78
|
+
/** One file the session never changed, read only; the route bounds it to the working directory. */
|
|
79
|
+
export async function fetchFilePreview(sessionId: string, filePath: string): Promise<FetchPreviewResult> {
|
|
80
|
+
let response: Response;
|
|
81
|
+
try {
|
|
82
|
+
response = await sealedTransport.fetch(previewUrl(sessionId, filePath));
|
|
83
|
+
} catch {
|
|
84
|
+
return { ok: false, error: UNREACHABLE };
|
|
85
|
+
}
|
|
86
|
+
const body = await readBody(response);
|
|
87
|
+
if (!response.ok) return { ok: false, error: errorOf(body, `The session answered ${response.status}.`) };
|
|
88
|
+
if (!isRecord(body)) return { ok: false, error: 'The session answered with no file.' };
|
|
89
|
+
return { ok: true, preview: body as unknown as FileEditsPreviewView };
|
|
90
|
+
}
|
|
91
|
+
|
|
74
92
|
export type SaveResult =
|
|
75
93
|
| { ok: true; hash: string }
|
|
76
94
|
/** The file moved under the editor; `hash` is what it holds now. */
|