feat(session): add QuickShell session manager
This commit is contained in:
parent
5567a76738
commit
d41131037f
5 changed files with 707 additions and 3 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
60
tests/test_session_manager_qml.py
Normal file
60
tests/test_session_manager_qml.py
Normal 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()
|
||||
Loading…
Reference in a new issue