feat(session): add QuickShell session manager

This commit is contained in:
fy59 2026-10-06 07:31:03 +02:00
parent 5567a76738
commit d41131037f
5 changed files with 707 additions and 3 deletions

View file

@ -662,3 +662,36 @@ par preflight intégral, revalidation avant chaque commande, postcondition aprè
chaque mutation, marks nettoyés et arrêt au premier écart. Il ne promet ni
rollback global, ni restauration d'un arbre arbitraire, ni reconstruction des
proportions, ni UI. `spatial-canvas-v1` demeure entièrement hors périmètre.
## STEP19D — Gestionnaire graphique QuickShell
Le Control Center expose une page dédiée sous la page système `Session`. La
page système et ses commandes de verrouillage, veille, déconnexion UWSM,
redémarrage et extinction restent distinctes. La navigation du gestionnaire
est `Sessions → Détail → Aperçu` et chaque retour remonte d'un niveau.
Le QML reste une frontière d'interface. Il appelle exclusivement
`session_snapshot.py` avec les commandes publiques `list`, `show`, `save`,
`delete`, `plan` et `apply <name> --execute`. Le chemin déployé est résolu par
`$XDG_CONFIG_HOME/quickshell/labfy-sway/session/session_snapshot.py`, avec le
repli XDG standard `$HOME/.config/...`. Aucun chemin utilisateur ou chemin de
checkout n'est figé dans le QML.
Un unique `Process` asynchrone sérialise les opérations. Ses commandes sont
toujours des tableaux argv directs ; aucun shell, aucune interpolation de nom
de session et aucune commande Sway ne sont accessibles à l'UI. `stdout` JSON
et `stderr` diagnostic sont collectés séparément. Un résultat n'est accepté
que si le processus termine avec succès et si le JSON attendu est entier et
structurellement cohérent. Pendant `save`, `delete` ou `apply`, les autres
mutations restent désactivées.
`plan` est un aperçu strictement read-only. Après confirmation, `apply
--execute` recharge le snapshot, observe un live frais, reconstruit le plan et
refait le preflight : le QML n'exécute jamais les actions du plan et ne peut
pas transformer un ancien aperçu en autorisation. Les limites sur l'état
interne des applications et les proportions de splits sont affichées avant la
confirmation.
Il n'existe aucun déclencheur de sauvegarde ou restauration au chargement, à
la connexion, à la déconnexion ou sur minuterie. Toutes les mutations sont la
conséquence immédiate d'une action utilisateur confirmée.

View file

@ -13,7 +13,8 @@ PopupWindow {
// CONTRACT: indices externes historiques conservés ; tout nouveau routage
// passe par ces noms afin de ne pas multiplier les indices StackLayout.
readonly property var pages: ({ main: 0, wifi: 1, bluetooth: 2, session: 3,
confirmation: 4, battery: 5, appearance: 6, wallpaper: 7, theme: 8, nightLight: 9, sunMode: 10, locationTime: 11 })
confirmation: 4, battery: 5, appearance: 6, wallpaper: 7, theme: 8,
nightLight: 9, sunMode: 10, locationTime: 11, sessionManager: 12 })
property int currentPage: pages.main
function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; }
signal thresholdApplied()
@ -81,7 +82,7 @@ PopupWindow {
: currentPage === 5 ? batteryPage.implicitHeight + 32
: currentPage === pages.wallpaper ? 550
: currentPage === pages.appearance ? 520
: currentPage === pages.locationTime ? 650 : 440
: currentPage === pages.locationTime || currentPage === pages.sessionManager ? 650 : 440
visible: false
// CONTRACT: PopupWindow n'applique un changement de grabFocus qu'après
// fermeture/réouverture ; le prendre dès MAIN garde le clavier disponible
@ -183,6 +184,7 @@ PopupWindow {
id: sessionPage
actions: popup.sessionActions
onBackRequested: popup.openPage("main")
onManagerRequested: popup.openPage("sessionManager")
onActionRequested: id => popup.requestSessionAction(id)
}
@ -233,6 +235,10 @@ PopupWindow {
LocationTimePage {
onBackRequested: popup.openPage("appearance")
}
SessionManager {
activePage: popup.visible && popup.currentPage === popup.pages.sessionManager
onBackRequested: popup.openPage("session")
}
}
}
}

View file

@ -0,0 +1,593 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Io
import "../components"
import "../theme"
Item {
id: page
required property bool activePage
signal backRequested()
// WHY: la configuration peut être déployée pour n'importe quel utilisateur.
// CONTRACT: le backend est celui de la configuration QuickShell XDG active.
// INVARIANT: aucun nom utilisateur ni chemin de dépôt n'est encodé ici.
readonly property string configHome: Quickshell.env("XDG_CONFIG_HOME")
|| ((Quickshell.env("HOME") || "") + "/.config")
readonly property string backend: configHome
+ "/quickshell/labfy-sway/session/session_snapshot.py"
property string view: "list"
property string phase: "idle"
property string operation: ""
property string pendingName: ""
property string nameDraft: ""
property string errorMessage: ""
property string technicalDetails: ""
property string feedback: ""
property var sessions: []
property var selectedSession: null
property var snapshot: null
property var plan: null
property var restoreReport: null
readonly property bool busy: backendProcess.running || phase === "running"
readonly property bool mutatingBusy: busy
&& ["save", "delete", "apply"].includes(operation)
readonly property bool validName: /^[A-Za-z0-9_-]{1,64}$/.test(nameDraft)
function commandFor(kind, name) {
// CONTRACT: chaque argument reste une valeur argv indépendante ; le nom
// de session ne traverse jamais une interpolation ou un shell.
const argv = ["python3", backend, "--compact"];
if (kind === "apply") argv.push("--execute");
argv.push(kind);
if (name) argv.push(name);
return argv;
}
function run(kind, name) {
// WHY: les locks backend protègent les données, mais pas le double-clic.
// CONTRACT: un unique Process porte list/show/plan et les mutations.
// INVARIANT: aucune nouvelle opération ne démarre tant qu'il est occupé.
if (busy) return false;
operation = kind;
pendingName = name || "";
phase = "running";
if (kind !== "list") {
errorMessage = "";
technicalDetails = "";
}
if (kind !== "list") feedback = "";
backendProcess.command = commandFor(kind, pendingName);
backendProcess.running = true;
return true;
}
function refresh() { run("list", ""); }
function openCreate() {
if (busy) return;
nameDraft = "";
errorMessage = "";
view = "create";
}
function requestSave() {
if (!validName || busy) return;
const exists = sessions.some(item => item.name === nameDraft);
view = exists ? "duplicate" : "create";
if (!exists) run("save", nameDraft);
}
function openDetails(item) {
if (busy || !item) return;
selectedSession = item;
snapshot = null;
view = "details";
run("show", item.name);
}
function preview() {
if (!selectedSession || busy) return;
plan = null;
view = "plan";
run("plan", selectedSession.name);
}
function goBack() {
errorMessage = "";
technicalDetails = "";
if (view === "list") {
backRequested();
} else if (["create", "duplicate", "details"].includes(view)) {
view = "list";
} else if (["plan", "deleteConfirm"].includes(view)) {
view = "details";
} else if (view === "restoreConfirm") {
view = "plan";
} else {
view = plan ? "plan" : "details";
}
}
function parseJson(text) {
try { return JSON.parse(text); }
catch (_) { return undefined; }
}
function errorFor(raw) {
const value = raw || "Erreur backend inconnue";
if (value.includes("RESTORE_ALREADY_RUNNING"))
return "Une restauration est déjà en cours.";
if (value.includes("RESTORE_TREE_DRIFTED"))
return "La disposition des fenêtres a changé pendant la restauration. Aucun traitement supplémentaire n’a été effectué.";
if (value.includes("RESTORE_LAUNCH_TIMEOUT"))
return "Une application n’a pas créé sa fenêtre dans le délai prévu.";
if (value.includes("RESTORE_LAUNCH_AMBIGUOUS"))
return "La nouvelle fenêtre ne peut pas être identifiée sans ambiguïté.";
if (value.includes("RESTORE_BLOCKED_UNSUPPORTED_ACTION"))
return "Le preflight a bloqué des actions non prises en charge.";
if (value.includes("session introuvable"))
return "Cette session n’existe plus.";
return "L’opération a échoué.";
}
function finishFailure(stdoutText, stderrText) {
const parsed = parseJson(stdoutText);
const raw = (parsed && parsed.reason) || stderrText.trim() || stdoutText.trim();
phase = "error";
errorMessage = errorFor(raw);
technicalDetails = raw;
if (operation === "apply") {
restoreReport = parsed || ({ status: "failed", reason: raw });
view = "result";
} else if ((operation === "show" || operation === "plan")
&& raw.includes("session introuvable")) {
selectedSession = null;
snapshot = null;
plan = null;
view = "list";
Qt.callLater(refresh);
}
}
function acceptSuccess(value) {
if (operation === "list") {
if (!Array.isArray(value)) return false;
sessions = value;
} else if (operation === "show") {
if (!value || !value.metadata || !Array.isArray(value.windows)
|| !Array.isArray(value.workspaces)) return false;
snapshot = value;
} else if (operation === "plan") {
if (!value || value.read_only !== true || !Array.isArray(value.actions)) return false;
plan = value;
} else if (operation === "save") {
if (!value || value.name !== pendingName) return false;
feedback = "Session sauvegardée";
view = "list";
Qt.callLater(refresh);
} else if (operation === "delete") {
if (!value || value.deleted !== pendingName) return false;
selectedSession = null;
snapshot = null;
plan = null;
feedback = "Session supprimée";
view = "list";
Qt.callLater(refresh);
} else if (operation === "apply") {
if (!value || value.status !== "success") return false;
restoreReport = value;
view = "result";
}
return true;
}
function formatDate(value) {
const date = new Date(value);
if (isNaN(date.getTime())) return value || "Date inconnue";
const now = new Date();
const sameDay = date.getFullYear() === now.getFullYear()
&& date.getMonth() === now.getMonth() && date.getDate() === now.getDate();
const time = date.toLocaleTimeString(Qt.locale(), "HH:mm");
return sameDay ? "Mis à jour aujourd’hui à " + time
: "Mis à jour le " + date.toLocaleDateString(Qt.locale(), "dd/MM/yyyy") + " à " + time;
}
function plural(count, singular, pluralValue) {
return count + " " + (count === 1 ? singular : pluralValue);
}
function applicationLabel(item) {
if (!item) return "Application inconnue";
const identity = item.restore_identity || {};
const desktop = identity.desktop_entry || item.desktop_entry;
if (desktop) {
const stem = desktop.replace(/\.desktop$/, "");
return stem.charAt(0).toUpperCase() + stem.slice(1);
}
return identity.app_id || item.app_id || identity.class
|| (item.xwayland && item.xwayland.class)
|| item.executable_basename || "Application inconnue";
}
function countAction(kind) {
if (!plan || !Array.isArray(plan.actions)) return 0;
return plan.actions.filter(item => item.action === kind).length;
}
function isBlocking(item) {
return item && (item.action === "manual-required" || item.confidence === "ambiguous");
}
function blockerCount() {
if (!plan || !Array.isArray(plan.actions)) return 0;
return plan.actions.filter(item => isBlocking(item)).length;
}
function groupFor(action) {
if (action === "reuse-window") return "RÉUTILISER";
if (action === "launch-application") return "LANCER";
if (action === "move-to-workspace") return "DÉPLACER";
if (action === "restore-tree-position") return "LAYOUT";
if (action === "restore-floating") return "FLOATING";
if (action === "restore-fullscreen") return "FULLSCREEN";
if (action.indexOf("restore-scratchpad") === 0) return "SCRATCHPAD";
if (action === "restore-focus") return "FOCUS";
if (action === "manual-required") return "INTERVENTION MANUELLE";
return "AUTRE";
}
function actionText(item) {
const label = item.desktop_entry
? applicationLabel({ desktop_entry: item.desktop_entry })
: (item.snapshot_identity || item.source_window || "Fenêtre");
if (item.action === "reuse-window" && item.confidence === "ambiguous")
return "! " + label + " : correspondance ambiguë";
if (item.action === "reuse-window") return "✓ " + label;
if (item.action === "launch-application") return "+ " + label;
if (item.action === "move-to-workspace") return "→ " + label + " vers WS " + item.desired_workspace;
if (item.action === "restore-tree-position") return "▦ Restaurer la disposition de " + label;
if (item.action === "restore-floating") return "◫ Restaurer le mode flottant de " + label;
if (item.action === "restore-fullscreen") return "󰊓 Restaurer le plein écran de " + label;
if (item.action === "restore-scratchpad-hidden") return "↘ " + label + " → cachée";
if (item.action === "restore-scratchpad-visible") return "↗ " + label + " → visible";
if (item.action === "restore-focus") return "◎ Rendre le focus à " + label;
if (item.reason === "missing-output") return "! Écran " + (item.desired_output || "requis") + " absent";
if (item.reason === "no-exact-launch-identity") return "! Application " + label + " non identifiable";
return "! " + label + " : " + (item.reason || "intervention requise");
}
function resultCount(key) {
return restoreReport && Number.isInteger(restoreReport[key]) ? restoreReport[key] : 0;
}
onActivePageChanged: {
if (activePage) {
view = "list";
errorMessage = "";
technicalDetails = "";
feedback = "";
selectedSession = null;
snapshot = null;
plan = null;
restoreReport = null;
refresh();
}
}
Process {
id: backendProcess
running: false
// CONTRACT: stdout reste un document JSON complet ; stderr ne participe
// jamais au parsing et conserve les diagnostics destinés à l'utilisateur.
stdout: StdioCollector { id: backendOutput; waitForEnd: true }
stderr: StdioCollector { id: backendError; waitForEnd: true }
onExited: (exitCode, exitStatus) => {
const stdoutText = backendOutput.text;
const stderrText = backendError.text;
if (exitCode !== 0 || exitStatus !== 0) {
page.finishFailure(stdoutText, stderrText);
return;
}
const value = page.parseJson(stdoutText);
if (value === undefined || !page.acceptSuccess(value)) {
page.phase = "error";
page.errorMessage = "Réponse invalide du gestionnaire de sessions.";
page.technicalDetails = stderrText.trim() || "JSON stdout invalide ou inattendu";
return;
}
page.phase = "success";
if (stderrText.trim().length > 0)
page.errorMessage = stderrText.trim();
}
}
Column {
anchors.fill: parent
spacing: 10
Row {
width: parent.width
height: 30
spacing: 8
ActionButton { label: ""; enabled: !page.busy; onClicked: page.goBack() }
Text {
width: parent.width - saveButton.width - 82
height: 30
verticalAlignment: Text.AlignVCenter
text: page.view === "list" ? "Sessions"
: page.view === "details" || page.view === "deleteConfirm" ? "Détail de la session"
: page.view === "plan" || page.view === "restoreConfirm" ? "Aperçu de restauration"
: page.view === "result" ? "Restauration" : "Sauvegarder une session"
color: Theme.foreground
font.pixelSize: 16
font.bold: true
elide: Text.ElideRight
}
ActionButton {
id: saveButton
visible: page.view === "list"
enabled: !page.busy
label: "Sauvegarder"
onClicked: page.openCreate()
}
}
Text {
width: parent.width
visible: page.busy
text: page.operation === "apply" ? "Restauration en cours…"
: page.operation === "list" ? "Actualisation…" : "Traitement en cours…"
color: Theme.secondaryForeground
font.pixelSize: 12
}
Text {
width: parent.width
visible: page.errorMessage.length > 0 && page.view !== "result"
text: page.errorMessage
wrapMode: Text.Wrap
color: Theme.warningForeground
font.pixelSize: 12
}
Text {
width: parent.width
visible: page.feedback.length > 0 && page.view === "list"
text: page.feedback
wrapMode: Text.Wrap
color: Theme.successForeground
font.pixelSize: 12
}
Loader {
width: parent.width
height: parent.height - y
sourceComponent: page.view === "list" ? listComponent
: page.view === "create" ? createComponent
: page.view === "duplicate" ? duplicateComponent
: page.view === "details" ? detailsComponent
: page.view === "deleteConfirm" ? deleteComponent
: page.view === "plan" ? planComponent
: page.view === "restoreConfirm" ? restoreConfirmComponent
: resultComponent
}
}
Component {
id: listComponent
Item {
Text {
anchors.centerIn: parent
visible: !page.busy && page.sessions.length === 0
text: "Aucune session sauvegardée"
color: Theme.secondaryForeground
font.pixelSize: 14
}
ActionButton {
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.verticalCenter
anchors.topMargin: 24
visible: !page.busy && page.sessions.length === 0
label: "Sauvegarder la session actuelle"
onClicked: page.openCreate()
}
ScrollView {
anchors.fill: parent
visible: page.sessions.length > 0
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
Column {
width: page.width - 14
spacing: 8
Repeater {
model: page.sessions
delegate: Rectangle {
required property var modelData
width: parent.width
height: 72
radius: 4
color: sessionPointer.containsMouse ? Theme.buttonHover : Theme.buttonBackground
Column {
anchors.left: parent.left; anchors.leftMargin: 12
anchors.right: parent.right; anchors.rightMargin: 34
anchors.verticalCenter: parent.verticalCenter; spacing: 3
Text { width: parent.width; text: modelData.name; elide: Text.ElideRight; color: Theme.foreground; font.pixelSize: 13; font.bold: true }
Text { text: page.plural(modelData.windows, "fenêtre", "fenêtres") + " • " + page.plural(modelData.workspaces, "espace", "espaces"); color: Theme.secondaryForeground; font.pixelSize: 11 }
Text { width: parent.width; text: page.formatDate(modelData.updated_at); elide: Text.ElideRight; color: Theme.disabledForeground; font.pixelSize: 10 }
}
Text { anchors.right: parent.right; anchors.rightMargin: 12; anchors.verticalCenter: parent.verticalCenter; text: ""; color: Theme.accentForeground; font.pixelSize: 13 }
MouseArea { id: sessionPointer; anchors.fill: parent; hoverEnabled: true; enabled: !page.busy; onClicked: page.openDetails(modelData) }
}
}
}
}
}
}
Component {
id: createComponent
Column {
spacing: 12
Text { text: "Nom de la session"; color: Theme.foreground; font.pixelSize: 13; font.bold: true }
TextField {
id: nameInput
width: parent.width
text: page.nameDraft
maximumLength: 64
placeholderText: "Développement"
enabled: !page.busy
onTextEdited: page.nameDraft = text
onAccepted: page.requestSave()
}
Text {
width: parent.width
text: page.nameDraft.length === 0 ? "1 à 64 caractères : A-Z, a-z, 0-9, - et _."
: page.validName ? "Nom valide" : "Utiliser uniquement A-Z, a-z, 0-9, - et _ (64 caractères maximum)."
color: page.nameDraft.length === 0 || page.validName ? Theme.secondaryForeground : Theme.warningForeground
wrapMode: Text.Wrap; font.pixelSize: 11
}
ActionButton { label: "Sauvegarder"; enabled: page.validName && !page.busy; onClicked: page.requestSave() }
}
}
Component {
id: duplicateComponent
Column {
spacing: 16
Text { width: parent.width; text: "La session existe déjà.\nMettre à jour cette sauvegarde ?"; wrapMode: Text.Wrap; color: Theme.foreground; font.pixelSize: 14; font.bold: true }
Row {
spacing: 8
ActionButton { label: "Annuler"; enabled: !page.busy; onClicked: page.view = "create" }
ActionButton { label: "Mettre à jour"; enabled: !page.busy; onClicked: page.run("save", page.nameDraft) }
}
}
}
Component {
id: detailsComponent
ScrollView {
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
Column {
width: page.width - 14
spacing: 9
Text { width: parent.width; text: page.selectedSession ? page.selectedSession.name : ""; wrapMode: Text.Wrap; color: Theme.foreground; font.pixelSize: 17; font.bold: true }
Text { visible: !!page.snapshot; text: "Créée : " + page.formatDate(page.snapshot ? page.snapshot.metadata.created_at : "").replace("Mis à jour ", ""); color: Theme.secondaryForeground; font.pixelSize: 11 }
Text { visible: !!page.snapshot; text: "Mise à jour : " + page.formatDate(page.snapshot ? page.snapshot.metadata.updated_at : "").replace("Mis à jour ", ""); color: Theme.secondaryForeground; font.pixelSize: 11 }
Text { visible: !!page.snapshot; text: page.plural(page.snapshot ? page.snapshot.workspaces.length : 0, "espace", "espaces") + " • " + page.plural(page.snapshot ? page.snapshot.windows.length : 0, "fenêtre", "fenêtres"); color: Theme.foreground; font.pixelSize: 12 }
Text { visible: !!page.snapshot; text: "Moteur de disposition : " + (page.snapshot ? page.snapshot.compositor.layout_engine : ""); color: Theme.secondaryForeground; font.pixelSize: 11 }
Text { visible: !!page.snapshot; text: "FENÊTRES"; color: Theme.accentForeground; font.pixelSize: 11; font.bold: true }
Repeater {
model: page.snapshot ? page.snapshot.windows : []
delegate: Rectangle {
required property var modelData
width: parent.width; height: 38; radius: 4; color: Theme.buttonBackground
Text { anchors.left: parent.left; anchors.leftMargin: 9; anchors.verticalCenter: parent.verticalCenter; width: parent.width - 90; text: page.applicationLabel(modelData); elide: Text.ElideRight; color: Theme.foreground; font.pixelSize: 12 }
Text { anchors.right: parent.right; anchors.rightMargin: 9; anchors.verticalCenter: parent.verticalCenter; text: modelData.workspace ? "WS " + modelData.workspace : modelData.scratchpad && modelData.scratchpad.member ? "Scratchpad" : "—"; color: Theme.secondaryForeground; font.pixelSize: 11 }
}
}
Row {
visible: !!page.snapshot
spacing: 8
ActionButton { label: "Mettre à jour"; enabled: !page.mutatingBusy && !page.busy; onClicked: page.run("save", page.selectedSession.name) }
ActionButton { label: "Prévisualiser"; enabled: !page.busy; onClicked: page.preview() }
ActionButton { label: "Supprimer"; danger: true; enabled: !page.mutatingBusy && !page.busy; onClicked: page.view = "deleteConfirm" }
}
}
}
}
Component {
id: deleteComponent
Column {
spacing: 16
Text { width: parent.width; text: "Supprimer « " + (page.selectedSession ? page.selectedSession.name : "") + " » ?"; wrapMode: Text.Wrap; color: Theme.danger; font.pixelSize: 15; font.bold: true }
Text { width: parent.width; text: "Cette sauvegarde sera définitivement supprimée."; wrapMode: Text.Wrap; color: Theme.secondaryForeground; font.pixelSize: 12 }
Row {
spacing: 8
ActionButton { label: "Annuler"; enabled: !page.busy; onClicked: page.view = "details" }
ActionButton { label: "Supprimer"; danger: true; enabled: !page.busy; onClicked: page.run("delete", page.selectedSession.name) }
}
}
}
Component {
id: planComponent
Column {
spacing: 8
Text { visible: !!page.plan && page.blockerCount() > 0; width: parent.width; text: "Restauration incomplète"; color: Theme.warningForeground; font.pixelSize: 13; font.bold: true }
Grid {
visible: !!page.plan
columns: 3; spacing: 5
Repeater {
model: page.plan ? [
["Réutilisées", page.countAction("reuse-window")], ["À lancer", page.countAction("launch-application")], ["À déplacer", page.countAction("move-to-workspace")],
["Layout", page.countAction("restore-tree-position")], ["Floating", page.countAction("restore-floating")], ["Fullscreen", page.countAction("restore-fullscreen")],
["Scratchpad", page.countAction("restore-scratchpad-hidden") + page.countAction("restore-scratchpad-visible")], ["Focus", page.countAction("restore-focus")], ["Intervention", page.blockerCount()]
] : []
delegate: Rectangle {
required property var modelData
width: (page.width - 24) / 3; height: 48; radius: 4; color: Theme.buttonBackground
Column { anchors.centerIn: parent; Text { anchors.horizontalCenter: parent.horizontalCenter; text: modelData[1]; color: Theme.foreground; font.pixelSize: 15; font.bold: true } Text { anchors.horizontalCenter: parent.horizontalCenter; text: modelData[0]; color: Theme.secondaryForeground; font.pixelSize: 9 } }
}
}
}
Text { visible: !!page.plan; text: "ACTIONS"; color: Theme.accentForeground; font.pixelSize: 11; font.bold: true }
ScrollView {
width: parent.width; height: 245; visible: !!page.plan; clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
Column {
width: page.width - 14; spacing: 4
Repeater {
model: page.plan ? page.plan.actions : []
delegate: Rectangle {
required property var modelData
width: parent.width; height: actionText.implicitHeight + 20; radius: 4
color: page.isBlocking(modelData) ? Theme.buttonHover : Theme.buttonBackground
Column { anchors.left: parent.left; anchors.leftMargin: 9; anchors.right: parent.right; anchors.rightMargin: 9; anchors.verticalCenter: parent.verticalCenter; spacing: 2
Text { text: page.groupFor(modelData.action); color: page.isBlocking(modelData) ? Theme.warningForeground : Theme.accentForeground; font.pixelSize: 9; font.bold: true }
Text { id: actionText; width: parent.width; text: page.actionText(modelData); wrapMode: Text.Wrap; color: Theme.foreground; font.pixelSize: 11 }
}
}
}
}
}
Text { visible: !!page.plan; width: parent.width; wrapMode: Text.Wrap; text: "La restauration replace les fenêtres et le layout. L’état interne des applications n’est pas garanti (onglets, documents, contenu d’un terminal, etc.). Les proportions exactes des splits ne sont pas restaurées."; color: Theme.secondaryForeground; font.pixelSize: 10 }
Text { visible: !!page.plan; width: parent.width; wrapMode: Text.Wrap; text: "L’état sera revérifié au moment de la restauration. La sauvegarde utilisée sera son état actuel au moment de l’exécution."; color: Theme.secondaryForeground; font.pixelSize: 10 }
ActionButton { visible: !!page.plan; label: "Restaurer"; enabled: !page.busy; onClicked: page.view = "restoreConfirm" }
}
}
Component {
id: restoreConfirmComponent
Column {
spacing: 16
Text { width: parent.width; text: "Restaurer « " + (page.selectedSession ? page.selectedSession.name : "") + " » ?"; wrapMode: Text.Wrap; color: Theme.foreground; font.pixelSize: 15; font.bold: true }
Text { width: parent.width; text: "Les applications manquantes pourront être lancées et les fenêtres existantes déplacées."; wrapMode: Text.Wrap; color: Theme.secondaryForeground; font.pixelSize: 12 }
Text { width: parent.width; text: "Un plan frais et le preflight seront recalculés avant toute action."; wrapMode: Text.Wrap; color: Theme.secondaryForeground; font.pixelSize: 11 }
Row {
spacing: 8
ActionButton { label: "Annuler"; enabled: !page.busy; onClicked: page.view = "plan" }
ActionButton { label: "Restaurer"; enabled: !page.busy; onClicked: page.run("apply", page.selectedSession.name) }
}
}
}
Component {
id: resultComponent
Column {
spacing: 12
Text { width: parent.width; text: page.restoreReport && page.restoreReport.status === "success" ? "Session restaurée" : "Restauration interrompue"; color: page.restoreReport && page.restoreReport.status === "success" ? Theme.successForeground : Theme.warningForeground; font.pixelSize: 16; font.bold: true }
Text { visible: page.restoreReport && page.restoreReport.status === "success"; text: page.plural(page.resultCount("applications_launched"), "application lancée", "applications lancées") + " • " + page.plural(page.resultCount("mutating_commands_executed"), "action exécutée", "actions exécutées"); color: Theme.foreground; font.pixelSize: 12 }
Text { visible: page.errorMessage.length > 0; width: parent.width; text: page.errorMessage; wrapMode: Text.Wrap; color: Theme.warningForeground; font.pixelSize: 12 }
Text { visible: page.technicalDetails.length > 0; width: parent.width; text: "Détails : " + page.technicalDetails; wrapMode: Text.Wrap; color: Theme.disabledForeground; font.pixelSize: 10 }
Row {
spacing: 8
ActionButton { label: "Fermer"; enabled: !page.busy; onClicked: { page.view = "details"; page.restoreReport = null; } }
ActionButton { label: "Actualiser"; enabled: !page.busy; onClicked: { page.view = "list"; page.restoreReport = null; page.refresh(); } }
}
}
}
}

View file

@ -8,10 +8,11 @@ Item {
required property var actions
signal backRequested()
signal managerRequested()
signal actionRequested(string actionId)
// La taille doit être connue avant la création des delegates pour éviter un popup de hauteur nulle.
implicitHeight: 28 + actions.length * 48 + actions.length * 10
implicitHeight: 28 + (actions.length + 1) * 48 + (actions.length + 1) * 10
Column {
id: content
@ -49,6 +50,17 @@ Item {
}
}
SessionAction {
width: content.width
actionInfo: ({
id: "session-manager",
label: "Gestionnaire de sessions",
icon: "󰆓",
accent: Theme.accent
})
onSelected: page.managerRequested()
}
Repeater {
model: page.actions
delegate: SessionAction {

View file

@ -0,0 +1,60 @@
from __future__ import annotations
import re
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[1]
CONTROL_CENTER = ROOT / "quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml"
SESSION_PAGE = ROOT / "quickshell/.config/quickshell/labfy-sway/controlcenter/SessionPage.qml"
SESSION_MANAGER = ROOT / "quickshell/.config/quickshell/labfy-sway/controlcenter/SessionManager.qml"
class SessionManagerQmlTest(unittest.TestCase):
def setUp(self) -> None:
self.control = CONTROL_CENTER.read_text(encoding="utf-8")
self.session_page = SESSION_PAGE.read_text(encoding="utf-8")
self.manager = SESSION_MANAGER.read_text(encoding="utf-8")
def test_navigation_preserves_power_session_and_adds_manager(self) -> None:
self.assertIn('logout", label: "Déconnexion"', self.control)
self.assertIn('command: ["uwsm", "stop"]', self.control)
self.assertIn("sessionManager: 12", self.control)
self.assertIn('onManagerRequested: popup.openPage("sessionManager")', self.control)
self.assertIn('onBackRequested: popup.openPage("session")', self.control)
self.assertIn('label: "Gestionnaire de sessions"', self.session_page)
def test_backend_is_xdg_relative_and_every_command_is_argv(self) -> None:
self.assertIn('Quickshell.env("XDG_CONFIG_HOME")', self.manager)
self.assertIn('/quickshell/labfy-sway/session/session_snapshot.py', self.manager)
self.assertNotIn("/home/fy59", self.manager)
self.assertIn('const argv = ["python3", backend, "--compact"]', self.manager)
self.assertNotRegex(self.manager, re.compile(r"\b(?:sh|bash)\s+-c\b"))
self.assertNotIn("Quickshell.execDetached", self.manager)
def test_user_mutations_are_explicit_and_restore_is_confirmed(self) -> None:
self.assertIn('view = exists ? "duplicate" : "create"', self.manager)
self.assertIn('page.view = "deleteConfirm"', self.manager)
self.assertIn('page.view = "restoreConfirm"', self.manager)
self.assertIn('page.run("apply", page.selectedSession.name)', self.manager)
self.assertIn('if (kind === "apply") argv.push("--execute")', self.manager)
self.assertNotIn("Timer {", self.manager)
self.assertNotIn("running: true", self.manager)
def test_process_contract_keeps_streams_separate_and_checks_exit(self) -> None:
self.assertIn("stdout: StdioCollector", self.manager)
self.assertIn("stderr: StdioCollector", self.manager)
self.assertIn("exitCode !== 0 || exitStatus !== 0", self.manager)
self.assertIn("JSON.parse(text)", self.manager)
self.assertIn('value.read_only !== true', self.manager)
def test_required_limitations_and_preflight_authority_are_visible(self) -> None:
self.assertIn("L’état interne des applications n’est pas garanti", self.manager)
self.assertIn("Les proportions exactes des splits ne sont pas restaurées", self.manager)
self.assertIn("Un plan frais et le preflight seront recalculés", self.manager)
self.assertIn("RESTORE_ALREADY_RUNNING", self.manager)
if __name__ == "__main__":
unittest.main()