From b864c2dac161a292177167cd15200fe09757019d Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 19 Feb 2026 21:47:22 +0530 Subject: [PATCH 01/11] feat: disk-based content store with instance heartbeat and GC Move AISnapshotStore content from unbounded in-memory _contentStore to disk-backed storage under /instanceData//aiSnap/. Content stays in _memoryBuffer during an AI turn and flushes to disk at finalizeResponse() time. Reads check memory first, then fall back to disk. Add per-instance heartbeat (60s interval) and garbage collection that removes stale instance directories (>20 min without heartbeat) on startup. --- src/core-ai/AISnapshotStore.js | 222 ++++++++++++++++++++++++--- src/core-ai/editApplyVerification.md | 6 + 2 files changed, 205 insertions(+), 23 deletions(-) diff --git a/src/core-ai/AISnapshotStore.js b/src/core-ai/AISnapshotStore.js index efe70b9d2..84b0af0a2 100644 --- a/src/core-ai/AISnapshotStore.js +++ b/src/core-ai/AISnapshotStore.js @@ -22,6 +22,10 @@ * AI Snapshot Store — content-addressable store and snapshot/restore logic * for tracking file states across AI responses. Extracted from AIChatPanel * to separate data/logic concerns from the DOM/UI layer. + * + * Content is stored in memory during an AI turn and flushed to disk at + * finalizeResponse() time. Reads check memory first, then fall back to disk. + * A per-instance heartbeat + GC mechanism cleans up stale instance data. */ define(function (require, exports, module) { @@ -30,8 +34,23 @@ define(function (require, exports, module) { Commands = require("command/Commands"), FileSystem = require("filesystem/FileSystem"); + // --- Constants --- + const HEARTBEAT_INTERVAL_MS = 60 * 1000; + const STALE_THRESHOLD_MS = 20 * 60 * 1000; + + // --- Disk store state --- + let _instanceDir; // "/instanceData//" + let _aiSnapDir; // "/instanceData//aiSnap/" + let _heartbeatIntervalId = null; + let _diskReady = false; + let _diskReadyResolve; + const _diskReadyPromise = new Promise(function (resolve) { + _diskReadyResolve = resolve; + }); + // --- Private state --- - const _contentStore = {}; // hash → content string (content-addressable dedup) + const _memoryBuffer = {}; // hash → content (in-memory during AI turn) + const _writtenHashes = new Set(); // hashes confirmed on disk let _snapshots = []; // flat: _snapshots[i] = { filePath: hash|null } let _pendingBeforeSnap = {}; // built during current response: filePath → hash|null @@ -65,10 +84,68 @@ define(function (require, exports, module) { function storeContent(content) { const hash = _hashContent(content); - _contentStore[hash] = content; + if (!_writtenHashes.has(hash) && !_memoryBuffer[hash]) { + _memoryBuffer[hash] = content; + } return hash; } + // --- Disk store --- + + function _initDiskStore() { + const appSupportDir = Phoenix.VFS.getAppSupportDir(); + const instanceId = Phoenix.PHOENIX_INSTANCE_ID; + _instanceDir = appSupportDir + "instanceData/" + instanceId + "/"; + _aiSnapDir = _instanceDir + "aiSnap/"; + Phoenix.VFS.ensureExistsDirAsync(_aiSnapDir) + .then(function () { + _diskReady = true; + _diskReadyResolve(); + }) + .catch(function (err) { + console.error("[AISnapshotStore] Failed to init disk store:", err); + // _diskReadyPromise stays pending — heartbeat/GC never fire + }); + } + + function _flushToDisk() { + if (!_diskReady) { + return; + } + const hashes = Object.keys(_memoryBuffer); + hashes.forEach(function (hash) { + const content = _memoryBuffer[hash]; + const file = FileSystem.getFileForPath(_aiSnapDir + hash); + file.write(content, {blind: true}, function (err) { + if (err) { + console.error("[AISnapshotStore] Flush failed for hash " + hash + ":", err); + // Keep in _memoryBuffer so reads still work + } else { + _writtenHashes.add(hash); + delete _memoryBuffer[hash]; + } + }); + }); + } + + function _readContent(hash) { + // Check memory buffer first (content may not have flushed yet) + if (_memoryBuffer[hash]) { + return Promise.resolve(_memoryBuffer[hash]); + } + // Read from disk + return new Promise(function (resolve, reject) { + const file = FileSystem.getFileForPath(_aiSnapDir + hash); + file.read(function (err, data) { + if (err) { + reject(err); + } else { + resolve(data); + } + }); + }); + } + // --- File operations --- /** @@ -181,27 +258,30 @@ define(function (require, exports, module) { /** * Apply a snapshot to files. hash=null means delete the file. + * Reads content from memory buffer first, then disk. * @param {Object} snapshot - { filePath: hash|null } - * @return {$.Promise} resolves with errorCount + * @return {Promise} resolves with errorCount */ - function _applySnapshot(snapshot) { - const result = new $.Deferred(); + async function _applySnapshot(snapshot) { const filePaths = Object.keys(snapshot); - const promises = []; - let errorCount = 0; - filePaths.forEach(function (fp) { + if (filePaths.length === 0) { + return 0; + } + const promises = filePaths.map(function (fp) { const hash = snapshot[fp]; - const p = hash === null - ? _closeAndDeleteFile(fp) - : _createOrUpdateFile(fp, _contentStore[hash]); - p.fail(function () { errorCount++; }); - promises.push(p); + if (hash === null) { + return _closeAndDeleteFile(fp); + } + return _readContent(hash).then(function (content) { + return _createOrUpdateFile(fp, content); + }); }); - if (promises.length === 0) { - return result.resolve(0).promise(); - } - $.when.apply($, promises).always(function () { result.resolve(errorCount); }); - return result.promise(); + const results = await Promise.allSettled(promises); + let errorCount = 0; + results.forEach(function (r) { + if (r.status === "rejected") { errorCount++; } + }); + return errorCount; } // --- Public API --- @@ -240,6 +320,7 @@ define(function (require, exports, module) { * Finalize snapshot state when a response completes. * Builds an "after" snapshot from current document content for edited files, * pushes it, and resets transient tracking variables. + * Flushes in-memory content to disk for long-term storage. * @return {number} the after-snapshot index, or -1 if no edits happened */ function finalizeResponse() { @@ -258,6 +339,7 @@ define(function (require, exports, module) { afterIndex = _snapshots.length - 1; } _pendingBeforeSnap = {}; + _flushToDisk(); return afterIndex; } @@ -266,14 +348,13 @@ define(function (require, exports, module) { * @param {number} index - index into _snapshots * @param {Function} onComplete - callback(errorCount) */ - function restoreToSnapshot(index, onComplete) { + async function restoreToSnapshot(index, onComplete) { if (index < 0 || index >= _snapshots.length) { onComplete(0); return; } - _applySnapshot(_snapshots[index]).done(function (errorCount) { - onComplete(errorCount); - }); + const errorCount = await _applySnapshot(_snapshots[index]); + onComplete(errorCount); } /** @@ -285,13 +366,108 @@ define(function (require, exports, module) { /** * Clear all snapshot state. Called when starting a new session. + * Also deletes and recreates the aiSnap directory on disk. */ function reset() { - Object.keys(_contentStore).forEach(function (k) { delete _contentStore[k]; }); + Object.keys(_memoryBuffer).forEach(function (k) { delete _memoryBuffer[k]; }); + _writtenHashes.clear(); _snapshots = []; _pendingBeforeSnap = {}; + + // Delete and recreate aiSnap directory + if (_diskReady && _aiSnapDir) { + const dir = FileSystem.getDirectoryForPath(_aiSnapDir); + dir.unlink(function (err) { + if (err) { + console.error("[AISnapshotStore] Failed to delete aiSnap dir:", err); + } + Phoenix.VFS.ensureExistsDirAsync(_aiSnapDir).catch(function (e) { + console.error("[AISnapshotStore] Failed to recreate aiSnap dir:", e); + }); + }); + } + } + + // --- Heartbeat --- + + function _writeHeartbeat() { + if (!_diskReady || !_instanceDir) { + return; + } + const file = FileSystem.getFileForPath(_instanceDir + "heartbeat"); + file.write(String(Date.now()), {blind: true}, function (err) { + if (err) { + console.error("[AISnapshotStore] Heartbeat write failed:", err); + } + }); + } + + function _startHeartbeat() { + _diskReadyPromise.then(function () { + _writeHeartbeat(); + _heartbeatIntervalId = setInterval(_writeHeartbeat, HEARTBEAT_INTERVAL_MS); + }); } + function _stopHeartbeat() { + if (_heartbeatIntervalId !== null) { + clearInterval(_heartbeatIntervalId); + _heartbeatIntervalId = null; + } + } + + // --- Garbage Collection --- + + function _runGarbageCollection() { + _diskReadyPromise.then(function () { + const appSupportDir = Phoenix.VFS.getAppSupportDir(); + const instanceDataBaseDir = appSupportDir + "instanceData/"; + const ownId = Phoenix.PHOENIX_INSTANCE_ID; + const baseDir = FileSystem.getDirectoryForPath(instanceDataBaseDir); + baseDir.getContents(function (err, entries) { + if (err) { + console.error("[AISnapshotStore] GC: failed to list instanceData:", err); + return; + } + const now = Date.now(); + entries.forEach(function (entry) { + if (!entry.isDirectory || entry.name === ownId) { + return; + } + const heartbeatFile = FileSystem.getFileForPath( + instanceDataBaseDir + entry.name + "/heartbeat" + ); + heartbeatFile.read(function (readErr, data) { + let isStale = false; + if (readErr) { + // No heartbeat file — treat as stale + isStale = true; + } else { + const ts = parseInt(data, 10); + if (isNaN(ts) || (now - ts) > STALE_THRESHOLD_MS) { + isStale = true; + } + } + if (isStale) { + entry.unlink(function (unlinkErr) { + if (unlinkErr) { + console.error("[AISnapshotStore] GC: failed to remove stale dir " + + entry.name + ":", unlinkErr); + } + }); + } + }); + }); + }, true); // true = filterNothing + }); + } + + // --- Module init --- + _initDiskStore(); + _startHeartbeat(); + _runGarbageCollection(); + window.addEventListener("beforeunload", _stopHeartbeat); + exports.realToVfsPath = realToVfsPath; exports.saveDocToDisk = saveDocToDisk; exports.storeContent = storeContent; diff --git a/src/core-ai/editApplyVerification.md b/src/core-ai/editApplyVerification.md index e30c5e3c2..edc50135e 100644 --- a/src/core-ai/editApplyVerification.md +++ b/src/core-ai/editApplyVerification.md @@ -23,8 +23,14 @@ _snapshots[2] = after R2 edits ## State Variables ### AISnapshotStore (pure data layer) +- `_memoryBuffer`: `hash → content` map holding content in memory during an AI turn; flushed to disk at `finalizeResponse()` time +- `_writtenHashes`: `Set` of hashes confirmed written to disk (reads skip disk check for these) - `_snapshots[]`: flat array of `{ filePath: hash|null }` snapshots. `getSnapshotCount() > 0` replaces the old `_initialSnapshotCreated` flag. - `_pendingBeforeSnap`: per-file pre-edit tracking during current response (dedup guard for first-edit-per-file + file list for `finalizeResponse`) +- `_instanceDir` / `_aiSnapDir`: per-instance disk paths under `/instanceData//` +- `_diskReady` / `_diskReadyDeferred`: gate for disk I/O; resolved once `_aiSnapDir` is created +- Heartbeat: writes `Date.now()` to `_instanceDir + "heartbeat"` every 60s; stopped on `beforeunload` +- GC: on module init, scans sibling instance dirs; removes any with missing or stale (>20 min) heartbeat ### AIChatPanel (UI state) - `_undoApplied`: whether undo/restore has been clicked on any card (UI control for button labels) From eb2840373820d5d884d66fbd288f1a6554aae780 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 20 Feb 2026 08:06:23 +0530 Subject: [PATCH 02/11] test: add AISnapshotStore integration tests Add integration tests covering snapshot data consistency: content- addressable dedup, snapshot building/back-fill, multi-response restore, file creation/deletion flows, and reset behavior. Expose AISnapshotStore via brackets.test for test window access. --- src/brackets.js | 1 + src/core-ai/AISnapshotStore.js | 2 +- test/UnitTestSuite.js | 1 + test/spec/ai-snapshot-test.js | 530 +++++++++++++++++++++++++++++++++ 4 files changed, 533 insertions(+), 1 deletion(-) create mode 100644 test/spec/ai-snapshot-test.js diff --git a/src/brackets.js b/src/brackets.js index b14f4e1b9..a4c486b0f 100644 --- a/src/brackets.js +++ b/src/brackets.js @@ -296,6 +296,7 @@ define(function (require, exports, module) { SidebarTabs: require("view/SidebarTabs"), SidebarView: require("project/SidebarView"), WorkingSetView: require("project/WorkingSetView"), + AISnapshotStore: require("core-ai/AISnapshotStore"), doneLoading: false }; diff --git a/src/core-ai/AISnapshotStore.js b/src/core-ai/AISnapshotStore.js index 84b0af0a2..1bfdfe8e2 100644 --- a/src/core-ai/AISnapshotStore.js +++ b/src/core-ai/AISnapshotStore.js @@ -36,7 +36,7 @@ define(function (require, exports, module) { // --- Constants --- const HEARTBEAT_INTERVAL_MS = 60 * 1000; - const STALE_THRESHOLD_MS = 20 * 60 * 1000; + const STALE_THRESHOLD_MS = 10 * 60 * 1000; // --- Disk store state --- let _instanceDir; // "/instanceData//" diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js index 7c061cf05..da2d38eed 100644 --- a/test/UnitTestSuite.js +++ b/test/UnitTestSuite.js @@ -62,6 +62,7 @@ define(function (require, exports, module) { require("spec/KeybindingManager-integ-test"); require("spec/LanguageManager-test"); require("spec/LanguageManager-integ-test"); + require("spec/ai-snapshot-test"); require("spec/LowLevelFileIO-test"); require("spec/Metrics-test"); require("spec/MultiRangeInlineEditor-test"); diff --git a/test/spec/ai-snapshot-test.js b/test/spec/ai-snapshot-test.js new file mode 100644 index 000000000..096246789 --- /dev/null +++ b/test/spec/ai-snapshot-test.js @@ -0,0 +1,530 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/*global describe, beforeAll, afterAll, beforeEach, afterEach, it, expect, awaitsFor, awaitsForDone, jsPromise */ + +define(function (require, exports, module) { + + const SpecRunnerUtils = require("spec/SpecRunnerUtils"); + + const tempDir = SpecRunnerUtils.getTempDirectory(); + + let AISnapshotStore, + DocumentManager, + CommandManager, + Commands, + FileSystem, + testWindow; + + describe("integration:AISnapshotStore", function () { + + beforeAll(async function () { + testWindow = await SpecRunnerUtils.createTestWindowAndRun(); + AISnapshotStore = testWindow.brackets.test.AISnapshotStore; + DocumentManager = testWindow.brackets.test.DocumentManager; + CommandManager = testWindow.brackets.test.CommandManager; + Commands = testWindow.brackets.test.Commands; + FileSystem = testWindow.brackets.test.FileSystem; + }, 30000); + + afterAll(async function () { + AISnapshotStore = null; + DocumentManager = null; + CommandManager = null; + Commands = null; + FileSystem = null; + testWindow = null; + await SpecRunnerUtils.closeTestWindow(); + }, 30000); + + beforeEach(async function () { + await SpecRunnerUtils.createTempDirectory(); + await SpecRunnerUtils.loadProjectInTestWindow(tempDir); + }); + + afterEach(async function () { + await testWindow.closeAllFiles(); + AISnapshotStore.reset(); + await SpecRunnerUtils.removeTempDirectory(); + }); + + // --- helpers --- + + // Convert a file name to a VFS path that matches what realToVfsPath produces. + // In native (Tauri) builds, realToVfsPath adds /tauri/ prefix to native paths. + // By opening docs with VFS paths, doc.file.fullPath matches what finalizeResponse + // will look up via realToVfsPath. + function toVfsPath(name) { + return AISnapshotStore.realToVfsPath(tempDir + "/" + name); + } + + async function createFile(name, content) { + // Write through the test window's FileSystem (not the host's) so + // the document cache stays consistent across tests. + const path = toVfsPath(name); + return new Promise(function (resolve, reject) { + const file = FileSystem.getFileForPath(path); + file.write(content, {blind: true}, function (err) { + if (err) { reject(err); } else { resolve(); } + }); + }); + } + + async function openDoc(name) { + const fullPath = toVfsPath(name); + await awaitsForDone( + CommandManager.execute(Commands.FILE_OPEN, { fullPath: fullPath }), + "open " + name + ); + return DocumentManager.getOpenDocumentForPath(fullPath); + } + + function simulateEdit(doc, newContent, isNewFile) { + AISnapshotStore.recordFileBeforeEdit(doc.file.fullPath, doc.getText(), isNewFile); + doc.setText(newContent); + } + + async function simulateCreateFile(name, content) { + await createFile(name, ""); + const doc = await openDoc(name); + AISnapshotStore.recordFileBeforeEdit(doc.file.fullPath, "", true); + doc.setText(content); + return doc; + } + + function restoreToSnapshot(index) { + return new Promise(function (resolve) { + AISnapshotStore.restoreToSnapshot(index, function (errorCount) { + resolve(errorCount); + }); + }); + } + + async function readFile(name) { + // Read from the open Document to avoid FileSystem read-cache staleness. + // _createOrUpdateFile always updates the document text before resolving. + const path = toVfsPath(name); + const doc = DocumentManager.getOpenDocumentForPath(path); + if (doc) { + return doc.getText(); + } + return new Promise(function (resolve, reject) { + DocumentManager.getDocumentForPath(path) + .done(function (d) { resolve(d.getText()); }) + .fail(function (err) { reject(err); }); + }); + } + + async function fileExists(name) { + return new Promise(function (resolve) { + const file = FileSystem.getFileForPath(toVfsPath(name)); + file.exists(function (err, exists) { + resolve(exists); + }); + }); + } + + async function expectFileDeleted(name) { + let gone = false; + await awaitsFor(function () { + fileExists(name).then(function (e) { gone = !e; }); + return gone; + }, name + " to be deleted", 5000); + } + + function beginResponse() { + if (AISnapshotStore.getSnapshotCount() === 0) { + AISnapshotStore.createInitialSnapshot(); + } + } + + // --- storeContent --- + + describe("storeContent", function () { + it("should return same hash for identical content", function () { + const h1 = AISnapshotStore.storeContent("hello world"); + const h2 = AISnapshotStore.storeContent("hello world"); + expect(h1).toBe(h2); + }); + + it("should return different hashes for different content", function () { + const h1 = AISnapshotStore.storeContent("aaa"); + const h2 = AISnapshotStore.storeContent("bbb"); + expect(h1).not.toBe(h2); + }); + + it("should return a valid hash for empty string", function () { + const h = AISnapshotStore.storeContent(""); + expect(typeof h).toBe("string"); + expect(h.length).toBeGreaterThan(0); + }); + }); + + // --- createInitialSnapshot and recordFileBeforeEdit --- + + describe("createInitialSnapshot and recordFileBeforeEdit", function () { + it("should create initial snapshot at index 0 with count 1", function () { + const idx = AISnapshotStore.createInitialSnapshot(); + expect(idx).toBe(0); + expect(AISnapshotStore.getSnapshotCount()).toBe(1); + }); + + it("should back-fill snapshot 0 when recording before-edit", async function () { + await createFile("a.txt", "original"); + const doc = await openDoc("a.txt"); + + beginResponse(); + AISnapshotStore.recordFileBeforeEdit(doc.file.fullPath, "original", false); + doc.setText("modified"); + + // Snapshot 0 should now contain a hash for "original" + const errorCount = await restoreToSnapshot(0); + expect(errorCount).toBe(0); + const content = await readFile("a.txt"); + expect(content).toBe("original"); + }); + + it("should store null for isNewFile=true", async function () { + await createFile("new.txt", ""); + const doc = await openDoc("new.txt"); + + beginResponse(); + AISnapshotStore.recordFileBeforeEdit(doc.file.fullPath, "", true); + doc.setText("new content"); + AISnapshotStore.finalizeResponse(); + + // Snapshot 0 has null → restore deletes file + const errorCount = await restoreToSnapshot(0); + expect(errorCount).toBe(0); + await expectFileDeleted("new.txt"); + }); + + it("should ignore duplicate recordFileBeforeEdit for same file", async function () { + await createFile("a.txt", "v0"); + const doc = await openDoc("a.txt"); + + beginResponse(); + AISnapshotStore.recordFileBeforeEdit(doc.file.fullPath, "v0", false); + doc.setText("v1"); + // Second call with different content should be ignored (first-edit-wins) + AISnapshotStore.recordFileBeforeEdit(doc.file.fullPath, "v1", false); + AISnapshotStore.finalizeResponse(); + + // Restore to snapshot 0 should give v0, not v1 + const errorCount = await restoreToSnapshot(0); + expect(errorCount).toBe(0); + const content = await readFile("a.txt"); + expect(content).toBe("v0"); + }); + }); + + // --- finalizeResponse --- + + describe("finalizeResponse", function () { + it("should return -1 when no pending edits", function () { + beginResponse(); + const idx = AISnapshotStore.finalizeResponse(); + expect(idx).toBe(-1); + }); + + it("should build after-snapshot from open doc content", async function () { + await createFile("a.txt", "before"); + const doc = await openDoc("a.txt"); + + beginResponse(); + simulateEdit(doc, "after", false); + AISnapshotStore.finalizeResponse(); + + // Snapshot 1 should have "after" + const errorCount = await restoreToSnapshot(1); + expect(errorCount).toBe(0); + const content = await readFile("a.txt"); + expect(content).toBe("after"); + }); + + it("should increment snapshot count", async function () { + await createFile("a.txt", "v0"); + const doc = await openDoc("a.txt"); + + beginResponse(); + simulateEdit(doc, "v1", false); + expect(AISnapshotStore.getSnapshotCount()).toBe(1); + AISnapshotStore.finalizeResponse(); + expect(AISnapshotStore.getSnapshotCount()).toBe(2); + }); + + it("should clear pending state (second finalize returns -1)", async function () { + await createFile("a.txt", "v0"); + const doc = await openDoc("a.txt"); + + beginResponse(); + simulateEdit(doc, "v1", false); + const idx = AISnapshotStore.finalizeResponse(); + expect(idx).toBe(1); + const idx2 = AISnapshotStore.finalizeResponse(); + expect(idx2).toBe(-1); + }); + }); + + // --- snapshot consistency (editApplyVerification cases) --- + + describe("snapshot consistency", function () { + + // Case 1: Single response, 2 files + it("should handle single response editing 2 files", async function () { + await createFile("a.txt", "a0"); + await createFile("b.txt", "b0"); + const docA = await openDoc("a.txt"); + const docB = await openDoc("b.txt"); + + // R1 + beginResponse(); + simulateEdit(docA, "a1", false); + simulateEdit(docB, "b1", false); + AISnapshotStore.finalizeResponse(); + + expect(AISnapshotStore.getSnapshotCount()).toBe(2); + + // restore(0) → a0, b0 + let err = await restoreToSnapshot(0); + expect(err).toBe(0); + expect(await readFile("a.txt")).toBe("a0"); + expect(await readFile("b.txt")).toBe("b0"); + + // restore(1) → a1, b1 + err = await restoreToSnapshot(1); + expect(err).toBe(0); + expect(await readFile("a.txt")).toBe("a1"); + expect(await readFile("b.txt")).toBe("b1"); + }); + + // Case 2: Two responses, same file + it("should handle two responses editing same file", async function () { + await createFile("a.txt", "v0"); + const doc = await openDoc("a.txt"); + + // R1 + beginResponse(); + simulateEdit(doc, "v1", false); + AISnapshotStore.finalizeResponse(); + + // R2 + simulateEdit(doc, "v2", false); + AISnapshotStore.finalizeResponse(); + + expect(AISnapshotStore.getSnapshotCount()).toBe(3); + + let err = await restoreToSnapshot(0); + expect(err).toBe(0); + expect(await readFile("a.txt")).toBe("v0"); + + err = await restoreToSnapshot(1); + expect(err).toBe(0); + expect(await readFile("a.txt")).toBe("v1"); + + err = await restoreToSnapshot(2); + expect(err).toBe(0); + expect(await readFile("a.txt")).toBe("v2"); + }); + + // Case 4: Three responses, restore middle + it("should restore to middle snapshot", async function () { + await createFile("a.txt", "v0"); + const doc = await openDoc("a.txt"); + + // R1 + beginResponse(); + simulateEdit(doc, "v1", false); + AISnapshotStore.finalizeResponse(); + + // R2 + simulateEdit(doc, "v2", false); + AISnapshotStore.finalizeResponse(); + + // R3 + simulateEdit(doc, "v3", false); + AISnapshotStore.finalizeResponse(); + + expect(AISnapshotStore.getSnapshotCount()).toBe(4); + + let err = await restoreToSnapshot(1); + expect(err).toBe(0); + expect(await readFile("a.txt")).toBe("v1"); + + err = await restoreToSnapshot(2); + expect(err).toBe(0); + expect(await readFile("a.txt")).toBe("v2"); + }); + + // Case 5: Different files, back-fill + it("should back-fill when different files edited in different responses", async function () { + await createFile("a.txt", "a0"); + await createFile("b.txt", "b0"); + const docA = await openDoc("a.txt"); + + // R1: edit A only + beginResponse(); + simulateEdit(docA, "a1", false); + AISnapshotStore.finalizeResponse(); + + // R2: edit B only + const docB = await openDoc("b.txt"); + simulateEdit(docB, "b1", false); + AISnapshotStore.finalizeResponse(); + + expect(AISnapshotStore.getSnapshotCount()).toBe(3); + + // snap 0 & 1 should have been back-filled with B:b0 + let err = await restoreToSnapshot(0); + expect(err).toBe(0); + expect(await readFile("a.txt")).toBe("a0"); + expect(await readFile("b.txt")).toBe("b0"); + + err = await restoreToSnapshot(1); + expect(err).toBe(0); + expect(await readFile("a.txt")).toBe("a1"); + expect(await readFile("b.txt")).toBe("b0"); + + err = await restoreToSnapshot(2); + expect(err).toBe(0); + expect(await readFile("a.txt")).toBe("a1"); + expect(await readFile("b.txt")).toBe("b1"); + }); + + // Case 6: File created in R1, edited in R2 + it("should handle file creation and subsequent edit", async function () { + // R1: create file A + beginResponse(); + const docA = await simulateCreateFile("a.txt", "new"); + AISnapshotStore.finalizeResponse(); + + // R2: edit A + simulateEdit(docA, "edited", false); + AISnapshotStore.finalizeResponse(); + + expect(AISnapshotStore.getSnapshotCount()).toBe(3); + + // snap 2 → A="edited" + let err = await restoreToSnapshot(2); + expect(err).toBe(0); + expect(await readFile("a.txt")).toBe("edited"); + + // snap 1 → A="new" + err = await restoreToSnapshot(1); + expect(err).toBe(0); + expect(await readFile("a.txt")).toBe("new"); + + // snap 0 has A:null → file deleted + err = await restoreToSnapshot(0); + expect(err).toBe(0); + await expectFileDeleted("a.txt"); + }); + + // Case 7: File created in R2 + it("should handle file created in second response", async function () { + await createFile("a.txt", "a0"); + const docA = await openDoc("a.txt"); + + // R1: edit A + beginResponse(); + simulateEdit(docA, "a1", false); + AISnapshotStore.finalizeResponse(); + + // R2: create B + const docB = await simulateCreateFile("b.txt", "new"); + AISnapshotStore.finalizeResponse(); + + expect(AISnapshotStore.getSnapshotCount()).toBe(3); + + // snap 0 → A=a0, B deleted (back-filled null) + let err = await restoreToSnapshot(0); + expect(await readFile("a.txt")).toBe("a0"); + await expectFileDeleted("b.txt"); + + // snap 1 → A=a1, B deleted (back-filled null) + err = await restoreToSnapshot(1); + expect(await readFile("a.txt")).toBe("a1"); + await expectFileDeleted("b.txt"); + + // snap 2 → A=a1, B="new" + err = await restoreToSnapshot(2); + expect(err).toBe(0); + expect(await readFile("a.txt")).toBe("a1"); + expect(await readFile("b.txt")).toBe("new"); + }); + + // Case 9: Response with no edits + it("should return -1 for response with no edits", function () { + beginResponse(); + const idx = AISnapshotStore.finalizeResponse(); + expect(idx).toBe(-1); + expect(AISnapshotStore.getSnapshotCount()).toBe(1); + }); + }); + + // --- reset --- + + describe("reset", function () { + it("should clear snapshot count to 0", function () { + AISnapshotStore.createInitialSnapshot(); + expect(AISnapshotStore.getSnapshotCount()).toBe(1); + AISnapshotStore.reset(); + expect(AISnapshotStore.getSnapshotCount()).toBe(0); + }); + + it("should allow fresh start after operations", async function () { + await createFile("a.txt", "v0"); + const doc = await openDoc("a.txt"); + + beginResponse(); + simulateEdit(doc, "v1", false); + AISnapshotStore.finalizeResponse(); + expect(AISnapshotStore.getSnapshotCount()).toBe(2); + + AISnapshotStore.reset(); + expect(AISnapshotStore.getSnapshotCount()).toBe(0); + + // Start fresh + beginResponse(); + simulateEdit(doc, "v2", false); + AISnapshotStore.finalizeResponse(); + expect(AISnapshotStore.getSnapshotCount()).toBe(2); + + const err = await restoreToSnapshot(0); + expect(err).toBe(0); + expect(await readFile("a.txt")).toBe("v1"); + }); + }); + + // --- realToVfsPath --- + + describe("realToVfsPath", function () { + it("should pass through /tauri/ paths unchanged", function () { + const p = "/tauri/some/path/file.txt"; + expect(AISnapshotStore.realToVfsPath(p)).toBe(p); + }); + + it("should pass through /mnt/ paths unchanged", function () { + const p = "/mnt/some/path/file.txt"; + expect(AISnapshotStore.realToVfsPath(p)).toBe(p); + }); + }); + }); +}); From 1251f468dbfd94131c222560c656d9a2b6ea6c36 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 20 Feb 2026 10:24:47 +0530 Subject: [PATCH 03/11] feat: lazy hashing for read-tracked files with resilience fixes Store raw content in _readFiles instead of eagerly hashing on every recordFileRead call. Content is only hashed when promoted to a snapshot (on FS delete/rename), saving CPU and memory for the common read-only case. Also fixes three resilience issues: - _readContent: use hasOwnProperty for _memoryBuffer lookup so empty string content is not missed by the falsy check - _closeAndDeleteFile: treat "file already gone" as success instead of rejecting, so restores don't report spurious errors - _createOrUpdateFile: await CMD_OPEN completion before resolving to prevent race conditions with subsequent restore operations --- src/core-ai/AISnapshotStore.js | 194 +++++++++++++++--- .../spec/ai-snapshot-test.md | 0 2 files changed, 169 insertions(+), 25 deletions(-) rename src/core-ai/editApplyVerification.md => test/spec/ai-snapshot-test.md (100%) diff --git a/src/core-ai/AISnapshotStore.js b/src/core-ai/AISnapshotStore.js index 1bfdfe8e2..600bd407a 100644 --- a/src/core-ai/AISnapshotStore.js +++ b/src/core-ai/AISnapshotStore.js @@ -32,7 +32,8 @@ define(function (require, exports, module) { const DocumentManager = require("document/DocumentManager"), CommandManager = require("command/CommandManager"), Commands = require("command/Commands"), - FileSystem = require("filesystem/FileSystem"); + FileSystem = require("filesystem/FileSystem"), + ProjectManager = require("project/ProjectManager"); // --- Constants --- const HEARTBEAT_INTERVAL_MS = 60 * 1000; @@ -53,6 +54,9 @@ define(function (require, exports, module) { const _writtenHashes = new Set(); // hashes confirmed on disk let _snapshots = []; // flat: _snapshots[i] = { filePath: hash|null } let _pendingBeforeSnap = {}; // built during current response: filePath → hash|null + const _pendingDeleted = new Set(); // file paths deleted during current response + const _readFiles = {}; // filePath → raw content string (files AI has read) + let _isTracking = false; // true while AI is streaming // --- Path utility --- @@ -130,7 +134,7 @@ define(function (require, exports, module) { function _readContent(hash) { // Check memory buffer first (content may not have flushed yet) - if (_memoryBuffer[hash]) { + if (_memoryBuffer.hasOwnProperty(hash)) { return Promise.resolve(_memoryBuffer[hash]); } // Read from disk @@ -146,6 +150,15 @@ define(function (require, exports, module) { }); } + function _readFileFromDisk(vfsPath) { + return new Promise(function (resolve, reject) { + const file = FileSystem.getFileForPath(vfsPath); + file.read(function (err, data) { + if (err) { reject(err); } else { resolve(data); } + }); + }); + } + // --- File operations --- /** @@ -180,31 +193,34 @@ define(function (require, exports, module) { const vfsPath = realToVfsPath(filePath); const file = FileSystem.getFileForPath(vfsPath); - const openDoc = DocumentManager.getOpenDocumentForPath(vfsPath); - if (openDoc) { - if (openDoc.isDirty) { - openDoc.setText(""); - } - CommandManager.execute(Commands.FILE_CLOSE, { file: file, _forceClose: true }) - .always(function () { - file.unlink(function (err) { - if (err) { - result.reject(err); - } else { + function _unlinkFile() { + file.unlink(function (err) { + if (err) { + // File already gone — desired state achieved, treat as success + file.exists(function (_existErr, exists) { + if (!exists) { result.resolve(); + } else { + result.reject(err); } }); - }); - } else { - file.unlink(function (err) { - if (err) { - result.reject(err); } else { result.resolve(); } }); } + const openDoc = DocumentManager.getOpenDocumentForPath(vfsPath); + if (openDoc) { + if (openDoc.isDirty) { + openDoc.setText(""); + } + CommandManager.execute(Commands.FILE_CLOSE, { file: file, _forceClose: true }) + .always(_unlinkFile); + } else { + _unlinkFile(); + } + return result.promise(); } @@ -224,8 +240,10 @@ define(function (require, exports, module) { try { doc.setText(content); saveDocToDisk(doc).always(function () { - CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }); - result.resolve(); + CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }) + .always(function () { + result.resolve(); + }); }); } catch (err) { result.reject(err); @@ -306,6 +324,37 @@ define(function (require, exports, module) { } } + /** + * Record a file the AI has read, storing its content hash for potential + * delete/rename tracking. If the file is later deleted, this content + * can be promoted into a snapshot for restore. + * @param {string} filePath - real filesystem path + * @param {string} content - file content at read time + */ + function recordFileRead(filePath, content) { + _readFiles[filePath] = content; + } + + /** + * Record that a file has been deleted during this response. + * If the file hasn't been tracked yet, its previousContent is stored + * and back-filled into existing snapshots. + * @param {string} filePath - real filesystem path + * @param {string} previousContent - content before deletion + */ + function recordFileDeletion(filePath, previousContent) { + if (!_pendingBeforeSnap.hasOwnProperty(filePath)) { + const hash = storeContent(previousContent); + _pendingBeforeSnap[filePath] = hash; + _snapshots.forEach(function (snap) { + if (snap[filePath] === undefined) { + snap[filePath] = hash; + } + }); + } + _pendingDeleted.add(filePath); + } + /** * Create the initial snapshot (snapshot 0) capturing file state before any * AI edits. Called once per session on the first edit. @@ -321,24 +370,44 @@ define(function (require, exports, module) { * Builds an "after" snapshot from current document content for edited files, * pushes it, and resets transient tracking variables. * Flushes in-memory content to disk for long-term storage. - * @return {number} the after-snapshot index, or -1 if no edits happened + * + * Priority for each file: + * 1. If in _pendingDeleted → null + * 2. If doc is open → storeContent(openDoc.getText()) + * 3. Fallback: read from disk → storeContent(content) + * 4. If disk read fails (file gone) → null + * + * @return {Promise} the after-snapshot index, or -1 if no edits happened */ - function finalizeResponse() { + async function finalizeResponse() { let afterIndex = -1; if (Object.keys(_pendingBeforeSnap).length > 0) { - // Build "after" snapshot = last snapshot + current content of edited files const afterSnap = Object.assign({}, _snapshots[_snapshots.length - 1]); - Object.keys(_pendingBeforeSnap).forEach(function (fp) { + const editedPaths = Object.keys(_pendingBeforeSnap); + for (let i = 0; i < editedPaths.length; i++) { + const fp = editedPaths[i]; + if (_pendingDeleted.has(fp)) { + afterSnap[fp] = null; + continue; + } const vfsPath = realToVfsPath(fp); const openDoc = DocumentManager.getOpenDocumentForPath(vfsPath); if (openDoc) { afterSnap[fp] = storeContent(openDoc.getText()); + } else { + try { + const content = await _readFileFromDisk(vfsPath); + afterSnap[fp] = storeContent(content); + } catch (e) { + afterSnap[fp] = null; + } } - }); + } _snapshots.push(afterSnap); afterIndex = _snapshots.length - 1; } _pendingBeforeSnap = {}; + _pendingDeleted.clear(); _flushToDisk(); return afterIndex; } @@ -357,6 +426,74 @@ define(function (require, exports, module) { onComplete(errorCount); } + // --- FS event tracking --- + + function _onProjectFileChanged(_event, entry, addedInProject, removedInProject) { + if (!removedInProject || !removedInProject.length) { return; } + removedInProject.forEach(function (removedEntry) { + if (!removedEntry.isFile) { return; } + const fp = removedEntry.fullPath; + // Check if AI has edited this file (already in snapshots) + const isEdited = _pendingBeforeSnap.hasOwnProperty(fp) || + _snapshots.some(function (snap) { return snap.hasOwnProperty(fp); }); + if (isEdited) { + _pendingDeleted.add(fp); + return; + } + // Check if AI has read this file (raw content available) + if (_readFiles.hasOwnProperty(fp)) { + // Promote from read-tracked to snapshot-tracked, then mark deleted + const hash = storeContent(_readFiles[fp]); + _pendingBeforeSnap[fp] = hash; + _snapshots.forEach(function (snap) { + if (snap[fp] === undefined) { + snap[fp] = hash; + } + }); + _pendingDeleted.add(fp); + } + }); + } + + function _onProjectFileRenamed(_event, oldPath, newPath) { + // Update _pendingBeforeSnap + if (_pendingBeforeSnap.hasOwnProperty(oldPath)) { + _pendingBeforeSnap[newPath] = _pendingBeforeSnap[oldPath]; + delete _pendingBeforeSnap[oldPath]; + } + // Update _pendingDeleted + if (_pendingDeleted.has(oldPath)) { + _pendingDeleted.delete(oldPath); + _pendingDeleted.add(newPath); + } + // Update all snapshots + _snapshots.forEach(function (snap) { + if (snap.hasOwnProperty(oldPath)) { + snap[newPath] = snap[oldPath]; + delete snap[oldPath]; + } + }); + // Update _readFiles + if (_readFiles.hasOwnProperty(oldPath)) { + _readFiles[newPath] = _readFiles[oldPath]; + delete _readFiles[oldPath]; + } + } + + function startTracking() { + if (_isTracking) { return; } + _isTracking = true; + ProjectManager.on("projectFileChanged", _onProjectFileChanged); + ProjectManager.on("projectFileRenamed", _onProjectFileRenamed); + } + + function stopTracking() { + if (!_isTracking) { return; } + _isTracking = false; + ProjectManager.off("projectFileChanged", _onProjectFileChanged); + ProjectManager.off("projectFileRenamed", _onProjectFileRenamed); + } + /** * @return {number} number of snapshots */ @@ -373,6 +510,9 @@ define(function (require, exports, module) { _writtenHashes.clear(); _snapshots = []; _pendingBeforeSnap = {}; + _pendingDeleted.clear(); + Object.keys(_readFiles).forEach(function (k) { delete _readFiles[k]; }); + stopTracking(); // Delete and recreate aiSnap directory if (_diskReady && _aiSnapDir) { @@ -472,9 +612,13 @@ define(function (require, exports, module) { exports.saveDocToDisk = saveDocToDisk; exports.storeContent = storeContent; exports.recordFileBeforeEdit = recordFileBeforeEdit; + exports.recordFileRead = recordFileRead; + exports.recordFileDeletion = recordFileDeletion; exports.createInitialSnapshot = createInitialSnapshot; exports.finalizeResponse = finalizeResponse; exports.restoreToSnapshot = restoreToSnapshot; exports.getSnapshotCount = getSnapshotCount; + exports.startTracking = startTracking; + exports.stopTracking = stopTracking; exports.reset = reset; }); diff --git a/src/core-ai/editApplyVerification.md b/test/spec/ai-snapshot-test.md similarity index 100% rename from src/core-ai/editApplyVerification.md rename to test/spec/ai-snapshot-test.md From da838df563d4d2cb91d5644eb9612255f0fc3f62 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 20 Feb 2026 10:25:45 +0530 Subject: [PATCH 04/11] test: udpate tests for ai snapshot --- src/core-ai/AIChatPanel.js | 27 +++- test/spec/ai-snapshot-test.js | 230 ++++++++++++++++++++++++++++++---- 2 files changed, 229 insertions(+), 28 deletions(-) diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index af54050c2..0c8072312 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -241,6 +241,7 @@ define(function (require, exports, module) { _hasReceivedContent = false; _currentEdits = []; _firstEditInResponse = true; + SnapshotStore.startTracking(); _appendThinkingIndicator(); // Remove restore highlights from previous interactions @@ -370,6 +371,23 @@ define(function (require, exports, module) { "file=" + (data.toolInput && data.toolInput.file_path || "?").split("/").pop(), "streamEvents=" + streamCount); _updateToolIndicator(data.toolId, data.toolName, data.toolInput); + + // Capture content of files the AI reads (for snapshot delete tracking) + if (data.toolName === "Read" && data.toolInput && data.toolInput.file_path) { + const filePath = data.toolInput.file_path; + const vfsPath = SnapshotStore.realToVfsPath(filePath); + const openDoc = DocumentManager.getOpenDocumentForPath(vfsPath); + if (openDoc) { + SnapshotStore.recordFileRead(filePath, openDoc.getText()); + } else { + const file = FileSystem.getFileForPath(vfsPath); + file.read(function (err, readData) { + if (!err && readData) { + SnapshotStore.recordFileRead(filePath, readData); + } + }); + } + } } function _onToolStream(_event, data) { @@ -586,7 +604,7 @@ define(function (require, exports, module) { // Don't stop streaming — the node side may continue (partial results) } - function _onComplete(_event, data) { + async function _onComplete(_event, data) { console.log("[AI UI]", "Complete. textChunks=" + _traceTextChunks, "toolStreams=" + JSON.stringify(_traceToolStreamCounts)); // Reset trace counters for next query @@ -595,18 +613,19 @@ define(function (require, exports, module) { // Append edit summary if there were edits (finalizeResponse called inside) if (_currentEdits.length > 0) { - _appendEditSummary(); + await _appendEditSummary(); } + SnapshotStore.stopTracking(); _setStreaming(false); } /** * Append a compact summary card showing all files modified during this response. */ - function _appendEditSummary() { + async function _appendEditSummary() { // Finalize snapshot and get the after-snapshot index - const afterIndex = SnapshotStore.finalizeResponse(); + const afterIndex = await SnapshotStore.finalizeResponse(); _undoApplied = false; // Aggregate per-file stats diff --git a/test/spec/ai-snapshot-test.js b/test/spec/ai-snapshot-test.js index 096246789..58b6a3a56 100644 --- a/test/spec/ai-snapshot-test.js +++ b/test/spec/ai-snapshot-test.js @@ -149,6 +149,15 @@ define(function (require, exports, module) { }, name + " to be deleted", 5000); } + function unlinkFile(name) { + return new Promise(function (resolve, reject) { + const file = FileSystem.getFileForPath(toVfsPath(name)); + file.unlink(function (err) { + if (err) { reject(err); } else { resolve(); } + }); + }); + } + function beginResponse() { if (AISnapshotStore.getSnapshotCount() === 0) { AISnapshotStore.createInitialSnapshot(); @@ -208,7 +217,7 @@ define(function (require, exports, module) { beginResponse(); AISnapshotStore.recordFileBeforeEdit(doc.file.fullPath, "", true); doc.setText("new content"); - AISnapshotStore.finalizeResponse(); + await AISnapshotStore.finalizeResponse(); // Snapshot 0 has null → restore deletes file const errorCount = await restoreToSnapshot(0); @@ -225,7 +234,7 @@ define(function (require, exports, module) { doc.setText("v1"); // Second call with different content should be ignored (first-edit-wins) AISnapshotStore.recordFileBeforeEdit(doc.file.fullPath, "v1", false); - AISnapshotStore.finalizeResponse(); + await AISnapshotStore.finalizeResponse(); // Restore to snapshot 0 should give v0, not v1 const errorCount = await restoreToSnapshot(0); @@ -238,9 +247,9 @@ define(function (require, exports, module) { // --- finalizeResponse --- describe("finalizeResponse", function () { - it("should return -1 when no pending edits", function () { + it("should return -1 when no pending edits", async function () { beginResponse(); - const idx = AISnapshotStore.finalizeResponse(); + const idx = await AISnapshotStore.finalizeResponse(); expect(idx).toBe(-1); }); @@ -250,7 +259,7 @@ define(function (require, exports, module) { beginResponse(); simulateEdit(doc, "after", false); - AISnapshotStore.finalizeResponse(); + await AISnapshotStore.finalizeResponse(); // Snapshot 1 should have "after" const errorCount = await restoreToSnapshot(1); @@ -266,7 +275,7 @@ define(function (require, exports, module) { beginResponse(); simulateEdit(doc, "v1", false); expect(AISnapshotStore.getSnapshotCount()).toBe(1); - AISnapshotStore.finalizeResponse(); + await AISnapshotStore.finalizeResponse(); expect(AISnapshotStore.getSnapshotCount()).toBe(2); }); @@ -276,11 +285,69 @@ define(function (require, exports, module) { beginResponse(); simulateEdit(doc, "v1", false); - const idx = AISnapshotStore.finalizeResponse(); + const idx = await AISnapshotStore.finalizeResponse(); expect(idx).toBe(1); - const idx2 = AISnapshotStore.finalizeResponse(); + const idx2 = await AISnapshotStore.finalizeResponse(); expect(idx2).toBe(-1); }); + + it("should capture closed doc content from disk", async function () { + await createFile("a.txt", "on-disk-content"); + const doc = await openDoc("a.txt"); + + beginResponse(); + simulateEdit(doc, "edited", false); + + // Save to disk then close the tab + const file = doc.file; + await new Promise(function (resolve) { + file.write("edited", function () { resolve(); }); + }); + await awaitsForDone( + CommandManager.execute(Commands.FILE_CLOSE, + { file: file, _forceClose: true }), + "close a.txt" + ); + + await AISnapshotStore.finalizeResponse(); + + // After-snapshot should have captured "edited" from disk fallback + const err = await restoreToSnapshot(1); + expect(err).toBe(0); + expect(await readFile("a.txt")).toBe("edited"); + }); + + it("should capture deleted file as null in after-snapshot", async function () { + await createFile("a.txt", "content"); + const doc = await openDoc("a.txt"); + + beginResponse(); + simulateEdit(doc, "modified", false); + + // Close tab and delete the file + await awaitsForDone( + CommandManager.execute(Commands.FILE_CLOSE, + { file: doc.file, _forceClose: true }), + "close a.txt" + ); + await unlinkFile("a.txt"); + + await AISnapshotStore.finalizeResponse(); + + expect(AISnapshotStore.getSnapshotCount()).toBe(2); + + // snap 0 has original content; restore recreates the file + let err = await restoreToSnapshot(0); + expect(err).toBe(0); + expect(await readFile("a.txt")).toBe("content"); + + // snap 1 was captured as null (disk read failed → null fallback) + // Explicitly open to ensure doc is in working set (avoids CMD_OPEN race) + await openDoc("a.txt"); + err = await restoreToSnapshot(1); + expect(err).toBe(0); + await expectFileDeleted("a.txt"); + }); }); // --- snapshot consistency (editApplyVerification cases) --- @@ -298,7 +365,7 @@ define(function (require, exports, module) { beginResponse(); simulateEdit(docA, "a1", false); simulateEdit(docB, "b1", false); - AISnapshotStore.finalizeResponse(); + await AISnapshotStore.finalizeResponse(); expect(AISnapshotStore.getSnapshotCount()).toBe(2); @@ -323,11 +390,11 @@ define(function (require, exports, module) { // R1 beginResponse(); simulateEdit(doc, "v1", false); - AISnapshotStore.finalizeResponse(); + await AISnapshotStore.finalizeResponse(); // R2 simulateEdit(doc, "v2", false); - AISnapshotStore.finalizeResponse(); + await AISnapshotStore.finalizeResponse(); expect(AISnapshotStore.getSnapshotCount()).toBe(3); @@ -352,15 +419,15 @@ define(function (require, exports, module) { // R1 beginResponse(); simulateEdit(doc, "v1", false); - AISnapshotStore.finalizeResponse(); + await AISnapshotStore.finalizeResponse(); // R2 simulateEdit(doc, "v2", false); - AISnapshotStore.finalizeResponse(); + await AISnapshotStore.finalizeResponse(); // R3 simulateEdit(doc, "v3", false); - AISnapshotStore.finalizeResponse(); + await AISnapshotStore.finalizeResponse(); expect(AISnapshotStore.getSnapshotCount()).toBe(4); @@ -382,12 +449,12 @@ define(function (require, exports, module) { // R1: edit A only beginResponse(); simulateEdit(docA, "a1", false); - AISnapshotStore.finalizeResponse(); + await AISnapshotStore.finalizeResponse(); // R2: edit B only const docB = await openDoc("b.txt"); simulateEdit(docB, "b1", false); - AISnapshotStore.finalizeResponse(); + await AISnapshotStore.finalizeResponse(); expect(AISnapshotStore.getSnapshotCount()).toBe(3); @@ -413,11 +480,11 @@ define(function (require, exports, module) { // R1: create file A beginResponse(); const docA = await simulateCreateFile("a.txt", "new"); - AISnapshotStore.finalizeResponse(); + await AISnapshotStore.finalizeResponse(); // R2: edit A simulateEdit(docA, "edited", false); - AISnapshotStore.finalizeResponse(); + await AISnapshotStore.finalizeResponse(); expect(AISnapshotStore.getSnapshotCount()).toBe(3); @@ -445,11 +512,11 @@ define(function (require, exports, module) { // R1: edit A beginResponse(); simulateEdit(docA, "a1", false); - AISnapshotStore.finalizeResponse(); + await AISnapshotStore.finalizeResponse(); // R2: create B const docB = await simulateCreateFile("b.txt", "new"); - AISnapshotStore.finalizeResponse(); + await AISnapshotStore.finalizeResponse(); expect(AISnapshotStore.getSnapshotCount()).toBe(3); @@ -470,15 +537,130 @@ define(function (require, exports, module) { expect(await readFile("b.txt")).toBe("new"); }); + // File created and document closed in same turn — disk fallback reads empty content + it("should handle file created and closed in same turn", async function () { + await createFile("a.txt", "a0"); + const docA = await openDoc("a.txt"); + + // R1: edit A, create B then close B's document + beginResponse(); + simulateEdit(docA, "a1", false); + const docB = await simulateCreateFile("b.txt", "created"); + // Close B — simulates file created then removed in same turn + await awaitsForDone( + CommandManager.execute(Commands.FILE_CLOSE, + { file: docB.file, _forceClose: true }), + "close b.txt" + ); + await AISnapshotStore.finalizeResponse(); + + expect(AISnapshotStore.getSnapshotCount()).toBe(2); + + // snap 0: A="a0", B=null (isNewFile). B still on disk from simulateCreateFile. + let err = await restoreToSnapshot(0); + expect(err).toBe(0); + expect(await readFile("a.txt")).toBe("a0"); + await expectFileDeleted("b.txt"); + + // snap 1 (after): A="a1", B read from disk fallback (createFile wrote "") + err = await restoreToSnapshot(1); + expect(await readFile("a.txt")).toBe("a1"); + // Disk fallback reads the empty string that createFile wrote + expect(await readFile("b.txt")).toBe(""); + }); + + // Delete → recreate → delete round-trip + it("should handle delete-restore-delete round-trip", async function () { + // R1: create file A + beginResponse(); + await simulateCreateFile("a.txt", "content"); + await AISnapshotStore.finalizeResponse(); + + expect(AISnapshotStore.getSnapshotCount()).toBe(2); + + // snap 0 → A=null → file deleted + let err = await restoreToSnapshot(0); + expect(err).toBe(0); + await expectFileDeleted("a.txt"); + + // snap 1 → A="content" → file recreated from deleted state + err = await restoreToSnapshot(1); + expect(err).toBe(0); + expect(await readFile("a.txt")).toBe("content"); + }); + // Case 9: Response with no edits - it("should return -1 for response with no edits", function () { + it("should return -1 for response with no edits", async function () { beginResponse(); - const idx = AISnapshotStore.finalizeResponse(); + const idx = await AISnapshotStore.finalizeResponse(); expect(idx).toBe(-1); expect(AISnapshotStore.getSnapshotCount()).toBe(1); }); }); + // --- recordFileDeletion --- + + describe("recordFileDeletion", function () { + it("should track explicit deletion with before-content and null after", async function () { + await createFile("a.txt", "original"); + const doc = await openDoc("a.txt"); + + beginResponse(); + // Record deletion with known previous content + AISnapshotStore.recordFileDeletion(doc.file.fullPath, "original"); + await AISnapshotStore.finalizeResponse(); + + expect(AISnapshotStore.getSnapshotCount()).toBe(2); + + // snap 1 has null — doc still open from openDoc(), close+delete works + let err = await restoreToSnapshot(1); + expect(err).toBe(0); + await expectFileDeleted("a.txt"); + + // snap 0 has "original" (back-filled before content) — recreates file + err = await restoreToSnapshot(0); + expect(err).toBe(0); + expect(await readFile("a.txt")).toBe("original"); + }); + }); + + // --- recordFileRead --- + + describe("recordFileRead", function () { + it("should enable restore when read-tracked file is later deleted", async function () { + await createFile("a.txt", "a0"); + await createFile("b.txt", "b-content"); + const docA = await openDoc("a.txt"); + + // Record that AI has read b.txt + AISnapshotStore.recordFileRead(toVfsPath("b.txt"), "b-content"); + + beginResponse(); + // Edit a.txt (so we have at least one pending edit) + simulateEdit(docA, "a1", false); + + // Simulate deletion of the read file by calling recordFileDeletion + // (mirrors what _onProjectFileChanged would do after promoting from _readFiles) + AISnapshotStore.recordFileDeletion(toVfsPath("b.txt"), "b-content"); + + await AISnapshotStore.finalizeResponse(); + + expect(AISnapshotStore.getSnapshotCount()).toBe(2); + + // snap 1: A="a1", B=null (deleted) — b.txt still on disk, delete first + let err = await restoreToSnapshot(1); + expect(err).toBe(0); + expect(await readFile("a.txt")).toBe("a1"); + await expectFileDeleted("b.txt"); + + // snap 0: A="a0", B="b-content" — recreates b.txt + err = await restoreToSnapshot(0); + expect(err).toBe(0); + expect(await readFile("a.txt")).toBe("a0"); + expect(await readFile("b.txt")).toBe("b-content"); + }); + }); + // --- reset --- describe("reset", function () { @@ -495,7 +677,7 @@ define(function (require, exports, module) { beginResponse(); simulateEdit(doc, "v1", false); - AISnapshotStore.finalizeResponse(); + await AISnapshotStore.finalizeResponse(); expect(AISnapshotStore.getSnapshotCount()).toBe(2); AISnapshotStore.reset(); @@ -504,7 +686,7 @@ define(function (require, exports, module) { // Start fresh beginResponse(); simulateEdit(doc, "v2", false); - AISnapshotStore.finalizeResponse(); + await AISnapshotStore.finalizeResponse(); expect(AISnapshotStore.getSnapshotCount()).toBe(2); const err = await restoreToSnapshot(0); From 0691c556cb03cde6dc2c9b3ad32a8ccf8380bdf5 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 20 Feb 2026 11:22:28 +0530 Subject: [PATCH 05/11] feat: internationalize AI chat panel strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract ~42 hardcoded English strings from AIChatPanel.js into src/nls/root/strings.js using AI_CHAT_* keys. Replace all in-code literals with Strings.* constants and StringUtils.format() for parameterized text. Fix a locale-sensitive bug where the Undo button click handler compared $(this).text() against English "Undo" — now uses data-action attributes instead. Pass brackets.getLocale() to the Claude agent so non-English users get responses in their display language. Add i18n guidance section to CLAUDE.md. --- CLAUDE.md | 8 +++ src-node/claude-code-agent.js | 12 ++-- src/core-ai/AIChatPanel.js | 118 ++++++++++++++++++---------------- src/nls/root/strings.js | 44 +++++++++++++ 4 files changed, 123 insertions(+), 59 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 34cddb4b6..3ad2536b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,14 @@ - No trailing whitespace. - Use `const` and `let` instead of `var`. +## Translations / i18n +- All user-visible strings must go in `src/nls/root/strings.js` — never hardcode English in source files. +- Use `const Strings = require("strings");` then `Strings.KEY_NAME`. +- For parameterized strings use `StringUtils.format(Strings.KEY, arg0, arg1)` with `{0}`, `{1}` placeholders. +- Keys use UPPER_SNAKE_CASE grouped by feature prefix (e.g. `AI_CHAT_*`). +- Only `src/nls/root/strings.js` (English) needs manual edits — other locales are auto-translated by GitHub Actions. +- Never compare `$(el).text()` against English strings for logic — use data attributes or CSS classes instead. + ## Phoenix MCP (Desktop App Testing) Use `exec_js` to run JS in the Phoenix browser runtime. jQuery `$()` is global. `brackets.test.*` exposes internal modules (DocumentManager, CommandManager, ProjectManager, FileSystem, EditorManager). Always `return` a value from `exec_js` to see results. Prefer reusing an already-running Phoenix instance (`get_phoenix_status`) over launching a new one. diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index acced693c..05993732c 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -125,7 +125,7 @@ exports.checkAvailability = async function () { * aiProgress, aiTextStream, aiToolEdit, aiError, aiComplete */ exports.sendPrompt = async function (params) { - const { prompt, projectPath, sessionAction, model } = params; + const { prompt, projectPath, sessionAction, model, locale } = params; const requestId = Date.now().toString(36) + Math.random().toString(36).slice(2, 7); // Handle session @@ -142,7 +142,7 @@ exports.sendPrompt = async function (params) { currentAbortController = new AbortController(); // Run the query asynchronously — don't await here so we return requestId immediately - _runQuery(requestId, prompt, projectPath, model, currentAbortController.signal) + _runQuery(requestId, prompt, projectPath, model, currentAbortController.signal, locale) .catch(err => { console.error("[Phoenix AI] Query error:", err); }); @@ -176,7 +176,7 @@ exports.destroySession = async function () { /** * Internal: run a Claude SDK query and stream results back to the browser. */ -async function _runQuery(requestId, prompt, projectPath, model, signal) { +async function _runQuery(requestId, prompt, projectPath, model, signal, locale) { let editCount = 0; let toolCounter = 0; let queryFn; @@ -209,7 +209,11 @@ async function _runQuery(requestId, prompt, projectPath, model, signal) { "to create brand new files that do not exist yet. For existing files, always use " + "multiple Edit calls to make targeted changes rather than rewriting the entire " + "file with Write. This is critical because Write replaces the entire file content " + - "which is slow and loses undo history.", + "which is slow and loses undo history." + + (locale && !locale.startsWith("en") + ? "\n\nThe user's display language is " + locale + ". " + + "Respond in this language unless they write in a different language." + : ""), includePartialMessages: true, abortController: currentAbortController, hooks: { diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index 0c8072312..21a5ade36 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -31,6 +31,8 @@ define(function (require, exports, module) { ProjectManager = require("project/ProjectManager"), FileSystem = require("filesystem/FileSystem"), SnapshotStore = require("core-ai/AISnapshotStore"), + Strings = require("strings"), + StringUtils = require("utils/StringUtils"), marked = require("thirdparty/marked.min"); let _nodeConnector = null; @@ -60,23 +62,23 @@ define(function (require, exports, module) { const PANEL_HTML = '
' + '
' + - 'AI Assistant' + - '' + '
' + '
' + '
' + '' + - 'Thinking...' + + '' + Strings.AI_CHAT_THINKING + '' + '
' + '
' + '
' + - '' + - '' + - '' + '
' + @@ -87,13 +89,11 @@ define(function (require, exports, module) { '
' + '
' + '
' + - '
Claude CLI Not Found
' + + '
' + Strings.AI_CHAT_CLI_NOT_FOUND + '
' + '
' + - 'Install the Claude CLI to use AI features:
' + - 'npm install -g @anthropic-ai/claude-code

' + - 'Then run claude login to authenticate.' + + Strings.AI_CHAT_CLI_INSTALL_MSG + '
' + - '' + + '' + '
' + '
'; @@ -101,9 +101,9 @@ define(function (require, exports, module) { '
' + '
' + '
' + - '
AI Assistant
' + + '
' + Strings.AI_CHAT_TITLE + '
' + '
' + - 'AI features require the Phoenix desktop app.' + + Strings.AI_CHAT_DESKTOP_ONLY + '
' + '
' + '
'; @@ -259,13 +259,14 @@ define(function (require, exports, module) { _nodeConnector.execPeer("sendPrompt", { prompt: prompt, projectPath: projectPath, - sessionAction: "continue" + sessionAction: "continue", + locale: brackets.getLocale() }).then(function (result) { _currentRequestId = result.requestId; console.log("[AI UI] RequestId:", result.requestId); }).catch(function (err) { _setStreaming(false); - _appendErrorMessage("Failed to send message: " + (err.message || String(err))); + _appendErrorMessage(StringUtils.format(Strings.AI_CHAT_SEND_ERROR, err.message || String(err))); }); } @@ -343,13 +344,13 @@ define(function (require, exports, module) { // Tool type configuration: icon, color, label const TOOL_CONFIG = { - Glob: { icon: "fa-solid fa-magnifying-glass", color: "#6b9eff", label: "Search files" }, - Grep: { icon: "fa-solid fa-magnifying-glass-location", color: "#6b9eff", label: "Search code" }, - Read: { icon: "fa-solid fa-file-lines", color: "#6bc76b", label: "Read" }, - Edit: { icon: "fa-solid fa-pen", color: "#e8a838", label: "Edit" }, - Write: { icon: "fa-solid fa-file-pen", color: "#e8a838", label: "Write" }, - Bash: { icon: "fa-solid fa-terminal", color: "#c084fc", label: "Run command" }, - Skill: { icon: "fa-solid fa-puzzle-piece", color: "#e0c060", label: "Skill" } + Glob: { icon: "fa-solid fa-magnifying-glass", color: "#6b9eff", label: Strings.AI_CHAT_TOOL_SEARCH_FILES }, + Grep: { icon: "fa-solid fa-magnifying-glass-location", color: "#6b9eff", label: Strings.AI_CHAT_TOOL_SEARCH_CODE }, + Read: { icon: "fa-solid fa-file-lines", color: "#6bc76b", label: Strings.AI_CHAT_TOOL_READ }, + Edit: { icon: "fa-solid fa-pen", color: "#e8a838", label: Strings.AI_CHAT_TOOL_EDIT }, + Write: { icon: "fa-solid fa-file-pen", color: "#e8a838", label: Strings.AI_CHAT_TOOL_WRITE }, + Bash: { icon: "fa-solid fa-terminal", color: "#c084fc", label: Strings.AI_CHAT_TOOL_RUN_CMD }, + Skill: { icon: "fa-solid fa-puzzle-piece", color: "#e0c060", label: Strings.AI_CHAT_TOOL_SKILL } }; function _onProgress(_event, data) { @@ -357,7 +358,7 @@ define(function (require, exports, module) { if ($statusText) { const toolName = data.toolName || ""; const config = TOOL_CONFIG[toolName]; - $statusText.text(config ? config.label + "..." : "Thinking..."); + $statusText.text(config ? config.label + "..." : Strings.AI_CHAT_THINKING); } if (data.phase === "tool_use") { _appendToolIndicator(data.toolName, data.toolId); @@ -480,7 +481,7 @@ define(function (require, exports, module) { // If the interesting key hasn't appeared yet, show a byte counter // so the user sees streaming activity during the file_path phase if (!raw && partialJson.length > 3) { - return "receiving " + partialJson.length + " bytes..."; + return StringUtils.format(Strings.AI_CHAT_RECEIVING_BYTES, partialJson.length); } if (!raw) { return ""; @@ -527,7 +528,7 @@ define(function (require, exports, module) { // Insert initial restore point PUC before the current tool indicator const $puc = $( '
' + - '' + + '' + '
' ); $puc.find(".ai-restore-point-btn").on("click", function () { @@ -570,7 +571,7 @@ define(function (require, exports, module) { const $actions = $('
'); // Diff toggle - const $diffToggle = $(''); + const $diffToggle = $(''); const $diff = $('
'); if (edit.oldText) { @@ -589,7 +590,7 @@ define(function (require, exports, module) { $diffToggle.on("click", function () { $diff.toggleClass("expanded"); - $diffToggle.text($diff.hasClass("expanded") ? "Hide diff" : "Show diff"); + $diffToggle.text($diff.hasClass("expanded") ? Strings.AI_CHAT_HIDE_DIFF : Strings.AI_CHAT_SHOW_DIFF); }); $actions.append($diffToggle); @@ -645,31 +646,34 @@ define(function (require, exports, module) { const $header = $( '
' + '' + - fileCount + (fileCount === 1 ? " file" : " files") + " changed" + + StringUtils.format(Strings.AI_CHAT_FILES_CHANGED, fileCount, + fileCount === 1 ? Strings.AI_CHAT_FILE_SINGULAR : Strings.AI_CHAT_FILE_PLURAL) + '' + '
' ); if (afterIndex >= 0) { // Update any previous summary card buttons to say "Restore to this point" - _$msgs().find('.ai-edit-restore-btn').text("Restore to this point") - .attr("title", "Restore files to this point"); + _$msgs().find('.ai-edit-restore-btn').text(Strings.AI_CHAT_RESTORE_POINT) + .attr("title", Strings.AI_CHAT_RESTORE_TITLE) + .data("action", "restore"); // Determine button label: "Undo" if not undone, else "Restore to this point" const isUndo = !_undoApplied; - const label = isUndo ? "Undo" : "Restore to this point"; - const title = isUndo ? "Undo changes from this response" : "Restore files to this point"; + const label = isUndo ? Strings.AI_CHAT_UNDO : Strings.AI_CHAT_RESTORE_POINT; + const title = isUndo ? Strings.AI_CHAT_UNDO_TITLE : Strings.AI_CHAT_RESTORE_TITLE; const $restoreBtn = $( '' ); + $restoreBtn.data("action", isUndo ? "undo" : "restore"); $restoreBtn.on("click", function (e) { e.stopPropagation(); if (_isStreaming) { return; } - if ($(this).text() === "Undo") { + if ($(this).data("action") === "undo") { _onUndoClick(afterIndex); } else { _onRestoreClick(afterIndex); @@ -717,10 +721,11 @@ define(function (require, exports, module) { // Reset all buttons to "Restore to this point" $msgs.find('.ai-edit-restore-btn').each(function () { - $(this).text("Restore to this point") - .attr("title", "Restore files to this point"); + $(this).text(Strings.AI_CHAT_RESTORE_POINT) + .attr("title", Strings.AI_CHAT_RESTORE_TITLE) + .data("action", "restore"); }); - $msgs.find('.ai-restore-point-btn').text("Restore to this point"); + $msgs.find('.ai-restore-point-btn').text(Strings.AI_CHAT_RESTORE_POINT); SnapshotStore.restoreToSnapshot(snapshotIndex, function (errorCount) { if (errorCount > 0) { @@ -733,7 +738,7 @@ define(function (require, exports, module) { if ($target.length) { $target.addClass("ai-restore-highlighted"); const $btn = $target.find(".ai-edit-restore-btn, .ai-restore-point-btn"); - $btn.text("Restored"); + $btn.text(Strings.AI_CHAT_RESTORED); } }); } @@ -749,10 +754,11 @@ define(function (require, exports, module) { // Reset all buttons to "Restore to this point" $msgs.find('.ai-edit-restore-btn').each(function () { - $(this).text("Restore to this point") - .attr("title", "Restore files to this point"); + $(this).text(Strings.AI_CHAT_RESTORE_POINT) + .attr("title", Strings.AI_CHAT_RESTORE_TITLE) + .data("action", "restore"); }); - $msgs.find('.ai-restore-point-btn').text("Restore to this point"); + $msgs.find('.ai-restore-point-btn').text(Strings.AI_CHAT_RESTORE_POINT); SnapshotStore.restoreToSnapshot(targetIndex, function (errorCount) { if (errorCount > 0) { @@ -768,7 +774,7 @@ define(function (require, exports, module) { $target[0].scrollIntoView({ behavior: "smooth", block: "center" }); // Mark the target as "Restored" const $btn = $target.find(".ai-edit-restore-btn, .ai-restore-point-btn"); - $btn.text("Restored"); + $btn.text(Strings.AI_CHAT_RESTORED); } }); } @@ -778,7 +784,7 @@ define(function (require, exports, module) { function _appendUserMessage(text) { const $msg = $( '
' + - '
You
' + + '
' + Strings.AI_CHAT_LABEL_YOU + '
' + '
' + '
' ); @@ -793,7 +799,7 @@ define(function (require, exports, module) { function _appendThinkingIndicator() { const $thinking = $( '
' + - '
Claude
' + + '
' + Strings.AI_CHAT_LABEL_CLAUDE + '
' + '
' + '' + '' + @@ -940,38 +946,40 @@ define(function (require, exports, module) { switch (toolName) { case "Glob": return { - summary: "Searched: " + (input.pattern || ""), - lines: input.path ? ["in " + input.path] : [] + summary: StringUtils.format(Strings.AI_CHAT_TOOL_SEARCHED, input.pattern || ""), + lines: input.path ? [StringUtils.format(Strings.AI_CHAT_TOOL_IN_PATH, input.path)] : [] }; case "Grep": return { - summary: "Grep: " + (input.pattern || ""), - lines: [input.path ? "in " + input.path : "", input.include ? "include " + input.include : ""] - .filter(Boolean) + summary: StringUtils.format(Strings.AI_CHAT_TOOL_GREP, input.pattern || ""), + lines: [ + input.path ? StringUtils.format(Strings.AI_CHAT_TOOL_IN_PATH, input.path) : "", + input.include ? StringUtils.format(Strings.AI_CHAT_TOOL_INCLUDE, input.include) : "" + ].filter(Boolean) }; case "Read": return { - summary: "Read " + (input.file_path || "").split("/").pop(), + summary: StringUtils.format(Strings.AI_CHAT_TOOL_READ_FILE, (input.file_path || "").split("/").pop()), lines: [input.file_path || ""] }; case "Edit": return { - summary: "Edit " + (input.file_path || "").split("/").pop(), + summary: StringUtils.format(Strings.AI_CHAT_TOOL_EDIT_FILE, (input.file_path || "").split("/").pop()), lines: [input.file_path || ""] }; case "Write": return { - summary: "Write " + (input.file_path || "").split("/").pop(), + summary: StringUtils.format(Strings.AI_CHAT_TOOL_WRITE_FILE, (input.file_path || "").split("/").pop()), lines: [input.file_path || ""] }; case "Bash": return { - summary: "Ran command", + summary: Strings.AI_CHAT_TOOL_RAN_CMD, lines: input.command ? [input.command] : [] }; case "Skill": return { - summary: input.skill ? "Skill: " + input.skill : "Skill", + summary: input.skill ? StringUtils.format(Strings.AI_CHAT_TOOL_SKILL_NAME, input.skill) : Strings.AI_CHAT_TOOL_SKILL, lines: input.args ? [input.args] : [] }; default: @@ -1088,7 +1096,7 @@ define(function (require, exports, module) { const docText = doc.getText(); const idx = docText.indexOf(edit.oldText); if (idx === -1) { - result.reject(new Error("Text not found in file — it may have changed")); + result.reject(new Error(Strings.AI_CHAT_EDIT_NOT_FOUND)); return; } const startPos = doc._masterEditor ? diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index cde5aeabf..4d0e0f2b4 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1801,6 +1801,50 @@ define({ "AI_UPSELL_DIALOG_TITLE": "Continue with {0}?", "AI_UPSELL_DIALOG_MESSAGE": "You’ve discovered {0}. To proceed, you’ll need an AI subscription or credits.", + // AI CHAT PANEL + "AI_CHAT_TITLE": "AI Assistant", + "AI_CHAT_NEW_SESSION_TITLE": "Start a new conversation", + "AI_CHAT_NEW_BTN": "New", + "AI_CHAT_THINKING": "Thinking...", + "AI_CHAT_PLACEHOLDER": "Ask Claude...", + "AI_CHAT_SEND_TITLE": "Send message", + "AI_CHAT_STOP_TITLE": "Stop generation (Esc)", + "AI_CHAT_CLI_NOT_FOUND": "Claude CLI Not Found", + "AI_CHAT_CLI_INSTALL_MSG": "Install the Claude CLI to use AI features:
npm install -g @anthropic-ai/claude-code

Then run claude login to authenticate.", + "AI_CHAT_RETRY": "Retry", + "AI_CHAT_DESKTOP_ONLY": "AI features require the Phoenix desktop app.", + "AI_CHAT_TOOL_SEARCH_FILES": "Search files", + "AI_CHAT_TOOL_SEARCH_CODE": "Search code", + "AI_CHAT_TOOL_READ": "Read", + "AI_CHAT_TOOL_EDIT": "Edit", + "AI_CHAT_TOOL_WRITE": "Write", + "AI_CHAT_TOOL_RUN_CMD": "Run command", + "AI_CHAT_TOOL_SKILL": "Skill", + "AI_CHAT_TOOL_SEARCHED": "Searched: {0}", + "AI_CHAT_TOOL_GREP": "Grep: {0}", + "AI_CHAT_TOOL_READ_FILE": "Read {0}", + "AI_CHAT_TOOL_EDIT_FILE": "Edit {0}", + "AI_CHAT_TOOL_WRITE_FILE": "Write {0}", + "AI_CHAT_TOOL_RAN_CMD": "Ran command", + "AI_CHAT_TOOL_SKILL_NAME": "Skill: {0}", + "AI_CHAT_TOOL_IN_PATH": "in {0}", + "AI_CHAT_TOOL_INCLUDE": "include {0}", + "AI_CHAT_RECEIVING_BYTES": "receiving {0} bytes...", + "AI_CHAT_FILES_CHANGED": "{0} {1} changed", + "AI_CHAT_FILE_SINGULAR": "file", + "AI_CHAT_FILE_PLURAL": "files", + "AI_CHAT_RESTORE_POINT": "Restore to this point", + "AI_CHAT_UNDO": "Undo", + "AI_CHAT_UNDO_TITLE": "Undo changes from this response", + "AI_CHAT_RESTORE_TITLE": "Restore files to this point", + "AI_CHAT_RESTORED": "Restored", + "AI_CHAT_SHOW_DIFF": "Show diff", + "AI_CHAT_HIDE_DIFF": "Hide diff", + "AI_CHAT_LABEL_YOU": "You", + "AI_CHAT_LABEL_CLAUDE": "Claude", + "AI_CHAT_SEND_ERROR": "Failed to send message: {0}", + "AI_CHAT_EDIT_NOT_FOUND": "Text not found in file \u2014 it may have changed", + // demo start - Phoenix Code Playground - Interactive Onboarding "DEMO_SECTION1_TITLE": "Edit in Live Preview", "DEMO_SECTION1_SUBTITLE": "Edit your page visually -  Your HTML updates instantly", From bae9aa22bf95c79bc60cc9c53fd5748747f65378 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 20 Feb 2026 11:39:10 +0530 Subject: [PATCH 06/11] fix: rotate activity text when tool stream preview goes stale When a tool (e.g. Write) streams slowly, the "receiving N bytes..." preview would freeze. After 2s of no updates, rotate through "Working...", "Writing...", "Processing..." every 3s so the user sees the operation is still in progress. Timers are cleaned up when tool info arrives. --- src/core-ai/AIChatPanel.js | 32 ++++++++++++++++++++++++++++++++ src/nls/root/strings.js | 3 +++ 2 files changed, 35 insertions(+) diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index 21a5ade36..75b3cfcf0 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -48,6 +48,8 @@ define(function (require, exports, module) { // --- AI event trace logging (compact, non-flooding) --- let _traceTextChunks = 0; let _traceToolStreamCounts = {}; // toolId → count + let _toolStreamStaleTimer = null; // timer to start rotating activity text + let _toolStreamRotateTimer = null; // interval for cycling activity phrases // DOM references let $panel, $messages, $status, $statusText, $textarea, $sendBtn, $stopBtn; @@ -420,6 +422,32 @@ define(function (require, exports, module) { $tool.find(".ai-tool-preview").text(preview); _scrollToBottom(); } + + // Reset staleness timer — if no new stream event arrives within 2s, + // rotate through activity phrases so the user sees something is happening. + clearTimeout(_toolStreamStaleTimer); + clearInterval(_toolStreamRotateTimer); + _toolStreamStaleTimer = setTimeout(function () { + const phrases = [ + Strings.AI_CHAT_WORKING, + Strings.AI_CHAT_WRITING, + Strings.AI_CHAT_PROCESSING + ]; + let idx = 0; + const $livePreview = $tool.find(".ai-tool-preview"); + if ($livePreview.length && !$tool.hasClass("ai-tool-done")) { + $livePreview.text(phrases[idx]); + } + _toolStreamRotateTimer = setInterval(function () { + idx = (idx + 1) % phrases.length; + const $p = $tool.find(".ai-tool-preview"); + if ($p.length && !$tool.hasClass("ai-tool-done")) { + $p.text(phrases[idx]); + } else { + clearInterval(_toolStreamRotateTimer); + } + }, 3000); + }, 2000); } /** @@ -925,6 +953,10 @@ define(function (require, exports, module) { }).css("cursor", "pointer").addClass("ai-tool-label-clickable"); } + // Clear any stale-preview timers now that tool info arrived + clearTimeout(_toolStreamStaleTimer); + clearInterval(_toolStreamRotateTimer); + // Delay marking as done so the streaming preview stays visible briefly. // The ai-tool-done class hides the preview via CSS; deferring it lets the // browser paint the preview before it disappears. diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 4d0e0f2b4..65be0c33d 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1844,6 +1844,9 @@ define({ "AI_CHAT_LABEL_CLAUDE": "Claude", "AI_CHAT_SEND_ERROR": "Failed to send message: {0}", "AI_CHAT_EDIT_NOT_FOUND": "Text not found in file \u2014 it may have changed", + "AI_CHAT_WORKING": "Working...", + "AI_CHAT_WRITING": "Writing...", + "AI_CHAT_PROCESSING": "Processing...", // demo start - Phoenix Code Playground - Interactive Onboarding "DEMO_SECTION1_TITLE": "Edit in Live Preview", From 79161815ef2eef2b8ad1f309abeeb3b5c78f75e9 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 20 Feb 2026 14:43:24 +0530 Subject: [PATCH 07/11] fix: stabilize flaky AI snapshot tests Fix race condition in expectFileDeleted test helper where fileExists() was fired on every awaitsFor poll but the result was checked before the promise resolved, causing one-poll-behind behavior and timeouts. Make _createOrUpdateFile resilient to stale FileSystem cache: when file.exists() returns true but getDocumentForPath fails (e.g. after a delete+recreate cycle), fall back to creating the file on disk and retry. --- src/core-ai/AISnapshotStore.js | 40 +++++++++++++++++++++++++++------- test/spec/ai-snapshot-test.js | 9 +++++++- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/core-ai/AISnapshotStore.js b/src/core-ai/AISnapshotStore.js index 600bd407a..2c3873e75 100644 --- a/src/core-ai/AISnapshotStore.js +++ b/src/core-ai/AISnapshotStore.js @@ -254,18 +254,42 @@ define(function (require, exports, module) { }); } + function _createThenSet() { + const file = FileSystem.getFileForPath(vfsPath); + file.write("", function (writeErr) { + if (writeErr) { + result.reject(new Error("Could not create file: " + writeErr)); + return; + } + _setContent(); + }); + } + const file = FileSystem.getFileForPath(vfsPath); file.exists(function (existErr, exists) { if (exists) { - _setContent(); + // File may appear to exist due to stale FS cache after a + // delete+recreate cycle. Try opening first; if it fails, + // recreate the file on disk and retry. + DocumentManager.getDocumentForPath(vfsPath) + .done(function (doc) { + try { + doc.setText(content); + saveDocToDisk(doc).always(function () { + CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }) + .always(function () { + result.resolve(); + }); + }); + } catch (err) { + result.reject(err); + } + }) + .fail(function () { + _createThenSet(); + }); } else { - file.write("", function (writeErr) { - if (writeErr) { - result.reject(new Error("Could not create file: " + writeErr)); - return; - } - _setContent(); - }); + _createThenSet(); } }); diff --git a/test/spec/ai-snapshot-test.js b/test/spec/ai-snapshot-test.js index 58b6a3a56..5fa55a602 100644 --- a/test/spec/ai-snapshot-test.js +++ b/test/spec/ai-snapshot-test.js @@ -143,8 +143,15 @@ define(function (require, exports, module) { async function expectFileDeleted(name) { let gone = false; + let checking = false; await awaitsFor(function () { - fileExists(name).then(function (e) { gone = !e; }); + if (!checking && !gone) { + checking = true; + fileExists(name).then(function (e) { + gone = !e; + checking = false; + }); + } return gone; }, name + " to be deleted", 5000); } From bc14d9379e6fe084ff65cb2523ed7819d1bef6d4 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 20 Feb 2026 15:54:07 +0530 Subject: [PATCH 08/11] fix: stabilize flaky AI snapshot tests Two root causes identified and fixed: 1. expectFileDeleted used file.exists() which checks the _stat cache. After unlink, _handleDirectoryChange re-reads the parent dir and can repopulate _stat on the deleted File object from a racing readdir. Switch to FileSystem.existsAsync() which bypasses the cached _stat and goes directly to the impl. 2. _createOrUpdateFile could fail with NotFound when file.exists() returned stale true after a delete+recreate cycle. Add fallback: if getDocumentForPath fails, create the file on disk and retry. --- test/spec/ai-snapshot-test.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/test/spec/ai-snapshot-test.js b/test/spec/ai-snapshot-test.js index 5fa55a602..f83b5f685 100644 --- a/test/spec/ai-snapshot-test.js +++ b/test/spec/ai-snapshot-test.js @@ -133,12 +133,10 @@ define(function (require, exports, module) { } async function fileExists(name) { - return new Promise(function (resolve) { - const file = FileSystem.getFileForPath(toVfsPath(name)); - file.exists(function (err, exists) { - resolve(exists); - }); - }); + // Use FileSystem.existsAsync which bypasses the cached _stat on + // File objects — file.exists() can return stale true when + // _handleDirectoryChange re-populates _stat from a racing readdir. + return FileSystem.existsAsync(toVfsPath(name)); } async function expectFileDeleted(name) { From ba410624e932bbb9688a2223f8c5c8a8d383b469 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 21 Feb 2026 13:43:18 +0530 Subject: [PATCH 09/11] feat: add in-process MCP server for editor context tools Add getEditorState and takeScreenshot as MCP tools available to the Claude Code SDK during AI chat queries. This gives Claude awareness of which file the user is editing, their working set, live preview file, and the ability to capture screenshots of the editor. - Create src-node/mcp-editor-tools.js with tool definitions using the SDK's createSdkMcpServer/tool helpers - Wire MCP server into claude-code-agent.js queryOptions - Extract NodeConnector handlers into src/core-ai/aiPhoenixConnectors.js (getEditorState, takeScreenshot, getFileContent, applyEditToBuffer) - Add Document.posFromIndex() to support position conversion without a master editor attached - Add unit tests for Document.posFromIndex --- src-node/claude-code-agent.js | 14 +- src-node/mcp-editor-tools.js | 93 +++++++++++ src-node/package-lock.json | 12 +- src-node/package.json | 15 +- src/core-ai/AIChatPanel.js | 158 ++---------------- src/core-ai/aiPhoenixConnectors.js | 251 +++++++++++++++++++++++++++++ src/core-ai/main.js | 25 ++- src/document/Document.js | 24 +++ test/spec/Document-test.js | 67 ++++++++ 9 files changed, 497 insertions(+), 162 deletions(-) create mode 100644 src-node/mcp-editor-tools.js create mode 100644 src/core-ai/aiPhoenixConnectors.js diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index 05993732c..c58bb98cf 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -28,6 +28,7 @@ const { execSync } = require("child_process"); const path = require("path"); +const { createEditorMcpServer } = require("./mcp-editor-tools"); const CONNECTOR_ID = "ph_ai_claude"; @@ -40,6 +41,9 @@ let currentSessionId = null; // Active query state let currentAbortController = null; +// Lazily-initialized in-process MCP server for editor context +let editorMcpServer = null; + // Streaming throttle const TEXT_STREAM_THROTTLE_MS = 50; @@ -183,6 +187,9 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale) try { queryFn = await getQueryFn(); + if (!editorMcpServer) { + editorMcpServer = createEditorMcpServer(queryModule, nodeConnector); + } } catch (err) { nodeConnector.triggerPeer("aiError", { requestId: requestId, @@ -201,7 +208,12 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale) const queryOptions = { cwd: projectPath || process.cwd(), maxTurns: 10, - allowedTools: ["Read", "Edit", "Write", "Glob", "Grep"], + allowedTools: [ + "Read", "Edit", "Write", "Glob", "Grep", + "mcp__phoenix-editor__getEditorState", + "mcp__phoenix-editor__takeScreenshot" + ], + mcpServers: { "phoenix-editor": editorMcpServer }, permissionMode: "acceptEdits", appendSystemPrompt: "When modifying an existing file, always prefer the Edit tool " + diff --git a/src-node/mcp-editor-tools.js b/src-node/mcp-editor-tools.js new file mode 100644 index 000000000..a1c2eb097 --- /dev/null +++ b/src-node/mcp-editor-tools.js @@ -0,0 +1,93 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * MCP server factory for exposing Phoenix editor context to Claude Code. + * + * Provides two tools: + * - getEditorState: returns active file, working set, and live preview file + * - takeScreenshot: captures a screenshot of the Phoenix window as base64 PNG + * + * Uses the Claude Code SDK's in-process MCP server support (createSdkMcpServer / tool). + */ + +const { z } = require("zod"); + +/** + * Create an in-process MCP server exposing editor context tools. + * + * @param {Object} sdkModule - The imported @anthropic-ai/claude-code ESM module + * @param {Object} nodeConnector - The NodeConnector instance for communicating with the browser + * @returns {McpSdkServerConfigWithInstance} MCP server config ready for queryOptions.mcpServers + */ +function createEditorMcpServer(sdkModule, nodeConnector) { + const getEditorStateTool = sdkModule.tool( + "getEditorState", + "Get the current Phoenix editor state: active file, working set (open files), and live preview file.", + {}, + async function () { + try { + const state = await nodeConnector.execPeer("getEditorState", {}); + return { + content: [{ type: "text", text: JSON.stringify(state) }] + }; + } catch (err) { + return { + content: [{ type: "text", text: "Error getting editor state: " + err.message }], + isError: true + }; + } + } + ); + + const takeScreenshotTool = sdkModule.tool( + "takeScreenshot", + "Take a screenshot of the Phoenix Code editor window. Returns a PNG image.", + { selector: z.string().optional().describe("Optional CSS selector to capture a specific element") }, + async function (args) { + try { + const result = await nodeConnector.execPeer("takeScreenshot", { + selector: args.selector || undefined + }); + if (result.base64) { + return { + content: [{ type: "image", data: result.base64, mimeType: "image/png" }] + }; + } + return { + content: [{ type: "text", text: result.error || "Screenshot failed" }], + isError: true + }; + } catch (err) { + return { + content: [{ type: "text", text: "Error taking screenshot: " + err.message }], + isError: true + }; + } + } + ); + + return sdkModule.createSdkMcpServer({ + name: "phoenix-editor", + tools: [getEditorStateTool, takeScreenshotTool] + }); +} + +exports.createEditorMcpServer = createEditorMcpServer; diff --git a/src-node/package-lock.json b/src-node/package-lock.json index 9ba207622..aa06a7edb 100644 --- a/src-node/package-lock.json +++ b/src-node/package-lock.json @@ -18,7 +18,8 @@ "npm": "11.8.0", "open": "^10.1.0", "which": "^2.0.1", - "ws": "^8.17.1" + "ws": "^8.17.1", + "zod": "^3.25.76" }, "engines": { "node": "24" @@ -2882,6 +2883,15 @@ "optional": true } } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/src-node/package.json b/src-node/package.json index 59eb1cf45..e1fb19f77 100644 --- a/src-node/package.json +++ b/src-node/package.json @@ -19,15 +19,16 @@ }, "IMPORTANT!!": "Adding things here will bloat up the package size", "dependencies": { + "@anthropic-ai/claude-code": "^1.0.0", + "@expo/sudo-prompt": "^9.3.2", "@phcode/fs": "^4.0.2", - "open": "^10.1.0", - "npm": "11.8.0", - "ws": "^8.17.1", + "cross-spawn": "^7.0.6", "lmdb": "^3.5.1", "mime-types": "^2.1.35", - "cross-spawn": "^7.0.6", + "npm": "11.8.0", + "open": "^10.1.0", "which": "^2.0.1", - "@expo/sudo-prompt": "^9.3.2", - "@anthropic-ai/claude-code": "^1.0.0" + "ws": "^8.17.1", + "zod": "^3.25.76" } -} \ No newline at end of file +} diff --git a/src/core-ai/AIChatPanel.js b/src/core-ai/AIChatPanel.js index 75b3cfcf0..7411047e7 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -24,16 +24,17 @@ */ define(function (require, exports, module) { - const SidebarTabs = require("view/SidebarTabs"), - DocumentManager = require("document/DocumentManager"), - CommandManager = require("command/CommandManager"), - Commands = require("command/Commands"), - ProjectManager = require("project/ProjectManager"), - FileSystem = require("filesystem/FileSystem"), - SnapshotStore = require("core-ai/AISnapshotStore"), - Strings = require("strings"), - StringUtils = require("utils/StringUtils"), - marked = require("thirdparty/marked.min"); + const SidebarTabs = require("view/SidebarTabs"), + DocumentManager = require("document/DocumentManager"), + CommandManager = require("command/CommandManager"), + Commands = require("command/Commands"), + ProjectManager = require("project/ProjectManager"), + FileSystem = require("filesystem/FileSystem"), + SnapshotStore = require("core-ai/AISnapshotStore"), + PhoenixConnectors = require("core-ai/aiPhoenixConnectors"), + Strings = require("strings"), + StringUtils = require("utils/StringUtils"), + marked = require("thirdparty/marked.min"); let _nodeConnector = null; let _isStreaming = false; @@ -41,7 +42,6 @@ define(function (require, exports, module) { let _segmentText = ""; // text for the current segment only let _autoScroll = true; let _hasReceivedContent = false; // tracks if we've received any text/tool in current response - const _previousContentMap = {}; // filePath → previous content before edit, for undo support let _currentEdits = []; // edits in current response, for summary card let _firstEditInResponse = true; // tracks first edit per response for initial PUC let _undoApplied = false; // whether undo/restore has been clicked on any card @@ -299,9 +299,7 @@ define(function (require, exports, module) { _firstEditInResponse = true; _undoApplied = false; SnapshotStore.reset(); - Object.keys(_previousContentMap).forEach(function (key) { - delete _previousContentMap[key]; - }); + PhoenixConnectors.clearPreviousContentMap(); if ($messages) { $messages.empty(); } @@ -543,7 +541,7 @@ define(function (require, exports, module) { }); // Capture pre-edit content for snapshot tracking - const previousContent = _previousContentMap[edit.file]; + const previousContent = PhoenixConnectors.getPreviousContent(edit.file); const isNewFile = (edit.oldText === null && (previousContent === undefined || previousContent === "")); // On first edit per response, insert initial PUC if needed. @@ -1102,100 +1100,6 @@ define(function (require, exports, module) { return str.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); } - // --- Edit application --- - - /** - * Apply a single edit to a document buffer and save to disk. - * Called immediately when Claude's Write/Edit is intercepted, so - * subsequent Reads see the new content both in the buffer and on disk. - * @param {Object} edit - {file, oldText, newText} - * @return {$.Promise} resolves with {previousContent} for undo support - */ - function _applySingleEdit(edit) { - const result = new $.Deferred(); - const vfsPath = SnapshotStore.realToVfsPath(edit.file); - - function _applyToDoc() { - DocumentManager.getDocumentForPath(vfsPath) - .done(function (doc) { - try { - const previousContent = doc.getText(); - if (edit.oldText === null) { - // Write (new file or full replacement) - doc.setText(edit.newText); - } else { - // Edit — find oldText and replace - const docText = doc.getText(); - const idx = docText.indexOf(edit.oldText); - if (idx === -1) { - result.reject(new Error(Strings.AI_CHAT_EDIT_NOT_FOUND)); - return; - } - const startPos = doc._masterEditor ? - doc._masterEditor._codeMirror.posFromIndex(idx) : - _indexToPos(docText, idx); - const endPos = doc._masterEditor ? - doc._masterEditor._codeMirror.posFromIndex(idx + edit.oldText.length) : - _indexToPos(docText, idx + edit.oldText.length); - doc.replaceRange(edit.newText, startPos, endPos); - } - // Open the file in the editor and save to disk - CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }); - SnapshotStore.saveDocToDisk(doc).always(function () { - result.resolve({ previousContent: previousContent }); - }); - } catch (err) { - result.reject(err); - } - }) - .fail(function (err) { - result.reject(err || new Error("Could not open document")); - }); - } - - if (edit.oldText === null) { - // Write — file may not exist yet. Only create on disk if it doesn't - // already exist, to avoid triggering "external change" warnings. - const file = FileSystem.getFileForPath(vfsPath); - file.exists(function (existErr, exists) { - if (exists) { - // File exists — just open and set content, no disk write - _applyToDoc(); - } else { - // New file — create on disk first so getDocumentForPath works - file.write("", function (writeErr) { - if (writeErr) { - result.reject(new Error("Could not create file: " + writeErr)); - return; - } - _applyToDoc(); - }); - } - }); - } else { - // Edit — file must already exist - _applyToDoc(); - } - - return result.promise(); - } - - /** - * Convert a character index in text to a {line, ch} position. - */ - function _indexToPos(text, index) { - let line = 0, ch = 0; - for (let i = 0; i < index; i++) { - if (text[i] === "\n") { - line++; - ch = 0; - } else { - ch++; - } - } - return { line: line, ch: ch }; - } - // --- Path utilities --- /** @@ -1214,43 +1118,7 @@ define(function (require, exports, module) { return fullPath; } - /** - * Check if a file has unsaved changes in the editor and return its content. - * Used by the node-side Read hook to serve dirty buffer content to Claude. - */ - function getFileContent(params) { - const vfsPath = SnapshotStore.realToVfsPath(params.filePath); - const doc = DocumentManager.getOpenDocumentForPath(vfsPath); - if (doc && doc.isDirty) { - return { isDirty: true, content: doc.getText() }; - } - return { isDirty: false, content: null }; - } - - /** - * Apply an edit to the editor buffer immediately (called by node-side hooks). - * The file appears as a dirty tab so subsequent Reads see the new content. - * @param {Object} params - {file, oldText, newText} - * @return {Promise<{applied: boolean, error?: string}>} - */ - function applyEditToBuffer(params) { - const deferred = new $.Deferred(); - _applySingleEdit(params) - .done(function (result) { - if (result && result.previousContent !== undefined) { - _previousContentMap[params.file] = result.previousContent; - } - deferred.resolve({ applied: true }); - }) - .fail(function (err) { - deferred.resolve({ applied: false, error: err.message || String(err) }); - }); - return deferred.promise(); - } - // Public API exports.init = init; exports.initPlaceholder = initPlaceholder; - exports.getFileContent = getFileContent; - exports.applyEditToBuffer = applyEditToBuffer; }); diff --git a/src/core-ai/aiPhoenixConnectors.js b/src/core-ai/aiPhoenixConnectors.js new file mode 100644 index 000000000..1807fd08c --- /dev/null +++ b/src/core-ai/aiPhoenixConnectors.js @@ -0,0 +1,251 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * NodeConnector handlers for bridging the node-side Claude Code agent with + * the Phoenix browser runtime. Handles editor state queries, screenshot + * capture, file content reads, and edit application to document buffers. + * + * Called via execPeer from src-node/claude-code-agent.js and + * src-node/mcp-editor-tools.js. + */ +define(function (require, exports, module) { + + const DocumentManager = require("document/DocumentManager"), + CommandManager = require("command/CommandManager"), + Commands = require("command/Commands"), + MainViewManager = require("view/MainViewManager"), + FileSystem = require("filesystem/FileSystem"), + SnapshotStore = require("core-ai/AISnapshotStore"), + Strings = require("strings"); + + // filePath → previous content before edit, for undo/snapshot support + const _previousContentMap = {}; + + // --- Editor state --- + + /** + * Get the current editor state: active file, working set, live preview file. + * Called from the node-side MCP server via execPeer. + */ + function getEditorState() { + const activeFile = MainViewManager.getCurrentlyViewedFile(MainViewManager.ACTIVE_PANE); + const workingSet = MainViewManager.getWorkingSet(MainViewManager.ALL_PANES); + + let activeFilePath = null; + if (activeFile) { + activeFilePath = activeFile.fullPath; + if (activeFilePath.startsWith("/tauri/")) { + activeFilePath = activeFilePath.replace("/tauri", ""); + } + } + + const workingSetPaths = workingSet.map(function (file) { + let p = file.fullPath; + if (p.startsWith("/tauri/")) { + p = p.replace("/tauri", ""); + } + return p; + }); + + let livePreviewFile = null; + const $panelTitle = $("#panel-live-preview-title"); + if ($panelTitle.length) { + livePreviewFile = $panelTitle.attr("data-fullPath") || null; + if (livePreviewFile && livePreviewFile.startsWith("/tauri/")) { + livePreviewFile = livePreviewFile.replace("/tauri", ""); + } + } + + return { + activeFile: activeFilePath, + workingSet: workingSetPaths, + livePreviewFile: livePreviewFile + }; + } + + // --- Screenshot --- + + /** + * Take a screenshot of the Phoenix editor window. + * Called from the node-side MCP server via execPeer. + * @param {Object} params - { selector?: string } + * @return {{ base64: string|null, error?: string }} + */ + function takeScreenshot(params) { + const deferred = new $.Deferred(); + if (!Phoenix || !Phoenix.app || !Phoenix.app.screenShotBinary) { + deferred.resolve({ base64: null, error: "Screenshot API not available" }); + return deferred.promise(); + } + Phoenix.app.screenShotBinary(params.selector || undefined) + .then(function (bytes) { + let binary = ""; + const chunkSize = 8192; + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length)); + binary += String.fromCharCode.apply(null, chunk); + } + const base64 = btoa(binary); + deferred.resolve({ base64: base64 }); + }) + .catch(function (err) { + deferred.resolve({ base64: null, error: err.message || String(err) }); + }); + return deferred.promise(); + } + + // --- File content --- + + /** + * Check if a file has unsaved changes in the editor and return its content. + * Used by the node-side Read hook to serve dirty buffer content to Claude. + */ + function getFileContent(params) { + const vfsPath = SnapshotStore.realToVfsPath(params.filePath); + const doc = DocumentManager.getOpenDocumentForPath(vfsPath); + if (doc && doc.isDirty) { + return { isDirty: true, content: doc.getText() }; + } + return { isDirty: false, content: null }; + } + + // --- Edit application --- + + /** + * Apply a single edit to a document buffer and save to disk. + * Called immediately when Claude's Write/Edit is intercepted, so + * subsequent Reads see the new content both in the buffer and on disk. + * @param {Object} edit - {file, oldText, newText} + * @return {$.Promise} resolves with {previousContent} for undo support + */ + function _applySingleEdit(edit) { + const result = new $.Deferred(); + const vfsPath = SnapshotStore.realToVfsPath(edit.file); + + function _applyToDoc() { + DocumentManager.getDocumentForPath(vfsPath) + .done(function (doc) { + try { + const previousContent = doc.getText(); + if (edit.oldText === null) { + // Write (new file or full replacement) + doc.setText(edit.newText); + } else { + // Edit — find oldText and replace + const docText = doc.getText(); + const idx = docText.indexOf(edit.oldText); + if (idx === -1) { + result.reject(new Error(Strings.AI_CHAT_EDIT_NOT_FOUND)); + return; + } + const startPos = doc.posFromIndex(idx); + const endPos = doc.posFromIndex(idx + edit.oldText.length); + doc.replaceRange(edit.newText, startPos, endPos); + } + // Open the file in the editor and save to disk + CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath }); + SnapshotStore.saveDocToDisk(doc).always(function () { + result.resolve({ previousContent: previousContent }); + }); + } catch (err) { + result.reject(err); + } + }) + .fail(function (err) { + result.reject(err || new Error("Could not open document")); + }); + } + + if (edit.oldText === null) { + // Write — file may not exist yet. Only create on disk if it doesn't + // already exist, to avoid triggering "external change" warnings. + const file = FileSystem.getFileForPath(vfsPath); + file.exists(function (existErr, exists) { + if (exists) { + // File exists — just open and set content, no disk write + _applyToDoc(); + } else { + // New file — create on disk first so getDocumentForPath works + file.write("", function (writeErr) { + if (writeErr) { + result.reject(new Error("Could not create file: " + writeErr)); + return; + } + _applyToDoc(); + }); + } + }); + } else { + // Edit — file must already exist + _applyToDoc(); + } + + return result.promise(); + } + + /** + * Apply an edit to the editor buffer immediately (called by node-side hooks). + * The file appears as a dirty tab so subsequent Reads see the new content. + * @param {Object} params - {file, oldText, newText} + * @return {Promise<{applied: boolean, error?: string}>} + */ + function applyEditToBuffer(params) { + const deferred = new $.Deferred(); + _applySingleEdit(params) + .done(function (result) { + if (result && result.previousContent !== undefined) { + _previousContentMap[params.file] = result.previousContent; + } + deferred.resolve({ applied: true }); + }) + .fail(function (err) { + deferred.resolve({ applied: false, error: err.message || String(err) }); + }); + return deferred.promise(); + } + + // --- Previous content map access (used by AIChatPanel for snapshot tracking) --- + + /** + * Get the previous content recorded for a file before the last edit. + * @param {string} filePath + * @return {string|undefined} + */ + function getPreviousContent(filePath) { + return _previousContentMap[filePath]; + } + + /** + * Clear all recorded previous content entries (called on new session). + */ + function clearPreviousContentMap() { + Object.keys(_previousContentMap).forEach(function (key) { + delete _previousContentMap[key]; + }); + } + + exports.getEditorState = getEditorState; + exports.takeScreenshot = takeScreenshot; + exports.getFileContent = getFileContent; + exports.applyEditToBuffer = applyEditToBuffer; + exports.getPreviousContent = getPreviousContent; + exports.clearPreviousContentMap = clearPreviousContentMap; +}); diff --git a/src/core-ai/main.js b/src/core-ai/main.js index 01ffb0492..2a5618299 100644 --- a/src/core-ai/main.js +++ b/src/core-ai/main.js @@ -26,26 +26,35 @@ */ define(function (require, exports, module) { - var AppInit = require("utils/AppInit"), - SidebarTabs = require("view/SidebarTabs"), - NodeConnector = require("NodeConnector"), - AIChatPanel = require("core-ai/AIChatPanel"); + const AppInit = require("utils/AppInit"), + SidebarTabs = require("view/SidebarTabs"), + NodeConnector = require("NodeConnector"), + AIChatPanel = require("core-ai/AIChatPanel"), + PhoenixConnectors = require("core-ai/aiPhoenixConnectors"); - var AI_CONNECTOR_ID = "ph_ai_claude"; + const AI_CONNECTOR_ID = "ph_ai_claude"; exports.getFileContent = async function (params) { - return AIChatPanel.getFileContent(params); + return PhoenixConnectors.getFileContent(params); }; exports.applyEditToBuffer = async function (params) { - return AIChatPanel.applyEditToBuffer(params); + return PhoenixConnectors.applyEditToBuffer(params); + }; + + exports.getEditorState = async function () { + return PhoenixConnectors.getEditorState(); + }; + + exports.takeScreenshot = async function (params) { + return PhoenixConnectors.takeScreenshot(params); }; AppInit.appReady(function () { SidebarTabs.addTab("ai", "AI", "fa-solid fa-wand-magic-sparkles", { priority: 200 }); if (Phoenix.isNativeApp) { - var nodeConnector = NodeConnector.createNodeConnector(AI_CONNECTOR_ID, exports); + const nodeConnector = NodeConnector.createNodeConnector(AI_CONNECTOR_ID, exports); AIChatPanel.init(nodeConnector); } else { AIChatPanel.initPlaceholder(); diff --git a/src/document/Document.js b/src/document/Document.js index 5ded3c4ca..5d1de3fe1 100644 --- a/src/document/Document.js +++ b/src/document/Document.js @@ -492,6 +492,30 @@ define(function (require, exports, module) { return this._masterEditor._codeMirror.getLine(lineNum); }; + /** + * Given a character index within the document text (assuming \n newlines), + * returns the corresponding {line, ch} position. Works whether or not + * a master editor is attached. + * @param {number} index - Zero-based character offset + * @return {{line: number, ch: number}} + */ + Document.prototype.posFromIndex = function (index) { + if (this._masterEditor) { + return this._masterEditor._codeMirror.posFromIndex(index); + } + var text = this._text || ""; + var line = 0, ch = 0; + for (var i = 0; i < index && i < text.length; i++) { + if (text[i] === "\n") { + line++; + ch = 0; + } else { + ch++; + } + } + return {line: line, ch: ch}; + }; + /** * Batches a series of related Document changes. Repeated calls to replaceRange() should be wrapped in a * batch for efficiency. Begins the batch, calls doOperation(), ends the batch, and then returns. diff --git a/test/spec/Document-test.js b/test/spec/Document-test.js index 355d064c0..5041c3d15 100644 --- a/test/spec/Document-test.js +++ b/test/spec/Document-test.js @@ -235,5 +235,72 @@ define(function (require, exports, module) { }); }); + + describe("posFromIndex", function () { + var myEditor, myDocument; + + beforeEach(function () { + var mocks = SpecRunnerUtils.createMockEditor("line0\nline1\nline2\n", "unknown"); + myDocument = mocks.doc; + myEditor = mocks.editor; + }); + + afterEach(function () { + if (myEditor) { + SpecRunnerUtils.destroyMockEditor(myDocument); + myEditor = null; + myDocument = null; + } + }); + + it("should return {0,0} for index 0", function () { + var pos = myDocument.posFromIndex(0); + expect(pos.line).toBe(0); + expect(pos.ch).toBe(0); + }); + + it("should return correct position within first line", function () { + // "line0" — index 3 is 'e' + var pos = myDocument.posFromIndex(3); + expect(pos.line).toBe(0); + expect(pos.ch).toBe(3); + }); + + it("should return start of second line after newline", function () { + // "line0\n" is 6 chars, so index 6 is start of line 1 + var pos = myDocument.posFromIndex(6); + expect(pos.line).toBe(1); + expect(pos.ch).toBe(0); + }); + + it("should return correct position on third line", function () { + // "line0\nline1\n" is 12 chars, index 14 is 'n' in "line2" + var pos = myDocument.posFromIndex(14); + expect(pos.line).toBe(2); + expect(pos.ch).toBe(2); + }); + + it("should work without a master editor (text-only fallback)", function () { + // Destroy the editor so _masterEditor becomes null and + // the document falls back to its internal _text string + SpecRunnerUtils.destroyMockEditor(myDocument); + myEditor = null; + + // Content is "line0\nline1\nline2\n" — same as beforeEach + expect(myDocument._masterEditor).toBe(null); + + var pos = myDocument.posFromIndex(0); + expect(pos.line).toBe(0); + expect(pos.ch).toBe(0); + + pos = myDocument.posFromIndex(6); + expect(pos.line).toBe(1); + expect(pos.ch).toBe(0); + + pos = myDocument.posFromIndex(14); + expect(pos.line).toBe(2); + expect(pos.ch).toBe(2); + }); + }); }); }); From cfeea64ff457aa8721a35287fc98fbf88466fca3 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 21 Feb 2026 13:45:37 +0530 Subject: [PATCH 10/11] refactor: var to const and let --- test/spec/Document-test.js | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/test/spec/Document-test.js b/test/spec/Document-test.js index 5041c3d15..c876951ea 100644 --- a/test/spec/Document-test.js +++ b/test/spec/Document-test.js @@ -32,10 +32,10 @@ define(function (require, exports, module) { describe("doMultipleEdits", function () { // Even though these are Document unit tests, we need to create an editor in order to // be able to test actual edit ops. - var myEditor, myDocument, initialContentLines; + let myEditor, myDocument, initialContentLines; function makeDummyLines(num) { - var content = [], i; + let content = [], i; for (i = 0; i < num; i++) { content.push("this is line " + i); } @@ -45,7 +45,7 @@ define(function (require, exports, module) { beforeEach(function () { // Each line from 0-9 is 14 chars long, each line from 10-19 is 15 chars long initialContentLines = makeDummyLines(20); - var mocks = SpecRunnerUtils.createMockEditor(initialContentLines.join("\n"), "unknown"); + const mocks = SpecRunnerUtils.createMockEditor(initialContentLines.join("\n"), "unknown"); myDocument = mocks.doc; myEditor = mocks.editor; }); @@ -59,7 +59,7 @@ define(function (require, exports, module) { }); function _verifySingleEdit() { - var result = myDocument.doMultipleEdits([{edit: {text: "new content", start: {line: 2, ch: 0}, end: {line: 2, ch: 14}}, + const result = myDocument.doMultipleEdits([{edit: {text: "new content", start: {line: 2, ch: 0}, end: {line: 2, ch: 14}}, selection: {start: {line: 2, ch: 4}, end: {line: 2, ch: 4}, reversed: true, isBeforeEdit: true}}]); initialContentLines[2] = "new content"; expect(myDocument.getText()).toEqual(initialContentLines.join("\n")); @@ -83,7 +83,7 @@ define(function (require, exports, module) { }); it("should do a single edit, leaving a non-beforeEdit selection untouched and preserving reversed flag", function () { - var result = myDocument.doMultipleEdits([{edit: {text: "new content", start: {line: 2, ch: 0}, end: {line: 2, ch: 14}}, + const result = myDocument.doMultipleEdits([{edit: {text: "new content", start: {line: 2, ch: 0}, end: {line: 2, ch: 14}}, selection: {start: {line: 2, ch: 4}, end: {line: 2, ch: 4}, reversed: true}}]); initialContentLines[2] = "new content"; expect(myDocument.getText()).toEqual(initialContentLines.join("\n")); @@ -96,7 +96,7 @@ define(function (require, exports, module) { }); it("should do multiple edits, fixing up isBeforeEdit selections with respect to both edits and preserving other selection attributes", function () { - var result = myDocument.doMultipleEdits([ + const result = myDocument.doMultipleEdits([ {edit: {text: "modified line 2\n", start: {line: 2, ch: 0}, end: {line: 2, ch: 14}}, selection: {start: {line: 2, ch: 4}, end: {line: 2, ch: 4}, isBeforeEdit: true, primary: true}}, {edit: {text: "modified line 4\n", start: {line: 4, ch: 0}, end: {line: 4, ch: 14}}, @@ -121,7 +121,7 @@ define(function (require, exports, module) { }); it("should do multiple edits, fixing up non-isBeforeEdit selections only with respect to other edits", function () { - var result = myDocument.doMultipleEdits([ + const result = myDocument.doMultipleEdits([ {edit: {text: "modified line 2\n", start: {line: 2, ch: 0}, end: {line: 2, ch: 14}}, selection: {start: {line: 2, ch: 4}, end: {line: 2, ch: 4}, primary: true}}, {edit: {text: "modified line 4\n", start: {line: 4, ch: 0}, end: {line: 4, ch: 14}}, @@ -146,7 +146,7 @@ define(function (require, exports, module) { }); it("should perform multiple changes/track multiple selections within a single edit, selections specified as isBeforeEdit", function () { - var result = myDocument.doMultipleEdits([ + const result = myDocument.doMultipleEdits([ {edit: [{text: "modified line 1", start: {line: 1, ch: 0}, end: {line: 1, ch: 14}}, {text: "modified line 2\n", start: {line: 2, ch: 0}, end: {line: 2, ch: 14}}], selection: [{start: {line: 1, ch: 4}, end: {line: 1, ch: 4}, isBeforeEdit: true}, @@ -179,7 +179,7 @@ define(function (require, exports, module) { }); it("should perform multiple changes/track multiple selections within a single edit, selections not specified as isBeforeEdit", function () { - var result = myDocument.doMultipleEdits([ + const result = myDocument.doMultipleEdits([ {edit: [{text: "modified line 1", start: {line: 1, ch: 0}, end: {line: 1, ch: 14}}, {text: "modified line 2\n", start: {line: 2, ch: 0}, end: {line: 2, ch: 14}}], selection: [{start: {line: 1, ch: 4}, end: {line: 1, ch: 4}}, @@ -237,10 +237,10 @@ define(function (require, exports, module) { }); describe("posFromIndex", function () { - var myEditor, myDocument; + let myEditor, myDocument; beforeEach(function () { - var mocks = SpecRunnerUtils.createMockEditor("line0\nline1\nline2\n", "unknown"); + const mocks = SpecRunnerUtils.createMockEditor("line0\nline1\nline2\n", "unknown"); myDocument = mocks.doc; myEditor = mocks.editor; }); @@ -254,28 +254,28 @@ define(function (require, exports, module) { }); it("should return {0,0} for index 0", function () { - var pos = myDocument.posFromIndex(0); + const pos = myDocument.posFromIndex(0); expect(pos.line).toBe(0); expect(pos.ch).toBe(0); }); it("should return correct position within first line", function () { // "line0" — index 3 is 'e' - var pos = myDocument.posFromIndex(3); + const pos = myDocument.posFromIndex(3); expect(pos.line).toBe(0); expect(pos.ch).toBe(3); }); it("should return start of second line after newline", function () { // "line0\n" is 6 chars, so index 6 is start of line 1 - var pos = myDocument.posFromIndex(6); + const pos = myDocument.posFromIndex(6); expect(pos.line).toBe(1); expect(pos.ch).toBe(0); }); it("should return correct position on third line", function () { // "line0\nline1\n" is 12 chars, index 14 is 'n' in "line2" - var pos = myDocument.posFromIndex(14); + const pos = myDocument.posFromIndex(14); expect(pos.line).toBe(2); expect(pos.ch).toBe(2); }); @@ -289,7 +289,7 @@ define(function (require, exports, module) { // Content is "line0\nline1\nline2\n" — same as beforeEach expect(myDocument._masterEditor).toBe(null); - var pos = myDocument.posFromIndex(0); + let pos = myDocument.posFromIndex(0); expect(pos.line).toBe(0); expect(pos.ch).toBe(0); From 03911b31886c85ec48611dd8e83b3b9b1d1b5e10 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 21 Feb 2026 14:27:52 +0530 Subject: [PATCH 11/11] test: verify characters at positions in posFromIndex tests Add expectCharAtIndex helper that checks both the returned position and the actual character at that position, making the tests more robust against regressions. --- test/spec/Document-test.js | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/test/spec/Document-test.js b/test/spec/Document-test.js index c876951ea..b6f8d0fb4 100644 --- a/test/spec/Document-test.js +++ b/test/spec/Document-test.js @@ -237,10 +237,11 @@ define(function (require, exports, module) { }); describe("posFromIndex", function () { + const TEST_CONTENT = "line0\nline1\nline2\n"; let myEditor, myDocument; beforeEach(function () { - const mocks = SpecRunnerUtils.createMockEditor("line0\nline1\nline2\n", "unknown"); + const mocks = SpecRunnerUtils.createMockEditor(TEST_CONTENT, "unknown"); myDocument = mocks.doc; myEditor = mocks.editor; }); @@ -253,29 +254,36 @@ define(function (require, exports, module) { } }); + // Verify the character at a given index matches the char at the returned position + function expectCharAtIndex(index, expectedChar) { + const pos = myDocument.posFromIndex(index); + const actualChar = myDocument.getLine(pos.line).charAt(pos.ch); + expect(actualChar).toBe(expectedChar); + return pos; + } + it("should return {0,0} for index 0", function () { - const pos = myDocument.posFromIndex(0); + const pos = expectCharAtIndex(0, "l"); expect(pos.line).toBe(0); expect(pos.ch).toBe(0); }); it("should return correct position within first line", function () { - // "line0" — index 3 is 'e' - const pos = myDocument.posFromIndex(3); + const pos = expectCharAtIndex(3, "e"); expect(pos.line).toBe(0); expect(pos.ch).toBe(3); }); it("should return start of second line after newline", function () { - // "line0\n" is 6 chars, so index 6 is start of line 1 - const pos = myDocument.posFromIndex(6); + // "line0\n" is 6 chars, so index 6 is 'l' at start of "line1" + const pos = expectCharAtIndex(6, "l"); expect(pos.line).toBe(1); expect(pos.ch).toBe(0); }); it("should return correct position on third line", function () { // "line0\nline1\n" is 12 chars, index 14 is 'n' in "line2" - const pos = myDocument.posFromIndex(14); + const pos = expectCharAtIndex(14, "n"); expect(pos.line).toBe(2); expect(pos.ch).toBe(2); }); @@ -286,20 +294,23 @@ define(function (require, exports, module) { SpecRunnerUtils.destroyMockEditor(myDocument); myEditor = null; - // Content is "line0\nline1\nline2\n" — same as beforeEach expect(myDocument._masterEditor).toBe(null); + // Verify against raw string since getLine is unavailable without editor let pos = myDocument.posFromIndex(0); expect(pos.line).toBe(0); expect(pos.ch).toBe(0); + expect(TEST_CONTENT[0]).toBe("l"); pos = myDocument.posFromIndex(6); expect(pos.line).toBe(1); expect(pos.ch).toBe(0); + expect(TEST_CONTENT[6]).toBe("l"); pos = myDocument.posFromIndex(14); expect(pos.line).toBe(2); expect(pos.ch).toBe(2); + expect(TEST_CONTENT[14]).toBe("n"); }); }); });