diff --git a/src/assets/default-project/en/Newly_added_features.md b/src/assets/default-project/en/Newly_added_features.md index 42936b1729..552c1e2f35 100644 --- a/src/assets/default-project/en/Newly_added_features.md +++ b/src/assets/default-project/en/Newly_added_features.md @@ -72,6 +72,14 @@ A new tools drawer brings Git, Terminal, Problems, and more into one place. Swit ![Image](https://docs-images.phcode.dev/in-app/bottom-panel.png) +## New Linux Platform + +`Added in April 2026` + +Phoenix Code for Linux has been rebuilt from the ground up. + +The previous Linux app was harder to install and didn’t match the experience we wanted to deliver. We heard that feedback loud and clear. This release is powered by a brand-new Linux platform, bringing faster performance, easier installation, and a desktop experience that now stands alongside Windows and macOS. + ## [Phoenix Neo Themes](https://docs.phcode.dev/app-links/themes) `Added in April 2026` diff --git a/src/extensionsIntegrated/Phoenix/phoenix-tour.js b/src/extensionsIntegrated/Phoenix/phoenix-tour.js index 33f8241764..c600789338 100644 --- a/src/extensionsIntegrated/Phoenix/phoenix-tour.js +++ b/src/extensionsIntegrated/Phoenix/phoenix-tour.js @@ -18,7 +18,7 @@ * */ -/*global PhStore */ +/*global PhStore, logger */ /** * One-shot, app-lifetime onboarding tour that introduces the design-mode @@ -32,6 +32,7 @@ define(function (require, exports, module) { const Strings = require("strings"), StringUtils = require("utils/StringUtils"), Metrics = require("utils/Metrics"), + BootGreetings = require("utils/BootGreetings"), SidebarView = require("project/SidebarView"), SidebarTabs = require("view/SidebarTabs"), ProjectManager = require("project/ProjectManager"), @@ -41,12 +42,6 @@ define(function (require, exports, module) { WorkspaceManager = require("view/WorkspaceManager"), CentralControlBar = require("view/CentralControlBar"); - // Capture the kernel trust ring at module-load time — it's deleted from - // `window` shortly after boot. Treated as optional: community-edition - // builds without the pro trial flow won't expose `loginService` and the - // tour will simply proceed without waiting. - const _LoginService = (window.KernalModeTrust && window.KernalModeTrust.loginService) || null; - const TOUR_STORAGE_KEY = "phoenixOnboardingTourState"; const CURRENT_TOUR_VERSION = 1; @@ -287,6 +282,7 @@ define(function (require, exports, module) { _ensureSidebarVisible(); const $btn = $("#ccbCollapseEditorBtn"); if (!$btn.length) { + logger.reportError(new Error("phoenix-tour: #ccbCollapseEditorBtn missing at step 1")); _markComplete(); _teardown(); return; @@ -337,7 +333,7 @@ define(function (require, exports, module) { _ensureSidebarVisible(); const $tab = $('.sidebar-tab[data-tab-id="ai"]'); if (!$tab.length) { - // No AI tab in this build — skip ahead to the next step. + logger.reportError(new Error("phoenix-tour: AI sidebar tab missing at step 2")); _runStep3(); return; } @@ -369,8 +365,7 @@ define(function (require, exports, module) { _ensureSidebarVisible(); const $newBtn = $("#newProject"); if (!$newBtn.length) { - // No new-project button — skip to the live-preview step instead - // of giving up on the tour entirely. + logger.reportError(new Error("phoenix-tour: #newProject missing at step 3")); _runStep4(); return; } @@ -457,8 +452,7 @@ define(function (require, exports, module) { const $btn = $("#previewModeLivePreviewButton"); if (!$btn.length) { - // LP panel never came up (custom server, unsupported file, etc.) - // — finalize the tour rather than stalling on a missing target. + logger.reportError(new Error("phoenix-tour: #previewModeLivePreviewButton missing at step 4")); _markComplete(); _teardown(); return; @@ -495,32 +489,9 @@ define(function (require, exports, module) { if (Phoenix.isTestWindow || Phoenix.isSpecRunnerWindow) { return false; } - if (CentralControlBar.isEditorCollapsed && CentralControlBar.isEditorCollapsed()) { - // User has already discovered design mode in some other way. - return false; - } - if (!$("#ccbCollapseEditorBtn").length) { - return false; - } return true; } - /** - * Resolves once the pro trial start dialog has been dismissed. The - * dialog is guaranteed to fire `proTrialStartDialogDismissed` on every - * boot path (including builds where the dialog isn't shown), so we - * just await it without a timeout fallback. - */ - function _waitForTrialStartDialogDismissed() { - const dismissed = _LoginService && _LoginService.proTrialStartDialogDismissed; - // Community-edition builds expose no login service at all — skip - // the wait so the tour still works there. - if (!dismissed) { - return Promise.resolve(); - } - return Promise.resolve(dismissed); - } - function startTour() { if (!_shouldRun()) { return; @@ -528,28 +499,11 @@ define(function (require, exports, module) { _ranThisSession = true; Metrics.countEvent(Metrics.EVENT_TYPE.GUIDE, "tour", "start"); - _waitForTrialStartDialogDismissed().then(function () { - // Re-check primary preconditions after the wait — the user may - // have already discovered design mode while a trial dialog was - // up, or the button may have been torn down. - if (!$("#ccbCollapseEditorBtn").length) { - _markComplete(); - _teardown(); - return; - } - if (CentralControlBar.isEditorCollapsed && CentralControlBar.isEditorCollapsed()) { - _markComplete(); - _teardown(); - return; - } - _timers.push(setTimeout(function () { - if (!$("#ccbCollapseEditorBtn").length) { - _markComplete(); - _teardown(); - return; - } - _runStep1(); - }, STEP_START_DELAY_MS)); + // Wait until every boot-time greeting dialog (auto/manual update + // "What's New", pro trial start, paid-Pro "What's New") has been + // dismissed. + BootGreetings.allDismissed().then(function () { + _timers.push(setTimeout(_runStep1, STEP_START_DELAY_MS)); }); } diff --git a/src/extensionsIntegrated/appUpdater/main.js b/src/extensionsIntegrated/appUpdater/main.js index 8f98e50ae1..a337a8b488 100644 --- a/src/extensionsIntegrated/appUpdater/main.js +++ b/src/extensionsIntegrated/appUpdater/main.js @@ -42,7 +42,17 @@ define(function (require, exports, module) { TaskManager = require("features/TaskManager"), StringUtils = require("utils/StringUtils"), NativeApp = require("utils/NativeApp"), + BootGreetings = require("utils/BootGreetings"), PreferencesManager = require("preferences/PreferencesManager"); + + // Reserve a slot in the boot-greeting coordinator so the tour can wait + // until the updater has either shown its "What's New" dialog (auto or + // manual update) or decided not to. Unblocked once per boot. + const UPDATER_GATE = "updater-tauri"; + BootGreetings.registerBlocker(UPDATER_GATE); + function _unblockUpdaterGate() { + BootGreetings.unblockBlocker(UPDATER_GATE); + } let updaterWindow, updateTask, updatePendingRestart, updateFailed; const TAURI_UPDATER_WINDOW_LABEL = "updater", @@ -519,14 +529,18 @@ define(function (require, exports, module) { let updateInstalledDialogShown = false, updateFailedDialogShown = false; AppInit.appReady(function () { if(Phoenix.isTestWindow) { + _unblockUpdaterGate(); return; } if(window.__ELECTRON__) { - // Electron updates handled by update-electron.js + // Electron updates handled by update-electron.js — that + // module owns its own blocker, so this one is a no-op. + _unblockUpdaterGate(); return; } if(!window.__TAURI__) { // app updates are only for desktop builds + _unblockUpdaterGate(); return; } if (brackets.platform === "mac") { @@ -616,13 +630,16 @@ define(function (require, exports, module) { const lastUpdateDetails = PreferencesManager.getViewState(KEY_LAST_UPDATE_DESCRIPTION); if(lastUpdateDetails && (lastUpdateDetails.updateVersion === Phoenix.metadata.apiVersion)) { let markdownHtml = marked.parse(lastUpdateDetails.releaseNotesMarkdown || ""); - Dialogs.showInfoDialog(Strings.UPDATE_WHATS_NEW, markdownHtml); + Dialogs.showInfoDialog(Strings.UPDATE_WHATS_NEW, markdownHtml) + .done(_unblockUpdaterGate); PreferencesManager.setViewState(KEY_LAST_UPDATE_DESCRIPTION, null); PreferencesManager.setViewState(KEY_UPDATE_AVAILABLE, false); // hide the update available icon as we are showing what's new dialog. In edge cases, there can be an update // at this time if the user opened phcode after an update, but a new update was just published or the user // didn't open phcode after last update, which a new update was published. $("#update-notification").addClass("forced-hidden"); + } else { + _unblockUpdaterGate(); } // check for updates at boot let lastUpdateCheckTime = PreferencesManager.getViewState(KEY_LAST_UPDATE_CHECK_TIME); diff --git a/src/extensionsIntegrated/appUpdater/update-electron.js b/src/extensionsIntegrated/appUpdater/update-electron.js index 0401d615bd..ccea096480 100644 --- a/src/extensionsIntegrated/appUpdater/update-electron.js +++ b/src/extensionsIntegrated/appUpdater/update-electron.js @@ -37,8 +37,18 @@ define(function (require, exports, module) { NotificationUI = require("widgets/NotificationUI"), TaskManager = require("features/TaskManager"), NativeApp = require("utils/NativeApp"), + BootGreetings = require("utils/BootGreetings"), PreferencesManager = require("preferences/PreferencesManager"); + // Reserve a slot in the boot-greeting coordinator so the tour can wait + // until the updater has either shown its "What's New" dialog (auto or + // manual update) or decided not to. Unblocked once per boot. + const UPDATER_GATE = "updater-electron"; + BootGreetings.registerBlocker(UPDATER_GATE); + function _unblockUpdaterGate() { + BootGreetings.unblockBlocker(UPDATER_GATE); + } + let updateTask, updatePendingRestart, updateFailed; const KEY_LAST_UPDATE_CHECK_TIME = "PH_LAST_UPDATE_CHECK_TIME", @@ -448,11 +458,13 @@ define(function (require, exports, module) { AppInit.appReady(async function () { if(!window.__ELECTRON__ || Phoenix.isTestWindow) { + _unblockUpdaterGate(); return; } // Electron updates only supported on Linux currently if (brackets.platform !== "linux") { console.error("App updates not yet implemented on this platform in Electron builds!"); + _unblockUpdaterGate(); return; } // Check if another window already scheduled an update (multi-window state persistence) @@ -496,10 +508,13 @@ define(function (require, exports, module) { const lastUpdateDetails = PreferencesManager.getViewState(KEY_LAST_UPDATE_DESCRIPTION); if(lastUpdateDetails && (lastUpdateDetails.updateVersion === Phoenix.metadata.apiVersion)) { let markdownHtml = marked.parse(lastUpdateDetails.releaseNotesMarkdown || ""); - Dialogs.showInfoDialog(Strings.UPDATE_WHATS_NEW, markdownHtml); + Dialogs.showInfoDialog(Strings.UPDATE_WHATS_NEW, markdownHtml) + .done(_unblockUpdaterGate); PreferencesManager.setViewState(KEY_LAST_UPDATE_DESCRIPTION, null); PreferencesManager.setViewState(KEY_UPDATE_AVAILABLE, false); $("#update-notification").addClass("forced-hidden"); + } else { + _unblockUpdaterGate(); } // check for updates at boot let lastUpdateCheckTime = PreferencesManager.getViewState(KEY_LAST_UPDATE_CHECK_TIME); diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 976b08d025..79e4ebc770 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2084,6 +2084,8 @@ define({ // promos "PROMO_UPGRADE_TITLE": "You’ve been upgraded to {0}", "PROMO_UPGRADE_MESSAGE": "Enjoy free access to these premium features for the next {0} days:", + "PROMO_PRO_WHATS_NEW_TITLE": "New in {0}", + "PROMO_PRO_WHATS_NEW_MESSAGE": "Thanks for being a {0} member. Here’s what’s new in this update:", "PROMO_CARD_1": "Edit In Live Preview", "PROMO_CARD_1_MESSAGE": "Edit text, update images, change links, drag elements, and more. Your code updates as you go.", "PROMO_CARD_2": "Try ideas, build pages, and fix issues with AI", diff --git a/src/styles/CentralControlBar.less b/src/styles/CentralControlBar.less index 6f82550f48..75dc500e50 100644 --- a/src/styles/CentralControlBar.less +++ b/src/styles/CentralControlBar.less @@ -47,8 +47,8 @@ editor on the right. A solid box-shadow with zero blur paints CCB-colored pixels into those gaps without shifting any geometry. */ box-shadow: - -2px 0 0 0 #222, - 2px 0 0 0 #222; + -2px 0 0 0 #222, + 2px 0 0 0 #222; .ccb-group { display: flex; @@ -160,13 +160,12 @@ .ccb-file-name { flex: 1 1 auto; min-height: 0; - /* `sideways-lr` rotates each glyph 90° CCW so the text reads - bottom-up naturally. Using this instead of `vertical-rl` + - `transform: rotate(180deg)` avoids the blurry sub-pixel - rasterization that the transform path produced on linux - electron, because Chromium can take its fast vertical-text - path for glyph layout and skip the rotated bitmap upscale. */ - writing-mode: sideways-lr; + /* Base: vertical-rl + rotate(180deg) gives the same bottom-to-top + reading direction as sideways-lr and works in all engines + (including WebKit/Tauri on macOS where sideways-lr is unsupported + and silently falls back to horizontal-tb). */ + writing-mode: vertical-rl; + text-orientation: sideways; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -175,8 +174,9 @@ font-weight: 500; /* Promote to its own compositing layer + force AA — keeps the rotated glyphs crisp even when the system falls back to the - slow text path. */ - transform: translateZ(0); + slow text path. rotate(180deg) flips vertical-rl top-to-bottom + into bottom-to-top reading order. */ + transform: rotate(180deg) translateZ(0); backface-visibility: hidden; -webkit-font-smoothing: antialiased; text-rendering: geometricPrecision; diff --git a/src/utils/BootGreetings.js b/src/utils/BootGreetings.js new file mode 100644 index 0000000000..9cc99f48a6 --- /dev/null +++ b/src/utils/BootGreetings.js @@ -0,0 +1,113 @@ +/* + * 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 logger*/ + +/** + * Coordinates the boot-time greeting dialogs (auto-update "What's New", + * pro trial start, paid-Pro "What's New") so that downstream UI like the + * onboarding tour can wait until none of them are on screen. + * + * Usage: + * // At module load (synchronously, before AppInit.appReady): + * BootGreetings.registerBlocker("pro-greeting"); + * + * // Once that module has decided/finished — every code path, including + * // error paths: + * BootGreetings.unblockBlocker("pro-greeting"); + * + * // From the consumer (e.g. onboarding tour): + * await BootGreetings.allDismissed(); + * + * Contract: every registered blocker MUST be unblocked on every code + * path. If a module forgets, `allDismissed` stays pending forever and the + * downstream UI never fires. The names make a forgotten gate easy to + * spot in a debugger by inspecting which entries are still pending. + */ +define(function (require, exports, module) { + + // Name -> entry. Each entry holds the promise and resolver and a + // `resolved` flag so a duplicate `unblockBlocker` is a no-op. + const _blockers = new Map(); + + /** + * Reserve a named blocker. Names must be unique and non-empty — + * misuse is reported via `logger.reportError` (which surfaces to the + * error logger without crashing the app) and the call is otherwise a + * no-op. Boot stability matters more than a strict contract here. + * + * @param {string} name Short identifier (e.g. "pro-greeting"). + */ + function registerBlocker(name) { + if (!name) { + logger.reportError(new Error("BootGreetings.registerBlocker called without a name")); + return; + } + if (_blockers.has(name)) { + logger.reportError(new Error( + "BootGreetings: blocker '" + name + "' is already registered")); + return; + } + let resolveFn; + const promise = new Promise(function (resolve) { + resolveFn = resolve; + }); + _blockers.set(name, { promise: promise, resolve: resolveFn, resolved: false }); + } + + /** + * Mark a registered blocker as done. Safe to call more than once. + * An unknown name is reported via `logger.reportError` but does not + * disrupt the boot flow. + * + * @param {string} name The name passed to `registerBlocker`. + */ + function unblockBlocker(name) { + const entry = _blockers.get(name); + if (!entry) { + logger.reportError(new Error( + "BootGreetings.unblockBlocker: unknown blocker '" + name + "'")); + return; + } + if (entry.resolved) { + return; + } + entry.resolved = true; + entry.resolve(); + } + + /** + * Resolves once every registered blocker has been unblocked. + * + * @return {Promise} + */ + function allDismissed() { + if (!_blockers.size) { + return Promise.resolve(); + } + const promises = []; + _blockers.forEach(function (entry) { promises.push(entry.promise); }); + return Promise.all(promises); + } + + exports.registerBlocker = registerBlocker; + exports.unblockBlocker = unblockBlocker; + exports.allDismissed = allDismissed; +}); diff --git a/src/view/CentralControlBar.js b/src/view/CentralControlBar.js index 23155f6ff4..7200b82094 100644 --- a/src/view/CentralControlBar.js +++ b/src/view/CentralControlBar.js @@ -226,6 +226,39 @@ define(function (require, exports, module) { } } + // Track cumulative session time in design mode and emit a metric the + // first time the total crosses each of these minute buckets. A + // 5-minute ticker rolls the in-progress stretch into the aggregate; + // an exit also flushes so a partial stretch isn't dropped. + const DESIGN_TIME_BUCKETS_MIN = [5, 10, 15, 20, 30, 45, 60]; + const MS_PER_MIN = 60 * 1000; + const DESIGN_TIME_TICK_MS = 5 * MS_PER_MIN; + let _designModeTimeFrom = null; + let _aggregateDesignMs = 0; + let _lastDesignBucketEmittedIdx = -1; + + function _emitCrossedDesignBuckets() { + const minutes = Math.floor(_aggregateDesignMs / MS_PER_MIN); + const checkIndex = _lastDesignBucketEmittedIdx + 1; + if (checkIndex < DESIGN_TIME_BUCKETS_MIN.length && + minutes >= DESIGN_TIME_BUCKETS_MIN[checkIndex]) { + _lastDesignBucketEmittedIdx = checkIndex; + Metrics.countEvent(Metrics.EVENT_TYPE.UI, "designTime", + DESIGN_TIME_BUCKETS_MIN[checkIndex] + "M"); + } + } + + function _onDesignTimeTick() { + if (_designModeTimeFrom == null) { + return; + } + const now = Date.now(); + _aggregateDesignMs += now - _designModeTimeFrom; + _designModeTimeFrom = now; + _emitCrossedDesignBuckets(); + } + setInterval(_onDesignTimeTick, DESIGN_TIME_TICK_MS); + function _setEditorCollapsed(collapsed, opts) { const wantCollapsed = !!collapsed; if (wantCollapsed === editorCollapsed) { @@ -247,6 +280,14 @@ define(function (require, exports, module) { } } editorCollapsed = wantCollapsed; + if (editorCollapsed) { + _designModeTimeFrom = Date.now(); + } else if (_designModeTimeFrom != null) { + // Flush the in-progress stretch into the aggregate so the + // sliver between the last tick and this exit isn't dropped. + _aggregateDesignMs += Date.now() - _designModeTimeFrom; + _designModeTimeFrom = null; + } $("body").toggleClass("ccb-editor-collapsed", editorCollapsed); const $collapseBtn = $("#ccbCollapseEditorBtn"); $collapseBtn.toggleClass("is-active", editorCollapsed) diff --git a/tracking-repos.json b/tracking-repos.json index 5d26a3f6a2..581b568dc5 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "99559768f4fbbab86ed3589e9a51d3a915f51986" + "commitID": "da29089c7397058410514ab3c8d29748bc16558b" } }