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..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; @@ -125,7 +129,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 +146,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,13 +180,16 @@ 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; 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) { 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 " + @@ -209,7 +221,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-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/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/AIChatPanel.js b/src/core-ai/AIChatPanel.js index af54050c2..7411047e7 100644 --- a/src/core-ai/AIChatPanel.js +++ b/src/core-ai/AIChatPanel.js @@ -24,14 +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"), - 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; @@ -39,13 +42,14 @@ 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 // --- 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; @@ -60,23 +64,23 @@ define(function (require, exports, module) { const PANEL_HTML = '
' + '
' + - 'AI Assistant' + - '' + '
' + '
' + '
' + '' + - 'Thinking...' + + '' + Strings.AI_CHAT_THINKING + '' + '
' + '
' + '
' + - '' + - '' + - '' + '
' + @@ -87,13 +91,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 +103,9 @@ define(function (require, exports, module) { '
' + '
' + '
' + - '
AI Assistant
' + + '
' + Strings.AI_CHAT_TITLE + '
' + '
' + - 'AI features require the Phoenix desktop app.' + + Strings.AI_CHAT_DESKTOP_ONLY + '
' + '
' + '
'; @@ -241,6 +243,7 @@ define(function (require, exports, module) { _hasReceivedContent = false; _currentEdits = []; _firstEditInResponse = true; + SnapshotStore.startTracking(); _appendThinkingIndicator(); // Remove restore highlights from previous interactions @@ -258,13 +261,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))); }); } @@ -295,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(); } @@ -342,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) { @@ -356,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); @@ -370,6 +372,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) { @@ -401,6 +420,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); } /** @@ -462,7 +507,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 ""; @@ -496,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. @@ -509,7 +554,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 () { @@ -552,7 +597,7 @@ define(function (require, exports, module) { const $actions = $('
'); // Diff toggle - const $diffToggle = $(''); + const $diffToggle = $(''); const $diff = $('
'); if (edit.oldText) { @@ -571,7 +616,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); @@ -586,7 +631,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 +640,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 @@ -626,31 +672,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); @@ -698,10 +747,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) { @@ -714,7 +764,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); } }); } @@ -730,10 +780,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) { @@ -749,7 +800,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); } }); } @@ -759,7 +810,7 @@ define(function (require, exports, module) { function _appendUserMessage(text) { const $msg = $( '
' + - '
You
' + + '
' + Strings.AI_CHAT_LABEL_YOU + '
' + '
' + '
' ); @@ -774,7 +825,7 @@ define(function (require, exports, module) { function _appendThinkingIndicator() { const $thinking = $( '
' + - '
Claude
' + + '
' + Strings.AI_CHAT_LABEL_CLAUDE + '
' + '
' + '' + '' + @@ -900,6 +951,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. @@ -921,38 +976,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: @@ -1043,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("Text not found in file — it may have changed")); - 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 --- /** @@ -1155,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/AISnapshotStore.js b/src/core-ai/AISnapshotStore.js index efe70b9d2..2c3873e75 100644 --- a/src/core-ai/AISnapshotStore.js +++ b/src/core-ai/AISnapshotStore.js @@ -22,18 +22,41 @@ * 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) { 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; + const STALE_THRESHOLD_MS = 10 * 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 + 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 --- @@ -65,10 +88,77 @@ 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.hasOwnProperty(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); + } + }); + }); + } + + 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 --- /** @@ -103,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(); } @@ -147,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); @@ -159,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(); } }); @@ -181,27 +300,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 --- @@ -226,6 +348,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. @@ -240,24 +393,46 @@ 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. - * @return {number} the after-snapshot index, or -1 if no edits happened + * Flushes in-memory content to disk for long-term storage. + * + * 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; } @@ -266,16 +441,83 @@ 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); + } + + // --- 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 */ @@ -285,20 +527,122 @@ 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 = {}; + _pendingDeleted.clear(); + Object.keys(_readFiles).forEach(function (k) { delete _readFiles[k]; }); + stopTracking(); + + // 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; 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/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/src/nls/root/strings.js b/src/nls/root/strings.js index cde5aeabf..65be0c33d 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1801,6 +1801,53 @@ 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", + "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", "DEMO_SECTION1_SUBTITLE": "Edit your page visually -  Your HTML updates instantly", 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/Document-test.js b/test/spec/Document-test.js index 355d064c0..b6f8d0fb4 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}}, @@ -235,5 +235,83 @@ define(function (require, exports, module) { }); }); + + describe("posFromIndex", function () { + const TEST_CONTENT = "line0\nline1\nline2\n"; + let myEditor, myDocument; + + beforeEach(function () { + const mocks = SpecRunnerUtils.createMockEditor(TEST_CONTENT, "unknown"); + myDocument = mocks.doc; + myEditor = mocks.editor; + }); + + afterEach(function () { + if (myEditor) { + SpecRunnerUtils.destroyMockEditor(myDocument); + myEditor = null; + myDocument = null; + } + }); + + // 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 = expectCharAtIndex(0, "l"); + expect(pos.line).toBe(0); + expect(pos.ch).toBe(0); + }); + + it("should return correct position within first line", function () { + 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 '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 = expectCharAtIndex(14, "n"); + 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; + + 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"); + }); + }); }); }); diff --git a/test/spec/ai-snapshot-test.js b/test/spec/ai-snapshot-test.js new file mode 100644 index 000000000..f83b5f685 --- /dev/null +++ b/test/spec/ai-snapshot-test.js @@ -0,0 +1,717 @@ +/* + * 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) { + // 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) { + let gone = false; + let checking = false; + await awaitsFor(function () { + if (!checking && !gone) { + checking = true; + fileExists(name).then(function (e) { + gone = !e; + checking = false; + }); + } + return gone; + }, 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(); + } + } + + // --- 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"); + await 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); + await 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", async function () { + beginResponse(); + const idx = await 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); + await 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); + await 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 = await AISnapshotStore.finalizeResponse(); + expect(idx).toBe(1); + 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) --- + + 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); + await 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); + await AISnapshotStore.finalizeResponse(); + + // R2 + simulateEdit(doc, "v2", false); + await 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); + await AISnapshotStore.finalizeResponse(); + + // R2 + simulateEdit(doc, "v2", false); + await AISnapshotStore.finalizeResponse(); + + // R3 + simulateEdit(doc, "v3", false); + await 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); + await AISnapshotStore.finalizeResponse(); + + // R2: edit B only + const docB = await openDoc("b.txt"); + simulateEdit(docB, "b1", false); + await 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"); + await AISnapshotStore.finalizeResponse(); + + // R2: edit A + simulateEdit(docA, "edited", false); + await 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); + await AISnapshotStore.finalizeResponse(); + + // R2: create B + const docB = await simulateCreateFile("b.txt", "new"); + await 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"); + }); + + // 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", async function () { + beginResponse(); + 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 () { + 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); + await AISnapshotStore.finalizeResponse(); + expect(AISnapshotStore.getSnapshotCount()).toBe(2); + + AISnapshotStore.reset(); + expect(AISnapshotStore.getSnapshotCount()).toBe(0); + + // Start fresh + beginResponse(); + simulateEdit(doc, "v2", false); + await 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); + }); + }); + }); +}); diff --git a/src/core-ai/editApplyVerification.md b/test/spec/ai-snapshot-test.md similarity index 93% rename from src/core-ai/editApplyVerification.md rename to test/spec/ai-snapshot-test.md index e30c5e3c2..edc50135e 100644 --- a/src/core-ai/editApplyVerification.md +++ b/test/spec/ai-snapshot-test.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)