diff --git a/.gitignore b/.gitignore index 53be4d0d9c..e7cd8fdf27 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ Thumbs.db /phoenix-builder-mcp/chrome_extension/build/ /phoenix-builder-mcp/chrome_extension/*.zip +# claude +.claude/**/*.lock + # ignore node_modules inside src /src/node_modules /src-node/node_modules diff --git a/CLAUDE.md b/CLAUDE.md index 6a0bc2647a..f127fea18c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,15 @@ - **Exception — Markdown viewer iframe** (`src-mdviewer/`): Has its own i18n system. Strings go in `src-mdviewer/src/locales/en.json` (root), not `src/nls/`. Other locale files in that folder are auto-translated by GitHub Actions. Use `t("key")` / `tp("key", { param })` from `src-mdviewer/src/core/i18n.js`. - Never compare `$(el).text()` against English strings for logic — use data attributes or CSS classes instead. +## File I/O APIs — which to use + +Phoenix has two parallel file APIs. Pick the right one for the situation: + +- **`Phoenix.VFS.readFileAsync(path, encoding)` / `Phoenix.VFS.writeFileAsync(path, content, encoding)` / `Phoenix.VFS.unlinkAsync(path)`** — for raw app data (config files, session JSONs, caches, snapshots). No size cap. `unlinkAsync` removes non-empty directories recursively. +- **`FileSystem.getFileForPath(path).read/.write/.unlink`** (and `getDirectoryForPath`) — *only* for files that may be opened as documents in the editor. Goes through the document layer (mtime tracking, dirty-buffer reconciliation). Has a 16 MB cap on reads/writes. + +If a file is purely app-internal data and never edited by the user as a document, use the VFS APIs. Mixing them on the same file leads to mtime confusion and surprise size limits. + ## 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/docs/API-Reference/command/Commands.md b/docs/API-Reference/command/Commands.md index ff8af1b778..b5ae5e4f04 100644 --- a/docs/API-Reference/command/Commands.md +++ b/docs/API-Reference/command/Commands.md @@ -452,6 +452,12 @@ Opens theme settings ## VIEW\_HIDE\_SIDEBAR Toggles sidebar visibility +**Kind**: global variable + + +## VIEW\_TOGGLE\_DESIGN\_MODE +Toggles the design (full live-preview) mode — collapses/expands the editor + **Kind**: global variable diff --git a/docs/API-Reference/view/PanelView.md b/docs/API-Reference/view/PanelView.md index 7a4455bcce..78d9eee962 100644 --- a/docs/API-Reference/view/PanelView.md +++ b/docs/API-Reference/view/PanelView.md @@ -68,12 +68,6 @@ recomputeLayout callback from WorkspaceManager ## \_defaultPanelId : string \| null The default/quick-access panel ID -**Kind**: global variable - - -## \_$addBtn : jQueryObject -The "+" button inside the tab overflow area - **Kind**: global variable @@ -147,6 +141,7 @@ Preference key for persisting the maximize state across reloads. * [.registerOnCloseRequestedHandler(handler)](#Panel+registerOnCloseRequestedHandler) * [.requestClose()](#Panel+requestClose) ⇒ Promise.<boolean> * [.show()](#Panel+show) + * [.addToTabBar()](#Panel+addToTabBar) * [.hide()](#Panel+hide) * [.focus()](#Panel+focus) ⇒ boolean * [.setVisible(visible)](#Panel+setVisible) @@ -212,6 +207,14 @@ registered, `hide()` is called. ### panel.show() Shows the panel +**Kind**: instance method of [Panel](#Panel) + + +### panel.addToTabBar() +Adds the panel to the tab bar and open set without showing the container. +Use this during startup to restore a panel's tab when the container +was collapsed by the user — avoids forcing the bottom panel open. + **Kind**: instance method of [Panel](#Panel) @@ -309,6 +312,26 @@ When the saved height is near-max or unknown, a sensible default is used. ## isMaximized() ⇒ boolean Returns true if the bottom panel is currently maximized. +**Kind**: global function + + +## collapseContainer() +Collapse the bottom panel container (transient hide) without touching +which panel is logically active. Fires EVENT_PANEL_HIDDEN with the +default panel id as a "container collapsed" signal so toolbar icons +and menu items that mirror container visibility deselect. +No-op if the container is already hidden. + +**Kind**: global function + + +## restoreContainer() +Re-show the bottom panel container after a previous collapse, with the +previously active panel still mounted. Fires EVENT_PANEL_SHOWN for the +active panel id so toolbar icons / menu items that mirror visibility +re-select. No-op if the container is already visible or there's no +active panel to restore. + **Kind**: global function diff --git a/docs/API-Reference/view/WorkspaceManager.md b/docs/API-Reference/view/WorkspaceManager.md index 2a7f2db812..bb0c11ed12 100644 --- a/docs/API-Reference/view/WorkspaceManager.md +++ b/docs/API-Reference/view/WorkspaceManager.md @@ -28,6 +28,7 @@ Events: * [.EVENT_WORKSPACE_UPDATE_LAYOUT](#module_view/WorkspaceManager..EVENT_WORKSPACE_UPDATE_LAYOUT) * [.EVENT_WORKSPACE_PANEL_SHOWN](#module_view/WorkspaceManager..EVENT_WORKSPACE_PANEL_SHOWN) * [.EVENT_WORKSPACE_PANEL_HIDDEN](#module_view/WorkspaceManager..EVENT_WORKSPACE_PANEL_HIDDEN) + * [.EVENT_WORKSPACE_DESIGN_MODE_CHANGE](#module_view/WorkspaceManager..EVENT_WORKSPACE_DESIGN_MODE_CHANGE) * [.createBottomPanel(id, $panel, [minSize], [title], [options])](#module_view/WorkspaceManager..createBottomPanel) ⇒ Panel * [.destroyBottomPanel(id)](#module_view/WorkspaceManager..destroyBottomPanel) * [.createPluginPanel(id, $panel, [minSize], $toolbarIcon, [initialSize])](#module_view/WorkspaceManager..createPluginPanel) ⇒ Panel @@ -36,6 +37,8 @@ Events: * [.recomputeLayout(refreshHint)](#module_view/WorkspaceManager..recomputeLayout) * [.isPanelVisible(panelID)](#module_view/WorkspaceManager..isPanelVisible) ⇒ boolean * [.setPluginPanelWidth(width)](#module_view/WorkspaceManager..setPluginPanelWidth) + * [.isInDesignMode()](#module_view/WorkspaceManager..isInDesignMode) ⇒ boolean + * [.setDesignMode(active)](#module_view/WorkspaceManager..setDesignMode) * [.addEscapeKeyEventHandler(consumerName, eventHandler)](#module_view/WorkspaceManager..addEscapeKeyEventHandler) ⇒ boolean * [.removeEscapeKeyEventHandler(consumerName)](#module_view/WorkspaceManager..removeEscapeKeyEventHandler) ⇒ boolean @@ -86,6 +89,13 @@ Event triggered when a panel is shown. ### view/WorkspaceManager.EVENT\_WORKSPACE\_PANEL\_HIDDEN Event triggered when a panel is hidden. +**Kind**: inner constant of [view/WorkspaceManager](#module_view/WorkspaceManager) + + +### view/WorkspaceManager.EVENT\_WORKSPACE\_DESIGN\_MODE\_CHANGE +Event triggered when design mode (editor collapsed, full live preview) is +entered or exited. Payload: `(active: boolean)`. + **Kind**: inner constant of [view/WorkspaceManager](#module_view/WorkspaceManager) @@ -180,7 +190,8 @@ Returns true if visible else false. ### view/WorkspaceManager.setPluginPanelWidth(width) Programmatically sets the plugin panel content width to the given value in pixels. The total toolbar width is adjusted to account for the plugin icons bar. -Width is clamped to respect panel minWidth and max size (75% of window). +If the requested width doesn't fit, the sidebar is progressively shrunk +(and collapsed if necessary) before clamping. No-op if no panel is currently visible. **Kind**: inner method of [view/WorkspaceManager](#module_view/WorkspaceManager) @@ -189,6 +200,26 @@ No-op if no panel is currently visible. | --- | --- | --- | | width | number | Desired content width in pixels | + + +### view/WorkspaceManager.isInDesignMode() ⇒ boolean +Returns true while the workspace is in design mode (editor collapsed and +live preview expanded to fill the editor area). + +**Kind**: inner method of [view/WorkspaceManager](#module_view/WorkspaceManager) + + +### view/WorkspaceManager.setDesignMode(active) +Sets the design-mode flag and fires EVENT_WORKSPACE_DESIGN_MODE_CHANGE when +the value actually changes. Intended to be called by the control bar; other +callers should use the dedicated toggle command instead. + +**Kind**: inner method of [view/WorkspaceManager](#module_view/WorkspaceManager) + +| Param | Type | +| --- | --- | +| active | boolean | + ### view/WorkspaceManager.addEscapeKeyEventHandler(consumerName, eventHandler) ⇒ boolean diff --git a/package.json b/package.json index 25f0000d93..ea919eae8c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "phoenix", - "version": "5.1.7-0", - "apiVersion": "5.1.7", + "version": "5.1.8-0", + "apiVersion": "5.1.8", "homepage": "https://core.ai", "issues": { "url": "https://github.com/phcode-dev/phoenix/issues" diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index ace042841c..3380e2e778 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -63,6 +63,11 @@ let _planResolve = null; // Pending bash confirmation resolver — used by Bash PreToolUse hook (Edit Mode) let _bashConfirmResolve = null; +// Pending plan-mode write confirmation resolver — set when an Edit/Write +// fires in plan mode and we're awaiting the user's "Allow & Switch to Edit +// Mode" / "Stay in Plan Mode" choice from the browser. +let _planModeConfirmResolve = null; + // Stores rejection feedback when user rejects a plan let _planRejectionFeedback = null; @@ -76,8 +81,96 @@ let _planApproved = false; // Shape: { text: string, images: [{mediaType, base64Data}] } or null let _queuedClarification = null; +// Module-level "runtime" permission mode that hooks read at decision time. +// Updated on every sendPrompt and via the setPermissionMode peer when the +// user cycles the panel's permission bar mid-stream — without this, the +// Bash hook would close over the value at query start and continue +// prompting for confirmation even after the user has flipped to Full Auto. +let _runtimePermissionMode = "acceptEdits"; + const nodeConnector = global.createNodeConnector(CONNECTOR_ID, exports); +/** + * Detect whether a PostToolUse `tool_response` represents an error result. + * Used to suppress diff-card painting when the SDK's native Edit/Write itself + * failed (e.g. oldText not found on disk). The shape of tool_response is + * `unknown` per the SDK types — handle the common variants defensively. + */ +function _isToolResponseError(toolResponse) { + if (!toolResponse) { return false; } + if (typeof toolResponse === "object") { + if (toolResponse.is_error === true || toolResponse.isError === true) { return true; } + if (Array.isArray(toolResponse.content)) { + for (const c of toolResponse.content) { + if (c && typeof c.text === "string" && //i.test(c.text)) { + return true; + } + } + } + } + if (typeof toolResponse === "string" && //i.test(toolResponse)) { + return true; + } + return false; +} + +// Bash commands the agent can run without prompting the user in Edit +// Mode. Mirrors the CLI's default "permissions.allow" set +// (cli.js:2925) plus a small handful of universally read-only shell +// utilities. The safety belt in _isSafeReadOnlyBash splits on +// `;` / `&&` / `||` and checks every segment, so chaining safe +// commands (e.g. `git status && git log`, `sleep 1; echo done`) +// works while `git status; rm -rf /` correctly falls through. +const _SAFE_BASH_PATTERNS = [ + // git read-only + /^git\s+status(\s|$)/, + /^git\s+log(\s|$)/, + /^git\s+diff(\s|$)/, + /^git\s+show(\s|$)/, + /^git\s+branch(\s|$)/, + /^git\s+ls-files(\s|$)/, + /^git\s+rev-parse(\s|$)/, + /^git\s+remote\s+show(\s|$)/, + /^git\s+--version$/, + // generic read-only shell + /^ls(\s|$)/, + /^pwd$/, + /^echo(\s|$)/, + /^which\s/, + /^cat(\s|$)/, + /^head(\s|$)/, + /^tail(\s|$)/, + /^wc(\s|$)/, + /^file\s/, + /^stat\s/, + // numeric-only sleep — no `sleep $(...)` since process substitution + // is rejected separately, but be explicit so `sleep $VAR` also fails. + /^sleep\s+\d+(\.\d+)?$/, + // version probes + /^node\s+--version$/, + /^npm\s+--version$/, + /^yarn\s+--version$/, + /^pnpm\s+--version$/ +]; + +function _isSafeReadOnlyBash(rawCmd) { + const cmd = (rawCmd || "").trim(); + if (!cmd) { return false; } + // Reject command/process substitution, redirection, and pipes — + // these can hide arbitrary commands or send output to dangerous + // places. Backticks, `$(...)`, `<`, `>`, `|`. Plain `$VAR` is + // allowed (substitution-without-command). + if (/[`<>|]|\$\(/.test(cmd)) { return false; } + // Split on `;`, `&&`, `||` and verify EVERY segment matches a safe + // pattern. Quotes around delimiters are not handled — a command + // like `echo "a; b"` will split mid-string and fail safe-check + // (which is fine: false negatives are OK, false positives are not). + const segments = cmd.split(/\s*(?:;|&&|\|\|)\s*/).filter(Boolean); + return segments.every(function (seg) { + return _SAFE_BASH_PATTERNS.some(function (rx) { return rx.test(seg); }); + }); +} + /** * Lazily import the ESM @anthropic-ai/claude-code module. */ @@ -212,7 +305,7 @@ exports.checkAvailability = async function () { * aiProgress, aiTextStream, aiToolEdit, aiError, aiComplete */ exports.sendPrompt = async function (params) { - const { prompt, projectPath, sessionAction, model, locale, selectionContext, images, envOverrides, permissionMode } = params; + const { prompt, projectPath, sessionAction, model, locale, selectionContext, images, envOverrides, permissionMode, additionalDirectories } = params; const requestId = Date.now().toString(36) + Math.random().toString(36).slice(2, 7); // Handle session @@ -255,7 +348,7 @@ exports.sendPrompt = async function (params) { } // Run the query asynchronously — don't await here so we return requestId immediately - _runQuery(requestId, enrichedPrompt, projectPath, model, currentAbortController.signal, locale, images, envOverrides, permissionMode) + _runQuery(requestId, enrichedPrompt, projectPath, model, currentAbortController.signal, locale, images, envOverrides, permissionMode, additionalDirectories) .catch(err => { console.error("[Phoenix AI] Query error:", err); }); @@ -270,12 +363,13 @@ exports.cancelQuery = async function () { if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; - // Clear session so next query starts fresh instead of resuming a killed session - currentSessionId = null; + // Keep currentSessionId so the next prompt resumes the same SDK session. + // Aborts leave an interrupt marker in the session log, not a corrupted state. // Clear any pending question or plan _questionResolve = null; _planResolve = null; _bashConfirmResolve = null; + _planModeConfirmResolve = null; _queuedClarification = null; return { success: true }; } @@ -318,6 +412,34 @@ exports.answerBashConfirm = async function (params) { return { success: true }; }; +/** + * Receive the user's response to a plan-mode write confirmation prompt. + * Called from browser via execPeer("answerPlanModeWriteConfirm", {approved}). + */ +exports.answerPlanModeWriteConfirm = async function (params) { + if (_planModeConfirmResolve) { + _planModeConfirmResolve(params); + _planModeConfirmResolve = null; + } + return { success: true }; +}; + +/** + * Apply a mid-stream permission-mode change so hooks running for the rest + * of the turn use the new value. Called from the browser when the user + * cycles the permission bar (so e.g. Bash stops prompting immediately + * after switching from Edit Mode to Full Auto). The next sendPrompt also + * passes permissionMode in params, so this peer is only strictly required + * during streaming — but calling it on every cycle keeps the agent's + * tracker authoritative. + */ +exports.setPermissionMode = async function (params) { + if (params && typeof params.mode === "string") { + _runtimePermissionMode = params.mode; + } + return { success: true }; +}; + /** * Resume a previous session by setting the session ID. * The next sendPrompt call will use queryOptions.resume with this session ID. @@ -387,9 +509,21 @@ exports.clearClarification = async function () { /** * Internal: run a Claude SDK query and stream results back to the browser. */ -async function _runQuery(requestId, prompt, projectPath, model, signal, locale, images, envOverrides, permissionMode) { +async function _runQuery(requestId, prompt, projectPath, model, signal, locale, images, envOverrides, permissionMode, additionalDirectories) { + // Sync the runtime mutable that hooks read for permission decisions — + // setPermissionMode (peer) updates this same variable when the user + // cycles modes mid-stream. + _runtimePermissionMode = permissionMode || "acceptEdits"; let editCount = 0; let toolCounter = 0; + // SDK tool_use id (e.g. "toolu_01...") → our sequential toolCounter so a + // tool_result block can be mapped back to its indicator on the browser. + const _toolUseIdToCounter = {}; + // Set true once the user clicks "Allow & Switch to Edit Mode" on a + // plan-mode write confirmation. Subsequent Edit/Write attempts in the same + // turn skip the prompt and use the cached "allow" decision so a multi-edit + // turn doesn't pop a dialog before every edit. + let _planExitApprovedThisTurn = false; let queryFn; let connectionTimer = null; @@ -422,10 +556,44 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } let _lastStderrLines = []; - const MAX_STDERR_LINES = 20; + const MAX_STDERR_LINES = 50; + let _hookErrorBuffer = ""; + let _hookErrorTimer = null; + const HOOK_ERROR_FLUSH_MS = 200; + + function _flushHookError() { + if (_hookErrorBuffer) { + const trimmed = _hookErrorBuffer.trim(); + console.error("[AI hook callback error] SDK threw delivering hook payload" + + " — tool likely ran natively in acceptEdits mode:\n" + trimmed); + try { + nodeConnector.triggerPeer("aiHookError", { + requestId: requestId, + error: trimmed + }); + } catch (e) { /* peer may be gone — ignore */ } + _hookErrorBuffer = ""; + } + _hookErrorTimer = null; + } + + // Validate the user-attached extra directories the browser sent. + // Drop entries that aren't absolute, don't exist, or duplicate cwd. + // Returns undefined for empty results so the SDK ignores the option + // rather than seeing a literal []. Each sendPrompt rebuilds this + // list, so adding/removing in the UI takes effect on the next turn. + const _cwdForValidation = projectPath || process.cwd(); + const validatedExtraDirs = (Array.isArray(additionalDirectories) + ? additionalDirectories.filter(function (p) { + if (typeof p !== "string" || !path.isAbsolute(p)) { return false; } + if (p === _cwdForValidation) { return false; } + try { return fs.existsSync(p); } catch (e) { return false; } + }) + : []); const queryOptions = { cwd: projectPath || process.cwd(), + additionalDirectories: validatedExtraDirs.length ? validatedExtraDirs : undefined, maxTurns: undefined, stderr: (data) => { console.log("[AI stderr]", data); @@ -433,6 +601,14 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, if (_lastStderrLines.length > MAX_STDERR_LINES) { _lastStderrLines.shift(); } + // Collect consecutive lines belonging to a hook callback error so + // we can log the full burst as one block. The SDK fragments the + // error across multiple stderr writes which is hard to read. + if (_hookErrorBuffer || /Error in hook callback/.test(data)) { + _hookErrorBuffer += data + "\n"; + clearTimeout(_hookErrorTimer); + _hookErrorTimer = setTimeout(_flushHookError, HOOK_ERROR_FLUSH_MS); + } }, allowedTools: [ "Read", "Edit", "Write", "Glob", "Grep", "Bash", @@ -522,7 +698,13 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, content = fs.readFileSync(input.tool_input.file_path, "utf8"); } if (input.tool_input.old_string && input.tool_input.new_string) { - content = content.replace(input.tool_input.old_string, input.tool_input.new_string); + if (input.tool_input.replace_all === true) { + content = content.split(input.tool_input.old_string) + .join(input.tool_input.new_string); + } else { + content = content.replace(input.tool_input.old_string, + input.tool_input.new_string); + } } const dir = path.dirname(input.tool_input.file_path); if (!fs.existsSync(dir)) { @@ -546,45 +728,106 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } }; } - const myToolId = toolCounter; // capture before any await - const edit = { - file: input.tool_input.file_path, - oldText: input.tool_input.old_string, - newText: input.tool_input.new_string - }; - editCount++; - let editResult; + // Plan mode + user-file Edit: ask the user whether + // to switch to Edit Mode. Mirrors the Bash confirm + // pattern (matcher: "Bash"). Once approved, the + // _planExitApprovedThisTurn flag suppresses the + // prompt for subsequent edits in the same turn. + const filePath = input.tool_input.file_path; + if (permissionMode === "plan" && !_planExitApprovedThisTurn) { + nodeConnector.triggerPeer("aiPlanModeWriteConfirm", { + requestId: requestId, + toolName: "Edit", + filePath: filePath + }); + let response; + try { + response = await new Promise((resolve, reject) => { + _planModeConfirmResolve = resolve; + if (signal.aborted) { + _planModeConfirmResolve = null; + reject(new Error("Aborted")); + return; + } + const onAbort = () => { + _planModeConfirmResolve = null; + reject(new Error("Aborted")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); + } catch (err) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "Edit cancelled." + } + }; + } + if (!response.approved) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "User chose to stay in Plan Mode. " + + "Use the ExitPlanMode tool to propose your changes for " + + "approval before editing." + } + }; + } + _planExitApprovedThisTurn = true; + } + // New flow: flush dirty buffer to disk so SDK reads + // the latest content, capture pre-edit content for + // snapshot tracking, then return {} (or "allow" if + // we're auto-exiting plan mode) so SDK runs native + // Edit on disk. Its mtime/read tracker stays + // consistent and the next Edit won't trip the + // "modified since read" safety check. + const oldString = input.tool_input.old_string; + let captured = { content: "" }; try { - editResult = await nodeConnector.execPeer("applyEditToBuffer", edit); + await nodeConnector.execPeer("saveBufferToDisk", { filePath }); + captured = await nodeConnector.execPeer( + "captureFileContent", { filePath }) || captured; } catch (err) { - console.warn("[Phoenix AI] Failed to apply edit to buffer:", err.message); - editResult = { applied: false, error: err.message }; + console.warn("[Phoenix AI] Edit prep failed:", filePath, err.message); } - nodeConnector.triggerPeer("aiToolEdit", { - requestId: requestId, - toolId: myToolId, - edit: edit - }); - let reason; - if (editResult && editResult.applied === false) { - reason = "Edit FAILED: " + (editResult.error || "unknown error"); - } else { - reason = "Edit applied successfully via Phoenix editor."; - if (editResult && editResult.isLivePreviewRelated) { - reason += " The edited file is part of the active live preview." + - " Reload when ready with execJsInLivePreview: `location.reload()`"; + // Pre-check: if the text to replace is no longer in + // the file (user typed/changed it since the last + // Read), deny with an informative reason instead of + // letting the SDK fail with a generic "oldText not + // found". Phoenix sees the buffer state the SDK + // can't, so this is a more useful failure. + if (oldString && (captured.content || "").indexOf(oldString) === -1) { + let reason = "Edit FAILED: the text you wanted to replace is not " + + "present in the file. It may have been modified by the user " + + "or by another tool since you last read it. Read the file again " + + "to see the current content before retrying."; + if (_queuedClarification) { + reason += CLARIFICATION_HINT; } + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: reason + } + }; } - if (_queuedClarification) { - reason += CLARIFICATION_HINT; + editCount++; + // In plan mode, after the user approved the + // confirmation prompt, we need an explicit "allow" + // to override the SDK's default plan-mode block. + if (permissionMode === "plan") { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow" + } + }; } - return { - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: reason - } - }; + return {}; } ] }, @@ -592,44 +835,20 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, matcher: "Read", hooks: [ async (input) => { - if (!input || !input.tool_input) { - return {}; - } - const filePath = input.tool_input.file_path; - if (!filePath) { + if (!input || !input.tool_input || !input.tool_input.file_path) { return {}; } + // Flush dirty buffer to disk so the SDK's native + // Read sees what the user is actually looking at. + // Returning {} lets the SDK run native Read so its + // read-tracker updates — required to avoid "file + // not read yet" rejections on subsequent edits. try { - const result = await nodeConnector.execPeer("getFileContent", { filePath }); - if (result && result.isDirty && result.content !== null) { - const MAX_LINES = 2000; - const MAX_LINE_LENGTH = 2000; - const lines = result.content.split("\n"); - const offset = input.tool_input.offset || 0; - const limit = input.tool_input.limit || MAX_LINES; - const selected = lines.slice(offset, offset + limit); - let formatted = selected.map((line, i) => { - const truncated = line.length > MAX_LINE_LENGTH - ? line.slice(0, MAX_LINE_LENGTH) + "..." - : line; - return String(offset + i + 1).padStart(6) + "\t" + truncated; - }).join("\n"); - formatted = filePath + " (" + - lines.length + " lines total)\n\n" + formatted; - console.log("[Phoenix AI] Serving dirty file content for:", filePath); - if (_queuedClarification) { - formatted += CLARIFICATION_HINT; - } - return { - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: formatted - } - }; - } + await nodeConnector.execPeer("saveBufferToDisk", + { filePath: input.tool_input.file_path }); } catch (err) { - console.warn("[Phoenix AI] Failed to check dirty state:", filePath, err.message); + console.warn("[Phoenix AI] Read prep failed:", + input.tool_input.file_path, err.message); } return {}; } @@ -671,45 +890,71 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } }; } - const myToolId = toolCounter; // capture before any await - const edit = { - file: input.tool_input.file_path, - oldText: null, - newText: input.tool_input.content - }; - editCount++; - let writeResult; + // Plan mode + user-file Write: same confirmation + // path as Edit. See Edit hook above for rationale. + const filePath = input.tool_input.file_path; + if (permissionMode === "plan" && !_planExitApprovedThisTurn) { + nodeConnector.triggerPeer("aiPlanModeWriteConfirm", { + requestId: requestId, + toolName: "Write", + filePath: filePath + }); + let response; + try { + response = await new Promise((resolve, reject) => { + _planModeConfirmResolve = resolve; + if (signal.aborted) { + _planModeConfirmResolve = null; + reject(new Error("Aborted")); + return; + } + const onAbort = () => { + _planModeConfirmResolve = null; + reject(new Error("Aborted")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); + } catch (err) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "Write cancelled." + } + }; + } + if (!response.approved) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "User chose to stay in Plan Mode. " + + "Use the ExitPlanMode tool to propose your changes for " + + "approval before writing." + } + }; + } + _planExitApprovedThisTurn = true; + } + // Mirror Edit: flush dirty buffer, capture pre-write + // content, return {} (or "allow" in plan mode) so + // SDK writes natively. try { - writeResult = await nodeConnector.execPeer("applyEditToBuffer", edit); + await nodeConnector.execPeer("saveBufferToDisk", { filePath }); + await nodeConnector.execPeer("captureFileContent", { filePath }); } catch (err) { - console.warn("[Phoenix AI] Failed to apply write to buffer:", err.message); - writeResult = { applied: false, error: err.message }; + console.warn("[Phoenix AI] Write prep failed:", filePath, err.message); } - nodeConnector.triggerPeer("aiToolEdit", { - requestId: requestId, - toolId: myToolId, - edit: edit - }); - let reason; - if (writeResult && writeResult.applied === false) { - reason = "Write FAILED: " + (writeResult.error || "unknown error"); - } else { - reason = "Write applied successfully via Phoenix editor."; - if (writeResult && writeResult.isLivePreviewRelated) { - reason += " The written file is part of the active live preview." + - " Reload when ready with execJsInLivePreview: `location.reload()`"; - } - } - if (_queuedClarification) { - reason += CLARIFICATION_HINT; + editCount++; + if (permissionMode === "plan") { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow" + } + }; } - return { - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: reason - } - }; + return {}; } ] }, @@ -717,12 +962,30 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, matcher: "Bash", hooks: [ async (input) => { - if (permissionMode !== "acceptEdits") { + // Read from the runtime mutable so mid-stream + // permission-mode flips (e.g. user switches Edit + // Mode → Full Auto while bash is in flight) take + // effect on the NEXT bash call without waiting + // for the next prompt. + if (_runtimePermissionMode !== "acceptEdits") { // Plan mode: SDK handles. Full Auto: allow freely. return {}; } // Edit Mode: ask user confirmation before running bash const command = input.tool_input.command || ""; + // Skip prompting for well-known read-only commands + // that mirror the Claude Code CLI's default safe + // patterns. Cuts down on prompt fatigue during + // typical "look around the repo" turns. + if (_isSafeReadOnlyBash(command)) { + console.log("[Phoenix AI] Auto-allowing safe bash:", command.slice(0, 80)); + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow" + } + }; + } console.log("[Phoenix AI] Bash confirmation requested:", command.slice(0, 80)); nodeConnector.triggerPeer("aiBashConfirm", { requestId: requestId, @@ -797,10 +1060,140 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } ] } + ], + PostToolUse: [ + { + matcher: "Edit", + hooks: [ + async (input, toolUseID) => { + const filePath = input && input.tool_input && input.tool_input.file_path; + if (!filePath) { return {}; } + // Plan files don't go through the editor + if (filePath.replace(/\\/g, "/").includes("/.claude/plans/")) { + return {}; + } + // If the SDK's native Edit itself failed (e.g. + // oldText not found on disk), don't paint a diff + // card. The existing aiToolResult flow will + // classify the indicator from the tool_result. + if (_isToolResponseError(input.tool_response)) { + return {}; + } + const editPayload = { + file: filePath, + oldText: input.tool_input.old_string, + newText: input.tool_input.new_string, + replaceAll: input.tool_input.replace_all === true + }; + // 1. Prefer applying the edit directly to the open + // buffer via doc.replaceRange — preserves + // CodeMirror marks outside the edit region (live + // preview HTML element marks). Falls back to a + // full refreshDocumentFromDisk if no doc is open + // or the buffer no longer contains old_string + // (e.g. user typed since save). + let result = {}; + try { + result = await nodeConnector.execPeer( + "applyEditToOpenBufferOnly", editPayload) || {}; + } catch (err) { + console.warn("[Phoenix AI] applyEditToOpenBufferOnly failed:", filePath, err.message); + } + if (!result.applied) { + try { + result = await nodeConnector.execPeer( + "refreshDocumentFromDisk", { filePath }) || result; + } catch (err) { + console.warn("[Phoenix AI] Edit refresh fallback failed:", filePath, err.message); + } + } + // 2. Trigger aiToolEdit so the AI panel renders the + // diff card and the snapshot store records it. + const counterId = _toolUseIdToCounter[toolUseID]; + if (counterId !== undefined) { + editPayload.isLivePreviewRelated = !!result.isLivePreviewRelated; + nodeConnector.triggerPeer("aiToolEdit", { + requestId: requestId, + toolId: counterId, + edit: editPayload + }); + } + // Catch-all PostToolUse below handles clarification. + return {}; + } + ] + }, + { + matcher: "Write", + hooks: [ + async (input, toolUseID) => { + const filePath = input && input.tool_input && input.tool_input.file_path; + if (!filePath) { return {}; } + if (filePath.replace(/\\/g, "/").includes("/.claude/plans/")) { + return {}; + } + if (_isToolResponseError(input.tool_response)) { + return {}; + } + let refreshResult = {}; + try { + refreshResult = await nodeConnector.execPeer( + "refreshDocumentFromDisk", { filePath }) || {}; + } catch (err) { + console.warn("[Phoenix AI] Write refresh failed:", filePath, err.message); + } + const counterId = _toolUseIdToCounter[toolUseID]; + if (counterId !== undefined) { + nodeConnector.triggerPeer("aiToolEdit", { + requestId: requestId, + toolId: counterId, + edit: { + file: filePath, + oldText: null, + newText: input.tool_input.content, + isLivePreviewRelated: !!refreshResult.isLivePreviewRelated + } + }); + } + // Catch-all PostToolUse below handles clarification. + return {}; + } + ] + }, + { + // Catch-all: surface a queued user follow-up after every + // tool. Edit/Write/Read have their own hooks above, but + // any tool can be a meaningful checkpoint (Bash, Grep, + // Glob, WebFetch, Task, the Phoenix MCP tools, etc.) so + // we register one matcher-less hook that just returns + // the clarification context if any is queued. Once + // getUserClarification runs and clears _queuedClarification, + // _maybeClarifyContext returns {} and this becomes a no-op. + hooks: [ + async () => { + return _maybeClarifyContext(); + } + ] + } ] } }; + // Returns a PostToolUse SyncHookJSONOutput that injects the clarification + // hint as additionalContext when the user has typed a follow-up while the + // AI is streaming. With our PreToolUse hooks now returning {} (allow), the + // old practice of appending CLARIFICATION_HINT to permissionDecisionReason + // no longer reaches Claude — PostToolUse additionalContext is the new path. + function _maybeClarifyContext() { + if (!_queuedClarification) { return {}; } + return { + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext: CLARIFICATION_HINT + } + }; + } + // Set Claude CLI path if found const claudePath = findGlobalClaudeCli(); if (claudePath) { @@ -1031,6 +1424,11 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, toolDeltaCount = 0; toolStreamSendCount = 0; lastToolStreamTime = 0; + // Map the SDK's tool_use id → our toolCounter so we can + // correlate later tool_result blocks back to the indicator. + if (event.content_block.id) { + _toolUseIdToCounter[event.content_block.id] = toolCounter; + } _log("Tool start:", activeToolName, "#" + toolCounter); nodeConnector.triggerPeer("aiProgress", { requestId: requestId, @@ -1151,6 +1549,47 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } } } + + // Tool results come back as user-typed messages with content blocks + // of type tool_result. Log isError + content size so we can correlate + // a "Tool done" (input stream) with what Claude actually saw as the reply. + if (message.type === "user" && message.message && Array.isArray(message.message.content)) { + for (const block of message.message.content) { + if (block && block.type === "tool_result") { + let len = 0; + let preview = ""; + if (typeof block.content === "string") { + len = block.content.length; + preview = block.content.slice(0, 120); + } else if (Array.isArray(block.content)) { + for (const c of block.content) { + if (c && c.type === "text" && typeof c.text === "string") { + len += c.text.length; + if (!preview) { preview = c.text.slice(0, 120); } + } else if (c && c.type === "image" && typeof c.data === "string") { + len += c.data.length; + if (!preview) { preview = "[image " + c.data.length + "ch]"; } + } + } + } + _log("Tool result:", block.tool_use_id || "?", + "isError=" + !!block.is_error, + "len=" + len + "ch", + preview ? ("preview=" + JSON.stringify(preview)) : ""); + // Forward the result so the browser can reflect outcome + // on the corresponding tool indicator (errored vs ran). + const counterId = _toolUseIdToCounter[block.tool_use_id]; + if (counterId !== undefined) { + nodeConnector.triggerPeer("aiToolResult", { + requestId: requestId, + toolId: counterId, + isError: !!block.is_error, + preview: preview + }); + } + } + } + } } // Flush any remaining accumulated text @@ -1219,13 +1658,11 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, return; } _log("Cancelled"); - // Send sessionId so browser side can save partial history for later resume - const cancelledSessionId = currentSessionId; - // Clear session so next query starts fresh - currentSessionId = null; + // Keep currentSessionId so the next prompt can resume the same SDK + // session — the abort just leaves an interrupt marker in the log. nodeConnector.triggerPeer("aiComplete", { requestId: requestId, - sessionId: cancelledSessionId + sessionId: currentSessionId }); return; } @@ -1251,8 +1688,9 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } } - // Clear session after error to prevent cascading failures from resuming a broken session - currentSessionId = null; + // Keep currentSessionId so the user can retry — errors are often + // transient (network, rate limit), and if the session really is broken + // the next attempt will surface a fresh error of its own. nodeConnector.triggerPeer("aiError", { requestId: requestId, @@ -1262,7 +1700,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, // Always send aiComplete after aiError so the UI exits streaming state nodeConnector.triggerPeer("aiComplete", { requestId: requestId, - sessionId: null + sessionId: currentSessionId }); } } diff --git a/src-node/mcp-editor-tools.js b/src-node/mcp-editor-tools.js index 17b9ca4e57..4896a598f1 100644 --- a/src-node/mcp-editor-tools.js +++ b/src-node/mcp-editor-tools.js @@ -35,6 +35,36 @@ const CLARIFICATION_HINT = "IMPORTANT: The user has typed a follow-up clarification while you were working." + " Call the getUserClarification tool to read it before proceeding."; +// Per-tool safety-net budgets for the browser round-trip. The node connector +// is reliable in practice, so these should never fire during normal use — +// they exist so a stalled promise chain (live preview wedged, etc.) surfaces +// a deterministic error to Claude instead of the handler hanging forever. +// Tools whose runtime is bounded by user-supplied code (execJsInLivePreview) +// intentionally have no timeout — the code is allowed to run as long as it takes. +const EXEC_PEER_TIMEOUT_MS = { + getEditorState: 5000, + takeScreenshot: 15000, + controlEditor: 5000, + resizeLivePreview: 5000 +}; + +function _execPeerWithTimeout(nodeConnector, fn, args, label) { + const ms = EXEC_PEER_TIMEOUT_MS[fn]; + const call = nodeConnector.execPeer(fn, args); + if (!ms) { + return call; // no timeout configured for this tool + } + let timer; + const timeout = new Promise(function (_resolve, reject) { + timer = setTimeout(function () { + reject(new Error(label + " timed out after " + ms + "ms")); + }, ms); + }); + return Promise.race([call, timeout]).finally(function () { + clearTimeout(timer); + }); +} + /** * Append a clarification hint to an MCP tool result if the user has queued a message. */ @@ -70,7 +100,7 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) async function () { let result; try { - const state = await nodeConnector.execPeer("getEditorState", {}); + const state = await _execPeerWithTimeout(nodeConnector, "getEditorState", {}, "getEditorState"); result = { content: [{ type: "text", text: JSON.stringify(state) }] }; @@ -107,11 +137,11 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) async function (args) { let toolResult; try { - const result = await nodeConnector.execPeer("takeScreenshot", { + const result = await _execPeerWithTimeout(nodeConnector, "takeScreenshot", { selector: args.selector || undefined, purePreview: args.purePreview || false, filePath: args.filePath || undefined - }); + }, "takeScreenshot"); if (result.filePath) { toolResult = { content: [{ type: "text", text: "Screenshot saved to: " + result.filePath }] @@ -148,9 +178,9 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) async function (args) { let toolResult; try { - const result = await nodeConnector.execPeer("execJsInLivePreview", { + const result = await _execPeerWithTimeout(nodeConnector, "execJsInLivePreview", { code: args.code - }); + }, "execJsInLivePreview"); if (result.error) { toolResult = { content: [{ type: "text", text: "Error: " + result.error }], @@ -202,7 +232,7 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) for (const op of args.operations) { console.log("[Phoenix AI] controlEditor:", op.operation, op.filePath); try { - const result = await nodeConnector.execPeer("controlEditor", op); + const result = await _execPeerWithTimeout(nodeConnector, "controlEditor", op, "controlEditor:" + op.operation); results.push(result); if (!result.success) { hasError = true; @@ -234,9 +264,9 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors) async function (args) { let toolResult; try { - const result = await nodeConnector.execPeer("resizeLivePreview", { + const result = await _execPeerWithTimeout(nodeConnector, "resizeLivePreview", { width: args.width - }); + }, "resizeLivePreview"); if (result.error) { toolResult = { content: [{ type: "text", text: "Error: " + result.error }], diff --git a/src-node/package-lock.json b/src-node/package-lock.json index af90c10333..fb219ba887 100644 --- a/src-node/package-lock.json +++ b/src-node/package-lock.json @@ -1,12 +1,12 @@ { "name": "@phcode/node-core", - "version": "5.1.7-0", + "version": "5.1.8-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@phcode/node-core", - "version": "5.1.7-0", + "version": "5.1.8-0", "hasInstallScript": true, "license": "GNU-AGPL3.0", "dependencies": { diff --git a/src-node/package.json b/src-node/package.json index a291056ad5..23760bb9ae 100644 --- a/src-node/package.json +++ b/src-node/package.json @@ -1,8 +1,8 @@ { "name": "@phcode/node-core", "description": "Phoenix Node Core", - "version": "5.1.7-0", - "apiVersion": "5.1.7", + "version": "5.1.8-0", + "apiVersion": "5.1.8", "keywords": [], "author": "arun@core.ai", "homepage": "https://github.com/phcode-dev/phoenix", diff --git a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js index 81964e71c5..5fe5e77665 100644 --- a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js +++ b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js @@ -918,7 +918,9 @@ function RemoteFunctions(config = {}) { const nodes = window.document.querySelectorAll(rule); - // Highlight all matching nodes + // Highlight all matching nodes. selectElement() will narrow _clickHighlight + // down to the chosen element below; createCssSelectorHighlight() then + // re-highlights the siblings in a separate overlay. for (let i = 0; i < nodes.length; i++) { highlight(nodes[i]); } @@ -927,22 +929,23 @@ function RemoteFunctions(config = {}) { _clickHighlight.selector = rule; } - // In edit mode, select the best element and create temporary highlights for the rest. - // In highlight mode, skip selection so all matching elements stay highlighted equally. - if (config.mode === 'edit') { - const { element, skipSelection } = findBestElementToSelect(nodes, rule); - - if (!skipSelection) { - if (element) { - selectElement(element, true); - } else { - // No valid element found, dismiss UI - dismissUIAndCleanupState(); - } - } + // Both edit and highlight modes go through the same selection path: + // selectElement() handles scroll-to-view and the prominent click-highlight, + // createCssSelectorHighlight() shows siblings dimly. fromEditor=true + // suppresses tool-handler invocation, so highlight mode gets the + // highlighting/scroll behavior without any UI boxes. + const { element, skipSelection } = findBestElementToSelect(nodes, rule); - createCssSelectorHighlight(nodes, rule); + if (!skipSelection) { + if (element) { + selectElement(element, true); + } else { + // No valid element found, dismiss UI + dismissUIAndCleanupState(); + } } + + createCssSelectorHighlight(nodes, rule); } // recreate UI boxes so that they are placed properly diff --git a/src/assets/phoenix-splash/live-preview-error.html b/src/assets/phoenix-splash/live-preview-error.html index a8b73d5bb5..eed67140e5 100644 --- a/src/assets/phoenix-splash/live-preview-error.html +++ b/src/assets/phoenix-splash/live-preview-error.html @@ -5,14 +5,40 @@ @@ -23,9 +49,9 @@
-

Uh Oh!
Your current browser doesn't support live preview.

+

Uh Oh!
Your current browser doesn't support live preview.

- Get the best live preview experience by downloading our native apps for Windows, Mac, and Linux from phcode.io.
+ Get the best live preview experience by downloading our native apps for Windows, Mac, and Linux from phcode.io.

diff --git a/src/config.json b/src/config.json index cf9d4479ca..14a63e04c1 100644 --- a/src/config.json +++ b/src/config.json @@ -46,8 +46,8 @@ "bugsnagEnv": "development" }, "name": "Phoenix Code", - "version": "5.1.7-0", - "apiVersion": "5.1.7", + "version": "5.1.8-0", + "apiVersion": "5.1.8", "homepage": "https://core.ai", "issues": { "url": "https://github.com/phcode-dev/phoenix/issues" diff --git a/src/document/DocumentCommandHandlers.js b/src/document/DocumentCommandHandlers.js index 7292015592..b7c2636446 100644 --- a/src/document/DocumentCommandHandlers.js +++ b/src/document/DocumentCommandHandlers.js @@ -54,6 +54,7 @@ define(function (require, exports, module) { Menus = require("command/Menus"), UrlParams = require("utils/UrlParams").UrlParams, StatusBar = require("widgets/StatusBar"), + ViewUtils = require("utils/ViewUtils"), WorkspaceManager = require("view/WorkspaceManager"), LanguageManager = require("language/LanguageManager"), NewFileContentManager = require("features/NewFileContentManager"), @@ -61,6 +62,7 @@ define(function (require, exports, module) { NodeUtils = require("utils/NodeUtils"), ChangeHelper = require("editor/EditorHelper/ChangeHelper"), SidebarTabs = require("view/SidebarTabs"), + SidebarView = require("project/SidebarView"), _ = require("thirdparty/lodash"); const KernalModeTrust = window.KernalModeTrust; @@ -1953,11 +1955,20 @@ define(function (require, exports, module) { function handleShowInTree() { let activeFile = MainViewManager.getCurrentlyViewedFile(MainViewManager.ACTIVE_PANE); if(activeFile){ - if (!$("#sidebar").is(":visible")) { + if (!SidebarView.isVisible()) { CommandManager.execute(Commands.VIEW_HIDE_SIDEBAR); } SidebarTabs.setActiveTab(SidebarTabs.SIDEBAR_TAB_FILES); - ProjectManager.showInTree(activeFile); + // FileTreeView only auto-scrolls when the selection flips unselected→selected. + // Re-invoking the command on an already-selected file would otherwise be a + // no-op when the user has scrolled away — force-scroll the selected node + // into view so "Show in File Tree" always reveals the row. + ProjectManager.showInTree(activeFile).always(function () { + const $selected = $("#project-files-container .selected-node").first(); + if ($selected.length) { + ViewUtils.scrollElementIntoView($("#project-files-container"), $selected, true); + } + }); } } diff --git a/src/editor/Editor.js b/src/editor/Editor.js index c19540a58f..3564a813c2 100644 --- a/src/editor/Editor.js +++ b/src/editor/Editor.js @@ -717,7 +717,8 @@ define(function (require, exports, module) { * @param {!string} text */ Editor.prototype._resetText = function (text) { - var currentText = this._codeMirror.getValue(); + var cm = this._codeMirror; + var currentText = cm.getValue(); // compare with ignoring line-endings, issue #11826 var textLF = text ? text.replace(/(\r\n|\r|\n)/g, "\n") : null; @@ -732,14 +733,51 @@ define(function (require, exports, module) { var cursorPos = this.getCursorPos(), scrollPos = this.getScrollPos(); - // This *will* fire a change event, but we clear the undo immediately afterward - this._codeMirror.setValue(text); - this._codeMirror.refresh(); + // First-time content load (e.g. opening a file): there's no useful + // undo state to preserve — fall back to setValue + clearHistory so + // the user can't ctrl-z into the empty doc that existed before open. + var isInitialLoad = currentText === "" && cm.historySize().undo === 0; - // Make sure we can't undo back to the empty state before setValue(), and mark - // the document clean. - this._codeMirror.clearHistory(); - this._codeMirror.markClean(); + if (isInitialLoad) { + cm.setValue(text); + cm.refresh(); + cm.clearHistory(); + } else { + // External-content reload (disk change, AI edit, git checkout, etc.): + // replace ONLY the differing middle of the document so the change + // is undoable AND CodeMirror marks outside the changed region are + // preserved. The latter matters for HTML files used in live + // preview (each rendered element holds a mark) — replacing the + // whole doc would clear every mark and force a full preview + // refresh on every edit. + // + // O(n) common prefix + suffix scan: cheap enough to apply to all + // languages, and AI edits are typically tiny relative to file size. + var prefixLen = 0; + var minLen = Math.min(currentText.length, text.length); + while (prefixLen < minLen && + currentText.charCodeAt(prefixLen) === text.charCodeAt(prefixLen)) { + prefixLen++; + } + var maxSuffix = Math.min(currentText.length - prefixLen, text.length - prefixLen); + var suffixLen = 0; + while (suffixLen < maxSuffix && + currentText.charCodeAt(currentText.length - 1 - suffixLen) === + text.charCodeAt(text.length - 1 - suffixLen)) { + suffixLen++; + } + var fromPos = cm.posFromIndex(prefixLen); + var toPos = cm.posFromIndex(currentText.length - suffixLen); + var middle = text.substring(prefixLen, text.length - suffixLen); + cm.operation(function () { + cm.replaceRange(middle, fromPos, toPos, "+disk"); + }); + cm.refresh(); + } + // markClean sets the new "saved" generation — disk == buffer right + // now, so dirty=false. Undoing past this generation will re-mark + // dirty, exactly like a manual edit. + cm.markClean(); // restore cursor and scroll positions this.setCursorPos(cursorPos); diff --git a/src/editor/EditorCommandHandlers.js b/src/editor/EditorCommandHandlers.js index df76575eb6..7a8aa3669d 100644 --- a/src/editor/EditorCommandHandlers.js +++ b/src/editor/EditorCommandHandlers.js @@ -33,6 +33,7 @@ define(function (require, exports, module) { Editor = require("editor/Editor").Editor, CommandManager = require("command/CommandManager"), EditorManager = require("editor/EditorManager"), + WorkspaceManager = require("view/WorkspaceManager"), StringUtils = require("utils/StringUtils"), TokenUtils = require("utils/TokenUtils"), CodeMirror = require("thirdparty/CodeMirror/lib/codemirror"), @@ -1185,6 +1186,16 @@ define(function (require, exports, module) { var editor = EditorManager.getFocusedEditor(); var result = new $.Deferred(); + // In design mode the active document often lives behind an external + // surface that owns DOM focus (e.g. the markdown viewer iframe in + // edit mode), so getFocusedEditor() returns null even though there + // is an underlying Phoenix editor whose doc captures the changes. + // Fall back to the active-pane full editor so toolbar undo/redo + // still drives the document's history in that state. + if (!editor && WorkspaceManager.isInDesignMode()) { + editor = EditorManager.getCurrentFullEditor(); + } + if (editor) { editor[operation](); result.resolve(); diff --git a/src/extensions/default/Git/src/Panel.js b/src/extensions/default/Git/src/Panel.js index 41a509cbb5..d86d19692b 100644 --- a/src/extensions/default/Git/src/Panel.js +++ b/src/extensions/default/Git/src/Panel.js @@ -1358,7 +1358,16 @@ define(function (require, exports) { // Show gitPanel when appropriate if (Preferences.get("panelEnabled") && Setup.isExtensionActivated()) { - toggle(true); + // If the bottom panel container is collapsed, just add the Git tab + // without forcing it open. The user collapsed it intentionally. + const $container = $("#bottom-panel-container"); + if ($container.is(":visible")) { + toggle(true); + } else { + gitPanel.addToTabBar(); + $("#git-toolbar-icon").removeClass("forced-hidden"); + refresh(); + } } _panelResized(); GutterManager.init(); @@ -1510,6 +1519,23 @@ define(function (require, exports) { CommandManager.get(Constants.CMD_GIT_TOGGLE_PANEL).setChecked(false); Preferences.set("panelEnabled", false); } + // When the bottom panel container is collapsed, deselect the icon + // but don't save preference — the panel is still logically open. + if (panelID === WorkspaceManager.DEFAULT_PANEL_ID && Main.$icon) { + Main.$icon.toggleClass("on", false); + Main.$icon.toggleClass("selected-button", false); + CommandManager.get(Constants.CMD_GIT_TOGGLE_PANEL).setChecked(false); + } + }); + + // When any bottom panel is shown (container is visible), + // re-select the git icon if git panel is still open. + WorkspaceManager.on(WorkspaceManager.EVENT_WORKSPACE_PANEL_SHOWN, function (event, panelID) { + if (Main.$icon && Preferences.get("panelEnabled")) { + Main.$icon.toggleClass("on", true); + Main.$icon.toggleClass("selected-button", true); + CommandManager.get(Constants.CMD_GIT_TOGGLE_PANEL).setChecked(true); + } }); exports.init = init; diff --git a/src/extensionsIntegrated/CustomSnippets/main.js b/src/extensionsIntegrated/CustomSnippets/main.js index 3235942cba..e88122f448 100644 --- a/src/extensionsIntegrated/CustomSnippets/main.js +++ b/src/extensionsIntegrated/CustomSnippets/main.js @@ -255,14 +255,33 @@ define(function (require, exports, module) { }); } + // Track whether the snippets panel has an open tab (survives container collapse). + let _snippetsTabOpen = false; + // When the panel tab is closed externally (e.g. via the × button), // update the menu checked state to stay in sync. WorkspaceManager.on(WorkspaceManager.EVENT_WORKSPACE_PANEL_HIDDEN, function (event, panelID) { if (panelID === PANEL_ID && customSnippetsPanel) { + _snippetsTabOpen = false; + CommandManager.get(MY_COMMAND_ID).setChecked(false); + } + // Container collapsed — uncheck menu item but keep tab-open flag + if (panelID === WorkspaceManager.DEFAULT_PANEL_ID) { CommandManager.get(MY_COMMAND_ID).setChecked(false); } }); + // When any bottom panel is shown (container is visible), + // re-check menu item if snippets panel still has an open tab. + WorkspaceManager.on(WorkspaceManager.EVENT_WORKSPACE_PANEL_SHOWN, function (event, panelID) { + if (panelID === PANEL_ID) { + _snippetsTabOpen = true; + } + if (_snippetsTabOpen) { + CommandManager.get(MY_COMMAND_ID).setChecked(true); + } + }); + AppInit.appReady(function () { CommandManager.register(MENU_ITEM_NAME, MY_COMMAND_ID, showCustomSnippetsPanel); // Render template with localized strings diff --git a/src/extensionsIntegrated/DisplayShortcuts/main.js b/src/extensionsIntegrated/DisplayShortcuts/main.js index 3c71d6b0c2..330ccb4225 100644 --- a/src/extensionsIntegrated/DisplayShortcuts/main.js +++ b/src/extensionsIntegrated/DisplayShortcuts/main.js @@ -539,14 +539,33 @@ define(function (require, exports, module) { KeyBindingManager.on(KeyBindingManager.EVENT_NEW_PRESET, _updatePresets); KeyBindingManager.on(KeyBindingManager.EVENT_PRESET_CHANGED, _updatePresets); + // Track whether the shortcuts panel has an open tab (survives container collapse). + let _shortcutsTabOpen = false; + // When the panel tab is closed externally (e.g. via the × button), // update the menu checked state and clean up resources. WorkspaceManager.on(WorkspaceManager.EVENT_WORKSPACE_PANEL_HIDDEN, function (event, panelID) { if (panelID === TOGGLE_SHORTCUTS_ID && panel) { + _shortcutsTabOpen = false; destroyKeyList(); _clearSortingEventHandlers(); CommandManager.get(TOGGLE_SHORTCUTS_ID).setChecked(false); } + // Container collapsed — uncheck menu item but keep tab-open flag + if (panelID === WorkspaceManager.DEFAULT_PANEL_ID) { + CommandManager.get(TOGGLE_SHORTCUTS_ID).setChecked(false); + } + }); + + // When any bottom panel is shown (container is visible), + // re-check menu item if shortcuts panel still has an open tab. + WorkspaceManager.on(WorkspaceManager.EVENT_WORKSPACE_PANEL_SHOWN, function (event, panelID) { + if (panelID === TOGGLE_SHORTCUTS_ID) { + _shortcutsTabOpen = true; + } + if (_shortcutsTabOpen) { + CommandManager.get(TOGGLE_SHORTCUTS_ID).setChecked(true); + } }); }); }); diff --git a/src/extensionsIntegrated/Phoenix-live-preview/live-preview.css b/src/extensionsIntegrated/Phoenix-live-preview/live-preview.css index c1a00d0e26..5b24056f2a 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/live-preview.css +++ b/src/extensionsIntegrated/Phoenix-live-preview/live-preview.css @@ -5,7 +5,7 @@ .live-preview-browser-btn { opacity: 0; visibility: hidden; - transition: opacity 1s, visibility 0s linear 1s; /* Fade-out effect */ + transition: opacity 1s, visibility 0s linear 1s; } #live-preview-plugin-toolbar { @@ -29,6 +29,10 @@ #panel-md-preview-frame { background-color: white; position: relative; + width: calc(100% + 2px); + margin: 0 -1px; + clip-path: inset(0 1px); + min-width: 0; } #panel-live-preview-frame[src*="no-preview.html"], @@ -48,6 +52,154 @@ display: flex; width: 100%; height: calc(100% - var(--toolbar-height)); + overflow: hidden; +} + +.frame-container.responsive-viewport { + position: relative; + justify-content: center; + align-items: stretch; + background: #1a1a1e; +} + +.frame-container.responsive-viewport > div:first-child:not(.responsive-handle) { + display: none; +} + +.responsive-handle { + flex-shrink: 0; + z-index: 5; + background: transparent; + transition: background 0.15s; +} + +.responsive-handle:hover, +.responsive-handle.dragging { + background: rgba(255, 255, 255, 0.08); +} + +.responsive-handle-horizontal { + width: 10px; + cursor: ew-resize; + position: relative; +} + +.responsive-handle-horizontal::before, +.responsive-handle-horizontal::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 1px; + height: 16px; + border-radius: 0.5px; + background: rgba(255, 255, 255, 0.3); +} + +.responsive-handle-horizontal::before { + transform: translate(calc(-50% - 2px), -50%); +} + +.responsive-handle-horizontal::after { + transform: translate(calc(-50% + 2px), -50%); +} + +.responsive-handle-horizontal:hover::before, +.responsive-handle-horizontal:hover::after, +.responsive-handle-horizontal.dragging::before, +.responsive-handle-horizontal.dragging::after { + background: rgba(255, 255, 255, 0.7); +} + +.responsive-handle-left { + opacity: 0; + transition: opacity 0.2s; +} + +.responsive-handle-left:hover, +.responsive-handle-left.dragging { + opacity: 1; +} + +.responsive-handle-vertical { + position: absolute; + height: 10px; + cursor: ns-resize; + box-sizing: border-box; +} + +.responsive-handle-vertical::before, +.responsive-handle-vertical::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 16px; + height: 1px; + border-radius: 0.5px; + background: rgba(255, 255, 255, 0.3); +} + +.responsive-handle-vertical::before { + transform: translate(-50%, calc(-50% - 2px)); +} + +.responsive-handle-vertical::after { + transform: translate(-50%, calc(-50% + 2px)); +} + +.responsive-handle-vertical:hover::before, +.responsive-handle-vertical:hover::after, +.responsive-handle-vertical.dragging::before, +.responsive-handle-vertical.dragging::after { + background: rgba(255, 255, 255, 0.7); +} + +.responsive-dimension-label { + position: absolute; + transform: translateX(-50%); + z-index: 10; + display: none; + padding: 6px 14px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.88); + border: 1px solid rgba(255, 255, 255, 0.18); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + color: #fff; + font-size: 13px; + font-weight: 600; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; + white-space: nowrap; + pointer-events: none; + letter-spacing: 0.3px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.06); +} + +.responsive-dimension-label .responsive-dimension-device { + color: rgba(255, 255, 255, 0.7); + font-weight: 400; +} + +.responsive-dimension-label .responsive-dimension-device i { + margin-right: 5px; + font-size: 11px; + vertical-align: middle; +} + +.responsive-dimension-label .responsive-dimension-name { + vertical-align: middle; +} + +.responsive-dimension-label .responsive-dimension-separator { + color: rgba(255, 255, 255, 0.35); + margin: 0 6px; + font-weight: 400; +} + +.responsive-dimension-label .responsive-dimension-size { + color: #fff; + font-weight: 600; } .plugin-toolbar { @@ -61,10 +213,30 @@ .toolbar-button { background-color: transparent; - width:28px; + width: 28px; height: 22px; } +#live-preview-plugin-toolbar .btn-alt-quiet:hover, +#live-preview-plugin-toolbar .btn-alt-quiet:focus, +#live-preview-plugin-toolbar .btn-alt-quiet:active { + border-color: rgba(255, 255, 255, 0.1) !important; + box-shadow: none !important; +} + +#live-preview-plugin-toolbar .lp-device-size-icon:hover, +#live-preview-plugin-toolbar .lp-device-size-icon:focus, +#live-preview-plugin-toolbar .lp-device-size-icon:active { + border: none !important; +} + +#live-preview-plugin-toolbar .lp-device-size-dropdown-chevron:hover, +#live-preview-plugin-toolbar .lp-device-size-dropdown-chevron:focus, +#live-preview-plugin-toolbar .lp-device-size-dropdown-chevron:active { + border: none !important; + border-left: 1px solid rgba(255, 255, 255, 0.1) !important; +} + .open-icon { background: url("./images/sprites.svg#open-icon") no-repeat 72.5%; width: 30px; @@ -101,7 +273,7 @@ opacity: 0; color: #a0a0a0; visibility: hidden; - transition: opacity 1s, visibility 0s linear 1s; /* Fade-out effect */ + transition: opacity 1s, visibility 0s linear 1s; width: 30px; height: 22px; padding: 1px 6px; @@ -109,40 +281,81 @@ margin-top: 0; } -.lp-device-size-icon { - min-width: fit-content; +.lp-device-size-btn-group { display: flex; align-items: center; + flex-shrink: 0; margin: 0 4px 0 3px; - cursor: pointer; - background: transparent; - box-shadow: none; border: 1px solid transparent; + border-radius: 3px; box-sizing: border-box; +} + +.lp-device-size-btn-group:hover { + border-color: rgba(255, 255, 255, 0.1); +} + +.lp-device-size-icon { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + cursor: pointer; + background: transparent; + box-shadow: none !important; + border: none; color: #a0a0a0; - padding: 0 0.35em; + padding: 0; + margin: 0; } .lp-device-size-icon:hover, .lp-device-size-icon:focus, .lp-device-size-icon:active { background: transparent !important; - border: 1px solid rgba(255, 255, 255, 0.1) !important; + border: none !important; + box-shadow: none !important; +} + +.lp-device-size-dropdown-chevron { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + background: transparent; box-shadow: none !important; + border: none; + border-left: 1px solid transparent; + border-radius: 0 3px 3px 0; + color: #a0a0a0; + padding: 0 4px; + margin: 0; + height: 22px; + font-size: 10px; } -#deviceSizeBtn.btn-dropdown::after { - position: static; - margin-left: 5px; +.lp-device-size-btn-group:hover .lp-device-size-dropdown-chevron { + border-left-color: rgba(255, 255, 255, 0.1); +} + +.lp-device-size-dropdown-chevron:hover, +.lp-device-size-dropdown-chevron:focus, +.lp-device-size-dropdown-chevron:active { + background: transparent !important; + border: none !important; + border-left: 1px solid rgba(255, 255, 255, 0.1) !important; + box-shadow: none !important; } .device-size-item-icon { margin-right: 6px; - width: 12px; - text-align: center; font-size: inherit; } +.device-size-item-icon-fit { + font-size: 11px; +} + .device-size-item-row { display: flex; justify-content: space-between; @@ -152,56 +365,89 @@ .device-size-item-width { margin-left: 10px; - opacity: 0.5; -} - -.device-size-item-disabled { - opacity: 0.35; + color: #9a9a9a; + font-size: 12.5px; } .device-size-item-breakpoint-icon { margin-right: 6px; - width: 12px; - text-align: center; font-size: inherit; color: rgba(100, 180, 255, 0.8); } -#livePreviewModeBtn { - min-width: fit-content; +.lp-mode-btn-group { display: flex; align-items: center; + flex-shrink: 0; margin: 0 4px 0 3px; - max-width: 80%; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - cursor: pointer; - background: transparent; - box-shadow: none; border: 1px solid transparent; + border-radius: 3px; box-sizing: border-box; +} + +.lp-mode-btn-group:hover { + border-color: rgba(255, 255, 255, 0.1); +} + +.lp-mode-icon { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + cursor: pointer; + background: transparent; + box-shadow: none !important; + border: none; color: #a0a0a0; - font-size: 14px; - font-weight: normal; - padding: 0 0.35em; + padding: 0; + margin: 0; } -#livePreviewModeBtn:hover, -#livePreviewModeBtn:focus, -#livePreviewModeBtn:active { +#live-preview-plugin-toolbar .lp-mode-icon:hover, +#live-preview-plugin-toolbar .lp-mode-icon:focus, +#live-preview-plugin-toolbar .lp-mode-icon:active { background: transparent !important; - border: 1px solid rgba(255, 255, 255, 0.1) !important; + border: none !important; + box-shadow: none !important; +} + +.lp-mode-icon.selected { + color: #FBB03B; +} + +.lp-mode-dropdown-chevron { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + background: transparent; box-shadow: none !important; + border: none; + border-left: 1px solid transparent; + border-radius: 0 3px 3px 0; + color: #a0a0a0; + padding: 0 4px; + margin: 0; + height: 22px; + font-size: 10px; } -#livePreviewModeBtn.btn-dropdown::after { - position: static; - margin-top: 2px; - margin-left: 5px; +.lp-mode-btn-group:hover .lp-mode-dropdown-chevron { + border-left-color: rgba(255, 255, 255, 0.1); +} + +#live-preview-plugin-toolbar .lp-mode-dropdown-chevron:hover, +#live-preview-plugin-toolbar .lp-mode-dropdown-chevron:focus, +#live-preview-plugin-toolbar .lp-mode-dropdown-chevron:active { + background: transparent !important; + border: none !important; + border-left: 1px solid rgba(255, 255, 255, 0.1) !important; + box-shadow: none !important; } #reloadLivePreviewButton { + color: #a0a0a0; margin-left: 3px; margin-top: 0; width: 30px; @@ -209,7 +455,7 @@ flex-shrink: 0; } -#previewModeLivePreviewButton { +#designModeToggleLivePreviewButton { color: #a0a0a0; margin-left: 3px; margin-top: 0; @@ -218,10 +464,6 @@ flex-shrink: 0; } -#previewModeLivePreviewButton.selected{ - color: #FBB03B; -} - .live-preview-status-overlay { display: flex; flex-direction: row; @@ -249,7 +491,6 @@ color: #fff; } -/* Persistent cursor-sync highlight on CM line corresponding to md viewer cursor */ .cm-cursor-sync-highlight { background-color: rgba(100, 150, 255, 0.18) !important; } diff --git a/src/extensionsIntegrated/Phoenix-live-preview/main.js b/src/extensionsIntegrated/Phoenix-live-preview/main.js index b08e322798..c2bbaafa18 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/main.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/main.js @@ -169,7 +169,9 @@ define(function (require, exports, module) { $firefoxButtonBallast, $panelTitle, $modeBtn, - $previewBtn; + $modeBtnGroup, + $previewBtn, + $designModeBtn; let customLivePreviewBannerShown = false; @@ -341,37 +343,34 @@ define(function (require, exports, module) { * Does not hide in custom server mode (handled by _isMdviewrActive being false). */ function _updateLPControlsForMdviewer() { + const inDesignMode = WorkspaceManager.isInDesignMode && WorkspaceManager.isInDesignMode(); + const showPen = !_isMdviewrActive; + // Dropdown is also hidden in design mode (see $designModeBtn wiring in + // _createExtensionPanel) because the preview-mode options are moot + // when the editor is fully collapsed. + const showChevron = !_isMdviewrActive && !inDesignMode; if ($previewBtn) { - $previewBtn.toggle(!_isMdviewrActive); + $previewBtn.toggle(showPen); } if ($modeBtn) { - $modeBtn.toggle(!_isMdviewrActive); + $modeBtn.toggle(showChevron); } - } - - function _updateModeButton(mode) { - if ($modeBtn) { - if (mode === "highlight") { - $modeBtn[0].textContent = Strings.LIVE_PREVIEW_MODE_HIGHLIGHT; - } else if (mode === "edit") { - $modeBtn[0].textContent = Strings.LIVE_PREVIEW_MODE_EDIT; - } else { - $modeBtn[0].textContent = Strings.LIVE_PREVIEW_MODE_PREVIEW; - } + if ($modeBtnGroup && $modeBtnGroup.length) { + $modeBtnGroup.toggle(showPen || showChevron); } } function _initializeMode() { const currentMode = LiveDevelopment.getCurrentMode(); - // when in preview mode, we need to give the play button a selected state - if (currentMode === LiveDevelopment.CONSTANTS.LIVE_PREVIEW_MODE) { + // Pencil button lights up only when edit mode is active; preview / + // highlight modes leave it un-tinted. Click toggles between edit + // and preview. The chevron next to it opens the full mode dropdown. + if (currentMode === LiveDevelopment.CONSTANTS.LIVE_EDIT_MODE) { $previewBtn.addClass('selected'); } else { $previewBtn.removeClass('selected'); } - - _updateModeButton(currentMode); } function _showModeSelectionDropdown(event) { @@ -739,19 +738,29 @@ define(function (require, exports, module) { } /** - * Handle preview button click - toggles between preview mode and the user's default mode. - * PRO users toggle between preview and edit mode. - * community users toggle between preview and highlight mode. + * Toggles between edit mode and preview mode. The pencil button lights up + * (via .selected applied in _initializeMode) when edit mode is active; + * clicking it when already in edit drops back to preview. Clicking when + * NOT in edit tries to enter edit through LiveDevelopment.setMode, which + * returns false for users without the live-edit entitlement — in that + * case we show the same pro upsell dialog the mode dropdown uses, + * mirroring its "Edit Mode" item. _initializeMode reconciles the + * .selected class against the actual current mode after the pref + * change lands. */ function _handlePreviewBtnClick() { - if($previewBtn.hasClass('selected')) { - $previewBtn.removeClass('selected'); - const defaultMode = isProEditUser ? 'edit' : 'highlight'; - PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, defaultMode); - } else { - // Currently NOT in preview mode - switch to preview - $previewBtn.addClass('selected'); - PreferencesManager.set(PREFERENCE_LIVE_PREVIEW_MODE, "preview"); + const currentMode = LiveDevelopment.getCurrentMode(); + if (currentMode === LiveDevelopment.CONSTANTS.LIVE_EDIT_MODE) { + LiveDevelopment.setMode(LiveDevelopment.CONSTANTS.LIVE_PREVIEW_MODE); + return; + } + if (!LiveDevelopment.setMode(LiveDevelopment.CONSTANTS.LIVE_EDIT_MODE)) { + if (KernalModeTrust.ProDialogs) { + KernalModeTrust.ProDialogs.showProUpsellDialog( + KernalModeTrust.ProDialogs.UPSELL_TYPE_LIVE_EDIT); + } else { + Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "proUpsellDlg", "fail"); + } } } @@ -760,7 +769,8 @@ define(function (require, exports, module) { Strings: Strings, livePreview: Strings.LIVE_DEV_STATUS_TIP_OUT_OF_SYNC, clickToReload: Strings.LIVE_DEV_CLICK_TO_RELOAD_PAGE, - clickToPreview: Strings.LIVE_PREVIEW_MODE_TOGGLE_PREVIEW, + clickToToggleEdit: Strings.LIVE_PREVIEW_MODE_TOGGLE_EDIT, + switchToDesignMode: Strings.CCB_SWITCH_TO_DESIGN_MODE, livePreviewSettings: Strings.LIVE_DEV_SETTINGS, livePreviewConfigureModes: Strings.LIVE_PREVIEW_CONFIGURE_MODES, clickToPopout: Strings.LIVE_DEV_CLICK_POPOUT, @@ -791,7 +801,9 @@ define(function (require, exports, module) { $panelTitle = $panel.find("#panel-live-preview-title"); $settingsIcon = $panel.find("#livePreviewSettingsBtn"); $modeBtn = $panel.find("#livePreviewModeBtn"); + $modeBtnGroup = $panel.find("#lpModeBtnGroup"); $previewBtn = $panel.find("#previewModeLivePreviewButton"); + $designModeBtn = $panel.find("#designModeToggleLivePreviewButton"); // Markdown theme toggle — persist user choice MarkdownSync.setThemeToggleHandler((theme) => { @@ -871,6 +883,31 @@ define(function (require, exports, module) { Metrics.countEvent(Metrics.EVENT_TYPE.LIVE_PREVIEW, "reloadBtn", "click"); }); + // Design-mode toggle: mirrors the CCB's pen-nib button so the user can + // enter/exit design mode without moving focus to the sidebar strip. + // Icon swaps between fa-expand (enter) and fa-compress (exit); while + // design mode is on, hide #livePreviewModeBtn (the preview-mode dropdown) + // since its options are moot when the editor is fully collapsed. + function _updateDesignModeButton() { + const on = WorkspaceManager.isInDesignMode && WorkspaceManager.isInDesignMode(); + const $icon = $designModeBtn.find("i"); + $icon.removeClass("fa-expand fa-compress") + .addClass(on ? "fa-compress" : "fa-expand"); + $designModeBtn.attr("title", + on ? Strings.CCB_SWITCH_TO_CODE_EDITOR : Strings.CCB_SWITCH_TO_DESIGN_MODE); + if ($modeBtn) { + $modeBtn.toggle(!on && !_isMdviewrActive); + } + } + $designModeBtn.click(()=>{ + CommandManager.execute(Commands.VIEW_TOGGLE_DESIGN_MODE); + Metrics.countEvent(Metrics.EVENT_TYPE.LIVE_PREVIEW, "designModeBtn", "click"); + }); + WorkspaceManager.off(WorkspaceManager.EVENT_WORKSPACE_DESIGN_MODE_CHANGE + ".livePreview"); + WorkspaceManager.on(WorkspaceManager.EVENT_WORKSPACE_DESIGN_MODE_CHANGE + ".livePreview", + _updateDesignModeButton); + _updateDesignModeButton(); + // init the status overlay _initOverlay(); } diff --git a/src/extensionsIntegrated/Phoenix-live-preview/panel.html b/src/extensionsIntegrated/Phoenix-live-preview/panel.html index aa2b432843..f6c33b3e34 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/panel.html +++ b/src/extensionsIntegrated/Phoenix-live-preview/panel.html @@ -1,11 +1,20 @@
- - - + + + + +
@@ -62,7 +71,7 @@
-
+