diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index 155cde1002..7fa96c00b5 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -1,4 +1,4 @@ -name: Deploy Notification To production phcode.dev +name: Deploy Notification To production web.phcode.dev # Please note to add branch protection rules for the prod branch in your repository. on: push: @@ -22,10 +22,10 @@ jobs: PRO_REPO_ACCESS_TOKEN: ${{ secrets.PRO_REPO_ACCESS_TOKEN }} run: | npm run release:prod - - name: Deploy Notification To production repository phcode.dev + - name: Deploy Notification To production repository web.phcode.dev uses: peter-evans/repository-dispatch@v2 with: token: ${{ secrets.PAT_PHOENIX_BOT_PUBLIC_REPO_ACCESS }} - repository: phcode-dev/phcode.dev + repository: phcode-dev/web.phcode.dev event-type: deploy-production client-payload: '{"source":"${{github.repositoryUrl}}", "workflow":"${{github.workflow}}", "run_id":"${{github.run_id}}", "run_number":"${{github.run_number}}", "ref": "${{ github.ref }}", "sha": "${{ github.sha }}"}' diff --git a/CLAUDE.md b/CLAUDE.md index be13693257..0f095c4618 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ - **`src/cacheManifest.json`** is a generated build artifact (gitignored, produced by `gulpfile.js/index.js`). It lists files + hashes for the service-worker cache. Never hand-edit or commit it — it is regenerated by the build, so edits are overwritten and won't be tracked anyway. When you add/remove/rename source files, just let the build regenerate it. ## Translations / i18n -- All user-visible strings must go in `src/nls/root/strings.js` — never hardcode English in source files. +- All user-visible strings must go in `src/nls/root/strings.js` — never hardcode English in source files. This applies only to genuinely translatable natural-language text. Content that must render identically in every locale — literal code syntax, keyword/identifier examples, brand names — is not translatable and must NOT go in strings.js; keep it as a local constant in the source file instead. Reason: `src/nls/root/strings.js` values are sent as-is to an automated AI translation pass (`gulpfile.js/translateStrings.js`) with no awareness that a given string represents code rather than prose, so a translatable-looking word embedded in code syntax (e.g. `name` in `function name() {...}`) can get mistranslated into garbled pseudo-code in other locales. - 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_*`). diff --git a/docs/API-Reference/editor/Editor.md b/docs/API-Reference/editor/Editor.md index 4db5c3420a..e139e3fbb8 100644 --- a/docs/API-Reference/editor/Editor.md +++ b/docs/API-Reference/editor/Editor.md @@ -1703,3 +1703,18 @@ Constant: Bulls-eye mode, strictly center the text always. ## CENTERING\_MARGIN **Kind**: global constant + + +## getMarkOptionTabstopOutline() +Mark option for a subdued outline box, used to show every remaining stop of an active +snippet/tab-stop session (see editor/TabstopManager.js) so the user can see at a glance how +many fields are left and where, even for the ones they haven't tabbed to yet. + +**Kind**: global function + + +## getMarkOptionTabstopOutlineActive() +Mark option for the bold/active variant of the above, layered on top of it for whichever stop +is currently selected in an active snippet/tab-stop session. + +**Kind**: global function diff --git a/package.json b/package.json index 8714283e69..41d182138a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "phoenix", - "version": "5.2.6-0", - "apiVersion": "5.2.6", + "version": "5.3.0-0", + "apiVersion": "5.3.0", "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 f3f5394037..c81b7ae484 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -39,6 +39,64 @@ 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."; +// Nudge the model when it has edited files that render in the user's live +// preview without ever looking at the result. Deliberately phrased as an FYI +// the model may act on or ignore — whether a change is worth verifying, and +// with which tool, is its call. +// +// Stated as a count rather than "you just edited…" because the PostToolUse +// fallback path can deliver this a tool call after the edit, and because +// naming the number makes it read as a summary rather than a per-edit echo. +function _livePreviewHintText(count) { + return "FYI: " + count + " file(s) you edited are rendered in the user's live preview," + + " and you have not inspected it since. Decide for yourself whether looking is worth" + + " a tool call here — a trivial or self-evident change usually is not. If it is, you" + + " pick the tool: execJsInLivePreview to read the DOM / computed styles / console," + + " takeScreenshot with selector='#panel-live-preview-frame' for a visual check, or" + + " resizeLivePreview for responsive behavior."; +} + +// Reason returned when a file-rewriting shell command is stopped on its first +// attempt. A speed bump, not a wall: re-running the identical command goes +// through (see _shellEditNeedsConfirm). That protects the first edit — which a +// note after the fact cannot, since by then undo is already gone — while +// leaving the final call with the model, at the cost of one extra round trip. +// +// Preferring Edit/Write is a default, not a rule. Shell rewrites genuinely win +// on mechanical bulk changes and on large files where Edit would burn tokens +// re-reading to change a little, so the text asks the model to weigh that +// against the lost undo rather than treating the shell as forbidden. One round +// trip is negligible next to the bulk operation it is gating. +function _shellEditDenyText(what) { + return "Phoenix did not run that. It rewrites a file from the shell (" + what + "), which" + + " bypasses the editor: the user's open buffer is not refreshed, no reviewable diff is" + + " rendered, and the change cannot be undone from the AI panel's Undo button. For an" + + " ordinary content change, use Edit for existing files or Write for new ones — those" + + " keep all three. But this is a default, not a rule: if the shell is genuinely the" + + " better tool here — the user named this command, the change is mechanical across many" + + " files or matches, doing it with Edit would mean dozens of calls or reading a very" + + " large file to change a little of it, or the target is generated / build output / a" + + " log — then run it again unchanged and it will go through. Weigh the token cost" + + " against the user losing undo for that file, and tell them which way you went."; +} + +// Nudge on the first unverified live preview edit, then stay quiet until this +// many more pile up without the model ever looking at the preview. +const LP_NUDGE_REPEAT_AFTER = 5; + +// Hard ceiling per user request. Without it a long unverified run (30 edits) +// would emit ~6 nudges, and every one persists in the transcript. If two +// haven't changed the model's behavior, a third won't either. +const LP_MAX_NUDGES_PER_REQUEST = 2; + +// Calling any of these means the model is already looking at the preview, so +// there is nothing to nag about — seeing one resets the pending count. +const LP_INSPECT_TOOLS = [ + "mcp__phoenix-editor__takeScreenshot", + "mcp__phoenix-editor__execJsInLivePreview", + "mcp__phoenix-editor__resizeLivePreview" +]; + // Lazy-loaded ESM module reference let queryModule = null; @@ -103,11 +161,14 @@ let _planApproved = false; 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"; +// One of "plan" | "acceptEdits" | "auto" (SDK classifier-approved) | +// "bypassPermissions" (Allow Everything). 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 Allow Everything. Defaults to "auto" to +// match the browser's default (see AIChatPanel.js's _permissionMode). +let _runtimePermissionMode = "auto"; const nodeConnector = global.createNodeConnector(CONNECTOR_ID, exports); @@ -174,6 +235,119 @@ const _SAFE_BASH_PATTERNS = [ /^pnpm\s+--version$/ ]; +// Shell constructs whose purpose is rewriting a file in place. Bash is not +// interchangeable with Edit/Write here: the Edit/Write PostToolUse hooks +// refresh the open buffer, paint the diff card that backs the panel's Undo +// button, and carry the live preview signal. A shell rewrite skips all +// three, so the user silently loses undo for that change. +// +// A match stops the command once and offers a retry (see _shellEditDenyText), +// so the cost of a false positive is one wasted round trip rather than a +// refusal. Still worth keeping narrow: only constructs that exist to rewrite +// files belong here. +const _INPLACE_EDIT_PATTERNS = [ + // sed -i / -i.bak / -ri / --in-place. The lookahead stops at a pipe or + // separator so `grep -i x | sed 's/a/b/'` isn't caught by the grep flag. + { rx: /\bsed\b(?=[^|;&]*\s-(?:-in-place|[a-zA-Z]*i))/, what: "sed -i" }, + // perl -pi -e / perl -i.bak + { rx: /\bperl\b(?=[^|;&]*\s-[a-zA-Z]*i)/, what: "perl -i" }, + { rx: /\bawk\b(?=[^|;&]*\s-i\s+inplace)/, what: "awk -i inplace" }, + { rx: /\bed\s+-s\b/, what: "ed -s" }, + { rx: /\bex\s+-s(c|\s)/, what: "ex -s" }, + // PowerShell equivalents — on Windows the model may reach for these + // instead of sed. Set-Content/Add-Content/Out-File all rewrite a file. + { rx: /\b(?:Set-Content|Add-Content|Out-File)\b/i, what: "PowerShell Set-Content / Out-File" } +]; + +// Redirection / tee targets that aren't the user's files: device sinks and +// scratch dirs. `> /dev/null` and `> $TMPDIR/x` are ubiquitous and carry no +// undo cost, so hinting about them would be pure noise. +// +// Covers all three platforms, since the model may be driving bash, PowerShell +// or cmd depending on where Phoenix is running: macOS puts TMPDIR under +// /var/folders, Windows under %TEMP% / AppData\Local\Temp, and the null sink +// is /dev/null, NUL or $null respectively. +const _EXEMPT_WRITE_TARGETS = [ + /^\/dev\//, + /^\/proc\//, + /^\/(?:private\/)?tmp\//, + /^\/var\/(?:tmp|folders)\//, + /^(?:nul|\$null)$/i, + /^\$\{?TMPDIR\}?[\\/]/i, + /^%(?:TEMP|TMP)%[\\/]/i, + /^[a-zA-Z]:[\\/](?:temp|tmp)[\\/]/i, + // Git Bash rewrites C:\Temp to MSYS form (/c/temp), and it is the shell + // the Bash tool actually uses on Windows. + /^\/[a-zA-Z]\/(?:temp|tmp)\//i, + /[\\/]AppData[\\/]Local[\\/]Temp[\\/]/i, + /^[a-zA-Z]:[\\/]Windows[\\/]Temp[\\/]/i +]; + +function _isExemptWriteTarget(target) { + if (target === "-") { return true; } + return _EXEMPT_WRITE_TARGETS.some(function (rx) { return rx.test(target); }); +} + +// Walk the command tracking quote state so a `>` inside a string literal +// (`echo "a > b"`, `python -c "print(1 > 0)"`) is not mistaken for a +// redirection. Returns the write destinations found outside quotes. +// A heuristic guard, not a shell parser. +function _shellWriteTargets(rawCmd) { + // Drop file-descriptor duplications (2>&1, >&2, 1>&2) up front. + const cmd = (rawCmd || "").replace(/\d*>&\d*/g, " "); + const targets = []; + let quote = null; + for (let i = 0; i < cmd.length; i++) { + const ch = cmd[i]; + if (quote) { + if (ch === quote && cmd[i - 1] !== "\\") { quote = null; } + continue; + } + if (ch === "\"" || ch === "'") { quote = ch; continue; } + if (ch !== ">") { continue; } + // Skip the rest of a `>>` pair, then the whitespace before the target. + let j = i + 1; + while (cmd[j] === ">") { j++; } + while (cmd[j] === " " || cmd[j] === "\t") { j++; } + // Read the target, honouring quotes around a path with spaces. + let target = ""; + if (cmd[j] === "\"" || cmd[j] === "'") { + const closer = cmd[j]; + j++; + while (j < cmd.length && cmd[j] !== closer) { target += cmd[j++]; } + } else { + while (j < cmd.length && !/[\s;|&()]/.test(cmd[j])) { target += cmd[j++]; } + } + if (target) { targets.push(target); } + i = j - 1; + } + // tee writes to its path arguments rather than via redirection. + const tee = /\btee\s+(?:-a\s+)?("[^"]*"|'[^']*'|[^\s;|&()-][^\s;|&()]*)/g; + let m; + while ((m = tee.exec(cmd)) !== null) { + targets.push(m[1].replace(/^["']|["']$/g, "")); + } + return targets.filter(function (t) { return t && !_isExemptWriteTarget(t); }); +} + +/** + * Classify a Bash command that would rewrite file content instead of going + * through Edit/Write. Returns a short description of what was matched, or + * null when the command is fine to run. + */ +function _describeInPlaceFileEdit(rawCmd) { + const cmd = (rawCmd || "").trim(); + if (!cmd) { return null; } + for (const entry of _INPLACE_EDIT_PATTERNS) { + if (entry.rx.test(cmd)) { return entry.what; } + } + const targets = _shellWriteTargets(cmd); + if (targets.length) { + return "shell redirection to " + targets[0]; + } + return null; +} + function _isSafeReadOnlyBash(rawCmd) { const cmd = (rawCmd || "").trim(); if (!cmd) { return false; } @@ -661,7 +835,7 @@ exports.answerPlanModeWriteConfirm = async function (params) { * 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 + * after switching from Edit Mode to Allow Everything). 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. @@ -758,7 +932,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, // 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"; + _runtimePermissionMode = permissionMode || "auto"; let editCount = 0; let toolCounter = 0; // SDK tool_use id (e.g. "toolu_01...") → our sequential toolCounter so a @@ -769,6 +943,39 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, // 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; + // Live preview nudge bookkeeping, per request so each new user prompt + // re-arms it. _lpPendingEdits counts live-preview-related edits since the + // model last inspected the preview; _lpNudgeCount enforces the hard cap. + let _lpPendingEdits = 0; + let _lpNudgeCount = 0; + // Shell-rewrite confirmation, scoped per request rather than per + // conversation: a new user prompt is a new intent, so the next request's + // first rewrite gets its own speed bump instead of riding on a + // confirmation given for something else. + let _shellEditAwaitingRetry = null; + let _shellEditConfirmed = false; + + // True when this command should be stopped and offered a retry. The + // identical command coming back means it was meant, so it goes through — + // and having confirmed once, the rest of the request goes through too. + // + // That last part matters: the model often has to fix its own command after + // the first attempt (BSD `sed -i ''` failing on GNU sed, say). Keying only + // on the exact string charged a second bump for what is one operation, so + // one confirmation now covers the request. The first edit is still + // protected, which is the whole point of the bump. + function _shellEditNeedsConfirm(command) { + if (_shellEditConfirmed) { + return false; + } + if (_shellEditAwaitingRetry === command) { + _shellEditAwaitingRetry = null; + _shellEditConfirmed = true; + return false; + } + _shellEditAwaitingRetry = command; + return true; + } let queryFn; let connectionTimer = null; @@ -901,7 +1108,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } }, mcpServers: { "phoenix-editor": editorMcpServer }, - permissionMode: permissionMode || "acceptEdits", + permissionMode: permissionMode || "auto", appendSystemPrompt: "When modifying an existing file, always prefer the Edit tool " + "(find-and-replace) instead of the Write tool. The Write tool should ONLY be used " + @@ -909,6 +1116,22 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, "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." + + "\n\nThe user's project root is " + (projectPath || process.cwd()) + ". For files " + + "under it, default to Edit and Write over shell rewrites (sed -i, perl -i, tee, " + + "Set-Content/Out-File, `>` / `>>` redirection). Phoenix routes Edit and Write " + + "through the editor, so they refresh the user's open buffer, render a reviewable " + + "diff, and stay undoable from the AI panel; a shell rewrite skips all three, and " + + "the user cannot undo it. Outside the project root — scratch files, temp output, " + + "logs — the shell is fine and needs no thought. " + + "\nThis is a default, not a prohibition. The shell is the better call when the " + + "change is mechanical across many files or matches, when Edit would mean dozens of " + + "calls or reading a large file to alter a little of it, or when the target is " + + "generated output. Phoenix stops the first shell rewrite of each command and " + + "explains why; re-run it unchanged and it goes through. Judge it on the merits — " + + "tokens saved against undo lost — and tell the user when you take the shell route. " + + "When the saving would be marginal, take Edit: one shell call and one Edit call " + + "cost about the same, so a handful of files is not a reason to give up undo. The " + + "shell has to earn it." + "\n\nALWAYS call getEditorState as your FIRST tool call on any question that " + "references the user's current work — not just \"what file am I on\". This includes " + "implicit-context questions like \"the page\", \"this layout\", \"the nav bar\", " + @@ -945,7 +1168,8 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, "phoenix-editor.resizeLivePreview, and phoenix-editor.controlEditor cover virtually " + "every \"look at / poke at the page\" need. Only fall back to chrome-devtools or " + "another browser MCP if the user explicitly asks for a non-Phoenix browser context. " + - "These tools are for active iteration, not just final verification:" + + "These tools are for active iteration AND for checking your own work — " + + "use them as you go, not only when the user asks:" + "\n- takeScreenshot: see the rendered HTML preview, the rendered Markdown preview, " + "the editor, or any panel. Use it to confirm visual output, diagnose layout/styling " + "bugs, or check that HTML or Markdown rendered as expected. Simple selector rule: " + @@ -957,7 +1181,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, "JS edits) — saves a tool call vs. reloading separately." + "\n- execJsInLivePreview: run JS inside the HTML preview iframe to read the DOM, " + "query computed styles, click elements, or capture console output. Use it to debug " + - "behavior, not just to verify." + + "behavior and to confirm an edit actually took effect." + "\n- resizeLivePreview: change the preview viewport width to test responsive " + "breakpoints." + "\n- controlEditor: open files, move the cursor, change selection, toggle the live " + @@ -982,6 +1206,15 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, "feature-docs URL and the GitHub source repo URL. Call once near the start of any " + "non-trivial editor-control task; then Read / Grep the apiDocsPath and WebFetch the " + "featureDocsURL as needed. Do NOT search the codebase blindly when this exists." + + "\n\nEDITS THAT LAND IN THE LIVE PREVIEW: when you edit the file getEditorState " + + "reported as livePreviewFile — or a CSS / JS / SVG file it links to — the user is " + + "watching the result render. Whether that is worth checking is your judgement call, " + + "and so is how: execJsInLivePreview to read the DOM / computed styles / console, " + + "takeScreenshot with selector='#panel-live-preview-frame' for a visual check, " + + "resizeLivePreview for responsive behavior, or nothing at all when the change is " + + "trivial or self-evident. Weigh it at meaningful checkpoints (after a section lands, " + + "before you report done) rather than after every small edit. Files outside the live " + + "preview do not raise the question at all." + "\n\nName-collision rule: \"Phoenix Code\" (the editor the user is sitting inside) " + "and \"Claude Code\" (the SDK / CLI you happen to run on) BOTH have settings, " + "configs, auto-update toggles, themes, etc. When the user says \"set / change / " + @@ -1286,17 +1519,45 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, matcher: "Bash", hooks: [ async (input) => { + // Stop a file rewrite the first time it is tried, + // in every permission mode — "auto" hands the call + // to the SDK classifier, which happily approves + // sed -i. Denying here is what actually protects + // the edit: a note after the fact arrives once undo + // is already gone. Re-running the same command + // confirms intent and goes through. + const command = (input.tool_input && input.tool_input.command) || ""; + const inPlaceEdit = _describeInPlaceFileEdit(command); + if (inPlaceEdit && _shellEditNeedsConfirm(command)) { + console.log("[Phoenix AI] Stopped shell file rewrite (" + + inPlaceEdit + "), offering retry: " + command.slice(0, 70)); + return { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: _shellEditDenyText(inPlaceEdit) + } + }; + } + if (inPlaceEdit) { + console.log("[Phoenix AI] Shell file rewrite confirmed by retry: " + + command.slice(0, 70)); + } // 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. + // Mode → Allow Everything 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. + // Plan mode: SDK handles. Auto: SDK's own + // classifier decides. Allow Everything: allow + // freely. Either way, Phoenix's own + // confirm-dialog/safe-bash-allowlist below is + // only for Edit Mode's manual approval flow. return {}; } - // Edit Mode: ask user confirmation before running bash - const command = input.tool_input.command || ""; + // Edit Mode: ask user confirmation before running bash. + // `command` is read above, for the rewrite check. // Skip prompting for well-known read-only commands // that mirror the Claude Code CLI's default safe // patterns. Cuts down on prompt fatigue during @@ -1431,7 +1692,14 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, console.warn("[Phoenix AI] Edit refresh fallback failed:", filePath, err.message); } } - // 2. Trigger aiToolEdit so the AI panel renders the + // 2. Count it toward the live preview nudge. Only + // incrementing here — the read-and-clear happens + // in one owner, since PostToolUse hooks can run + // concurrently for parallel tool calls. + if (result.isLivePreviewRelated) { + _lpPendingEdits++; + } + // 3. Trigger aiToolEdit so the AI panel renders the // diff card and the snapshot store records it. const counterId = _toolUseIdToCounter[toolUseID]; if (counterId !== undefined) { @@ -1466,6 +1734,9 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } catch (err) { console.warn("[Phoenix AI] Write refresh failed:", filePath, err.message); } + if (refreshResult.isLivePreviewRelated) { + _lpPendingEdits++; + } const counterId = _toolUseIdToCounter[toolUseID]; if (counterId !== undefined) { nodeConnector.triggerPeer("aiToolEdit", { @@ -1489,13 +1760,45 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, // 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 + // we register one matcher-less hook that returns the + // clarification context if any is queued. Once // getUserClarification runs and clears _queuedClarification, - // _maybeClarifyContext returns {} and this becomes a no-op. + // that part becomes a no-op. + // + // It also carries the live preview nudge as a fallback for + // Claude CLI versions that predate PostToolBatch: the batch + // hook below is the primary path, but we run the user's + // global CLI (findGlobalClaudeCli) so we can't assume it. + // Whichever fires first takes the hint; the other sees a + // cleared counter. + hooks: [ + async (input) => { + return _buildPostToolUseHint(input); + } + ] + } + ], + PostToolBatch: [ + { + // Primary emit point for the live preview nudge. Fires once + // after every tool call in a batch resolves, so unlike + // PostToolUse (which may run concurrently for parallel tool + // calls) it can safely read-and-clear shared state, and it + // sees the whole batch — including whether the model already + // inspected the preview itself. hooks: [ - async () => { - return _maybeClarifyContext(); + async (input) => { + const names = (input.tool_calls || []).map(function (call) { + return call.tool_name; + }); + const hint = _takeLivePreviewHint(names); + if (!hint) { return {}; } + return { + hookSpecificOutput: { + hookEventName: "PostToolBatch", + additionalContext: hint + } + }; } ] } @@ -1503,17 +1806,59 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale, } }; - // 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 {}; } + // Read-and-clear for the live preview nudge. Returns the hint text when the + // model has piled up unverified live-preview edits, else null. Called from + // the PostToolBatch hook (primary) and the PostToolUse catch-all (fallback + // for older CLIs) — the body is synchronous, so whichever gets here first + // takes the hint and the other finds the counter already cleared. + // + // toolNames is what the model just called: seeing it inspect the preview + // itself means there is nothing to nag about. + function _takeLivePreviewHint(toolNames) { + if (toolNames && toolNames.some(function (name) { + return LP_INSPECT_TOOLS.indexOf(name) !== -1; + })) { + _lpPendingEdits = 0; + return null; + } + if (_lpNudgeCount >= LP_MAX_NUDGES_PER_REQUEST) { + return null; + } + const threshold = _lpNudgeCount === 0 ? 1 : LP_NUDGE_REPEAT_AFTER; + if (_lpPendingEdits < threshold) { + return null; + } + const text = _livePreviewHintText(_lpPendingEdits); + console.log("[Phoenix AI] live preview nudge:", _lpPendingEdits, "edit(s) unverified"); + _lpPendingEdits = 0; + _lpNudgeCount++; + return text; + } + + // Returns a PostToolUse SyncHookJSONOutput carrying whatever the model + // should see after a tool call: the clarification hint when the user has + // typed a follow-up while the AI is streaming, and/or the live preview + // nudge. 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. + // + // _queuedClarification is deliberately not cleared here; it clears only + // when the model calls getUserClarification. The live preview counter is + // cleared by _takeLivePreviewHint, so that half cannot repeat. + function _buildPostToolUseHint(input) { + const parts = []; + if (_queuedClarification) { + parts.push(CLARIFICATION_HINT); + } + const lpHint = _takeLivePreviewHint(input && input.tool_name ? [input.tool_name] : null); + if (lpHint) { + parts.push(lpHint); + } + if (!parts.length) { return {}; } return { hookSpecificOutput: { hookEventName: "PostToolUse", - additionalContext: CLARIFICATION_HINT + additionalContext: parts.join("\n\n") } }; } diff --git a/src-node/package-lock.json b/src-node/package-lock.json index 14ca2e2f48..9cbf71d1f0 100644 --- a/src-node/package-lock.json +++ b/src-node/package-lock.json @@ -1,12 +1,12 @@ { "name": "@phcode/node-core", - "version": "5.2.4-0", + "version": "5.3.0-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@phcode/node-core", - "version": "5.2.4-0", + "version": "5.3.0-0", "hasInstallScript": true, "license": "GNU-AGPL3.0", "dependencies": { diff --git a/src-node/package.json b/src-node/package.json index 998a1914b9..b6b8ff9346 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.2.6-0", - "apiVersion": "5.2.6", + "version": "5.3.0-0", + "apiVersion": "5.3.0", "keywords": [], "author": "arun@core.ai", "homepage": "https://github.com/phcode-dev/phoenix", diff --git a/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js b/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js index 9686ad405c..195e11c444 100644 --- a/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js +++ b/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js @@ -61,6 +61,9 @@ define(function (require, exports, module) { this._onChange = this._onChange.bind(this); this.doc.on("change", this._onChange); + this._onDeleted = this._onDeleted.bind(this); + this.doc.on("deleted", this._onDeleted); + this._onRelated = this._onRelated.bind(this); this.protocol.on("DocumentRelated", this._onRelated); @@ -152,6 +155,7 @@ define(function (require, exports, module) { LiveHTMLDocument.prototype.close = function () { this.doc.releaseRef(); this.doc.off("change", this._onChange); + this.doc.off("deleted", this._onDeleted); this.protocol.off("DocumentRelated", this._onRelated); this.protocol.off("StylesheetAdded", this._onStylesheetAdded); this.protocol.off("StylesheetRemoved", this._onStylesheetRemoved); @@ -161,6 +165,19 @@ define(function (require, exports, module) { this.parentClass.close.call(this); }; + /** + * @private + * Handles the backing Document's file being deleted from disk (eg. from the file tree). + * Without this, our extra addRef() on the doc (on top of any editor's own ref) would never + * be released - close() is otherwise only called by the live preview session lifecycle, not + * in response to the file disappearing. + * @param {$.Event} event + */ + LiveHTMLDocument.prototype._onDeleted = function (event) { + this.close(); + this.trigger("deleted", [this]); + }; + /** * @override * Update the highlights in the browser based on the cursor position. diff --git a/src/brackets.config.dist.json b/src/brackets.config.dist.json index 0e8dfcc466..1a8800fe19 100644 --- a/src/brackets.config.dist.json +++ b/src/brackets.config.dist.json @@ -1,9 +1,9 @@ { - "googleAnalyticsID" : "G-FBK9RP5YK2", + "googleAnalyticsID" : "G-G3DTS3ZR09", "googleAnalyticsIDDesktop": "G-M7MX9BYZZ3", "mixPanelID" : "8cb6814f733e37c05cc59b4adad26407", "coreAnalyticsID" : "phoenix", - "coreAnalyticsAppName" : "phoenix-prod", + "coreAnalyticsAppName" : "phoenix-web", "coreAnalyticsAppNameDesktop" : "desktop-prod", "environment" : "production", "buildtype" : "production", diff --git a/src/config.json b/src/config.json index f378d48bf7..40aac1d4aa 100644 --- a/src/config.json +++ b/src/config.json @@ -51,8 +51,8 @@ "bugsnagEnv": "development" }, "name": "Phoenix Code", - "version": "5.2.6-0", - "apiVersion": "5.2.6", + "version": "5.3.0-0", + "apiVersion": "5.3.0", "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 fa50fdfeff..3a418a63e9 100644 --- a/src/document/DocumentCommandHandlers.js +++ b/src/document/DocumentCommandHandlers.js @@ -41,6 +41,7 @@ define(function (require, exports, module) { FileUtils = require("file/FileUtils"), FileViewController = require("project/FileViewController"), InMemoryFile = require("document/InMemoryFile"), + EncodingDetector = require("document/EncodingDetector"), StringUtils = require("utils/StringUtils"), Async = require("utils/Async"), Metrics = require("utils/Metrics"), @@ -509,22 +510,66 @@ define(function (require, exports, module) { }); var file = FileSystem.getFileForPath(fullPath); + + function _openFileInPane() { + MainViewManager._open(paneId, file, options) + .done(function () { + result.resolve(file); + }) + .fail(function (fileError) { + _showErrorAndCleanUp(fileError, fullPath); + result.reject(); + }); + } + + // File.read() caches _contents/_stat keyed together with whatever _encoding was in + // effect at the time of that read (see File.js). Bare-reassigning file._encoding + // without invalidating that cache would let the imminent real open - which reads with + // this same newly-assigned encoding - incorrectly cache-hit and hand back stale + // content cached under the OLD encoding (worse, raw bytes, if that prior read used a + // byte-array encoding, eg via the "Download" command or an image-attach feature) + // instead of doing a real re-read. Always route encoding changes through here so that + // can never happen. + function _setFileEncoding(newEncoding) { + if (file._encoding !== newEncoding) { + file._clearCachedData(); + file._encoding = newEncoding; + } + } + if (options && options.encoding) { - file._encoding = options.encoding; + _setFileEncoding(options.encoding); + _openFileInPane(); } else { const encoding = PreferencesManager.getViewState("encoding", PreferencesManager.STATE_PROJECT_CONTEXT); if (encoding && encoding[fullPath]) { - file._encoding = encoding[fullPath]; + _setFileEncoding(encoding[fullPath]); + _openFileInPane(); + } else if (EncodingDetector.isKnownTextEncoding(file._encoding)) { + // File instances are cached/reused per path for the session (FileSystem._index), + // so a known-text _encoding here means we've already read (and so already + // detected or defaulted) this exact file once before as text - eg it's being + // reopened after a close. Re-running detection would mean re-reading the whole + // file from disk a second time for no new information, so just reuse what we + // already know. (isKnownTextEncoding - rather than a plain truthiness check - + // matters here: other code paths read this same File instance for non-text + // reasons, eg downloading it or attaching it as a chat image, and can leave a + // non-text sentinel encoding cached on it even though it was never opened as a + // document - we must not mistake that for "already detected".) + _openFileInPane(); + } else { + // No explicit, previously chosen, or already-known encoding for this file - see if + // it self-declares a non-UTF-8 charset (eg a legacy HTML file with + // ) so we don't silently and irreversibly corrupt it + // by force-decoding as UTF-8. See EncodingDetector. + EncodingDetector.detectFileEncoding(file).then(function (detectedEncoding) { + // Always land on a definite, known-text value - never leave file._encoding as + // whatever a prior non-text read (see above) may have left it as. + _setFileEncoding(detectedEncoding || "utf8"); + _openFileInPane(); + }); } } - MainViewManager._open(paneId, file, options) - .done(function () { - result.resolve(file); - }) - .fail(function (fileError) { - _showErrorAndCleanUp(fileError, fullPath); - result.reject(); - }); } return result.promise(); diff --git a/src/document/EncodingDetector.js b/src/document/EncodingDetector.js new file mode 100644 index 0000000000..6761e4ae7d --- /dev/null +++ b/src/document/EncodingDetector.js @@ -0,0 +1,268 @@ +/* + * 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 fs*/ + +/** + * Phoenix always decodes newly opened files as UTF-8 by default (see File.js/AppshellFileSystem.js). + * That's correct for the vast majority of files, but some files - most commonly HTML/XML documents + * authored a long time ago, or exported by legacy tools (old FrontPage/Dreamweaver, Windows editors, + * etc) - are actually encoded in a legacy 8-bit charset like `windows-1252`, and self-declare that + * fact via a ``/`Content-Type` tag. Force-decoding such a file as UTF-8 doesn't just + * look wrong - the browser's `TextDecoder("utf8")` is non-fatal, so every undecodable byte is + * silently and *irreversibly* replaced with U+FFFD ("<27>") the moment the file is read. Once that + * happens the original bytes are gone; if the user then saves, the corruption is baked into the file + * on disk too. + * + * This module lets us catch that case before the first (lossy) read ever happens, so newly opened + * files get decoded with the encoding they actually declare - the same behavior a web browser + * exhibits when honoring a page's own declared charset. + * + * Two independent signals are checked, from strongest to weakest: + * 1. A byte-order-mark (BOM) - an unambiguous, extension-independent signal, so it's checked for + * any non-binary file (see detectFileEncoding's use of LanguageManager.isBinary()). This also + * means files like plain .txt with a genuine UTF-16/UTF-32 BOM now get decoded correctly on + * first open too, which - surprisingly - nothing did automatically before this module existed; + * previously that required manually picking the encoding from the status bar dropdown. + * 2. A self-declared charset (``/`Content-Type`) - only meaningful for markup file + * extensions (see SNIFFABLE_EXTENSIONS), and only trusted when the raw bytes are NOT already + * valid UTF-8. If they are, we trust that over any declaration - this avoids second-guessing + * modern UTF-8 files that simply have a stale/incorrect meta tag left over from a copy-paste. + * There is no BOM equivalent for single-byte legacy charsets like windows-1252 - a document + * declaring itself is the only signal there is. + * + * Either way, this is only ever used for a fresh, first-time open. Once a user has explicitly + * picked an encoding for a path (via the status bar dropdown), that choice always wins - see + * DocumentCommandHandlers. + */ +define(function (require, exports, module) { + + + const FileUtils = require("file/FileUtils"), + LanguageManager = require("language/LanguageManager"); + + /** + * File extensions for which we attempt to sniff a self-declared charset. + * @type {Array.} + */ + const SNIFFABLE_EXTENSIONS = ["html", "htm", "xhtml", "shtml", "php", "xml"]; + + // The HTML5 spec only requires user agents to scan the first 1024 bytes of a document for a + // charset declaration before starting to parse it; we use the same limit here. + const SNIFF_BYTE_LIMIT = 1024; + + // Matches both `` and + // `` style declarations. + const META_CHARSET_RE = /]+charset\s*=\s*["']?\s*([a-zA-Z0-9_\-:.]+)/i; + + // Charset aliases that browsers commonly treat as equivalent to a related, better-supported + // name (keyed/valued by the normalized form - see _normalizeEncodingName). Per the WHATWG + // encoding spec, content labeled iso-8859-1 is treated as windows-1252 in practice, since + // windows-1252 is a strict superset (it just assigns printable characters to the 0x80-0x9F + // range that iso-8859-1 leaves as C1 control codes, which real-world content essentially + // never intentionally uses). + const CHARSET_ALIASES = { + "latin1": "windows1252", + "iso88591": "windows1252" + }; + + /** + * @private + * Normalizes a charset name the same way Phoenix's underlying iconv-lite based fs layer does + * when looking up a codec (lower-cased, non-alphanumeric characters stripped) - eg + * "windows-1252" and "Windows_1252" both become "windows1252". `fs.SUPPORTED_ENCODINGS` is + * itself a list of already-normalized names, so declared charsets must be normalized the same + * way before being compared against it or handed back as the encoding to decode with. + * @param {string} name + * @return {string} + */ + function _normalizeEncodingName(name) { + return name.toLowerCase().replace(/[^0-9a-z]/g, ""); + } + + const BOM_SIGNATURES = [ + {bytes: [0xEF, 0xBB, 0xBF], encoding: "utf8"}, + {bytes: [0xFF, 0xFE, 0x00, 0x00], encoding: "utf32le"}, + {bytes: [0x00, 0x00, 0xFE, 0xFF], encoding: "utf32be"}, + {bytes: [0xFF, 0xFE], encoding: "utf16le"}, + {bytes: [0xFE, 0xFF], encoding: "utf16be"} + ]; + + /** + * @private + * Returns the encoding named by a recognized byte-order-mark at the start of `bytes`, or null. + * @param {Uint8Array} bytes + * @return {?string} + */ + function _detectBOM(bytes) { + for (const sig of BOM_SIGNATURES) { + if (bytes.length >= sig.bytes.length && sig.bytes.every(function (b, i) { + return bytes[i] === b; + })) { + return sig.encoding; + } + } + return null; + } + + /** + * @private + * Extracts a declared charset name from a `` tag, if present in the given bytes. The + * declaration itself is always plain ASCII per spec, so it's safe to scan for it by treating + * the raw bytes as Latin-1/ASCII regardless of the file's real encoding. + * @param {Uint8Array} bytes + * @return {?string} lower-cased charset name, or null if none found + */ + function _extractDeclaredCharset(bytes) { + const prefix = bytes.subarray(0, Math.min(bytes.length, SNIFF_BYTE_LIMIT)); + let asciiText = ""; + for (let i = 0; i < prefix.length; i++) { + asciiText += String.fromCharCode(prefix[i]); + } + const match = META_CHARSET_RE.exec(asciiText); + return match ? match[1].toLowerCase() : null; + } + + /** + * @private + * @param {Uint8Array} bytes + * @return {boolean} true if `bytes` is well-formed UTF-8 + */ + function _isValidUTF8(bytes) { + try { + new TextDecoder("utf8", {fatal: true}).decode(bytes); + return true; + } catch (e) { + return false; + } + } + + /** + * Given the raw bytes of a file and its extension, determine whether a non-default encoding + * should be used to decode it. Pure/synchronous - does no I/O. + * + * @param {string} extension lower-case file extension, no leading dot + * @param {Uint8Array} bytes raw file content + * @param {Array.=} supportedEncodings encoding names Phoenix's fs layer can decode with; + * defaults to `fs.SUPPORTED_ENCODINGS` when running in the app. + * @return {?string} the encoding name to use, or null to keep the default (utf8) + */ + function detectEncodingFromBytes(extension, bytes, supportedEncodings) { + if (!bytes || !bytes.length) { + return null; + } + + const bom = _detectBOM(bytes); + if (bom) { + // An explicit BOM is unambiguous. A utf-8 BOM just means "definitely utf-8", which is + // already our default, so nothing to override there. + return bom === "utf8" ? null : bom; + } + + if (SNIFFABLE_EXTENSIONS.indexOf(extension) === -1) { + return null; + } + + if (_isValidUTF8(bytes)) { + return null; + } + + const rawDeclared = _extractDeclaredCharset(bytes); + if (!rawDeclared) { + return null; + } + + let declared = _normalizeEncodingName(rawDeclared); + declared = CHARSET_ALIASES[declared] || declared; + + if (declared === "utf8") { + // Declared utf-8 but isn't valid utf-8 bytes - nothing sane we can substitute, so we + // just keep decoding as utf-8 (matching today's behavior) rather than guessing further. + return null; + } + + supportedEncodings = supportedEncodings || (typeof fs !== "undefined" && fs.SUPPORTED_ENCODINGS); + if (supportedEncodings && supportedEncodings.indexOf(declared) === -1) { + return null; + } + + return declared; + } + + /** + * Attempts to detect a non-default encoding for `file` before it's opened for the first time, + * by reading its raw bytes and looking for a BOM or (for markup files) a self-declared charset + * (see detectEncodingFromBytes). Never rejects - resolves with null if detection isn't + * applicable to this file, or nothing conclusive was found, in which case the caller should + * just fall back to the normal default (utf8) decode. + * + * We only skip reading the file at all when it's a known binary type (image, font, zip, etc) - + * same check the rest of the app uses (LanguageManager's isBinary()) to decide whether a file + * should ever be treated as text. Anything else is fair game for a BOM, even if its extension + * isn't one we scan for a `` declaration (see SNIFFABLE_EXTENSIONS) - a BOM is a + * cheap, unambiguous signal that doesn't depend on file type the way a meta tag scan does. + * + * @param {File} file + * @return {$.Promise} resolved with the detected encoding name, or null + */ + function detectFileEncoding(file) { + const result = new $.Deferred(); + const language = LanguageManager.getLanguageForPath(file.fullPath); + + if (language.isBinary()) { + result.resolve(null); + return result.promise(); + } + + const extension = FileUtils.getFileExtension(file.fullPath).toLowerCase(); + file.read({encoding: window.fs.BYTE_ARRAY_ENCODING, doNotCache: true}, function (err, content) { + if (err || !content) { + result.resolve(null); + return; + } + const bytes = new Uint8Array(content); + result.resolve(detectEncodingFromBytes(extension, bytes, window.fs.SUPPORTED_ENCODINGS)); + }); + + return result.promise(); + } + + /** + * True if `encoding` is a real text codec name, as opposed to the non-text sentinel value + * (`fs.BYTE_ARRAY_ENCODING`, i.e. "byte_array" - notably still present in + * `fs.SUPPORTED_ENCODINGS`, so that list alone can't be used to tell them apart) that plenty of + * *other* call sites across the codebase pass to `File.read()` for legitimate non-text reasons + * (downloading a file, attaching an image, exporting a zip, etc) - and, unless they also pass + * `doNotCache: true`, leave cached in `file._encoding` as a side effect of File.read()'s + * caching (see File.js). A File instance touched that way before ever being opened as a + * document would otherwise look "already known" to a naive truthiness check, silently + * defeating both detection and the re-open-skip optimization in DocumentCommandHandlers. + * @param {?string} encoding + * @return {boolean} + */ + function isKnownTextEncoding(encoding) { + return !!encoding && encoding !== window.fs.BYTE_ARRAY_ENCODING; + } + + exports.SNIFFABLE_EXTENSIONS = SNIFFABLE_EXTENSIONS; + exports.detectEncodingFromBytes = detectEncodingFromBytes; + exports.detectFileEncoding = detectFileEncoding; + exports.isKnownTextEncoding = isKnownTextEncoding; +}); diff --git a/src/editor/Editor.js b/src/editor/Editor.js index b105553689..8e0b9b2b84 100644 --- a/src/editor/Editor.js +++ b/src/editor/Editor.js @@ -1396,6 +1396,37 @@ define(function (require, exports, module) { }; } + /** + * Mark option for a subdued outline box, used to show every remaining stop of an active + * snippet/tab-stop session (see editor/TabstopManager.js) so the user can see at a glance how + * many fields are left and where, even for the ones they haven't tabbed to yet. + */ + function getMarkOptionTabstopOutline() { + return { + className: "editor-text-tabstop-outline", + startStyle: "editor-text-tabstop-outline-left", + endStyle: "editor-text-tabstop-outline-right", + clearWhenEmpty: false, + inclusiveLeft: true, + inclusiveRight: true + }; + } + + /** + * Mark option for the bold/active variant of the above, layered on top of it for whichever stop + * is currently selected in an active snippet/tab-stop session. + */ + function getMarkOptionTabstopOutlineActive() { + return { + className: "editor-text-tabstop-outline-active", + startStyle: "editor-text-tabstop-outline-active-left", + endStyle: "editor-text-tabstop-outline-active-right", + clearWhenEmpty: false, + inclusiveLeft: true, + inclusiveRight: true + }; + } + /** * Mark option to underline errors. */ @@ -1430,6 +1461,8 @@ define(function (require, exports, module) { * Mark option for renaming outlines. */ Editor.getMarkOptionRenameOutline = getMarkOptionRenameOutline; + Editor.getMarkOptionTabstopOutline = getMarkOptionTabstopOutline; + Editor.getMarkOptionTabstopOutlineActive = getMarkOptionTabstopOutlineActive; /** * Can be used to mark a range of text with a specific CSS class name. cursorFrom and cursorTo should be {line, ch} diff --git a/src/editor/TabstopManager.js b/src/editor/TabstopManager.js index ee4907d5fa..5fd62842ad 100644 --- a/src/editor/TabstopManager.js +++ b/src/editor/TabstopManager.js @@ -35,11 +35,19 @@ * stop, and (when there is more than one stop) starts a Tab-navigable session backed by markers * so the stops follow any later edits (e.g. an auto-import line inserted above). * - * NOTE: this is currently wired only into the LSP completion path (languageTools/DefaultProviders). - * The Emmet expander (HTMLCodeHints) and the custom-snippets feature have their own stable cursor - * handling and were intentionally left untouched; they can migrate onto this manager in future. + * Used by the LSP completion path (languageTools/DefaultProviders), DocCommentHints, and Custom + * Snippets (extensionsIntegrated/CustomSnippets/snippetCursorManager.js). The Emmet expander + * (HTMLCodeHints) still has its own separate cursor handling. + * + * While a Tab-navigable session is active, every remaining stop gets a subdued outline box (see + * Editor.getMarkOptionTabstopOutline) so the user can see at a glance how many fields are left and + * where, with the currently-selected one getting a bolder "active" outline layered on top (see + * Editor.getMarkOptionTabstopOutlineActive) - matches the visual language RenameIdentifier.js already + * uses for its own outline box. Zero-width stops (a bare `$N`/`$0` with no default text - just a + * caret position, no marker range) don't get an outline, since there's no span to box. */ define(function (require, exports, module) { + const Editor = require("editor/Editor").Editor; /** * Expand an LSP snippet into plain text plus the list of tab-stops. @@ -174,7 +182,7 @@ define(function (require, exports, module) { // ---- Tab-navigation session ---------------------------------------------------------------- - var _session = null; // { editor, markers: [marker], index, keymap } + var _session = null; // { editor, markers: [marker], index, keymap, activeOutlineMarker } function _clearSession() { if (!_session) { @@ -185,10 +193,49 @@ define(function (require, exports, module) { session.markers.forEach(function (m) { m.clear(); }); + if (session.activeOutlineMarker) { + session.activeOutlineMarker.clear(); + } session.editor._codeMirror.removeKeyMap(session.keymap); session.editor.off(".tabstop"); } + /** + * @param {{line: number, ch: number}} pos - a document position + * @return {boolean} true if `pos` falls within the line span currently covered by the active + * session's markers (i.e. the snippet the user is still tabbing through) + */ + function _isWithinSessionBounds(pos) { + var minLine = Infinity, + maxLine = -Infinity; + _session.markers.forEach(function (m) { + var r = _markerRange(m); + if (r) { + minLine = Math.min(minLine, r.from.line); + maxLine = Math.max(maxLine, r.to.line); + } + }); + if (minLine === Infinity) { + return false; // no markers left resolve-able + } + return pos.line >= minLine && pos.line <= maxLine; + } + + /** + * Ends the session as soon as the user's cursor leaves the snippet's lines (e.g. clicks + * elsewhere to fix something unrelated) or a multi-cursor selection is made - matches standard + * editor behavior (VS Code et al.) and avoids a stray later Tab press unexpectedly jumping the + * cursor back into a snippet the user has moved on from. + */ + function _handleCursorActivity(event, editor) { + if (!_session || _session.editor !== editor) { + return; + } + if (editor.getSelections().length > 1 || !_isWithinSessionBounds(editor.getCursorPos())) { + _clearSession(); + } + } + /** * Resolve a marker (markText range or bookmark) to a {from, to} document range, or null if the * marker no longer exists in the document. @@ -215,10 +262,28 @@ define(function (require, exports, module) { } _session.index = index; _session.editor.setSelection(range.from, range.to); + + // swap the bold "active" outline onto whichever stop we just landed on - only meaningful for + // a real span (a bare $N/$0 with no default text is a zero-width caret, nothing to box) + if (_session.activeOutlineMarker) { + _session.activeOutlineMarker.clear(); + _session.activeOutlineMarker = null; + } + if (range.from.line !== range.to.line || range.from.ch !== range.to.ch) { + _session.activeOutlineMarker = _session.editor.markText( + "tabstop-active", range.from, range.to, Editor.getMarkOptionTabstopOutlineActive()); + } return true; } function _gotoNext() { + if (!_session) { + // no-op: goToNextStop/goToPreviousStop are exported as public API (see bottom of file) + // for callers like Custom Snippets' snippetCursorManager.js to drive navigation directly, + // not only via the CodeMirror keymap installed below (which only exists while a session is + // active, so it could never reach this function with no session) - a direct caller could. + return; + } // Move forward through the stops; leaving the last one ends the session (caret stays put). for (var i = _session.index + 1; i < _session.markers.length; i++) { if (_selectStop(i)) { @@ -233,6 +298,9 @@ define(function (require, exports, module) { } function _gotoPrev() { + if (!_session) { + return; // see _gotoNext's no-op comment - same reasoning applies here + } for (var i = _session.index - 1; i >= 0; i--) { if (_selectStop(i)) { return; @@ -286,6 +354,11 @@ define(function (require, exports, module) { } // Multiple stops: lay down markers and start a Tab-navigable session. + // the subdued outline (visual only) is layered onto the SAME functional tracking options + // below by className/startStyle/endStyle alone - deliberately not spreading the whole helper + // object in, since its own inclusiveLeft/clearWhenEmpty differ from what marker TRACKING here + // actually needs (inclusiveLeft: false is what makes typing at a stop's start not stick to it). + var outlineOption = Editor.getMarkOptionTabstopOutline(); var markers = parsed.stops.map(function (stop) { var ms = posFromOffset(stop.start), me = posFromOffset(stop.end); @@ -295,7 +368,10 @@ define(function (require, exports, module) { return editor.markText("tabstop", ms, me, { clearWhenEmpty: false, inclusiveLeft: false, - inclusiveRight: true + inclusiveRight: true, + className: outlineOption.className, + startStyle: outlineOption.startStyle, + endStyle: outlineOption.endStyle }); }); @@ -312,11 +388,13 @@ define(function (require, exports, module) { } }; - _session = { editor: editor, markers: markers, index: -1, keymap: keymap }; + _session = { editor: editor, markers: markers, index: -1, keymap: keymap, activeOutlineMarker: null }; editor._codeMirror.addKeyMap(keymap); - // End the session if the editor it belongs to is destroyed (file closed). Namespaced so - // _clearSession can remove it with a single off(".tabstop"). + // End the session if the editor it belongs to is destroyed (file closed), or the user moves + // on (cursor leaves the snippet's lines, or a multi-cursor selection is made). Namespaced so + // _clearSession can remove both with a single off(".tabstop"). editor.on("beforeDestroy.tabstop", _clearSession); + editor.on("cursorActivity.tabstop", _handleCursorActivity); _selectStop(0); return parsed; @@ -338,4 +416,8 @@ define(function (require, exports, module) { exports.insertSnippet = insertSnippet; exports.hasActiveSession = hasActiveSession; exports.endSession = endSession; + // exposed so other features with their own stable session lifecycle (e.g. custom snippets) + // can drive Tab / Shift-Tab navigation without duplicating this logic + exports.goToNextStop = _gotoNext; + exports.goToPreviousStop = _gotoPrev; }); diff --git a/src/extensionsIntegrated/CustomSnippets/codeHintIntegration.js b/src/extensionsIntegrated/CustomSnippets/codeHintIntegration.js index 2159fe8d4b..cdd422f8ee 100644 --- a/src/extensionsIntegrated/CustomSnippets/codeHintIntegration.js +++ b/src/extensionsIntegrated/CustomSnippets/codeHintIntegration.js @@ -23,7 +23,6 @@ define(function (require, exports, module) { const EditorManager = require("editor/EditorManager"); const Metrics = require("utils/Metrics"); - const Global = require("./global"); const Driver = require("./driver"); const Helper = require("./helper"); const SnippetCursorManager = require("./snippetCursorManager"); @@ -84,7 +83,8 @@ define(function (require, exports, module) { if (matchingSnippets.length > 0) { const customSnippetHints = matchingSnippets.map((snippet) => { - return Helper.createHintItem(snippet.abbreviation, needle.word, snippet.description); + return Helper.createHintItem( + snippet.abbreviation, needle.word, snippet.description, snippet.insertionKey); }); return { @@ -108,34 +108,33 @@ define(function (require, exports, module) { insertHint: function (hint) { // check if the hint is a custom snippet if (hint && hint.jquery && hint.attr("data-isCustomSnippet")) { - // handle custom snippet insertion - const abbreviation = hint.attr("data-val"); - if (Global.SnippetHintsList) { - const matchedSnippet = Global.SnippetHintsList.find( - (snippet) => snippet.abbreviation === abbreviation - ); + // handle custom snippet insertion. The hint list was already built from the correctly + // language-scoped candidates (see getHints above), and each hint element carries the + // exact resolved snippet's insertionKey - so accepting it is a direct O(1) lookup, not + // a re-search by abbreviation + the (possibly since-changed) current language context + const insertionKey = hint.attr("data-insertion-key"); + + // Get current editor from EditorManager since it's not passed + const editor = EditorManager.getActiveEditor(); + if (editor) { + const matchedSnippet = Helper.getSnippetByInsertionKey(insertionKey); if (matchedSnippet) { - // Get current editor from EditorManager since it's not passed - const editor = EditorManager.getActiveEditor(); - - if (editor) { - // to track the usage metrics - const fileCategory = Helper.categorizeFileExtensionForMetrics(matchedSnippet.fileExtension); - Metrics.countEvent(Metrics.EVENT_TYPE.EDITOR, "snipt", `use.${fileCategory}`); - - // replace the typed abbreviation with the template text using cursor manager - const wordInfo = Driver.getWordBeforeCursor(); - const start = { line: wordInfo.line, ch: wordInfo.ch + 1 }; - const end = editor.getCursorPos(); - - SnippetCursorManager.insertSnippetWithTabStops( - editor, - matchedSnippet.templateText, - start, - end - ); - return true; // handled - } + // to track the usage metrics + const fileCategory = Helper.categorizeFileExtensionForMetrics(matchedSnippet.fileExtension); + Metrics.countEvent(Metrics.EVENT_TYPE.EDITOR, "snipt", `use.${fileCategory}`); + + // replace the typed abbreviation with the template text using cursor manager + const wordInfo = Driver.getWordBeforeCursor(); + const start = { line: wordInfo.line, ch: wordInfo.ch + 1 }; + const end = editor.getCursorPos(); + + SnippetCursorManager.insertSnippetWithTabStops( + editor, + matchedSnippet.templateText, + start, + end + ); + return true; // handled } } } diff --git a/src/extensionsIntegrated/CustomSnippets/defaultSnippets.js b/src/extensionsIntegrated/CustomSnippets/defaultSnippets.js new file mode 100644 index 0000000000..38e38ebdd6 --- /dev/null +++ b/src/extensionsIntegrated/CustomSnippets/defaultSnippets.js @@ -0,0 +1,114 @@ +/* + * 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. + * + */ + +define(function (require, exports, module) { + // INDENT is templateText's own indent-unit marker (see snippetCursorManager.js INDENT_TOKEN) - + // resolved to this specific file's actual detected/configured indent (spaces or tabs, whatever + // width) at insertion time, instead of hardcoding a literal " " that would look wrong the + // moment a user's file uses 2-space indent, tabs, etc. + const INDENT = require("./snippetCursorManager").INDENT_TOKEN; + + // These are literal code syntax shown verbatim in the hint tooltip, not natural-language prose - + // there is nothing in them for a translator to translate, so per the i18n rule in CLAUDE.md they + // are local constants here rather than strings.js keys (only genuinely translatable strings belong + // in strings.js; content that must render identically in every locale - like code syntax - does + // not, and should never be sent through the automated AI translation pass). + const FUNCTION_DESC = "function name() {...}"; + const ARROW_DESC = "const name = () => {...}"; + const PYTHON_FUNCTION_DESC = "def name(): ..."; + + /** + * Built-in snippets shipped with Phoenix (see https://github.com/phcode-dev/phoenix/issues/618). + * + * These are NOT persisted to the user's customSnippets.json and NOT part of `Global.SnippetHintsList` + * at all - they're merged into the matching engine's optimized structures directly (see helper.js + * `rebuildOptimizedStructures`), which is also why they never appear in the Custom Snippets panel + * (snippetsList.js/driver.js only ever read/write `Global.SnippetHintsList`, so a built-in is simply + * invisible to add/edit/delete there) and can't be user-edited or deleted. Because they're always + * re-derived straight from this file on every boot, template/description improvements in a later + * Phoenix release reach every user immediately - there's nothing to keep in sync. + * + * `isDefault: true` marks an entry as one of these built-ins (as opposed to a user-created snippet) + * wherever the merged/optimized snippet objects are inspected. + * + * `prefixTrigger: true` lets the hint pop up as soon as the user has typed a leading prefix of + * `abbreviation` (2+ chars - see helper.js `hasExactMatchingSnippet`), not only once the whole word + * is typed - e.g. typing "fu"/"fun"/"func" all offer the "function" entry. Regular user-created + * snippets never get this flag, so their exact-match-only behavior is unaffected. + */ + const DEFAULT_SNIPPETS = [ + { + id: "default-function", + isDefault: true, + abbreviation: "function", + prefixTrigger: true, + description: FUNCTION_DESC, + templateText: + "function ${1:name}(${2}) {\n" + + INDENT + "${0}\n" + + "}", + fileExtension: ".js, .jsx, .ts, .tsx" + }, + { + id: "default-arrow-function", + isDefault: true, + abbreviation: "arrow", + prefixTrigger: true, + description: ARROW_DESC, + templateText: + "const ${1:name} = (${2}) => {\n" + + INDENT + "${0}\n" + + "};", + fileExtension: ".js, .jsx, .ts, .tsx" + }, + { + // PHP genuinely uses the same `function` keyword as JS - deliberately shares the same + // abbreviation, scoped to .php only. Requires hasExactMatchingSnippet to check ALL + // same-named candidates per language (see helper.js) rather than a single Map winner. + id: "default-function-php", + isDefault: true, + abbreviation: "function", + prefixTrigger: true, + description: FUNCTION_DESC, + templateText: + "function ${1:name}(${2}) {\n" + + INDENT + "${0}\n" + + "}", + fileExtension: ".php" + }, + { + // Python has no braces - the body is indentation-scoped under the colon-terminated + // `def` line, so this needs its own trigger word ("def") and template shape entirely. + // Unlike JS/PHP, an empty body here is a real syntax error (IndentationError), so the + // final stop defaults to "pass" (selected, ready to type over) instead of being empty. + id: "default-function-python", + isDefault: true, + abbreviation: "def", + prefixTrigger: true, + description: PYTHON_FUNCTION_DESC, + templateText: + "def ${1:name}(${2}):\n" + + INDENT + "${0:pass}", + fileExtension: ".py" + } + ]; + + exports.DEFAULT_SNIPPETS = DEFAULT_SNIPPETS; +}); diff --git a/src/extensionsIntegrated/CustomSnippets/helper.js b/src/extensionsIntegrated/CustomSnippets/helper.js index 43713dcda5..89fd7d71ab 100644 --- a/src/extensionsIntegrated/CustomSnippets/helper.js +++ b/src/extensionsIntegrated/CustomSnippets/helper.js @@ -23,6 +23,7 @@ define(function (require, exports, module) { const Global = require("./global"); const UIHelper = require("./UIHelper"); const Strings = require("strings"); + const DefaultSnippets = require("./defaultSnippets"); // list of all the navigation and function keys that are allowed inside the input fields const ALLOWED_NAVIGATION_KEYS = [ @@ -56,7 +57,13 @@ define(function (require, exports, module) { // Optimized data structures for fast snippet lookups let snippetsByLanguage = new Map(); let snippetsByAbbreviation = new Map(); + let snippetsByInsertionKey = new Map(); let allSnippetsOptimized = []; + // small dedicated subset of allSnippetsOptimized (only built-in defaults ever set prefixTrigger - + // see defaultSnippets.js), scanned on every keystroke by hasExactMatchingSnippet below. Kept + // separate from allSnippetsOptimized so that scan's cost never grows with the user's own + // (potentially much larger) custom snippet count. + let prefixTriggerSnippets = []; /** * Preprocesses a snippet to add optimized lookup properties @@ -69,6 +76,12 @@ define(function (require, exports, module) { // pre-compute lowercase abbreviation for faster matching optimizedSnippet.abbreviationLower = snippet.abbreviation.toLowerCase(); + // a stable key identifying this exact snippet for insertion (see findSnippetForInsertion / + // getSnippetByInsertionKey) - built-ins use their unique `id`; regular user snippets don't + // have one, but driver.js already enforces globally-unique abbreviations for those on add, so + // the abbreviation itself is a safe stable key for them + optimizedSnippet.insertionKey = snippet.id || snippet.abbreviation; + // parse and create a Set of supported extensions for O(1) lookup if (snippet.fileExtension.toLowerCase() === "all") { optimizedSnippet.supportedLangSet = new Set(["all"]); @@ -90,20 +103,44 @@ define(function (require, exports, module) { * Rebuilds optimized data structures from the current snippet list * we call this function whenever snippets are loaded, added, modified, or deleted * i.e. whenever the snippetList is updated + * + * This is also where built-in default snippets (see defaultSnippets.js) get merged into the + * matching engine - they are NOT part of Global.SnippetHintsList and are never persisted/shown + * in the panel, they only exist here, in this optimized/derived view. */ function rebuildOptimizedStructures() { // clear existing structures snippetsByLanguage.clear(); snippetsByAbbreviation.clear(); + snippetsByInsertionKey.clear(); allSnippetsOptimized.length = 0; + prefixTriggerSnippets.length = 0; - // Process each snippet - Global.SnippetHintsList.forEach(snippet => { + // Process each snippet - user snippets first, so a user snippet that happens to share an + // abbreviation with a default is index-order-first (relevant only for iteration order, since + // matching itself checks every candidate for language support regardless of order) + Global.SnippetHintsList.concat(DefaultSnippets.DEFAULT_SNIPPETS).forEach(snippet => { const optimizedSnippet = preprocessSnippet(snippet); allSnippetsOptimized.push(optimizedSnippet); - // Index by abbreviation (lowercase) for exact matches - snippetsByAbbreviation.set(optimizedSnippet.abbreviationLower, optimizedSnippet); + if (optimizedSnippet.prefixTrigger) { + prefixTriggerSnippets.push(optimizedSnippet); + } + + // O(1) lookup by stable identity for insertion - see findSnippetForInsertion. Collisions + // aren't expected (built-ins key by unique `id`; user snippets key by their own + // abbreviation, which driver.js already enforces is unique among user snippets on add) - + // if one somehow occurs, last one indexed wins, same as any other Map.set. + snippetsByInsertionKey.set(optimizedSnippet.insertionKey, optimizedSnippet); + + // Index by abbreviation (lowercase) for exact matches. Multiple snippets CAN share the + // same abbreviation (e.g. "function" for both JS and PHP) as long as they're scoped to + // different, non-overlapping languages - so this maps to an array of candidates, not a + // single winner, and hasExactMatchingSnippet below checks all of them. + if (!snippetsByAbbreviation.has(optimizedSnippet.abbreviationLower)) { + snippetsByAbbreviation.set(optimizedSnippet.abbreviationLower, []); + } + snippetsByAbbreviation.get(optimizedSnippet.abbreviationLower).push(optimizedSnippet); // Index by supported languages/extensions if (optimizedSnippet.supportsAllLanguages) { @@ -366,24 +403,66 @@ define(function (require, exports, module) { return false; } + // Minimum characters the user must type before a `prefixTrigger` snippet (see defaultSnippets.js) + // is allowed to fire on a partial/in-progress prefix of its abbreviation, instead of only once + // fully typed. Guards against 1-character noise (e.g. every word starting with "f"). + const MIN_PREFIX_TRIGGER_LENGTH = 2; + /** - * Checks if there's at least one exact match for the query + * Checks if there's at least one matching snippet for the query - either an exact abbreviation + * match, or, for snippets opted into `prefixTrigger` (see defaultSnippets.js), a leading prefix + * of their abbreviation once the user has typed at least MIN_PREFIX_TRIGGER_LENGTH characters. + * Regular user-created snippets never set `prefixTrigger`, so they keep requiring an exact match, + * unaffected by this. + * + * Multiple snippets can share the same abbreviation across different languages (e.g. "function" + * for both JS and PHP) - every candidate for a given abbreviation/prefix is checked against the + * current language context, so one language's entry never shadows another's. * @param {string} query - The search query * @param {Editor} editor - The editor instance - * @returns {boolean} - True if there's an exact match + * @returns {boolean} - True if there's a matching snippet */ function hasExactMatchingSnippet(query, editor) { const queryLower = query.toLowerCase(); const languageContext = getCurrentLanguageContext(editor); - const snippet = snippetsByAbbreviation.get(queryLower); - if (snippet) { - return isSnippetSupportedInLanguageContext(snippet, languageContext, editor); + const exactCandidates = snippetsByAbbreviation.get(queryLower); + if (exactCandidates && exactCandidates.some((snippet) => + isSnippetSupportedInLanguageContext(snippet, languageContext, editor))) { + return true; + } + + if (queryLower.length >= MIN_PREFIX_TRIGGER_LENGTH) { + // scoped to the small prefixTriggerSnippets subset (built-ins only), not the user's full + // (potentially much larger) snippet list - see its declaration for why + const hasPrefixMatch = prefixTriggerSnippets.some((snippet) => + snippet.abbreviationLower.startsWith(queryLower) && + isSnippetSupportedInLanguageContext(snippet, languageContext, editor) + ); + if (hasPrefixMatch) { + return true; + } } return false; } + /** + * Looks up the exact snippet to insert by its insertionKey (see preprocessSnippet) - an O(1) Map + * lookup, not a re-derivation of language context. The hint list shown to the user was already + * built from the correctly language-scoped candidates (getMatchingSnippets/hasExactMatchingSnippet + * run at hint-display time); each rendered hint element carries the exact resolved snippet's + * insertionKey (see createHintItem), so accepting a hint just needs to look that key up directly - + * whatever was shown is exactly what gets inserted, with no risk of re-resolving to a different + * snippet than the one actually displayed (e.g. if the cursor's language context could ever change + * between the hint being shown and being accepted). + * @param {string} insertionKey - the `data-insertion-key` carried by the accepted hint element + * @returns {Object|null} the matching snippet, or null if not found + */ + function getSnippetByInsertionKey(insertionKey) { + return snippetsByInsertionKey.get(insertionKey) || null; + } + /** * Gets all snippets that match the query (prefix matches) * @param {string} query - The search query @@ -450,14 +529,21 @@ define(function (require, exports, module) { * @param {String} abbr - the abbreviation text that is to be displayed in the code hint * @param {String} query - the query string typed by the user for highlighting matching characters * @param {String} description - the description of the snippet to be displayed + * @param {String} [insertionKey] - the exact snippet's insertionKey (see preprocessSnippet), + * carried on the element so insertHint can look it up directly (getSnippetByInsertionKey) + * instead of re-resolving by abbreviation + current language context at accept time * @returns {JQuery} - the jquery item that has the abbr text and the Snippet icon */ - function createHintItem(abbr, query, description) { + function createHintItem(abbr, query, description, insertionKey) { var $hint = $("") .addClass("brackets-css-hints brackets-hints custom-snippets-hint") .attr("data-val", abbr) .attr("data-isCustomSnippet", true); + if (insertionKey !== undefined && insertionKey !== null) { + $hint.attr("data-insertion-key", insertionKey); + } + // add the tooltip for the description shown when the hint is hovered if (description && description.trim() !== "") { $hint.attr("title", description.trim()); @@ -917,6 +1003,7 @@ define(function (require, exports, module) { exports.isSnippetSupportedInLanguageContext = isSnippetSupportedInLanguageContext; exports.isSnippetSupportedInFile = isSnippetSupportedInFile; exports.hasExactMatchingSnippet = hasExactMatchingSnippet; + exports.getSnippetByInsertionKey = getSnippetByInsertionKey; exports.getMatchingSnippets = getMatchingSnippets; exports.sanitizeFileExtensionInput = sanitizeFileExtensionInput; exports.handleFileExtensionInput = handleFileExtensionInput; diff --git a/src/extensionsIntegrated/CustomSnippets/main.js b/src/extensionsIntegrated/CustomSnippets/main.js index e88122f448..7b1b843cc3 100644 --- a/src/extensionsIntegrated/CustomSnippets/main.js +++ b/src/extensionsIntegrated/CustomSnippets/main.js @@ -290,7 +290,9 @@ define(function (require, exports, module) { _addToMenu(); CodeHintIntegration.init(); - // load snippets from file storage + // load snippets from file storage. Built-in default snippets (see defaultSnippets.js) are + // NOT part of this user data - they're merged directly into the matching engine's optimized + // structures (see helper.js rebuildOptimizedStructures), so they never touch this file. const _snippetsLoadedPromise = SnippetsState.loadSnippetsFromState() .then(function () { // track boot-time snippet count (only if user has snippets) @@ -304,8 +306,6 @@ define(function (require, exports, module) { logger.reportError(error, "Custom Snippets: didn't load on app init"); }); - SnippetCursorManager.registerHandlers(); - // Expose modules for integration testing if (brackets.test) { brackets.test.CustomSnippetsGlobal = Global; @@ -313,6 +313,7 @@ define(function (require, exports, module) { brackets.test.CustomSnippetsCursorManager = SnippetCursorManager; brackets.test.CustomSnippetsCodeHintHandler = CodeHintIntegration._CustomSnippetsHandler; brackets.test.CustomSnippetsDriver = Driver; + brackets.test.CustomSnippetsState = SnippetsState; brackets.test._customSnippetsLoadedPromise = _snippetsLoadedPromise; } }); diff --git a/src/extensionsIntegrated/CustomSnippets/snippetCursorManager.js b/src/extensionsIntegrated/CustomSnippets/snippetCursorManager.js index a7ce3de810..ee71c83a8d 100644 --- a/src/extensionsIntegrated/CustomSnippets/snippetCursorManager.js +++ b/src/extensionsIntegrated/CustomSnippets/snippetCursorManager.js @@ -20,124 +20,76 @@ define(function (require, exports, module) { const KeyEvent = require("utils/KeyEvent"); - const EditorManager = require("editor/EditorManager"); - - // tab stops regex to handle ${1}, ${2}.... etc. - const TAB_STOP_REGEX = /\$\{(\d+)\}/g; - - // this is to check whether an active snippet session is on or off - let activeSnippetSession = null; + const TabstopManager = require("editor/TabstopManager"); + const Editor = require("editor/Editor").Editor; /** - * this represents an active snippet session with tab stops + * Marker a snippet's templateText can use to mean "one indent level, in whatever this specific + * file/editor is actually configured/detected to use" (spaces vs tabs, and how many) - resolved by + * resolveIndentToken below, entirely within this module's own preprocessing, before the text ever + * reaches TabstopManager's shared LSP-grammar parser. That's deliberate: TabstopManager is also + * used directly by LSP completions and DocCommentHints (see editor/TabstopManager.js), and this + * marker is never taught to THAT shared parser at all - by the time TabstopManager sees the text, + * this token has already been fully replaced with literal characters, so there is nothing here for + * it to interpret, and real LSP-served snippet text (which never passes through this module) can + * never trigger this substitution either. Deliberately NOT `$`-prefixed, so it can never collide + * with real `${...}` tab-stop/placeholder syntax even if this substitution were ever skipped - it + * would just show up as this literal, obviously-wrong-looking text instead of silently misbehaving. */ - function SnippetSession(editor, tabStops, startLine, endLine) { - this.editor = editor; - this.tabStops = tabStops; // this is an array of {number, line} sorted by number - this.currentTabNumber = tabStops.length > 0 ? tabStops[0].number : 1; - this.startLine = startLine; - this.endLine = endLine; - this.isActive = true; - } + const INDENT_TOKEN = "@@INDENT@@"; /** - * this function is responsible to parse the template text and extract all the tab stops + * Resolves what "one indent level" literally looks like right now for the given editor's file - + * same auto-detection + project/language preference cascade Phoenix's own Tab-key handling uses + * (see Editor.getUseTabChar/getSpaceUnits), so it always matches what pressing Tab in that file + * would actually insert. * - * @param {string} templateText - the template text with tab stops - * @returns {Object} - Object containing the text and tab stop information + * @param {Editor} editor - the editor instance being inserted into + * @returns {string} - e.g. " " or "\t", scoped to this specific file/language/project */ - function parseTemplateText(templateText) { - const tabStops = []; - let match; - - // reset regex - TAB_STOP_REGEX.lastIndex = 0; - - // find all the tab stops - while ((match = TAB_STOP_REGEX.exec(templateText)) !== null) { - const tabNumber = parseInt(match[1], 10); - tabStops.push({ - number: tabNumber - }); - } - - // sort the tab stops by number. note: 0 should come at last - tabStops.sort((a, b) => { - if (a.number === 0) { - return 1; - } - if (b.number === 0) { - return -1; - } - return a.number - b.number; - }); - - return { - text: templateText, - tabStops: tabStops - }; + function getOneIndentUnit(editor) { + const fullPath = editor && editor.document && editor.document.file && editor.document.file.fullPath; + return Editor.getUseTabChar(fullPath) ? "\t" : " ".repeat(Editor.getSpaceUnits(fullPath)); } /** - * Find tab stops in the snippet lines and return their positions - * this is called after snippet insertion to find actual positions in the editor + * Replaces every INDENT_TOKEN in templateText with the current editor's actual one-indent-level + * string. See INDENT_TOKEN's own doc comment for why this is a plain string substitution done here + * rather than new tab-stop syntax taught to the shared TabstopManager parser. * - * @param {Editor} editor - editor instance - * @param {number} startLine - Start line of snippet - * @param {number} endLine - End line of snippet - * @returns {Array} - array of {number, line, start, end} sorted by number + * @param {string} templateText - the raw template text, may contain zero or more INDENT_TOKENs + * @param {Editor} editor - the editor instance being inserted into + * @returns {string} - templateText with every INDENT_TOKEN replaced */ - function findTabStops(editor, startLine, endLine) { - const tabStops = []; - const document = editor.document; - - for (let line = startLine; line <= endLine; line++) { - const lineText = document.getLine(line); - let match; - - TAB_STOP_REGEX.lastIndex = 0; - while ((match = TAB_STOP_REGEX.exec(lineText)) !== null) { - const tabNumber = parseInt(match[1], 10); - tabStops.push({ - number: tabNumber, - line: line, - start: { line: line, ch: match.index }, - end: { line: line, ch: match.index + match[0].length } - }); - } + function resolveIndentToken(templateText, editor) { + if (templateText.indexOf(INDENT_TOKEN) === -1) { + return templateText; // fast path - most snippets (all user-authored ones, today) skip this } - - tabStops.sort((a, b) => { - if (a.number === 0) { - return 1; - } - if (b.number === 0) { - return -1; - } - return a.number - b.number; - }); - - return tabStops; + const unit = getOneIndentUnit(editor); + return templateText.split(INDENT_TOKEN).join(unit); } /** - * responsible to check if session should continue (tab stops still exist in template area) - * we need this because users can delete tab stops while typing + * Custom snippet templateText historically only ever recognized the braced form `${1}` as a + * tab stop (regex `/\$\{(\d+)\}/g`) - a bare `$1`, `$scope`, `$5`, etc. was always just literal + * text. TabstopManager understands the fuller LSP snippet grammar (bare `$1` tab stops, `${VAR}` + * variables that get silently dropped if unresolved, `${1:default}` placeholders, `\$`/`\}`/`\\` + * escapes). To keep every already-saved snippet behaving exactly as before after this migration, + * we escape every '$' that isn't immediately starting a `${...}` group before handing the text to + * TabstopManager - this way only the braced forms are ever treated as snippet syntax, exactly + * matching the old engine's behavior, while additively allowing `${1:default text}` and + * `${1|a,b,c|}` for anyone (including our own default snippets) who wants richer placeholders. * - * @returns {boolean} + * @param {string} text - the raw template text + * @returns {string} - text with any bare (non-`${`) '$' escaped as '\$' */ - function shouldContinueSession() { - if (!activeSnippetSession || !activeSnippetSession.isActive) { - return false; - } - - const session = activeSnippetSession; - const tabStops = findTabStops(session.editor, session.startLine, session.endLine); - - // update the session with current tab stops - session.tabStops = tabStops; - - return tabStops.length > 0; + function escapeBareDollarSigns(text) { + // escape pre-existing literal backslashes first, so they aren't misread as introducing a + // \$, \}, \\ escape sequence once the next step injects backslashes next to '$' characters + let escaped = text.replace(/\\/g, "\\\\"); + // escape every '$' not immediately followed by '{' + escaped = escaped.replace(/\$(?!\{)/g, "\\$"); + return escaped; } /** @@ -202,333 +154,102 @@ define(function (require, exports, module) { * @param {Object} endPos - End position for insertion */ function insertSnippetWithTabStops(editor, templateText, startPos, endPos) { - const parsed = parseTemplateText(templateText); + // Resolve any INDENT_TOKEN to this file's actual indent unit first, so everything downstream + // just sees plain literal characters - see resolveIndentToken's doc comment for why this must + // happen before escaping/parsing, not as new syntax taught to the shared TabstopManager parser. + const withIndentResolved = resolveIndentToken(templateText, editor); + + const escapedText = escapeBareDollarSigns(withIndentResolved); // Get the current line's indentation to apply to all subsequent lines const baseIndent = getLineIndentation(editor, startPos); // Apply proper indentation to the snippet text for multi-line snippets - const indentedText = addIndentationToSnippet(parsed.text, baseIndent); + const indentedText = addIndentationToSnippet(escapedText, baseIndent); - editor.document.replaceRange(indentedText, startPos, endPos); - - // calculate snippet bounds - const lines = indentedText.split("\n"); - const startLine = startPos.line; - const endLine = startPos.line + lines.length - 1; - - // find tab stops in the inserted snippet - const tabStops = findTabStops(editor, startLine, endLine); - - if (tabStops.length > 0) { - activeSnippetSession = new SnippetSession(editor, tabStops, startLine, endLine); - - // move to first tab stop. this is the default behaviour - navigateToTabStop(activeSnippetSession.currentTabNumber); - } else { - // when no tab stops, we just place cursor at end - const finalPos = { - line: endLine, - ch: lines.length === 1 ? startPos.ch + lines[0].length : lines[lines.length - 1].length - }; - editor.setCursorPos(finalPos); - } + return TabstopManager.insertSnippet(editor, indentedText, startPos, endPos); } /** - * Navigate to a specific tab stop by number - * @param {number} tabNumber - Tab stop number to navigate to + * Check if we're currently in a snippet session + * @returns {boolean} */ - function navigateToTabStop(tabNumber) { - if (!shouldContinueSession()) { - endSnippetSession(); - return; - } - - const session = activeSnippetSession; - - // find the tab stop with the specified number - const tabStop = session.tabStops.find((t) => t.number === tabNumber); - - if (tabStop) { - session.currentTabNumber = tabNumber; + function isInSnippetSession() { + return TabstopManager.hasActiveSession(); + } - // select the entire tab stop placeholder - session.editor.setSelection(tabStop.start, tabStop.end); - session.editor.focus(); - } else { - endSnippetSession(); - } + /** + * End the current snippet session + */ + function endSnippetSession() { + TabstopManager.endSession(); } /** * Navigate to the next tab stop - * this handles the logic for finding the next available tab stop in sequence + * @returns {boolean} true if a session was active and navigation happened */ function navigateToNextTabStop() { - if (!shouldContinueSession()) { - endSnippetSession(); + if (!TabstopManager.hasActiveSession()) { return false; } - - const session = activeSnippetSession; - const currentNumber = session.currentTabNumber; - - let nextTabStop = null; - - // If we're currently at ${0}, there's no next tab stop so we need to end the session - if (currentNumber === 0) { - endSnippetSession(); - return false; - } - - // at first, look for the next numbered tab stop (greater than current) - for (let i = 0; i < session.tabStops.length; i++) { - if (session.tabStops[i].number > currentNumber && session.tabStops[i].number !== 0) { - nextTabStop = session.tabStops[i]; - break; - } - } - - // If no numbered tab stop found, look for ${0} as the final stop - if (!nextTabStop) { - nextTabStop = session.tabStops.find((t) => t.number === 0); - } - - if (nextTabStop) { - navigateToTabStop(nextTabStop.number); - return true; - } - endSnippetSession(); - return false; + TabstopManager.goToNextStop(); + return true; } /** * Navigate to the previous tab stop - * this handles shift+tab navigation to go backwards + * @returns {boolean} true if a session was active and navigation happened */ function navigateToPreviousTabStop() { - if (!shouldContinueSession()) { - endSnippetSession(); - return false; - } - - const session = activeSnippetSession; - const currentNumber = session.currentTabNumber; - - // Find the previous tab stop number in the sorted array - let prevTabStop = null; - - // If we're currently at ${0}, find the highest numbered tab stop - if (currentNumber === 0) { - let maxNumber = -1; - for (let i = 0; i < session.tabStops.length; i++) { - if (session.tabStops[i].number !== 0 && session.tabStops[i].number > maxNumber) { - maxNumber = session.tabStops[i].number; - prevTabStop = session.tabStops[i]; - } - } - } else { - // Find the previous numbered tab stop (less than current, but not 0) - for (let i = session.tabStops.length - 1; i >= 0; i--) { - if (session.tabStops[i].number < currentNumber && session.tabStops[i].number !== 0) { - prevTabStop = session.tabStops[i]; - break; - } - } - } - - if (prevTabStop) { - navigateToTabStop(prevTabStop.number); - return true; - } - return false; - } - - /** - * End the current snippet session - * this cleans up all remaining tab stop placeholders and resets the session - */ - function endSnippetSession() { - if (activeSnippetSession) { - const session = activeSnippetSession; - - // Remove any remaining tab stop placeholders - const tabStops = findTabStops(session.editor, session.startLine, session.endLine); - tabStops.reverse().forEach((tabStop) => { - session.editor.document.replaceRange("", tabStop.start, tabStop.end); - }); - - activeSnippetSession.isActive = false; - activeSnippetSession = null; - } - } - - /** - * Check if we're currently in a snippet session - * @returns {boolean} - */ - function isInSnippetSession() { - return activeSnippetSession && activeSnippetSession.isActive; - } - - /** - * Check if cursor is within snippet lines - * we need this to end the session if user moves cursor outside the snippet area - * - * @param {Object} cursorPos - Current cursor position - * @returns {boolean} - */ - function isCursorInSnippetLines(cursorPos) { - if (!activeSnippetSession) { + if (!TabstopManager.hasActiveSession()) { return false; } - - return cursorPos.line >= activeSnippetSession.startLine && cursorPos.line <= activeSnippetSession.endLine; + TabstopManager.goToPreviousStop(); + return true; } /** - * Handle key events for tab navigation - * this is where all the tab/shift+tab/escape key handling happens + * Handle key events for tab navigation. + * NOTE: real Tab/Shift-Tab/Esc handling during an active session is now owned by + * TabstopManager's own CodeMirror keymap (installed per-session in insertSnippet). This + * function is kept only as a thin compatibility shim for callers/tests that dispatch a + * synthesized key event directly instead of going through the real DOM/CodeMirror path. * - * @param {Event} jqEvent - jQuery event + * @param {Event} jqEvent - jQuery event (unused, kept for signature compatibility) * @param {Editor} editor - Editor instance * @param {KeyboardEvent} event - Keyboard event */ function handleKeyEvent(jqEvent, editor, event) { - if (!isInSnippetSession() || activeSnippetSession.editor !== editor) { - return false; - } - - // make sure that the cursor is still within snippet lines - const cursorPos = editor.getCursorPos(); - if (!isCursorInSnippetLines(cursorPos)) { - endSnippetSession(); + if (!TabstopManager.hasActiveSession()) { return false; } - // Tab key handling if (event.keyCode === KeyEvent.DOM_VK_TAB) { - if (event.shiftKey) { - // Shift+Tab: go to previous tab stop - if (navigateToPreviousTabStop()) { - event.preventDefault(); - return true; - } - } else { - // Tab: go to next tab stop - if (navigateToNextTabStop()) { - event.preventDefault(); - return true; - } + const moved = event.shiftKey ? navigateToPreviousTabStop() : navigateToNextTabStop(); + if (moved) { + event.preventDefault(); + return true; } } - // 'Esc' key to end snippet session if (event.keyCode === KeyEvent.DOM_VK_ESCAPE) { endSnippetSession(); event.preventDefault(); return true; } - // handle Delete/Backspace - check if session should continue - // we need this because users might delete the template text from the editor - if (event.keyCode === KeyEvent.DOM_VK_DELETE || event.keyCode === KeyEvent.DOM_VK_BACK_SPACE) { - // just to let the delete/backspace complete - setTimeout(() => { - if (!shouldContinueSession()) { - endSnippetSession(); - } - }, 10); - } - return false; } - /** - * Handle cursor position changes - * this ends the session if user moves cursor outside snippet bounds or creates multiple selections - * @param {Event} event - Cursor activity event - * @param {Editor} editor - Editor instance - */ - function handleCursorActivity(event, editor) { - if (!isInSnippetSession() || activeSnippetSession.editor !== editor) { - return; - } - - // end session if user creates multiple selections - if (editor.getSelections().length > 1) { - endSnippetSession(); - return; - } - - const cursorPos = editor.getCursorPos(); - if (!isCursorInSnippetLines(cursorPos)) { - endSnippetSession(); - } - } - - /** - * This function is responsible to register all the required handers - * we need this to set up all the event listeners for cursor navigation - */ - function registerHandlers() { - // register the event handler for snippet cursor navigation - const editorHolder = $("#editor-holder")[0]; - if (editorHolder) { - editorHolder.addEventListener( - "keydown", - function (event) { - const editor = EditorManager.getActiveEditor(); - if (editor) { - handleKeyEvent(null, editor, event); - } - }, - true - ); - } - - // Listen for editor changes to end snippet sessions - EditorManager.on("activeEditorChange", function (event, current, previous) { - if (isInSnippetSession()) { - endSnippetSession(); - } - }); - - // Register cursor activity handler for current and future editors - function registerCursorActivityForEditor(editor) { - if (editor) { - editor.on("cursorActivity", handleCursorActivity); - } - } - - // Register for current editor - const currentEditor = EditorManager.getActiveEditor(); - if (currentEditor) { - registerCursorActivityForEditor(currentEditor); - } - - // Register for editor changes - EditorManager.on("activeEditorChange", function (event, current, previous) { - if (previous) { - previous.off("cursorActivity", handleCursorActivity); - } - if (current) { - registerCursorActivityForEditor(current); - } - if (isInSnippetSession()) { - endSnippetSession(); - } - }); - } - - exports.parseTemplateText = parseTemplateText; + exports.escapeBareDollarSigns = escapeBareDollarSigns; exports.insertSnippetWithTabStops = insertSnippetWithTabStops; exports.isInSnippetSession = isInSnippetSession; exports.handleKeyEvent = handleKeyEvent; - exports.handleCursorActivity = handleCursorActivity; exports.endSnippetSession = endSnippetSession; - exports.registerHandlers = registerHandlers; exports.navigateToNextTabStop = navigateToNextTabStop; // exposed for integration testing exports.navigateToPreviousTabStop = navigateToPreviousTabStop; // exposed for integration testing + exports.INDENT_TOKEN = INDENT_TOKEN; // referenced by defaultSnippets.js templateText + exports.resolveIndentToken = resolveIndentToken; // exposed for unit testing + exports.getOneIndentUnit = getOneIndentUnit; // exposed for unit testing }); diff --git a/src/extensionsIntegrated/Phoenix-live-preview/images/sprites.svg b/src/extensionsIntegrated/Phoenix-live-preview/images/sprites.svg index 721b454884..2d50de3e0c 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/images/sprites.svg +++ b/src/extensionsIntegrated/Phoenix-live-preview/images/sprites.svg @@ -7,20 +7,20 @@ ]]> - + - + - + - + diff --git a/src/extensionsIntegrated/Phoenix-live-preview/live-preview.css b/src/extensionsIntegrated/Phoenix-live-preview/live-preview.css index 1f6c64d549..40fcf5e973 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/live-preview.css +++ b/src/extensionsIntegrated/Phoenix-live-preview/live-preview.css @@ -1,11 +1,23 @@ :root { --toolbar-height: 30px; + --lp-toolbar-btn: 24px; + --lp-toolbar-chevron: 14px; + --lp-toolbar-gap: 6px; + --lp-toolbar-inset: 6px; + --lp-toolbar-fg: #a0a0a0; + --lp-toolbar-border: rgba(255, 255, 255, 0.1); } .live-preview-browser-btn { opacity: 0; visibility: hidden; - transition: opacity 1s, visibility 0s linear 1s; + transition: opacity 1s, visibility 0s linear 1s; +} + +.live-preview-browser-btn img { + display: block; + width: 14px; + height: 14px; } #live-preview-plugin-toolbar { @@ -204,17 +216,21 @@ .plugin-toolbar { height: var(--toolbar-height); - color: #a0a0a0; - display: flex; - justify-content: center; - align-content: center; - flex-direction: column; + color: var(--lp-toolbar-fg); } .toolbar-button { + width: var(--lp-toolbar-btn); + height: var(--lp-toolbar-btn); + padding: 0; + margin: 0; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-sizing: border-box; background-color: transparent; - width: 28px; - height: 22px; + color: var(--lp-toolbar-fg); } #live-preview-plugin-toolbar .btn-alt-quiet:hover, @@ -238,23 +254,19 @@ } .open-icon { - background: url("./images/sprites.svg#open-icon") no-repeat 72.5%; - width: 30px; - margin-left: 4px; + background: url("./images/sprites.svg#open-icon") center / 20px no-repeat; } .pin-icon { - background: url("./images/sprites.svg#pinned-icon") no-repeat 72.5%; - width: 30px; + background: url("./images/sprites.svg#pinned-icon") center / 20px no-repeat; } .unpin-icon { - background: url("./images/sprites.svg#unpinned-icon") no-repeat 72.5%; - width: 30px; + background: url("./images/sprites.svg#unpinned-icon") center / 20px no-repeat; } .reload-icon { - background: url("./images/sprites.svg#reload-icon") center no-repeat; + background: url("./images/sprites.svg#reload-icon") center / 20px no-repeat; } #live-preview-plugin-toolbar:hover .lp-settings-icon { @@ -271,42 +283,8 @@ .lp-settings-icon { opacity: 0; - color: #a0a0a0; visibility: hidden; - transition: opacity 1s, visibility 0s linear 1s; - width: 30px; - height: 22px; - padding: 1px 6px; - flex-shrink: 0; - margin-top: 0; -} - -.lp-device-size-btn-group { - display: flex; - align-items: center; - flex-shrink: 0; - margin: 0 4px 0 3px; - 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; - margin: 0; + transition: opacity 1s, visibility 0s linear 1s; } .lp-device-size-icon:hover, @@ -317,25 +295,8 @@ 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; -} - .lp-device-size-btn-group:hover .lp-device-size-dropdown-chevron { - border-left-color: rgba(255, 255, 255, 0.1); + border-left-color: var(--lp-toolbar-border); } .lp-device-size-dropdown-chevron:hover, @@ -381,46 +342,37 @@ flex-direction: row; } -.lp-toolbar-left { +.lp-toolbar-left, +.lp-toolbar-centre, +.lp-toolbar-right { display: flex; align-items: center; - gap: 6px; - padding-left: 6px; + gap: var(--lp-toolbar-gap); box-sizing: border-box; - flex-shrink: 0; +} + +.lp-toolbar-left { + flex: 1 1 0; + padding-left: var(--lp-toolbar-inset); } .lp-toolbar-centre { - flex: 1; + flex: 0 1 auto; min-width: 0; - display: flex; justify-content: center; - align-items: center; overflow: hidden; } .lp-toolbar-right { - display: flex; - align-items: center; + flex: 1 1 0; + padding-right: var(--lp-toolbar-inset); justify-content: flex-end; - flex-shrink: 0; } #reloadLivePreviewButton, #designModeToggleLivePreviewButton { - width: 28px; - height: 24px; - margin: 0; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - background: transparent; - color: #a0a0a0; border: 1px solid transparent; border-radius: 3px; - box-sizing: border-box; - flex-shrink: 0; } #live-preview-plugin-toolbar #reloadLivePreviewButton:hover, @@ -430,10 +382,11 @@ box-shadow: none !important; } -.lp-mode-btn-group { +.lp-mode-btn-group, +.lp-device-size-btn-group { display: flex; align-items: stretch; - height: 24px; + height: var(--lp-toolbar-btn); margin: 0; border: 1px solid transparent; border-radius: 3px; @@ -441,23 +394,26 @@ flex-shrink: 0; } -.lp-mode-btn-group:hover { - border-color: rgba(255, 255, 255, 0.1); +.lp-mode-btn-group:hover, +.lp-device-size-btn-group:hover { + border-color: var(--lp-toolbar-border); } -.lp-mode-icon { +.lp-mode-icon, +.lp-device-size-icon { display: flex; align-items: center; justify-content: center; - width: 24px; + width: var(--lp-toolbar-btn); height: 100%; + padding: 0; + margin: 0; + flex-shrink: 0; cursor: pointer; background: transparent; box-shadow: none !important; border: none; - color: #a0a0a0; - padding: 0; - margin: 0; + color: var(--lp-toolbar-fg); } #live-preview-plugin-toolbar .lp-mode-icon:hover, @@ -472,26 +428,28 @@ color: #FBB03B; } -.lp-mode-dropdown-chevron { +.lp-mode-dropdown-chevron, +.lp-device-size-dropdown-chevron { display: flex; align-items: center; justify-content: center; - width: 14px; + width: var(--lp-toolbar-chevron); height: 100%; + padding: 0; + margin: 0; + flex-shrink: 0; cursor: pointer; background: transparent; box-shadow: none !important; border: none; border-left: 1px solid transparent; border-radius: 0; - color: #a0a0a0; - padding: 0; - margin: 0; + color: var(--lp-toolbar-fg); font-size: 10px; } .lp-mode-btn-group:hover .lp-mode-dropdown-chevron { - border-left-color: rgba(255, 255, 255, 0.1); + border-left-color: var(--lp-toolbar-border); } #live-preview-plugin-toolbar .lp-mode-dropdown-chevron:hover, diff --git a/src/extensionsIntegrated/Terminal/TerminalInstance.js b/src/extensionsIntegrated/Terminal/TerminalInstance.js index e0afb59b98..2d6539b765 100644 --- a/src/extensionsIntegrated/Terminal/TerminalInstance.js +++ b/src/extensionsIntegrated/Terminal/TerminalInstance.js @@ -41,11 +41,19 @@ define(function (require, exports, module) { /** - * Read terminal theme colors from CSS variables + * Read terminal theme colors from CSS variables. + * @param {Element} [baseEl] - Element to resolve --terminal-* custom + * properties from (CSS custom properties inherit down the DOM tree, + * so this instance picks up whichever ancestor defines them). Pass + * the instance's own container so each embedding context controls + * its own theme — e.g. the bottom Terminal panel's light/dark-aware + * .terminal-panel-container vs. the always-dark AI sidebar's fixed + * palette (see .ai-chat-body-cli in Extn-AIChatPanel.less). Falls + * back to the old global lookup when omitted. * @returns {Object} xterm.js theme object */ - function _getThemeFromCSS() { - const panelEl = document.querySelector('.terminal-panel-container') || document.documentElement; + function _getThemeFromCSS(baseEl) { + const panelEl = baseEl || document.querySelector('.terminal-panel-container') || document.documentElement; const style = getComputedStyle(panelEl); function v(name) { return style.getPropertyValue(name).trim() || undefined; @@ -122,7 +130,7 @@ define(function (require, exports, module) { // Create xterm.js instance this.terminal = new Terminal({ - theme: _getThemeFromCSS(), + theme: _getThemeFromCSS(this.$container[0]), fontFamily: "'Menlo', 'DejaVu Sans Mono', 'Consolas', 'Lucida Console', monospace", fontSize: 13, lineHeight: 1.2, @@ -189,8 +197,11 @@ define(function (require, exports, module) { /** * Spawn the PTY process on the Node side + * @param {Object} [env] - Extra environment variables to merge into the + * PTY's process env (e.g. custom API endpoint overrides). Optional — + * existing callers that omit it are unaffected. */ - TerminalInstance.prototype.spawn = async function () { + TerminalInstance.prototype.spawn = async function (env) { const dims = this.fitAddon.proposeDimensions(); try { const result = await this.nodeConnector.execPeer("createTerminal", { @@ -199,7 +210,8 @@ define(function (require, exports, module) { args: this.shellProfile.args || [], cwd: this.cwd, cols: dims ? dims.cols : 80, - rows: dims ? dims.rows : 24 + rows: dims ? dims.rows : 24, + env: env || undefined }); this.pid = result.pid; this.isAlive = true; @@ -406,7 +418,7 @@ define(function (require, exports, module) { */ TerminalInstance.prototype.updateTheme = function () { if (this.terminal) { - this.terminal.options.theme = _getThemeFromCSS(); + this.terminal.options.theme = _getThemeFromCSS(this.$container ? this.$container[0] : null); } }; diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index a9e10ae4e1..060136b47f 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -1008,6 +1008,20 @@ define(function (require, exports, module) { exports.CMD_VIEW_TERMINAL = CMD_VIEW_TERMINAL; exports.CMD_NEW_TERMINAL = CMD_NEW_TERMINAL; + /** + * Get the shared "phoenix_terminal" NodeConnector so other extensions + * (e.g. the AI chat panel's embedded CLI terminal) can spawn their own + * independent TerminalInstance without registering a second connector + * on the same id, which throws. Safe to call any time after boot — + * _initNodeConnector() runs unconditionally on AppInit.appReady, before + * any user interaction. + * @return {Object|null} The terminal NodeConnector, or null if this is + * not a native app build (terminal is unavailable there). + */ + exports.getNodeConnector = function () { + return nodeConnector; + }; + if (Phoenix.isTestWindow) { exports._getActiveTerminal = _getActiveTerminal; exports._refreshAllProcesses = _refreshAllProcesses; diff --git a/src/features/BeautificationManager.js b/src/features/BeautificationManager.js index 9198879038..4908e458b8 100644 --- a/src/features/BeautificationManager.js +++ b/src/features/BeautificationManager.js @@ -323,6 +323,10 @@ define(function (require, exports, module) { if(!_isBeautifyOnSaveEnabled() || !editor || editor.document.file.fullPath !== doc.file.fullPath){ return; } + if(!_getEnabledProviders(doc.file.fullPath).length){ + // no beautify provider registered for this file type, silently skip instead of showing an error. + return; + } editor.clearSelection(); _beautifyCommand(); } diff --git a/src/features/QuickViewManager.js b/src/features/QuickViewManager.js index 17a47f7adf..2970f9fd48 100644 --- a/src/features/QuickViewManager.js +++ b/src/features/QuickViewManager.js @@ -241,6 +241,23 @@ define(function (require, exports, module) { animationRequest, quickViewLocked = false; + // True while some other menu-like UI is open - the top menu bar, a context menu (including the + // editor's right-click menu), the autocomplete Code Hints list, or an InlineMenu picker like + // jump-to-definition's multi-target picker (see languageTools/DefaultProviders.js) or Extract + // to Variable/Function. QuickView shouldn't pop up while any of those has the user's attention. + // All of them share the same underlying convention - a `