updates: make popup window

This commit is contained in:
2 * r + 2 * t 2025-01-16 22:29:13 +11:00
parent e539fe0bab
commit 9e32cd4b61
8 changed files with 238 additions and 29 deletions

View file

@ -5,6 +5,7 @@ import Launcher from "./src/modules/launcher";
import Notifications from "./src/modules/notifications";
import NotifPopups from "./src/modules/notifpopups";
import Osds from "./src/modules/osds";
import Updates from "./src/modules/updates";
import Monitors from "./src/services/monitors";
import Players from "./src/services/players";
@ -26,6 +27,7 @@ App.start({
<Osds />;
Monitors.get_default().forEach(m => <Bar monitor={m} />);
<Notifications />;
<Updates />;
console.log("Caelestia started");
},

View file

@ -88,7 +88,7 @@
color: scheme.$rosewater;
}
.updates {
.pkg-updates {
color: scheme.$blue;
}

74
scss/updates.scss Normal file
View file

@ -0,0 +1,74 @@
@use "sass:color";
@use "scheme";
@use "lib";
@use "font";
.updates {
@include lib.rounded(8);
@include lib.border(scheme.$blue, 0.4, 2);
@include lib.shadow;
@include font.mono;
min-width: lib.s(600);
min-height: lib.s(400);
background-color: scheme.$base;
color: scheme.$blue;
padding: lib.s(10) lib.s(12);
font-size: lib.s(14);
.wrapper {
@include lib.element-decel;
&:hover,
&:focus {
color: color.mix(scheme.$blue, scheme.$base, 80%);
}
&:active {
color: color.mix(scheme.$blue, scheme.$base, 60%);
}
}
.header {
@include lib.spacing(8);
padding: 0 lib.s(5);
margin-bottom: lib.s(8);
font-size: lib.s(15);
button {
@include lib.rounded(5);
@include lib.element-decel;
padding: lib.s(3) lib.s(8);
&:hover,
&:focus {
background-color: scheme.$surface0;
}
&:active {
background-color: scheme.$surface1;
}
}
}
.icon {
font-size: lib.s(32);
}
.repos {
@include lib.spacing($vertical: true);
.repo {
.header {
font-size: lib.s(16);
}
.list {
color: scheme.$text;
margin-left: lib.s(12);
}
}
}
}

View file

@ -221,14 +221,7 @@ const Network = () => (
network.wifi.scan();
execAsync(
"uwsm app -- foot -T nmtui fish -c 'sleep .1; set -e COLORTERM; TERM=xterm-old nmtui connect'"
).catch(err => {
// Idk why but foot always throws this error when it opens
if (
err.message !==
"warn: wayland.c:1619: compositor does not implement the XDG toplevel icon protocol\nwarn: terminal.c:1973: slave exited with signal 1 (Hangup)"
)
console.error(err);
});
).catch(() => {}); // Ignore errors
});
}}
setup={self => {
@ -372,8 +365,16 @@ const StatusIcons = () => (
);
const PkgUpdates = () => (
<box
className="module updates"
<button
onClick={(self, event) => {
if (event.button === Astal.MouseButton.PRIMARY) {
const popup = App.get_window("updates") as PopupWindow | null;
if (popup) {
if (popup.visible) popup.hide();
else popup.popup_at_widget(self, event);
}
}
}}
setup={self =>
setupCustomTooltip(
self,
@ -381,9 +382,11 @@ const PkgUpdates = () => (
)
}
>
<label className="icon" label="download" />
<label label={bind(Updates.get_default(), "numUpdates").as(String)} />
</box>
<box className="module pkg-updates">
<label className="icon" label="download" />
<label label={bind(Updates.get_default(), "numUpdates").as(String)} />
</box>
</button>
);
const Unread = () => {

102
src/modules/updates.tsx Normal file
View file

@ -0,0 +1,102 @@
import { bind, execAsync, Variable } from "astal";
import { App, Astal, Gtk } from "astal/gtk3";
import Updates, { Repo as IRepo, Update as IUpdate } from "../services/updates";
import { MenuItem } from "../utils/widgets";
import PopupWindow from "../widgets/popupwindow";
const constructItem = (label: string, exec: string, quiet = true) =>
new MenuItem({
label,
onActivate() {
App.get_window("updates")?.hide();
execAsync(exec).catch(e => !quiet && console.error(e));
},
});
const Update = (update: IUpdate) => {
const menu = new Gtk.Menu();
menu.append(constructItem("Open info in browser", `xdg-open '${update.url}'`, false));
menu.append(constructItem("Open info in terminal", `uwsm app -- foot -H pacman -Qi ${update.name}`));
menu.append(constructItem("Reinstall", `uwsm app -T -- yay -S ${update.name}`));
menu.append(constructItem("Remove with dependencies", `uwsm app -T -- yay -Rns ${update.name}`));
return (
<button
onClick={(_, event) => event.button === Astal.MouseButton.SECONDARY && menu.popup_at_pointer(null)}
onDestroy={() => menu.destroy()}
>
<label
truncate
xalign={0}
label={`${update.name} (${update.version.old} -> ${update.version.new})\n ${update.description}`}
/>
</button>
);
};
const Repo = (repo: IRepo) => {
const expanded = Variable(false);
return (
<box vertical className="repo">
<button className="wrapper" cursor="pointer" onClicked={() => expanded.set(!expanded.get())}>
<box className="header">
<label className="icon" label={repo.icon} />
<label label={`${repo.name} (${repo.updates.length})`} />
<box hexpand />
<label className="icon" label={bind(expanded).as(e => (e ? "expand_less" : "expand_more"))} />
</box>
</button>
<revealer
revealChild={bind(expanded)}
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
transitionDuration={200}
>
<box vertical className="list">
{repo.updates.map(Update)}
</box>
</revealer>
</box>
);
};
const List = () => (
<box vertical valign={Gtk.Align.START} className="repos">
{bind(Updates.get_default(), "updateData").as(d => d.repos.map(Repo))}
</box>
);
export default () => (
<PopupWindow name="updates">
<box vertical className="updates">
<box className="header">
<label label={bind(Updates.get_default(), "numUpdates").as(n => `${n} update${n === 1 ? "" : "s"}`)} />
<box hexpand />
<button
cursor="pointer"
onClicked={() =>
execAsync("uwsm app -T -- yay")
.then(() => Updates.get_default().getUpdates())
// Ignore errors
.catch(() => {})
}
label="Update all"
/>
<button cursor="pointer" onClicked={() => Updates.get_default().getUpdates()} label="Reload" />
</box>
<stack
transitionType={Gtk.StackTransitionType.CROSSFADE}
transitionDuration={150}
shown={bind(Updates.get_default(), "numUpdates").as(n => (n > 0 ? "list" : "empty"))}
>
<box vertical valign={Gtk.Align.CENTER} name="empty">
<label className="icon" label="deployed_code_history" />
<label label="All packages up to date!" />
</box>
<scrollable expand hscroll={Gtk.PolicyType.NEVER} name="list">
<List />
</scrollable>
</stack>
</box>
</PopupWindow>
);

View file

@ -1,19 +1,25 @@
import { execAsync, GLib, GObject, property, readFileAsync, register, writeFileAsync } from "astal";
import { updates as config } from "../../config";
interface Update {
name: string;
export interface Update {
full: string;
name: string;
description: string;
url: string;
version: {
old: string;
new: string;
};
}
interface Repo {
export interface Repo {
repo?: string[];
updates: Update[];
icon: string;
name: string;
}
interface Data {
export interface Data {
cached?: boolean;
repos: Repo[];
errors: string[];
@ -40,7 +46,7 @@ export default class Updates extends GObject.Object {
}
@property(Object)
get data() {
get updateData() {
return this.#data;
}
@ -56,15 +62,29 @@ export default class Updates extends GObject.Object {
async #updateFromCache() {
this.#data = JSON.parse(await readFileAsync(this.#cachePath));
this.notify("data");
this.notify("update-data");
this.notify("list");
this.notify("num-updates");
}
async #getRepo(repo: string) {
async getRepo(repo: string) {
return (await execAsync(`bash -c "comm -12 <(pacman -Qq | sort) <(pacman -Slq '${repo}' | sort)"`)).split("\n");
}
async constructUpdate(update: string) {
const info = await execAsync(`pacman -Qi ${update.split(" ")[0]}`);
return info.split("\n").reduce(
(acc, line) => {
let [key, value] = line.split(" : ");
key = key.trim().toLowerCase();
if (key === "name" || key === "description" || key === "url") acc[key] = value.trim();
else if (key === "version") acc.version.old = value.trim();
return acc;
},
{ version: { new: update.split("->")[1].trim() } } as Update
);
}
getUpdates() {
// Return if already getting updates
if (this.#loading) return;
@ -80,15 +100,15 @@ export default class Updates extends GObject.Object {
// Pacman updates (checkupdates)
if (pacman.status === "fulfilled") {
const repos: Repo[] = [
{ repo: await this.#getRepo("core"), updates: [], icon: "hub", name: "Core repository" },
{ repo: await this.getRepo("core"), updates: [], icon: "hub", name: "Core repository" },
{
repo: await this.#getRepo("extra"),
repo: await this.getRepo("extra"),
updates: [],
icon: "add_circle",
name: "Extra repository",
},
{
repo: await this.#getRepo("multilib"),
repo: await this.getRepo("multilib"),
updates: [],
icon: "account_tree",
name: "Multilib repository",
@ -98,7 +118,7 @@ export default class Updates extends GObject.Object {
for (const update of pacman.value.split("\n")) {
const pkg = update.split(" ")[0];
for (const repo of repos)
if (repo.repo?.includes(pkg)) repo.updates.push({ name: pkg, full: update });
if (repo.repo?.includes(pkg)) repo.updates.push(await this.constructUpdate(update));
}
for (const repo of repos) if (repo.updates.length > 0) data.repos.push(repo);
@ -110,7 +130,7 @@ export default class Updates extends GObject.Object {
for (const update of yay.value.split("\n")) {
if (/^\s*->/.test(update)) data.errors.push(update); // Error
else aur.updates.push({ name: update.split(" ")[0], full: update });
else aur.updates.push(await this.constructUpdate(update));
}
if (aur.updates.length > 0) data.repos.push(aur);
@ -122,7 +142,7 @@ export default class Updates extends GObject.Object {
// Cache and set
writeFileAsync(this.#cachePath, JSON.stringify({ cached: true, ...data })).catch(console.error);
this.#data = data;
this.notify("data");
this.notify("update-data");
this.notify("list");
this.notify("num-updates");
}

View file

@ -1,5 +1,5 @@
import { Binding } from "astal";
import { Astal, Widget } from "astal/gtk3";
import { Binding, register } from "astal";
import { Astal, astalify, Gtk, Widget, type ConstructProps } from "astal/gtk3";
import AstalHyprland from "gi://AstalHyprland";
export const setupCustomTooltip = (self: any, text: string | Binding<string>) => {
@ -43,3 +43,10 @@ export const setupCustomTooltip = (self: any, text: string | Binding<string>) =>
export const setupChildClickthrough = (self: any) =>
self.connect("size-allocate", () => self.get_window()?.set_child_input_shapes());
@register()
export class MenuItem extends astalify(Gtk.MenuItem) {
constructor(props: ConstructProps<MenuItem, Gtk.MenuItem.ConstructorProps, { onActivate: [] }>) {
super(props as any);
}
}

View file

@ -7,6 +7,7 @@
@use "scss/launcher";
@use "scss/osds";
@use "scss/notifications";
@use "scss/updates";
* {
all: unset; // Remove GTK theme styles