mirror of
https://github.com/logos-co/logos-basecamp.git
synced 2026-08-27 06:41:14 +00:00
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.
520 lines
23 KiB
QML
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);
|
|
}
|
|
}
|
|
}
|