Merge pull request 'clan-vm-manager: Fix incorrect use of all Glib.idle_add uses' (#892) from Qubasa-main into main

This commit is contained in:
clan-bot
2024-03-03 08:49:07 +00:00
6 changed files with 133 additions and 114 deletions

View File

@@ -35,14 +35,14 @@ class HistoryEntry:
self.flake = FlakeConfig(**self.flake)
def merge_dicts(d1: dict, d2: dict) -> dict:
def _merge_dicts(d1: dict, d2: dict) -> dict:
# create a new dictionary that copies d1
merged = dict(d1)
# iterate over the keys and values of d2
for key, value in d2.items():
# if the key is in d1 and both values are dictionaries, merge them recursively
if key in d1 and isinstance(d1[key], dict) and isinstance(value, dict):
merged[key] = merge_dicts(d1[key], value)
merged[key] = _merge_dicts(d1[key], value)
# otherwise, update the value of the key in the merged dictionary
else:
merged[key] = value
@@ -59,7 +59,7 @@ def list_history() -> list[HistoryEntry]:
parsed = read_history_file()
for i, p in enumerate(parsed.copy()):
# Everything from the settings dict is merged into the flake dict, and can override existing values
parsed[i] = merge_dicts(p, p.get("settings", {}))
parsed[i] = _merge_dicts(p, p.get("settings", {}))
logs = [HistoryEntry(**p) for p in parsed]
except (json.JSONDecodeError, TypeError) as ex:
raise ClanError(f"History file at {user_history_file()} is corrupted") from ex
@@ -76,40 +76,47 @@ def new_history_entry(url: str, machine: str) -> HistoryEntry:
)
def add_history(uri: ClanURI, *, all_machines: bool) -> list[HistoryEntry]:
def add_all_to_history(uri: ClanURI) -> list[HistoryEntry]:
history = list_history()
new_entries: list[HistoryEntry] = []
for machine in list_machines(uri.get_internal()):
new_entry = _add_maschine_to_history_list(uri.get_internal(), machine, history)
new_entries.append(new_entry)
write_history_file(history)
return new_entries
def add_history(uri: ClanURI) -> HistoryEntry:
user_history_file().parent.mkdir(parents=True, exist_ok=True)
history = list_history()
if not all_machines:
add_maschine_to_history(uri.get_internal(), uri.params.flake_attr, history)
if all_machines:
for machine in list_machines(uri.get_internal()):
add_maschine_to_history(uri.get_internal(), machine, history)
new_entry = _add_maschine_to_history_list(
uri.get_internal(), uri.params.flake_attr, history
)
write_history_file(history)
return history
return new_entry
def add_maschine_to_history(
uri_path: str, uri_machine: str, logs: list[HistoryEntry]
) -> None:
found = False
for entry in logs:
def _add_maschine_to_history_list(
uri_path: str, uri_machine: str, entries: list[HistoryEntry]
) -> HistoryEntry:
for new_entry in entries:
if (
entry.flake.flake_url == str(uri_path)
and entry.flake.flake_attr == uri_machine
new_entry.flake.flake_url == str(uri_path)
and new_entry.flake.flake_attr == uri_machine
):
found = True
entry.last_used = datetime.datetime.now().isoformat()
new_entry.last_used = datetime.datetime.now().isoformat()
return new_entry
if not found:
history = new_history_entry(uri_path, uri_machine)
logs.append(history)
new_entry = new_history_entry(uri_path, uri_machine)
entries.append(new_entry)
return new_entry
def add_history_command(args: argparse.Namespace) -> None:
add_history(args.uri, all_machines=args.all)
if args.all:
add_all_to_history(args.uri)
else:
add_history(args.uri)
# takes a (sub)parser and configures it

View File

@@ -95,6 +95,10 @@ class GKVStore(GObject.GObject, Gio.ListModel, Generic[K, V]):
# #
#########################
def insert(self, position: int, item: V) -> None:
log.warning("Inserting is O(n) in GKVStore. Better use append")
log.warning(
"This functions may have incorrect items_changed signal behavior. Please test it"
)
key = self.key_gen(item)
if key in self._items:
raise ValueError("Key already exists in the dictionary")
@@ -141,12 +145,12 @@ class GKVStore(GObject.GObject, Gio.ListModel, Generic[K, V]):
log.warning("Updating an existing key in GKVStore is O(n)")
position = self.keys().index(key)
self._items[key] = value
self.items_changed(position, 0, 1)
self.items_changed(position, 1, 1)
else:
# Add the new key-value pair
position = max(len(self._items) - 1, 0)
self._items[key] = value
self._items.move_to_end(key)
position = max(len(self._items) - 1, 0)
self.items_changed(position, 0, 1)
# O(n) operation

View File

@@ -4,11 +4,10 @@ from collections.abc import Callable
from typing import Any, ClassVar
import gi
from clan_cli import ClanError
from clan_cli.clan_uri import ClanURI
from clan_cli.history.add import add_history
from clan_cli.history.add import HistoryEntry, add_history
from clan_vm_manager.errors.show_error import show_error_dialog
from clan_vm_manager.models.use_vms import VMs
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
@@ -25,29 +24,33 @@ class JoinValue(GObject.Object):
}
url: ClanURI
entry: HistoryEntry | None
def __init__(
self, url: ClanURI, on_join: Callable[["JoinValue", Any], None]
) -> None:
def _join_finished(self) -> bool:
self.emit("join_finished", self)
return GLib.SOURCE_REMOVE
def __init__(self, url: ClanURI) -> None:
super().__init__()
self.url = url
self.connect("join_finished", on_join)
self.entry = None
def __join(self) -> None:
add_history(self.url, all_machines=False)
GLib.idle_add(self.emit, "join_finished", self)
new_entry = add_history(self.url)
self.entry = new_entry
GLib.idle_add(self._join_finished)
def join(self) -> None:
threading.Thread(target=self.__join).start()
class Join:
class JoinList:
"""
This is a singleton.
It is initialized with the first call of use()
"""
_instance: "None | Join" = None
_instance: "None | JoinList" = None
list_store: Gio.ListStore
# Make sure the VMS class is used as a singleton
@@ -55,38 +58,39 @@ class Join:
raise RuntimeError("Call use() instead")
@classmethod
def use(cls: Any) -> "Join":
def use(cls: Any) -> "JoinList":
if cls._instance is None:
cls._instance = cls.__new__(cls)
cls.list_store = Gio.ListStore.new(JoinValue)
return cls._instance
def push(self, url: ClanURI, on_join: Callable[[JoinValue], None]) -> None:
def is_empty(self) -> bool:
return self.list_store.get_n_items() == 0
def push(
self, value: JoinValue, after_join: Callable[[JoinValue, JoinValue], None]
) -> None:
"""
Add a join request.
This method can add multiple join requests if called subsequently for each request.
"""
if url.get_id() in [item.url.get_id() for item in self.list_store]:
log.info(f"Join request already exists: {url}")
if value.url.get_id() in [item.url.get_id() for item in self.list_store]:
log.info(f"Join request already exists: {value.url}. Ignoring.")
return
def after_join(item: JoinValue, _: Any) -> None:
self.discard(item)
print("Refreshed list after join")
on_join(item)
value.connect("join_finished", self._on_join_finished)
value.connect("join_finished", after_join)
self.list_store.append(JoinValue(url, after_join))
self.list_store.append(value)
def join(self, item: JoinValue) -> None:
try:
log.info(f"trying to join: {item.url}")
item.join()
except ClanError as e:
show_error_dialog(e)
def _on_join_finished(self, _source: GObject.Object, value: JoinValue) -> None:
log.info(f"Join finished: {value.url}")
self.discard(value)
VMs.use().push_history_entry(value.entry)
def discard(self, item: JoinValue) -> None:
(has, idx) = self.list_store.find(item)
def discard(self, value: JoinValue) -> None:
(has, idx) = self.list_store.find(value)
if has:
self.list_store.remove(idx)

View File

@@ -18,6 +18,8 @@ from clan_cli.errors import ClanError
from clan_cli.history.add import HistoryEntry
from clan_cli.machines.machines import Machine
from clan_vm_manager import assets
from .executor import MPProcess, spawn
from .gkvstore import GKVStore
@@ -35,6 +37,10 @@ class VM(GObject.Object):
"vm_status_changed": (GObject.SignalFlags.RUN_FIRST, None, [GObject.Object])
}
def vm_status_changed(self) -> bool:
self.emit("vm_status_changed", self)
return GLib.SOURCE_REMOVE
def __init__(
self,
icon: Path,
@@ -75,12 +81,12 @@ class VM(GObject.Object):
self.switch_handler_id: int = self.switch.connect(
"notify::active", self.on_switch_toggle
)
self.connect("vm_status_changed", self.vm_status_changed)
self.connect("vm_status_changed", self.on_vm_status_changed)
# Make sure the VM is killed when the reference to this object is dropped
self._finalizer = weakref.finalize(self, self.kill_ref_drop)
def vm_status_changed(self, vm: "VM", _vm: "VM") -> None:
def on_vm_status_changed(self, vm: "VM", _vm: "VM") -> None:
self.switch.set_state(self.is_running() and not self.is_building())
if self.switch.get_sensitive() is False and not self.is_building():
self.switch.set_sensitive(True)
@@ -145,7 +151,7 @@ class VM(GObject.Object):
tmpdir=log_dir,
vm=self.data.flake.vm,
)
GLib.idle_add(self.emit, "vm_status_changed", self)
GLib.idle_add(self.vm_status_changed)
# Start the logs watcher
self._logs_id = GLib.timeout_add(
@@ -170,7 +176,7 @@ class VM(GObject.Object):
# Check if the VM was built successfully
if self.build_process.proc.exitcode != 0:
log.error(f"Failed to build VM {self.get_id()}")
GLib.idle_add(self.emit, "vm_status_changed", self)
GLib.idle_add(self.vm_status_changed)
return
log.info(f"Successfully built VM {self.get_id()}")
@@ -182,7 +188,7 @@ class VM(GObject.Object):
vm=self.data.flake.vm,
)
log.debug(f"Started VM {self.get_id()}")
GLib.idle_add(self.emit, "vm_status_changed", self)
GLib.idle_add(self.vm_status_changed)
# Start the logs watcher
self._logs_id = GLib.timeout_add(50, self._get_logs_task, self.vm_process)
@@ -193,7 +199,7 @@ class VM(GObject.Object):
# Wait for the VM to stop
self.vm_process.proc.join()
log.debug(f"VM {self.get_id()} has stopped")
GLib.idle_add(self.emit, "vm_status_changed", self)
GLib.idle_add(self.vm_status_changed)
def start(self) -> None:
if self.is_running():
@@ -269,7 +275,7 @@ class VM(GObject.Object):
# Try 20 times to stop the VM
time.sleep(self.KILL_TIMEOUT / 20)
GLib.idle_add(self.emit, "vm_status_changed", self)
GLib.idle_add(self.vm_status_changed)
log.debug(f"VM {self.get_id()} has stopped")
def shutdown(self) -> None:
@@ -338,6 +344,22 @@ class VMs:
def clan_store(self) -> GKVStore[str, VMStore]:
return self._clan_store
def create_vm_task(self, vm: HistoryEntry) -> bool:
self.push_history_entry(vm)
return GLib.SOURCE_REMOVE
def push_history_entry(self, entry: HistoryEntry) -> None:
if entry.flake.icon is None:
icon = assets.loc / "placeholder.jpeg"
else:
icon = entry.flake.icon
vm = VM(
icon=Path(icon),
data=entry,
)
self.push(vm)
def push(self, vm: VM) -> None:
url = vm.data.flake.flake_url

View File

@@ -8,7 +8,7 @@ from clan_cli import history, machines
from clan_cli.clan_uri import ClanURI
from clan_vm_manager.models.interfaces import ClanConfig
from clan_vm_manager.models.use_join import Join, JoinValue
from clan_vm_manager.models.use_join import JoinList, JoinValue
from clan_vm_manager.models.use_vms import VM, VMs, VMStore
gi.require_version("Adw", "1")
@@ -54,7 +54,7 @@ class ClanList(Gtk.Box):
# Add join list
self.join_boxed_list = create_boxed_list(
model=Join.use().list_store, render_row=self.render_join_row
model=JoinList.use().list_store, render_row=self.render_join_row
)
self.join_boxed_list.add_css_class("join-list")
self.append(self.join_boxed_list)
@@ -113,8 +113,10 @@ class ClanList(Gtk.Box):
# ====== Display Avatar ======
avatar = Adw.Avatar()
machine_icon = flake.vm.machine_icon
# If there is a machine icon, display it else
# display the clan icon
if machine_icon:
avatar.set_custom_image(Gdk.Texture.new_from_filename(str(machine_icon)))
elif flake.icon:
@@ -128,10 +130,11 @@ class ClanList(Gtk.Box):
# ====== Display Name And Url =====
row.set_title(flake.flake_attr)
row.set_title_lines(1)
row.set_title_selectable(True)
# If there is a machine description, display it else
# display the clan name
if flake.vm.machine_description:
row.set_subtitle(flake.vm.machine_description)
else:
@@ -139,37 +142,35 @@ class ClanList(Gtk.Box):
row.set_subtitle_lines(1)
# ==== Display build progress bar ====
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
box.set_valign(Gtk.Align.CENTER)
box.append(vm.progress_bar)
box.set_homogeneous(False)
row.add_suffix(box) # This allows children to have different sizes
build_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
build_box.set_valign(Gtk.Align.CENTER)
build_box.append(vm.progress_bar)
build_box.set_homogeneous(False)
row.add_suffix(build_box) # This allows children to have different sizes
# ==== Action buttons ====
switch_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
switch_box.set_valign(Gtk.Align.CENTER)
switch_box.append(vm.switch)
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
box.set_valign(Gtk.Align.CENTER)
button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
button_box.set_valign(Gtk.Align.CENTER)
## Drop down menu
open_action = Gio.SimpleAction.new("edit", GLib.VariantType.new("s"))
open_action.connect("activate", self.on_edit)
app = Gio.Application.get_default()
app.add_action(open_action)
menu_model = Gio.Menu()
menu_model.append("Edit", f"app.edit::{vm.get_id()}")
pref_button = Gtk.MenuButton()
pref_button.set_icon_name("open-menu-symbolic")
pref_button.set_menu_model(menu_model)
button_box.append(pref_button)
box.append(switch_box)
box.append(pref_button)
## VM switch button
switch_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
switch_box.set_valign(Gtk.Align.CENTER)
switch_box.append(vm.switch)
button_box.append(switch_box)
# suffix.append(box)
row.add_suffix(box)
row.add_suffix(button_box)
return row
@@ -221,24 +222,20 @@ class ClanList(Gtk.Box):
def on_join_request(self, widget: Any, url: str) -> None:
log.debug("Join request: %s", url)
clan_uri = ClanURI.from_str(url)
Join.use().push(clan_uri, self.after_join)
value = JoinValue(url=clan_uri)
JoinList.use().push(value, self.on_after_join)
def after_join(self, item: JoinValue) -> None:
def on_after_join(self, source: JoinValue, item: JoinValue) -> None:
# If the join request list is empty disable the shadow artefact
if not Join.use().list_store.get_n_items():
if JoinList.use().is_empty():
self.join_boxed_list.add_css_class("no-shadow")
print("after join in list")
def on_trust_clicked(self, item: JoinValue, widget: Gtk.Widget) -> None:
def on_trust_clicked(self, value: JoinValue, widget: Gtk.Widget) -> None:
widget.set_sensitive(False)
self.cancel_button.set_sensitive(False)
value.join()
# TODO(@hsjobeki): Confirm and edit details
# Views.use().view.set_visible_child_name("details")
Join.use().join(item)
def on_discard_clicked(self, item: JoinValue, widget: Gtk.Widget) -> None:
Join.use().discard(item)
if not Join.use().list_store.get_n_items():
def on_discard_clicked(self, value: JoinValue, widget: Gtk.Widget) -> None:
JoinList.use().discard(value)
if JoinList.use().is_empty():
self.join_boxed_list.add_css_class("no-shadow")

View File

@@ -1,15 +1,13 @@
import logging
import threading
from pathlib import Path
from typing import Any
import gi
from clan_cli.history.list import list_history
from clan_vm_manager import assets
from clan_vm_manager.models.interfaces import ClanConfig
from clan_vm_manager.models.use_views import Views
from clan_vm_manager.models.use_vms import VM, VMs
from clan_vm_manager.models.use_vms import VMs
from clan_vm_manager.views.details import Details
from clan_vm_manager.views.list import ClanList
@@ -61,24 +59,11 @@ class MainWindow(Adw.ApplicationWindow):
self.connect("destroy", self.on_destroy)
def push_vm(self, vm: VM) -> bool:
VMs.use().push(vm)
return GLib.SOURCE_REMOVE
def _populate_vms(self) -> None:
# Execute `clan flakes add <path>` to democlan for this to work
# TODO: Make list_history a generator function
for entry in list_history():
if entry.flake.icon is None:
icon = assets.loc / "placeholder.jpeg"
else:
icon = entry.flake.icon
vm = VM(
icon=Path(icon),
data=entry,
)
GLib.idle_add(self.push_vm, vm)
GLib.idle_add(VMs.use().create_vm_task, entry)
def on_destroy(self, *_args: Any) -> None:
self.tray_icon.destroy()