Files
logos-basecamp/src/Basecamp/Shell/ConfirmationDialog.qml
T
Dario Gabriel Lipicar 9b8cf6e47e feat(shell): ship main_ui as a plugin that links no logos runtime
Measured on the built artefact: main_ui.dylib DEFINES zero and IMPORTS zero of
TokenManager, StoreRegistry, LogosAPI, LogosAPIClient and logos_core_*, out of
3410 symbols read. It links Qt and nothing else from this workspace, which
nix/symbol-gate.nix enforces across the in-process image set.

Getting there needed the last non-Qt types off the boundary: the model
properties cross as QAbstractItemModel*, catalogInstallStageChanged carries an
int rather than InstallStage::Value, and the two prebuilt AppsFilterProxy
instances are declared in QML instead of owned by MainUIBackend. That last one
removes a real inversion -- PackageCoordinator called setRequiredPackages() on
a proxy the host held a pointer to; it now emits requiredPackagesResolved() and
QML binds to the republished property.

Window resolves the plugin, qobject_casts it to IShellView, checks
hostAbiVersion() against IShellHost_abi, and calls createShell(IShellHost*).
No error-label fallback widget: that degraded to something that looked like a
working app with an empty window.

Filter proxies read role constants off host-side models and InstallEnums is
used by nine host files, so app/interfaces/ gains the contract headers both
sides compile against -- the models inherit the role structs, leaving every
AppsModel::NameRole call site unchanged. The plugin's include path is
app/interfaces only, so including a host header does not compile.

Four things only running it finds:
  * Logos::DesignSystem may be linked by exactly ONE image -- both linked it
    and the app aborted with "Cannot add multiple registrations for
    Logos.Icons"; QML module registration is process-global
  * qmltyperegistrar emits no #include for a SOURCES header given as an
    absolute path outside the project
  * each qt_add_qml_module is its own target and inherits no include dirs
  * AUTOMOC pairs header<->cpp by same-basename-same-DIRECTORY, which the
    split breaks

tst_AppManagerView.qml grows five tests for the QML binding, checked with a
negative control: breaking one assertion fails qml-tests, so they run.
2026-08-22 16:42:13 -03:00

520 lines
23 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Logos.Controls
import Logos.Icons
import Logos.Theme
// Reusable dialog for dependency-aware confirmation / informational prompts.
//
// Display variants, selected via `mode`:
// - "missingDeps" — informational; user tried to load a plugin whose
// dependencies aren't installed. The only action is an
// "OK" that closes the dialog — Cancel is hidden, since
// nothing was started and there is nothing to abort.
// - "unloadCascade" — confirmation; unloading this module would leave
// other loaded modules stranded. Continue cascades
// the unload via the backend; Cancel aborts.
// - "upgradeCascade" — confirmation; upgrading/downgrading/reinstalling
// this module. The package_manager performs an
// uninstall step first, so currently-running dependents
// are unloaded for the swap; `loadedItems` names them.
// Installed-but-not-running dependents are NOT listed:
// they pick up the new version on their next load and
// aren't user-visibly affected. Title and body lead
// with the new version + the UpgradeMode (Upgrade /
// Downgrade / Reinstall) so the user knows the
// operation isn't a bare uninstall. Confirm/Cancel flow
// through `continueClicked` / `cancelClicked`.
// - "installGate" — confirmation before a fresh install routed through the
// package_manager requestInstall gate. Every install the
// app performs comes through here: package_manager_ui
// initiates them all, whether the source is a catalog
// download or a local .lgx the user picked. Leads with the
// package + target version and lists the resolved
// transitive `depChanges`. No dependent-impact lists (a
// fresh install unloads nothing). Confirm/Cancel flow
// through continueClicked / cancelClicked →
// confirmInstallGate / cancelInstallGate.
//
// Note: for a local .lgx the initiator has no catalog to
// resolve against, so `depChanges` arrives empty and the
// dialog is name + version only.
// - "installError" — informational; an install failed. Shows the package
// (or picked file) name + the reported error, OK only.
//
// Uninstall confirmation is NOT here — it lives in UninstallDialog.qml, which
// renders a resolved plan (what goes, what stays and why, what breaks) rather
// than a pair of dependent lists.
//
// The dialog is controlled by calling `openWith(mode, name, items)` for the
// one-list modes, `openWithUpgrade(name, version, upgradeMode, installedDeps, loadedDeps, depChanges)`
// for upgradeCascade, or `openWithInstallGate(name, version, depChanges)` for
// the install gate. Backend wiring listens for continueClicked/cancelClicked
// and calls the appropriate slot with `name`.
Dialog {
id: root
// "missingDeps" | "unloadCascade" | "upgradeCascade" | "installGate" | "installError"
property string mode: "missingDeps"
property string moduleName: ""
property var items: []
// Only used in upgradeCascade mode — the dependents currently loaded,
// which get torn down for the version swap.
property var loadedItems: []
// Only used in upgradeCascade mode. `upgradeTargetVersion` is the
// pinned target (e.g. "1.0.0") supplied by the caller's requestUpgrade
// call; `upgradeModeKind` mirrors PackageTypes/UpgradeMode —
// 0 = Upgrade, 1 = Downgrade, 2 = Sidegrade (Reinstall).
property string upgradeTargetVersion: ""
property int upgradeModeKind: 0
// Transitive dependency changes for the upgradeCascade + installGate modes.
// Each entry: { name, action: "install"|"upgrade"|"downgrade",
// fromVersion, toVersion, repository }. Resolved by the
// initiator (package_manager_ui) and passed through the
// module's beforeUpgrade / beforeInstall gate so this single
// dialog lists exactly what else will change. Empty = nothing
// else needs to change.
property var depChanges: []
property string errorMessage: ""
property var displayNameLookup: function(name) { return name; }
// Internal: tracks whether a button explicitly handled the close
// so the onClosed handler doesn't double-fire cancelClicked. Set
// true in continue/cancel onClicked before we call close().
property bool _explicitClose: false
signal continueClicked(string name)
signal cancelClicked(string name)
modal: true
anchors.centerIn: parent
width: 560
padding: Theme.spacing.large
closePolicy: Popup.CloseOnEscape
// API for parent components — simpler than setting props + open() each time.
function openWith(mode_, name_, items_) {
root.mode = mode_;
root.moduleName = name_ || "";
root.items = items_ || [];
root.loadedItems = [];
root._explicitClose = false;
open();
}
// Upgrade/Downgrade/Reinstall variant. The upgrade flow does an uninstall
// step first, so it carries the same loaded-dependent set as an uninstall
// would — but the title + body lead with the target version and the
// UpgradeMode so the user sees the full operation, not just
// "Uninstall and Unload Dependents?".
function openWithUpgrade(name_, version_, upgradeMode_, installedDeps_, loadedDeps_, depChanges_) {
root.mode = "upgradeCascade";
root.moduleName = name_ || "";
root.upgradeTargetVersion = version_ || "";
root.upgradeModeKind = upgradeMode_ | 0;
root.items = installedDeps_ || [];
root.loadedItems = loadedDeps_ || [];
root.depChanges = depChanges_ || [];
root._explicitClose = false;
open();
}
// Fresh catalog-install variant. Unlike upgradeCascade there is no
// dependent-impact set (nothing is uninstalled/unloaded); the dialog
// simply confirms the install and lists the transitive `depChanges`.
// Continue / Cancel still flow through continueClicked / cancelClicked;
// the backend routes those to confirmInstallGate / cancelInstallGate.
function openWithInstallGate(name_, version_, depChanges_) {
root.mode = "installGate";
root.moduleName = name_ || "";
root.upgradeTargetVersion = version_ || "";
root.items = [];
root.loadedItems = [];
root.depChanges = depChanges_ || [];
root._explicitClose = false;
open();
}
function openWithInstallError(name_, errorMessage_) {
root.mode = "installError";
root.moduleName = name_ || "";
root.errorMessage = errorMessage_ || "";
root.items = [];
root.loadedItems = [];
root.depChanges = [];
root._explicitClose = false;
open();
}
background: Rectangle {
color: Theme.palette.surfaceRaised
border.color: Theme.palette.border
border.width: 1
radius: 8
}
contentItem: ColumnLayout {
spacing: 12
RowLayout {
Layout.fillWidth: true
spacing: 8
LogosIcon {
Layout.alignment: Qt.AlignVCenter
Layout.preferredWidth: 24
Layout.preferredHeight: 24
source: LogosIcons.warning
color: Theme.palette.error
brightness: 1.0
}
LogosText {
Layout.fillWidth: true
text: {
if (root.mode === "missingDeps")
return "Missing Dependencies";
if (root.mode === "unloadCascade")
return "Unload Dependent Modules?";
if (root.mode === "upgradeCascade") {
if (root.upgradeModeKind === 1) return "Downgrade Package?";
if (root.upgradeModeKind === 2) return "Reinstall Package?";
return "Upgrade Package?";
}
if (root.mode === "installGate")
return "Install Package?";
if (root.mode === "installError")
return "Install Failed";
return "";
}
font.pixelSize: Theme.typography.panelTitleText
font.weight: Theme.typography.weightBold
color: Theme.palette.text
wrapMode: Text.Wrap
}
}
LogosText {
Layout.fillWidth: true
wrapMode: Text.Wrap
color: Theme.palette.textSecondary
readonly property string _label: root.displayNameLookup(root.moduleName) || root.moduleName
text: {
if (root.mode === "missingDeps")
return "'" + _label + "' cannot be loaded because the "
+ "following modules are not installed:";
if (root.mode === "unloadCascade")
return "The following modules are currently loaded and depend on '"
+ _label + "'. Unloading will terminate them:";
if (root.mode === "upgradeCascade") {
// Lead with the operation in plain English so the
// user sees this isn't a bare uninstall — the
// package_manager removes the current version as the
// first phase of the swap, which is why this dialog
// fires at all. Target version goes here (not in the
// title) so long version strings don't truncate.
var verb = "Upgrade";
if (root.upgradeModeKind === 1) verb = "Downgrade";
else if (root.upgradeModeKind === 2) verb = "Reinstall";
var verPhrase = root.upgradeTargetVersion.length > 0
? " to v" + root.upgradeTargetVersion : "";
var head = verb + " '" + _label + "'" + verPhrase
+ ". The current version will be removed first, "
+ "then the new one downloaded and installed.";
// We only surface the *loaded* dependents here — the
// installed-but-not-running set picks up the new
// version on their next load and isn't "affected" in
// any user-visible way, so listing them implies
// alarm that doesn't exist (this was the
// pre-existing copy bug). If nothing is currently
// running on top of the module, the head sentence
// stands on its own.
if (root.loadedItems.length === 0)
return head;
return head + " The modules below are running on top of it and "
+ "will be unloaded for the swap — they keep working "
+ "with the new version once it lands.";
}
if (root.mode === "installGate") {
var ivPhrase = root.upgradeTargetVersion.length > 0
? " v" + root.upgradeTargetVersion : "";
var iHead = "Install '" + _label + "'" + ivPhrase + ".";
// The dep-change list below spells out the transitive set.
// When it's empty, say so plainly so a bare install still
// reads as a deliberate, complete confirmation.
if ((root.depChanges || []).length === 0)
return iHead + " No other packages need to change.";
return iHead + " Installing it also applies the dependency "
+ "changes listed below:";
}
if (root.mode === "installError") {
return "'" + _label + "' could not be installed. "
+ "The package manager reported:";
}
return "";
}
}
Rectangle {
Layout.fillWidth: true
color: "#1e1e1e"
radius: 4
border.color: "#3d3d3d"
border.width: 1
implicitHeight: errorMessageText.implicitHeight + 16
visible: root.mode === "installError" && root.errorMessage.length > 0
LogosText {
id: errorMessageText
anchors.fill: parent
anchors.margins: 8
text: root.errorMessage
color: "#e0e0e0"
font.pixelSize: 13
wrapMode: Text.Wrap
}
}
// Single-list block — used by missingDeps and unloadCascade.
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Math.min(150, Math.max(30, root.items.length * 24))
color: "#1e1e1e"
radius: 4
border.color: "#3d3d3d"
border.width: 1
visible: root.mode !== "upgradeCascade"
&& root.mode !== "installGate"
&& root.items.length > 0
LogosListView {
id: itemList
anchors.fill: parent
anchors.margins: 8
model: root.items
clip: true
delegate: LogosText {
text: "• " + (root.displayNameLookup(modelData) || modelData)
color: "#e0e0e0"
font.pixelSize: 13
}
}
}
// Loaded-dependent block for upgradeCascade. Installed-but-not-running
// dependents pick up the new version on their next load and aren't
// user-visibly affected, so listing them under "will stop working"
// would be a lie — only the running ones are named. The body sentence
// above already tells the "brief unload, then back on the new version"
// story; this just names them.
ColumnLayout {
Layout.fillWidth: true
spacing: 8
visible: root.mode === "upgradeCascade" && root.loadedItems.length > 0
LogosText {
Layout.fillWidth: true
text: "Currently running (will be temporarily unloaded):"
color: "#c0c0c0"
font.pixelSize: 13
font.weight: Theme.typography.weightBold
wrapMode: Text.Wrap
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Math.min(120, Math.max(30, root.loadedItems.length * 24))
color: "#1e1e1e"
radius: 4
border.color: "#3d3d3d"
border.width: 1
LogosListView {
anchors.fill: parent
anchors.margins: 8
model: root.loadedItems
clip: true
delegate: LogosText {
text: "• " + (root.displayNameLookup(modelData) || modelData)
color: "#e0e0e0"
font.pixelSize: 13
}
}
}
}
// Transitive dependency-change list — shared by upgradeCascade and
// installGate. Renders each change as [action chip] name (repo) vA → vB,
// mirroring the package_manager_ui per-row confirm so the single
// basecamp dialog surfaces exactly what else the operation installs /
// upgrades / downgrades. Hidden when the initiator resolved no changes.
ColumnLayout {
Layout.fillWidth: true
spacing: 6
visible: (root.mode === "upgradeCascade" || root.mode === "installGate")
&& (root.depChanges || []).length > 0
LogosText {
Layout.fillWidth: true
Layout.topMargin: 4
text: "Dependency changes:"
color: "#c0c0c0"
font.pixelSize: 13
font.weight: Theme.typography.weightBold
wrapMode: Text.Wrap
}
Rectangle {
Layout.fillWidth: true
color: "#1e1e1e"
radius: 4
border.color: "#3d3d3d"
border.width: 1
implicitHeight: depChangeList.implicitHeight + 16
LogosListView {
id: depChangeList
anchors.fill: parent
anchors.margins: 8
model: root.depChanges
spacing: 4
clip: true
interactive: contentHeight > height
implicitHeight: Math.min(160, Math.max(contentHeight, 0))
delegate: RowLayout {
width: ListView.view ? ListView.view.width : 0
spacing: 8
// Action chip — colour-keyed like the PMU row pills.
Rectangle {
Layout.preferredWidth: 76
Layout.preferredHeight: 20
radius: 10
color: {
switch (modelData.action) {
case "install": return Theme.colors.getColor(Theme.palette.success, 0.18);
case "upgrade": return Theme.colors.getColor(Theme.palette.info, 0.18);
case "downgrade": return Theme.colors.getColor(Theme.palette.warning, 0.18);
default: return Theme.colors.getColor(Theme.palette.info, 0.18);
}
}
LogosText {
anchors.centerIn: parent
text: {
var a = modelData.action || "";
return a ? a.charAt(0).toUpperCase() + a.slice(1) : "";
}
color: {
switch (modelData.action) {
case "install": return Theme.palette.success;
case "upgrade": return Theme.palette.info;
case "downgrade": return Theme.palette.warning;
default: return Theme.palette.text;
}
}
font.pixelSize: 12
font.weight: Theme.typography.weightMedium
}
}
// name (repo) vFrom → vTo
LogosText {
Layout.fillWidth: true
text: {
var name = modelData.name || "";
var repo = modelData.repository || "";
var to = modelData.toVersion ? "v" + modelData.toVersion : "";
var from = modelData.fromVersion ? "v" + modelData.fromVersion : "";
var ver = (from && to && from !== to) ? (from + " → " + to)
: (to ? to : "");
var head = repo ? (name + " (" + repo + ")") : name;
return ver ? (head + " " + ver) : head;
}
color: "#e0e0e0"
font.pixelSize: 13
elide: Text.ElideMiddle
}
}
}
}
}
RowLayout {
Layout.fillWidth: true
Layout.topMargin: 8
spacing: 8
Item { Layout.fillWidth: true }
// Cancel button — hidden in the informational modes since
// there's only one button to press there.
//
// Both buttons carry a mode-derived objectName so UI automation
// can target them exactly. Text-based clicking is ambiguous here:
// labels like "Uninstall" also appear on the module-row buttons,
// and the inspector's text search doesn't distinguish open from
// closed dialogs.
//
// These names are only stable because OverlayDialogs instantiates
// one dialog per mode and pins `mode` declaratively. Reusing a
// single instance across modes would silently move the handle out
// from under the specs that click it.
LogosButton {
objectName: "confirmationDialog." + root.mode + ".cancel"
text: "Cancel"
visible: root.mode !== "missingDeps" && root.mode !== "installError"
onClicked: {
root._explicitClose = true;
root.cancelClicked(root.moduleName);
root.close();
}
}
LogosButton {
objectName: "confirmationDialog." + root.mode + ".confirm"
text: {
if (root.mode === "missingDeps") return "OK";
if (root.mode === "unloadCascade") return "Unload All";
if (root.mode === "upgradeCascade") {
if (root.upgradeModeKind === 1) return "Downgrade";
if (root.upgradeModeKind === 2) return "Reinstall";
return "Upgrade";
}
if (root.mode === "installGate") return "Install";
return "OK";
}
variant: LogosButton.Variant.Primary
onClicked: {
root._explicitClose = true;
root.continueClicked(root.moduleName);
root.close();
}
}
}
}
onClosed: {
// Auto-cancel on Escape and other Dialog-managed dismissals for
// cascade modes so the pending state in the backend gets cleared.
// closePolicy is Popup.CloseOnEscape only — outside-click does NOT
// dismiss these dialogs (they're destructive and require an
// explicit button). A button click sets _explicitClose before
// calling close(), so this onClosed handler only fires on
// dismissals that went through the Dialog's own close path
// (Escape today; any future policy additions as well).
if (root._explicitClose) {
root._explicitClose = false;
return;
}
if (root.mode === "unloadCascade" || root.mode === "upgradeCascade"
|| root.mode === "installGate") {
root.cancelClicked(root.moduleName);
}
}
}