aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRubén Beltrán del Río <jj@r.bdr.sh>2026-09-24 21:01:06 +0200
committerRubén Beltrán del Río <jj@r.bdr.sh>2026-09-25 20:16:02 +0200
commit628406f0a50e040d4661629446c620a3a495441f (patch)
treee1a2b783296b1779a4040b5a7ea55d72547a37e8
Initial commitHEADmain
-rw-r--r--LICENSE662
-rw-r--r--README.md56
-rw-r--r--codesettings.json9
-rw-r--r--noctalia.d.luau796
-rw-r--r--sourcehut-builds/bar.luau98
-rw-r--r--sourcehut-builds/constants.luau49
-rw-r--r--sourcehut-builds/date_utils.luau16
-rw-r--r--sourcehut-builds/empty_state.luau9
-rw-r--r--sourcehut-builds/error_utils.luau8
-rw-r--r--sourcehut-builds/panel.luau204
-rw-r--r--sourcehut-builds/plugin.toml79
-rw-r--r--sourcehut-builds/poller.luau121
-rw-r--r--sourcehut-builds/string_utils.luau5
-rw-r--r--sourcehut-builds/table_utils.luau11
-rw-r--r--sourcehut-builds/translations/en.json59
-rw-r--r--sourcehut_builds.d.luau47
16 files changed, 2229 insertions, 0 deletions
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..209174f
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,662 @@
+GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ noctalia-plugin-sourcehut-builds, Noctalia plugin to display sourcehut
+ builds in a panel.
+ Copyright (C) 2025 Ruben Beltran del Rio
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<https://www.gnu.org/licenses/>.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a44db79
--- /dev/null
+++ b/README.md
@@ -0,0 +1,56 @@
+# Sourcehut Builds Noctalia Plugin
+
+A plugin for noctalia v5 that displays your last few sourcehut builds.
+
+It will display icons for recent running, failed or successful jobs
+(with a configurable window.)
+
+## Plugin
+
+| Field | Value |
+| --- | --- |
+| ID | `rbdr/sourcehut-builds` |
+| Plugin API | 30 |
+| Entries | Bar widget: `bar`, panel: `panel`, service: `poller` |
+
+## Usage
+
+Configure your sourcehut API token in the plugin configuration, then add the
+bar widget.
+
+You can toggle, open or close the panel by targeting `rbdr/sourcehut-builds:panel`
+
+```sh
+noctalia msg panel-toggle rbdr/sourcehut-builds:panel
+```
+
+And you can open the settings by using the plugin id.
+
+```sh
+noctalia msg settings-open-plugin rbdr/sourcehut-builds
+```
+
+| Action | Effect |
+| --- | --- |
+| Left click (bar glyph) | Open/close the panel |
+| Left click on a build label | Open the build in the browser |
+| Left click on a glyph for a task | Open the build in the browser, scroll the task into view |
+
+## Settings
+
+| Setting | What it does |
+| --- | --- |
+| Sourcehut API token| The token to use to connect to the sourcehut API |
+| Build Count | The number of builds to show |
+| Cutoff for recent builds | The number of seconds to consider "recent" for the glyph displays |
+
+## Install
+
+Checkout this repo somewhere in your computer, then add it as a plugin source
+in noctalia settings, then enable the plugin.
+
+## No LLM Usage
+
+This project has not used LLMs in any stage of its development and does not
+accept contributions where LLMs have been used in any point: whether it's
+planning, debugging, testing, implementing or anything else.
diff --git a/codesettings.json b/codesettings.json
new file mode 100644
index 0000000..c249f67
--- /dev/null
+++ b/codesettings.json
@@ -0,0 +1,9 @@
+{
+ "luau-lsp.platform.type": "standard",
+ "luau-lsp.sourcemap.enabled": false,
+ "luau-lsp.types.definitionFiles": {
+ "noctalia": "noctalia.d.luau",
+ "sourcehut_builds": "sourcehut_builds.d.luau"
+ },
+ "luau-lsp.ignoreGlobs": ["**/*.d.luau"]
+}
diff --git a/noctalia.d.luau b/noctalia.d.luau
new file mode 100644
index 0000000..5ec34a0
--- /dev/null
+++ b/noctalia.d.luau
@@ -0,0 +1,796 @@
+--!strict
+-- Type definitions for the Noctalia plugin API (plugin_api 32).
+--
+-- luau-lsp *definition file*: it declares the host-injected globals (noctalia.*,
+-- barWidget.*, shortcut.*, launcher.*, desktopWidget.*, panel.*, ui.*) so authors
+-- get autocomplete and typo diagnostics. Annotations are a runtime no-op.
+-- See README.md ("Editor setup") for pointing luau-lsp at this file.
+--
+-- Prop tables are exhaustive: the host logs and skips any prop not listed here.
+-- "API n" marks the plugin_api level a member requires.
+
+-- ── Shared value shapes ──────────────────────────────────────────────────────
+
+export type CommandResult = {
+ exitCode: number,
+ stdout: string,
+ stderr: string,
+ timedOut: boolean,
+ stdoutTruncated: boolean,
+ stderrTruncated: boolean,
+}
+
+export type HttpRequest = {
+ url: string,
+ method: string?, -- defaults to "GET"
+ headers: { string }?, -- each entry is a full "Header: value" line
+ body: string?,
+ basic_username: string?,
+ basic_password: string?,
+ follow_redirects: boolean?,
+ -- Disables origin certificate and hostname verification; trusted endpoints only. API 7.
+ allow_insecure_tls: boolean?,
+}
+
+export type HttpResponse = {
+ ok: boolean, -- transport success (not the HTTP status)
+ status: number,
+ body: string,
+}
+
+export type HttpStreamResult = {
+ ok: boolean, -- transport success (not the HTTP status)
+ status: number, -- HTTP status code (0 when ok is false)
+}
+
+export type HttpStreamHandle = {
+ stop: () -> (), -- cancel the stream; idempotent, suppresses onClose
+}
+
+export type Output = {
+ name: string,
+ description: string,
+ width: number,
+ height: number,
+ x: number,
+ y: number,
+ scale: number,
+ focused: boolean,
+}
+
+export type WallpaperMask = {
+ path: string,
+ wallpaperPath: string,
+}
+
+export type PanelContextMenuAction = {
+ kind: "item"?, -- may be omitted for action rows
+ id: string,
+ label: string,
+ enabled: boolean?, -- defaults to true
+}
+
+export type PanelContextMenuHeader = {
+ kind: "header",
+ label: string,
+}
+
+export type PanelContextMenuSeparator = {
+ kind: "separator",
+}
+
+export type PanelContextMenuItem = PanelContextMenuAction | PanelContextMenuHeader | PanelContextMenuSeparator
+
+export type PanelContextMenuRequest = {
+ items: { PanelContextMenuItem },
+ onActivate: string,
+ context: (string | number | boolean)?,
+ maxVisible: number?, -- defaults to 12, valid range 1..30
+}
+
+-- A tooltip row: { key, value } or the positional array form { key, value }.
+export type TooltipRow = { key: string?, value: string? }
+
+export type LauncherResult = {
+ id: string?,
+ title: string?,
+ subtitle: string?,
+ glyph: string?,
+ icon: string?,
+ badge: string?,
+ category: string?, -- must match a [[launcher_provider.category]] label
+ presentation: string?,
+ query: string?, -- on activate, set this provider's query to this sub-query (host adds the prefix)
+ score: number?,
+}
+
+export type SystemStats = {
+ sampledAtMs: number?, -- epoch ms of the latest aggregate sample (API 16)
+ -- Absent sensors are nil rather than 0, so "no probe" is distinguishable from "idle".
+ cpu: { usagePercent: number, tempC: number?, freqMhz: number?, maxFreqMhz: number? },
+ ram: { usagePercent: number, usedMb: number, totalMb: number },
+ swap: { usedMb: number, totalMb: number },
+ gpu: { tempC: number?, usagePercent: number?, vramUsedBytes: number?, vramTotalBytes: number? },
+ net: {
+ rxBytesPerSec: number,
+ txBytesPerSec: number,
+ interfaces: { [string]: { rxBytesPerSec: number, txBytesPerSec: number } },
+ },
+ loadAvg: { number }, -- 1, 5 and 15 minute averages
+}
+
+export type DiskMount = {
+ path: string,
+ source: string,
+ filesystem: string,
+}
+
+export type DiskStats = {
+ usagePercent: number,
+ totalBytes: number,
+ freeBytes: number,
+ availableBytes: number,
+}
+
+-- Noctalia provides Luau's built-in `require(path: string): any` for explicit relative `.luau` modules (API 22).
+
+-- ── noctalia.* - shared across every entry type ──────────────────────────────
+
+export type NoctaliaState = {
+ set: (key: string, value: any) -> (),
+ get: (key: string) -> any,
+ watch: (key: string, callback: (value: any) -> ()) -> (),
+}
+
+export type NoctaliaJson = {
+ decode: (str: string) -> (any, string?), -- value, or (nil, err)
+ encode: (value: any, pretty: boolean?) -> (string?, string?),
+}
+
+export type NoctaliaString = {
+ trim: (s: string) -> string,
+ urlEncode: (s: string) -> string,
+ urlDecode: (s: string) -> string,
+}
+
+-- API 20. Paths resolve like the filesystem APIs. `load` returning true means the
+-- request was accepted, not that decoding succeeded; at most eight loads may be
+-- pending. Names and pending callbacks are released when this runtime reloads.
+export type NoctaliaSound = {
+ load: (name: string, path: string, onLoaded: (ok: boolean, error: string?) -> ()) -> boolean,
+ play: (name: string) -> (),
+}
+
+export type Noctalia = {
+ log: (msg: string) -> (),
+
+ -- Subprocess. A string runs through /bin/sh -c; an argv table (API 24) executes
+ -- the program directly. With no callback, runAsync is a detached fire-and-forget
+ -- launch; timeoutMs is clamped to [50, 60000].
+ runAsync: (cmdOrArgv: string | { string }, onResult: ((result: CommandResult) -> ())?, timeoutMs: number?) -> boolean,
+ runStream: (cmd: string, onLine: (line: string) -> ()) -> boolean,
+ runInTerminal: (cmd: string) -> boolean,
+ commandExists: (name: string) -> boolean,
+ -- onResult(true) iff a running process command line matches all needles.
+ processMatches: (onResult: (matched: boolean) -> (), ...string) -> boolean,
+ flatpakAppInstalled: (appId: string) -> boolean,
+ portalAvailable: () -> boolean,
+
+ -- Outputs / display.
+ focusedOutputName: () -> string?,
+ outputs: () -> { Output },
+ isDarkMode: () -> boolean,
+ -- Active theme palette color for a role ("primary", "surface", "on_surface", ...) as
+ -- "#RRGGBB"; nil for unknown role names. API 31.
+ getColor: (role: string) -> string?,
+ -- Effective shell config by dotted path ("bar.main.position", "shell.offline_mode").
+ -- Array indices are zero-based ("bar.order[0]"); nil when the path matches nothing. API 26.
+ getSetting: (path: string) -> any,
+
+ -- Resolves an app id (desktop-entry id / StartupWMClass), or a raw icon name when no
+ -- entry matches, to an icon path for ui.image. nil when nothing resolves.
+ appIconPath: (appIdOrIconName: string, sizePx: number?) -> string?,
+
+ -- Wallpaper. setWallpaper(path) targets all outputs; setWallpaper(connector, path) targets one.
+ setWallpaperEnabled: (connector: string, enabled: boolean) -> (),
+ setWallpaper: (connectorOrPath: string, path: string?) -> (),
+ wallpaperDirectory: () -> string?,
+ -- Source-aligned output mask: 0 keeps desktop widgets visible, 255 erases them to
+ -- reveal the wallpaper. API 25.
+ wallpaperPath: (connector: string) -> string?,
+ setWallpaperMask: (connector: string, mask: WallpaperMask?) -> (),
+
+ togglePanel: (panelId: string) -> (), -- "author/plugin:panel"
+ -- Opens the settings window at this plugin's settings; no-op without settings. API 15.
+ openSettings: () -> (),
+ -- The callback receives the canonical #RRGGBB color, or nil when cancelled.
+ openColorPicker: (initialColor: string, onClose: (color: string?) -> ()) -> boolean,
+
+ notify: (title: string, body: string?) -> (),
+ notifyError: (title: string, body: string?) -> (),
+
+ copyToClipboard: (text: string, mimeType: string) -> boolean,
+ clipboardText: () -> string?, -- nil when empty or non-text
+ getenv: (name: string) -> string?,
+ expandPath: (path: string) -> string,
+ formatTime: (pattern: string, unixSeconds: number?, timezone: string?) -> string,
+ timeFormat: () -> string, -- [shell].time_format, e.g. "{:%H:%M}" (API 19)
+ dateFormat: () -> string, -- [shell].date_format, e.g. "%A, %x" (API 19)
+ -- True when `name` is empty (system local) or names a zone in the active database. API 19.
+ isValidTimezone: (name: string) -> boolean,
+ nowMs: () -> number, -- the only sub-second clock; formatTime and os.time are whole-second (API 12)
+
+ -- System monitor. nil when [system.monitor] is disabled. The first systemStats call
+ -- opts this plugin into its optional CPU/GPU probes. API 12.
+ systemStats: () -> SystemStats?,
+ -- Per-core usage in /proc/stat order; nil until the first delta lands. Offline cores are
+ -- absent, so length can change and an index is not a core id.
+ cpuCores: () -> { number }?,
+ -- Physical block-device filesystems, deduped by source, sorted by mount path. API 16.
+ diskMounts: () -> { DiskMount },
+ -- statvfs snapshot for an absolute or ~/ path; the path is retained for sampling. API 16.
+ diskStats: (path: string) -> DiskStats?,
+
+ setUpdateInterval: (ms: number) -> (), -- update() tick rate, clamped to >= 16ms
+
+ -- Filesystem (paths resolve ~ -> $HOME, absolute verbatim, else plugin-relative).
+ readFile: (path: string) -> (string?, string?), -- contents, or (nil, err)
+ readFileAsync: (path: string, onResult: (contents: string?, err: string?) -> ()) -> boolean, -- API 23
+ writeFile: (path: string, contents: string) -> (boolean, string?),
+ mkdirAll: (path: string) -> (boolean, string?), -- like mkdir -p; existing dir is success
+ removeFile: (path: string) -> (boolean, string?), -- files only, refuses directories
+ renameFile: (from: string, to: string) -> (boolean, string?),
+ fileExists: (path: string) -> boolean,
+ fileInfo: (path: string) -> ({ size: number, mtime: number, isDir: boolean }?, string?),
+ listDir: (path: string) -> ({ string }?, string?),
+ pluginDir: () -> string?,
+ -- Per-plugin persistent data dir, created on demand; survives updates, honors
+ -- NOCTALIA_STATE_HOME. Use for durable data (state is in-memory only).
+ pluginDataDir: () -> (string?, string?),
+
+ -- Registers a font file so its family works in setFont / a label's fontFamily.
+ -- Returns the family name, or (nil, err); visible to every surface once loaded.
+ loadFont: (path: string) -> (string?, string?),
+
+ -- Translation against the plugin's own translations/<lang>.json.
+ tr: (key: string, subst: { [string]: string | number | boolean }?) -> string,
+ trp: (key: string, count: number, subst: { [string]: string | number | boolean }?) -> string,
+
+ -- HTTP (honors shell.offline_mode; download dest resolves like readFile).
+ http: (req: HttpRequest, onResponse: (response: HttpResponse) -> ()) -> boolean,
+ -- Long-lived stream (e.g. SSE). onLine fires per line (CR trimmed); onClose fires once
+ -- unless stopped through the handle. Non-2xx bodies stream to onLine and the status
+ -- arrives in onClose. Cancelled on script reload; nil when it could not start. API 4.
+ httpStream: (
+ req: HttpRequest,
+ onLine: (line: string) -> (),
+ onClose: (result: HttpStreamResult) -> ()
+ ) -> HttpStreamHandle?,
+ download: (url: string, destPath: string, onDone: (success: boolean) -> ()) -> boolean,
+
+ fuzzyScore: (pattern: string, text: string) -> number?, -- nil if no match
+
+ getConfig: (key: string) -> any, -- string | number | boolean | {string} | {[string]: string} | nil
+
+ state: NoctaliaState,
+ sound: NoctaliaSound,
+ json: NoctaliaJson,
+ string: NoctaliaString,
+}
+
+declare noctalia: Noctalia
+
+-- ── ui.* - declarative control tree (bar widgets, desktop widgets, panels) ───
+
+-- One node of a ui.* tree.
+export type UiNode = {
+ type: string,
+ props: { [string]: any },
+ children: { UiNode },
+}
+
+-- A palette role ("primary", "on_surface"), a role with alpha ("primary/0.6",
+-- resolved live against the palette), or a hex value ("#rrggbb" / "#rrggbbaa").
+export type UiColor = string
+
+-- A callback prop takes the name of a plugin global, or a function (API 9) that is
+-- render-scoped: re-rendering replaces it, and an event on a node the current tree no
+-- longer contains does nothing. An empty name counts as unset. Every argument arrives
+-- as a string, for named handlers and closures alike.
+export type UiClickHandler = string | (() -> ())
+-- state is "true" on enter and "false" on leave; key is the node's `key` ("" when unset).
+-- Only the innermost hovered node reports, and every "true" is matched by a "false".
+export type UiHoverHandler = string | ((state: string, key: string) -> ())
+export type UiChangeHandler = string | ((value: string) -> ())
+export type UiSelectHandler = string | ((index: string, text: string) -> ())
+export type UiScrollHandler = string | ((offset: string, maxOffset: string) -> ())
+-- Pointer position normalized to the graph's own box, "0.0000".."1.0000", 0,0 top-left.
+export type UiPointerHandler = string | ((normX: string, normY: string) -> ())
+export type UiDropHandler = string | ((payload: string, value: string) -> ())
+
+-- Common to every node. `opacity` is a group opacity: it fades children too, so use a
+-- translucent `fill` for a translucent background. `key` gives a child stable identity
+-- across renders (keeps input text, hover state, and closures aligned with their row).
+export type UiCommonProps = {
+ key: string?,
+ width: number?,
+ height: number?,
+ flexGrow: number?,
+ opacity: number?,
+ visible: boolean?,
+}
+
+-- ui.column / ui.row. Children stretch across the cross axis unless `align` says otherwise.
+-- onClick makes the whole container a click target and joins the tab order (Enter/Space);
+-- a container with only onHover passes clicks through to an enclosing target.
+export type UiFlexProps = {
+ key: string?,
+ width: number?,
+ height: number?,
+ flexGrow: number?,
+ opacity: number?,
+ visible: boolean?,
+ gap: number?,
+ padding: number?,
+ paddingH: number?,
+ paddingV: number?,
+ align: ("start" | "center" | "end" | "stretch")?,
+ justify: ("start" | "center" | "end" | "space_between")?,
+ fill: UiColor?,
+ radius: number?,
+ border: UiColor?,
+ borderWidth: number?,
+ minWidth: number?,
+ minHeight: number?,
+ onClick: UiClickHandler?,
+ onHover: UiHoverHandler?,
+ tooltip: string?, -- shown on hover (API 32); wraps the container in a hover target; cleared when dropped
+}
+
+-- ui.box. Leaf node: it takes no children (use a column/row for content).
+export type UiBoxProps = {
+ key: string?,
+ width: number?,
+ height: number?,
+ flexGrow: number?,
+ opacity: number?,
+ visible: boolean?,
+ fill: UiColor?,
+ radius: number?,
+ border: UiColor?,
+ borderWidth: number?,
+ softness: number?,
+ onClick: UiClickHandler?,
+ onHover: UiHoverHandler?,
+ tooltip: string?, -- shown on hover (API 32); wraps the box in a hover target; cleared when dropped
+}
+
+-- ui.label. Unset text props inherit the host defaults (in a bar, the bar's or widget's
+-- font_family/font_weight and scale). fontFamily needs noctalia.loadFont first.
+-- baseline "pictographic" centers art/icon fonts anchored at the ink top.
+export type UiLabelProps = {
+ key: string?,
+ width: number?,
+ height: number?,
+ flexGrow: number?,
+ opacity: number?,
+ visible: boolean?,
+ text: string?,
+ fontSize: number?,
+ color: UiColor?,
+ fontWeight: ("thin" | "light" | "normal" | "medium" | "semibold" | "bold" | "heavy")?,
+ fontFamily: string?,
+ baseline: ("text" | "textFixedHeight" | "inkCentered" | "pictographic")?,
+ maxWidth: number?,
+ maxLines: number?,
+ textAlign: ("start" | "center" | "end")?,
+}
+
+-- ui.markdown. Read-only block; re-parsed only when text or the surface scale changes. API 21.
+export type UiMarkdownProps = {
+ key: string?,
+ width: number?,
+ height: number?,
+ flexGrow: number?,
+ opacity: number?,
+ visible: boolean?,
+ text: string?,
+}
+
+-- ui.glyph. `name` is a Tabler/Nerd-Font glyph; `size` is a glyph size in px.
+export type UiGlyphProps = {
+ key: string?,
+ width: number?,
+ height: number?,
+ flexGrow: number?,
+ opacity: number?,
+ visible: boolean?,
+ name: string?,
+ size: number?,
+ color: UiColor?,
+}
+
+-- ui.image. Local files only: download remote previews first, then pass the saved path.
+export type UiImageProps = {
+ key: string?,
+ width: number?,
+ height: number?,
+ flexGrow: number?,
+ opacity: number?,
+ visible: boolean?,
+ path: string?, -- plugin-relative, ~, or absolute
+ radius: number?,
+ fit: ("contain" | "cover" | "stretch")?,
+ border: UiColor?,
+ borderWidth: number?,
+ onClick: UiClickHandler?,
+ onHover: UiHoverHandler?,
+ tooltip: string?, -- shown on hover (API 32); wraps the image in a hover target; cleared when dropped
+}
+
+export type UiSeparatorProps = {
+ key: string?,
+ width: number?,
+ height: number?,
+ flexGrow: number?,
+ opacity: number?,
+ visible: boolean?,
+ thickness: number?,
+ color: UiColor?,
+ spacing: number?,
+ orientation: ("auto" | "horizontal" | "vertical")?,
+}
+
+-- ui.spacer: flexible filler, sized with flexGrow.
+export type UiSpacerProps = UiCommonProps
+
+export type UiProgressProps = {
+ key: string?,
+ width: number?,
+ height: number?,
+ flexGrow: number?,
+ opacity: number?,
+ visible: boolean?,
+ progress: number?, -- 0..1
+ fill: UiColor?,
+ track: UiColor?,
+ radius: number?,
+}
+
+-- ui.button. Setting only `glyph` clears a retained button's previous text. `tooltip`
+-- shows in bar widgets and panels, never on desktop widgets; dropping it clears it.
+-- In bar widgets a button hugs its content unless sized with width/height/controlSize.
+export type UiButtonProps = {
+ key: string?,
+ width: number?,
+ height: number?,
+ flexGrow: number?,
+ opacity: number?,
+ visible: boolean?,
+ text: string?,
+ glyph: string?,
+ fontSize: number?,
+ glyphSize: number?,
+ variant: ("default" | "primary" | "secondary" | "destructive" | "outline" | "ghost")?,
+ contentAlign: ("start" | "center" | "end")?,
+ controlSize: ("sm" | "md" | "lg")?, -- 32 / 38 / 44px tiers; `height` wins when both are set
+ tooltip: string?,
+ enabled: boolean?,
+ selected: boolean?,
+ onClick: UiClickHandler?,
+ onRightClick: UiClickHandler?, -- the only place panel.openContextMenu may be called
+ onHover: UiHoverHandler?,
+}
+
+-- ui.graph. Takes no clicks. The pointer callbacks (API 29) are coalesced on one shared
+-- stream, so the newest event wins and a leave never arrives ahead of a position it
+-- followed. Every entered graph reports its leave, including when the graph is dropped or
+-- rewired - but that teardown leave only reaches a *named* handler, since a closure from
+-- the render that dropped the graph is already superseded.
+export type UiGraphProps = {
+ key: string?,
+ width: number?,
+ height: number?,
+ flexGrow: number?,
+ opacity: number?,
+ visible: boolean?,
+ values: { number }?, -- 0..1, clamped
+ values2: { number }?,
+ color: UiColor?,
+ color2: UiColor?,
+ lineWidth: number?,
+ fillOpacity: number?,
+ onPointerMove: UiPointerHandler?,
+ onPointerLeave: UiClickHandler?,
+}
+
+-- ui.input (panels only; skipped with a warning in the bar). Uncontrolled: `value` seeds
+-- the field once, then the host owns the text - keep a stable `key` so edits survive a
+-- re-render, and read them through onChange/onSubmit. `focus` grabs the keyboard when the
+-- control is *created*, never on a later render; a fresh `key` focuses again.
+-- multiline and password are mutually exclusive; multiline submits on Ctrl+Enter, or on
+-- Enter with submitOnEnter (Shift+Enter then inserts the newline).
+export type UiInputProps = {
+ key: string?,
+ width: number?,
+ height: number?,
+ flexGrow: number?,
+ opacity: number?,
+ visible: boolean?,
+ value: string?,
+ placeholder: string?,
+ fontSize: number?,
+ controlSize: ("sm" | "md" | "lg")?,
+ password: boolean?,
+ multiline: boolean?,
+ submitOnEnter: boolean?, -- API 21
+ frameVisible: boolean?, -- false hides the native background/border, keeps editing (API 27)
+ focus: boolean?,
+ enabled: boolean?,
+ onChange: UiChangeHandler?,
+ onSubmit: UiChangeHandler?,
+}
+
+-- ui.select (panels only; no dropdowns inside a persistent panel). Value-driven:
+-- pass selectedIndex on every render and update it from onChange, which receives the
+-- selected index and its text.
+export type UiSelectProps = {
+ key: string?,
+ width: number?,
+ height: number?,
+ flexGrow: number?,
+ opacity: number?,
+ visible: boolean?,
+ options: { string }?,
+ selectedIndex: number?,
+ placeholder: string?,
+ controlSize: ("sm" | "md" | "lg")?,
+ enabled: boolean?,
+ onChange: UiSelectHandler?,
+}
+
+-- ui.slider. Value-driven, but `value` is re-applied only while not dragging, so
+-- re-rendering mid-drag with a draft value is safe. onChange reports every change
+-- (coalesced); onDragEnd fires with no arguments when the interaction ends - pointer
+-- release and keyboard adjustment. The wheel does not adjust plugin sliders.
+export type UiSliderProps = {
+ key: string?,
+ width: number?,
+ height: number?,
+ flexGrow: number?,
+ opacity: number?,
+ visible: boolean?,
+ min: number?,
+ max: number?,
+ step: number?,
+ value: number?,
+ controlSize: ("sm" | "md" | "lg")?,
+ enabled: boolean?,
+ onChange: UiChangeHandler?,
+ onDragEnd: UiClickHandler?,
+}
+
+-- ui.toggle. Value-driven; onChange receives "true" / "false".
+export type UiToggleProps = {
+ key: string?,
+ width: number?,
+ height: number?,
+ flexGrow: number?,
+ opacity: number?,
+ visible: boolean?,
+ checked: boolean?,
+ enabled: boolean?,
+ onChange: UiChangeHandler?,
+}
+
+-- ui.scroll (panels only; skipped with a warning in the bar). Vertical scrolling
+-- container with a column's layout props. stickToBottom, onScroll and
+-- scrollToBottomRev are API 21.
+export type UiScrollProps = {
+ key: string?,
+ width: number?,
+ height: number?,
+ flexGrow: number?,
+ opacity: number?,
+ visible: boolean?,
+ gap: number?,
+ padding: number?,
+ paddingH: number?,
+ paddingV: number?,
+ align: ("start" | "center" | "end" | "stretch")?,
+ justify: ("start" | "center" | "end" | "space_between")?,
+ fill: UiColor?,
+ radius: number?,
+ border: UiColor?,
+ borderWidth: number?,
+ stickToBottom: boolean?, -- stay pinned to the bottom until the user scrolls away
+ scrollToBottomRev: number?, -- jumps to the bottom on first sight and on every change
+ onScroll: UiScrollHandler?,
+}
+
+-- ui.dragSource (panels only, API 5). Marks a subtree draggable: a grip glyph, or a whole
+-- row through previewAncestor. dragType and payload are required - a missing, mistyped,
+-- empty or over-limit value disables the control for that render. Limits: payload 16 KiB,
+-- dragType 256 bytes.
+export type UiDragSourceProps = {
+ key: string?,
+ width: number?,
+ height: number?,
+ flexGrow: number?,
+ opacity: number?,
+ visible: boolean?,
+ gap: number?,
+ padding: number?,
+ paddingH: number?,
+ paddingV: number?,
+ align: ("start" | "center" | "end" | "stretch")?,
+ justify: ("start" | "center" | "end" | "space_between")?,
+ fill: UiColor?,
+ radius: number?,
+ border: UiColor?,
+ borderWidth: number?,
+ minWidth: number?,
+ minHeight: number?,
+ dragType: string, -- matched against a dropZone's `accepts`
+ payload: string, -- opaque; first onDrop argument
+ enabled: boolean?,
+ tooltip: string?,
+ previewAncestor: number?, -- integer 0..8 parent levels the ghost shows; 1 previews the row around a grip
+ liftFromLayout: boolean?, -- remove the previewed row from layout while dragging
+}
+
+-- ui.dropZone (panels only, API 5). Flex container that accepts drops; accepts, value and
+-- onDrop are required (`accepts = {}` accepts nothing). Nested zones resolve to the
+-- deepest accepting zone, hitSlop zones first, closest wins. The host moves nothing: the
+-- callback mutates the plugin's model and re-renders. Limits: value/onDrop/each accepts
+-- entry 256 bytes, at most 16 accepts entries.
+export type UiDropZoneProps = {
+ key: string?,
+ width: number?,
+ height: number?,
+ flexGrow: number?,
+ opacity: number?,
+ visible: boolean?,
+ gap: number?,
+ padding: number?,
+ paddingH: number?,
+ paddingV: number?,
+ align: ("start" | "center" | "end" | "stretch")?,
+ justify: ("start" | "center" | "end" | "space_between")?,
+ fill: UiColor?,
+ radius: number?,
+ border: UiColor?,
+ borderWidth: number?,
+ minWidth: number?,
+ minHeight: number?,
+ accepts: { string }, -- drag types
+ value: string, -- opaque; second onDrop argument
+ onDrop: UiDropHandler,
+ direction: ("column" | "row")?,
+ enabled: boolean?,
+ expandOnDrag: boolean?, -- a fixed-height zone animates to the dragged row's height
+ hitSlop: number?, -- extra drag-only hit distance, without changing layout or clicks
+}
+
+-- Only column, row, scroll, dragSource and dropZone host children; the rest are leaves.
+declare ui: {
+ column: (props: UiFlexProps?, children: { UiNode }?) -> UiNode,
+ row: (props: UiFlexProps?, children: { UiNode }?) -> UiNode,
+ scroll: (props: UiScrollProps?, children: { UiNode }?) -> UiNode,
+ dragSource: (props: UiDragSourceProps, children: { UiNode }?) -> UiNode,
+ dropZone: (props: UiDropZoneProps, children: { UiNode }?) -> UiNode,
+ box: (props: UiBoxProps?) -> UiNode,
+ label: (props: UiLabelProps?) -> UiNode,
+ markdown: (props: UiMarkdownProps?) -> UiNode,
+ glyph: (props: UiGlyphProps?) -> UiNode,
+ image: (props: UiImageProps?) -> UiNode,
+ separator: (props: UiSeparatorProps?) -> UiNode,
+ spacer: (props: UiSpacerProps?) -> UiNode,
+ progress: (props: UiProgressProps?) -> UiNode,
+ button: (props: UiButtonProps?) -> UiNode,
+ graph: (props: UiGraphProps?) -> UiNode,
+ input: (props: UiInputProps?) -> UiNode,
+ select: (props: UiSelectProps?) -> UiNode,
+ slider: (props: UiSliderProps?) -> UiNode,
+ toggle: (props: UiToggleProps?) -> UiNode,
+}
+
+-- ── barWidget.* - [[widget]] presentation ────────────────────────────────────
+
+declare barWidget: {
+ setText: (text: string) -> (),
+ setGlyph: (name: string) -> (),
+ setImage: (path: string, watch: boolean?, width: number?, height: number?) -> (),
+ setTooltip: (tooltip: (string | TooltipRow | { TooltipRow })?) -> (),
+ clearTooltip: () -> (),
+ -- family: a font family name (load a file with noctalia.loadFont first).
+ -- baseline: "text" (default) | "textFixedHeight" | "inkCentered" | "pictographic".
+ setFont: (family: string, baseline: string?) -> (),
+ setColor: (role: string, mode: string?) -> (),
+ setGlyphColor: (role: string, mode: string?) -> (),
+ isVertical: () -> boolean,
+ -- Connector of the output this widget instance's bar is on; per-instance, unlike
+ -- noctalia.focusedOutputName(). nil when unknown.
+ outputName: () -> string?,
+ setVisible: (visible: boolean) -> (),
+ -- Declarative alternative to setText/setGlyph: the tree replaces the built-in
+ -- glyph/text row. ui.input/ui.select/ui.scroll are not supported in the bar.
+ render: (tree: UiNode) -> (),
+}
+
+-- Gestures are configuration, not code: the user binds them per widget instance in
+-- [widget.<id>.actions] (left, right, middle, back, forward, scroll_up, scroll_down,
+-- scroll_left, scroll_right), and a binding wins over the matching callback. An action is
+-- an IPC command ("media toggle"), `exec <command line>`, or `none`. Manifests declare
+-- their own defaults in [widget.actions] (API 14).
+--
+-- Middle click is the one to know about: every widget defaults to
+-- `middle = "settings-open-widget"`, so onMiddleClick does not fire until the manifest or
+-- the user binds `middle = "none"`. Scroll has one extra gate: `enable_scroll = false`
+-- turns onScroll off regardless of bindings.
+
+-- ── shortcut.* - [[shortcut]] quick-toggle tile ──────────────────────────────
+
+declare shortcut: {
+ setLabel: (label: string) -> (),
+ setIcon: (on: string, off: string?) -> (),
+ setActive: (active: boolean) -> (),
+ setEnabled: (enabled: boolean) -> (),
+}
+
+-- ── launcher.* - [[launcher_provider]] results ───────────────────────────────
+
+declare launcher: {
+ setResults: (query: string, results: { LauncherResult }) -> (),
+ setQuery: (text: string) -> (), -- prefix + text (stays in provider); "" resets to root
+}
+
+-- ── desktopWidget.* - [[desktop_widget]] declarative UI ──────────────────────
+
+declare desktopWidget: {
+ render: (tree: UiNode) -> (),
+ setWantsSecondTicks: (wants: boolean) -> (), -- run update() on second boundaries
+ setNeedsFrameTick: (needs: boolean) -> (), -- deliver onFrameTick(deltaMs) every frame
+}
+
+-- ── panel.* - [[panel]] declarative UI ───────────────────────────────────────
+
+declare panel: {
+ render: (tree: UiNode) -> (),
+ close: () -> (),
+ -- Opens a native menu at the originating direct pointer callback; false outside a live
+ -- one. onActivate receives (actionId, context) in the panel script. API 28.
+ openContextMenu: (request: PanelContextMenuRequest) -> boolean,
+ setWantsSecondTicks: (wants: boolean) -> (),
+ setNeedsFrameTick: (needs: boolean) -> (), -- onFrameTick(deltaMs) while open (API 18)
+}
+
+-- ── Entry-point callbacks ────────────────────────────────────────────────────
+--
+-- Your entry defines the globals the host calls, as plain global functions - the
+-- host only calls a callback if the entry defines it:
+--
+-- function update() end -- bar/desktop widget, service tick
+-- function onIpc(event, payload) end -- any entry (payload: string?)
+-- function onClick() / onRightClick() end -- shortcut, bar widget
+-- function onMiddleClick() end -- bar widget (see "Gestures" above)
+-- function onHover(entered) end -- bar widget pointer enter / leave
+-- function onScroll(axis, steps, startsGesture) end -- bar widget scroll; axis is "vertical" | "horizontal",
+-- -- steps is whole wheel detents (negative = up / left),
+-- -- startsGesture is true only on the first step of a flick
+-- function onQuery(text) / onActivate(id) end -- launcher provider
+-- function onFrameTick(deltaMs) end -- desktop widget, or open panel (API 18); after
+-- -- setNeedsFrameTick(true), frames coalesced
+-- function onAudioSpectrum(valuesCsv, stateCsv) end -- audio-reactive bar widget
+-- function onOpen(context) / onClose() end -- panel lifecycle
+-- function onKey(chord, pressed) end -- panel: a capture_keys chord, verbatim from the
+-- -- manifest (API 13)
+-- function onConfigChanged() end -- service: settings changed; getConfig() is now new
+-- function onEnable() end -- service: plugin explicitly enabled (API 17)
+-- function onOutputsChanged() end -- service: output set or geometry changed
+-- function onExit(signal, reason) end -- any entry teardown; signal is 0 normally, 2 SIGINT,
+-- -- 15 SIGTERM; reason is "reload" | "disable" |
+-- -- "uninstall" | "shutdown" (API 17)
+--
+-- These are intentionally NOT declared here: declaring them as globals makes
+-- luau-lsp treat your definition as overwriting a built-in.
diff --git a/sourcehut-builds/bar.luau b/sourcehut-builds/bar.luau
new file mode 100644
index 0000000..862287d
--- /dev/null
+++ b/sourcehut-builds/bar.luau
@@ -0,0 +1,98 @@
+--!nonstrict
+-- Sourcehut Builds - Bar widget that toggles the build status panel and shows the status of recent tasks.
+--
+-- You can configure the glyphs shown for the different statuses, as well as
+-- when there are no recent builds.
+
+local K = require("./constants.luau")
+
+--------------------------------------------------------------------------------
+-- Constants
+--------------------------------------------------------------------------------
+local GLYPHS = {
+ default = "glyph",
+ ok = "ok_glyph",
+ failed = "failed_glyph",
+ inProgress = "in_progress_glyph",
+}
+local STYLE = {
+ color = {
+ closed = "on_surface",
+ open = "primary"
+ }
+}
+
+--------------------------------------------------------------------------------
+-- State
+--------------------------------------------------------------------------------
+local isPanelOpen: boolean = false
+local buildState: SourcehutBuildsState = require("./empty_state.luau")
+
+--------------------------------------------------------------------------------
+-- Internal Functions
+--------------------------------------------------------------------------------
+
+------------------------------------------------------------------ Logic Helpers
+
+local function hasRecentBuilds(): boolean
+ return buildState.successful > 0 or buildState.failed > 0 or buildState.inProgress > 0
+end
+
+----------------------------------------------------------------------------- UI
+
+-- Renders the bar
+local function render()
+ barWidget.setGlyphColor(isPanelOpen and STYLE.color.open or STYLE.color.closed)
+
+ barWidget.setTooltip(noctalia.tr("bar.tooltip"))
+
+ local container = barWidget.isVertical() and ui.column or ui.row
+ barWidget.render(container({ gap = 6, align = "center" }, {
+ not hasRecentBuilds() and ui.glyph({ name = noctalia.getConfig(GLYPHS.default), size = 14, color = isPanelOpen and STYLE.color.open or STYLE.color.closed }) or nil,
+ buildState.successful > 0 and ui.glyph({ name = noctalia.getConfig(GLYPHS.ok), size = 14, color = isPanelOpen and STYLE.color.open or STYLE.color.closed }) or nil,
+ buildState.failed > 0 and ui.glyph({ name = noctalia.getConfig(GLYPHS.failed), size = 14, color = isPanelOpen and STYLE.color.open or STYLE.color.closed }) or nil,
+ buildState.inProgress > 0 and ui.glyph({ name = noctalia.getConfig(GLYPHS.inProgress), size = 14, color = isPanelOpen and STYLE.color.open or STYLE.color.closed }) or nil,
+ }))
+end
+
+
+---------------------------------------------------------------- Event Listeners
+
+local function onPanelStateChanged(value: boolean)
+ isPanelOpen = value
+ render()
+end
+
+local function onBuildsStateChanged(value: SourcehutBuildsState)
+ buildState = value
+ render()
+end
+
+---------------------------------------------------------------------- Lifecycle
+
+local function run()
+ noctalia.setUpdateInterval(K.bar.update_interval)
+end
+
+--------------------------------------------------------------------------------
+-- Listeners
+--------------------------------------------------------------------------------
+
+noctalia.state.watch(K.events.panel_state_updated, onPanelStateChanged)
+noctalia.state.watch(K.events.builds_state_updated, onBuildsStateChanged)
+
+--------------------------------------------------------------------------------
+-- Public API
+--------------------------------------------------------------------------------
+function update()
+ render()
+end
+
+function onClick()
+ noctalia.togglePanel(K.panel.id)
+end
+
+function onConfigChanged()
+end
+
+run()
diff --git a/sourcehut-builds/constants.luau b/sourcehut-builds/constants.luau
new file mode 100644
index 0000000..5133849
--- /dev/null
+++ b/sourcehut-builds/constants.luau
@@ -0,0 +1,49 @@
+--------------------------------------------------------------------------------
+-- Events
+--------------------------------------------------------------------------------
+local CONSTANTS = {
+ events = {
+ panel_state_updated = "sourcehut_panel_state_updated",
+ builds_state_updated = "sourcehut_builds_state_updated",
+ poller_state_updated = "sourcehut_poller_state_updated"
+ },
+ panel = {
+ id = "rbdr/sourcehut-builds:panel",
+ theme = {
+ fontSize = {
+ heading = 18
+ },
+ fontWeight = {
+ heading = "bold"
+ }
+ }
+ },
+ bar = {
+ update_interval = 2000
+ },
+ poller = {
+ state = {
+ pending = {type= "pending"},
+ ok = {type= "ok"}
+ },
+ update_interval = 30000,
+ errors = {
+ request_failed = noctalia.tr("error.request_failed"),
+ bad_response = noctalia.tr("error.bad_response"),
+ unauthorized = noctalia.tr("error.unauthorized")
+ },
+ cutoff_configuration_key = "recent_cutoff",
+ build_count_configuration_key = "build_count"
+ },
+ sourcehut = {
+ builds_url = "https://builds.sr.ht/query",
+ web_url = "https://builds.sr.ht/",
+ query = "{\"query\": \"query { me { canonicalName jobs { results { id created updated status note tags tasks { name status } } } } }\"}",
+ token_configuration_key = "sourcehut_api_token",
+ errors = {
+ unauthorized = "ERR_UNAUTHORIZED"
+ }
+ }
+}
+
+return CONSTANTS
diff --git a/sourcehut-builds/date_utils.luau b/sourcehut-builds/date_utils.luau
new file mode 100644
index 0000000..be47b3a
--- /dev/null
+++ b/sourcehut-builds/date_utils.luau
@@ -0,0 +1,16 @@
+return {
+ parseDate = function(dateString: string): number
+ local pattern = "(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+).(%d+)"
+ local runyear, runmonth, runday,
+ runhour, runminute, runseconds = dateString:match(pattern)
+
+ return os.time({
+ year = runyear,
+ month = runmonth,
+ day = runday,
+ hour = runhour,
+ min = runminute,
+ sec = runseconds
+ })
+ end
+}
diff --git a/sourcehut-builds/empty_state.luau b/sourcehut-builds/empty_state.luau
new file mode 100644
index 0000000..259c607
--- /dev/null
+++ b/sourcehut-builds/empty_state.luau
@@ -0,0 +1,9 @@
+local buildState: SourcehutBuildsState = {
+ successful = 0,
+ failed = 0,
+ inProgress = 0,
+ user = "",
+ builds = {}
+}
+
+return buildState
diff --git a/sourcehut-builds/error_utils.luau b/sourcehut-builds/error_utils.luau
new file mode 100644
index 0000000..2969a54
--- /dev/null
+++ b/sourcehut-builds/error_utils.luau
@@ -0,0 +1,8 @@
+local K = require("./constants.luau")
+
+return {
+ isUnauthorized = function(error): boolean
+ return type(error.extensions) == "table"
+ and error.extensions.code == K.sourcehut.errors.unauthorized
+ end
+}
diff --git a/sourcehut-builds/panel.luau b/sourcehut-builds/panel.luau
new file mode 100644
index 0000000..a720721
--- /dev/null
+++ b/sourcehut-builds/panel.luau
@@ -0,0 +1,204 @@
+--!nonstrict
+-- Sourcehut Builds - Panel that shows the
+--
+-- You can configure the glyphs shown for the different statuses, as well as
+-- when there are no recent builds.
+
+local EmptyState = require("./empty_state.luau")
+local StringUtils = require("./string_utils.luau")
+local K = require("./constants.luau")
+
+--------------------------------------------------------------------------------
+-- State
+--------------------------------------------------------------------------------
+local isOpen = false
+-- I'm being lazy and not cloning this because my assumption is that I'll always
+-- replace the object from the network call and not modify it. But I know I will
+-- forget and break that assumption, so this comment is left here because
+-- whatever bug you're finding with stale state or bad empty state is caused by
+-- not cloning empty state across the files.
+local buildState: SourcehutBuildsState = EmptyState
+local pollerStatus: PollerStatus = K.poller.state.pending
+
+--------------------------------------------------------------------------------
+-- Internal Functions
+--------------------------------------------------------------------------------
+
+------------------------------------------------------------ Translation Helpers
+
+local function emptyStateLabel(): string
+ if pollerStatus.type == "pending" then
+ return noctalia.tr("panel.loading.label")
+ elseif pollerStatus.type == "error" then
+ return noctalia.tr("panel.error.label", {error = pollerStatus.error})
+ else
+ return noctalia.tr("panel.empty.label")
+ end
+end
+
+------------------------------------------------------------------------ Actions
+
+local function openBuild(build: SourcehutBuild, task: SourcehutBuildTask | nil)
+ local url = K.sourcehut.web_url .. buildState.user .. '/job/' .. build.id
+
+ if task ~= nil then
+ url = url .. "#task-" .. task.name
+ end
+
+ noctalia.runAsync("gio open '" .. url .. "' || xdg-open '" .. url .. "'")
+end
+
+
+--------------------------------------------------------------------- UI: Glyphs
+
+local function buildGlyph(status: SourcehutBuildStatus): string
+ if status == "PENDING" then
+ return "circle"
+ elseif status == "RUNNING" then
+ return "circle-caret-right"
+ elseif status == "FAILED" then
+ return "x"
+ elseif status == "SUCCESS" then
+ return "check"
+ elseif status == "TIMEOUT" then
+ return "clock-x"
+ elseif status == "CANCELLED" then
+ return "circle-off"
+ end
+ return "question-mark"
+end
+
+local function taskGlyph(status: SourcehutBuildTaskStatus): string
+ if status == "PENDING" then
+ return "circle-dotted"
+ elseif status == "RUNNING" then
+ return "circle-caret-right"
+ elseif status == "FAILED" then
+ return "x"
+ elseif status == "SUCCESS" then
+ return "check"
+ elseif status == "SKIPPED" then
+ return "circle-dashed-minus"
+ end
+ return "question-mark"
+end
+
+----------------------------------------------------------------- UI: Components
+
+local function taskButton(build: SourcehutBuild, task: SourcehutBuildTask): UiNode
+ return ui.button({
+ glyph = taskGlyph(task.status),
+ controlSize = "sm",
+ variant = "ghost",
+ tooltip = noctalia.tr("panel.task.tooltip", {
+ task = task.name,
+ status = StringUtils.capitalize(task.status),
+ }),
+ onClick = function() openBuild(build, task) end
+ })
+end
+
+local function buildButton(build: SourcehutBuild): UiNode
+ return ui.button({
+ glyph = buildGlyph(build.status),
+ text = noctalia.tr("panel.build.label", {
+ user = buildState.user,
+ repo = build.tags[1],
+ id = build.id
+ }),
+ controlSize = "sm",
+ variant = "ghost",
+ tooltip = noctalia.tr("panel.build.tooltip", {
+ status = StringUtils.capitalize(build.status),
+ }),
+ onClick = function() openBuild(build) end
+ })
+end
+
+local function buildRow(build: SourcehutBuild): UiNode
+ local tasks: {[number]: UiNode} = {}
+ for _k, task in ipairs(build.tasks) do
+ tasks[#tasks + 1] = taskButton(build, task)
+ end
+ return ui.row({align = "start", justify = "space_between", gap = 2, flexGrow = 1}, {
+ buildButton(build),
+ ui.row({justify = "end", gap = 0, flexGrow = 0}, tasks)
+ })
+end
+
+local function buildRows(builds: {[number]: SourcehutBuild}): UiNode
+ local build_rows: {[number]: UiNode} = {}
+ for _, build in ipairs(builds) do
+ build_rows[#build_rows+1] = buildRow(build)
+ end
+ return ui.column({ flexGrow = 1, align = "top", justify = "start" }, build_rows)
+end
+
+-- Renders the panel
+local function render()
+ local body
+ local header = ui.row({ align = "center", gap = 8 }, {
+ ui.label({
+ text = noctalia.tr("panel.title"),
+ fontSize = K.panel.theme.fontSize.heading,
+ fontWeight = K.panel.theme.fontWeight.heading,
+ flexGrow = 1
+ }),
+ })
+
+ if #buildState.builds == 0 then
+ body = ui.column({ flexGrow = 1, align = "center", justify = "center" }, {
+ ui.label({ text = emptyStateLabel(), color = "on_surface_variant" }),
+ })
+ else
+ body = buildRows(buildState.builds)
+ end
+
+ if isOpen then
+ panel.render(ui.column({ flexGrow = 1, gap = 10, align = "stretch" }, { header, body }))
+ end
+end
+
+---------------------------------------------------------------- Event Listeners
+
+local function onPollerStateChanged(value: PollerStatus)
+ pollerStatus = value
+ render()
+end
+
+local function onBuildsStateChanged(value: SourcehutBuildsState)
+ buildState = value
+ render()
+end
+
+--------------------------------------------------------------------------------
+-- Listeners
+--------------------------------------------------------------------------------
+
+-- Synchronizes the panel state
+noctalia.state.watch(K.events.poller_state_updated, onPollerStateChanged)
+noctalia.state.watch(K.events.builds_state_updated, onBuildsStateChanged)
+
+--------------------------------------------------------------------------------
+-- Public API
+--------------------------------------------------------------------------------
+
+function onOpen(_context)
+ isOpen = true
+ noctalia.state.set(K.events.panel_state_updated, isOpen)
+ render()
+end
+
+function onClose()
+ isOpen = false
+ noctalia.state.set(K.events.panel_state_updated, isOpen)
+end
+
+function onToggle(value: boolean)
+ isOpen = value
+ render()
+end
+
+function onConfigChanged()
+ render()
+end
diff --git a/sourcehut-builds/plugin.toml b/sourcehut-builds/plugin.toml
new file mode 100644
index 0000000..1d443e2
--- /dev/null
+++ b/sourcehut-builds/plugin.toml
@@ -0,0 +1,79 @@
+id = "rbdr/sourcehut-builds"
+name = "Sourcehut Builds"
+version = "1.0.0"
+plugin_api = 30
+author = "rbdr"
+license = "AGPL-3"
+dependencies = []
+tags = ["bar", "panel", "development"]
+icon = "circle"
+description = "A widget that shows your recent sourcehut builds"
+
+[[setting]]
+key = "sourcehut_api_token"
+type = "string"
+label_key = "settings.sourcehut_api_token.label"
+description_key = "settings.sourcehut_api_token.description"
+default = ""
+
+[[setting]]
+key = "build_count"
+type = "int"
+label_key = "settings.build_count.label"
+description_key = "settings.build_count.description"
+default = 10
+min = 1
+max = 20
+
+[[setting]]
+key = "recent_cutoff"
+type = "int"
+label_key = "settings.recent_cutoff.label"
+description_key = "settings.recent_cutoff.description"
+default = 900
+min = 60
+max = 86400
+
+[[panel]]
+id = "panel"
+entry = "panel.luau"
+width = 450
+height = 320
+placement = "attached"
+open_near_click = true
+
+[[service]]
+id = "poller"
+entry = "poller.luau"
+
+[[widget]]
+id = "bar"
+entry = "bar.luau"
+
+ [[widget.setting]]
+ key = "glyph"
+ type = "glyph"
+ label_key = "settings.glyph.label"
+ description_key = "settings.glyph.description"
+ default = "circle"
+
+ [[widget.setting]]
+ key = "ok_glyph"
+ type = "glyph"
+ label_key = "settings.ok_glyph.label"
+ description_key = "settings.ok_glyph.description"
+ default = "check"
+
+ [[widget.setting]]
+ key = "failed_glyph"
+ type = "glyph"
+ label_key = "settings.failed_glyph.label"
+ description_key = "settings.failed_glyph.description"
+ default = "x"
+
+ [[widget.setting]]
+ key = "in_progress_glyph"
+ type = "glyph"
+ label_key = "settings.in_progress_glyph.label"
+ description_key = "settings.in_progress_glyph.description"
+ default = "circle-caret-right"
diff --git a/sourcehut-builds/poller.luau b/sourcehut-builds/poller.luau
new file mode 100644
index 0000000..39cfa5b
--- /dev/null
+++ b/sourcehut-builds/poller.luau
@@ -0,0 +1,121 @@
+--!nonstrict
+-- Poller for sourcehut, checks the actual poll
+
+local K = require("./constants.luau")
+local ErrorUtils = require("./error_utils.luau")
+local DateUtils = require("./date_utils.luau")
+local TableUtils = require("./table_utils.luau")
+
+--------------------------------------------------------------------------------
+-- Internal Functions
+--------------------------------------------------------------------------------
+
+--------------------------------------------------------------- State Management
+
+local function notifyStateChange(state: PollerStatus)
+ noctalia.state.set(K.events.poller_state_updated, state)
+end
+
+local function notifyError(error: string)
+ notifyStateChange({type = "error", error = error})
+end
+
+local function notifyBuildsChange(state: SourcehutBuildsState)
+ noctalia.state.set(K.events.builds_state_updated, state)
+end
+
+------------------------------------------------------------------------ Parsing
+
+local function parseBuilds(builds: SourcehutBuildResponse)
+ local successful = 0
+ local failed = 0
+ local inProgress = 0
+ local cutoff = noctalia.getConfig(K.poller.cutoff_configuration_key)
+ local build_count = noctalia.getConfig(K.poller.build_count_configuration_key)
+ local now = os.time()
+ local eligible_builds = TableUtils.trim(builds.data.me.jobs.results, build_count)
+ for _, job in ipairs(eligible_builds) do
+ local jobDate = DateUtils.parseDate(job.created)
+ if now - jobDate > cutoff then
+ continue
+ end
+ if job.status == "SUCCESS" then
+ successful += 1
+ end
+ if job.status == "FAILED" then
+ failed += 1
+ end
+ if job.status == "RUNNING" then
+ inProgress += 1
+ end
+ end
+ local state = {
+ successful = successful,
+ failed = failed,
+ inProgress = inProgress,
+ user = builds.data.me.canonicalName,
+ builds = eligible_builds
+ }
+ notifyStateChange(K.poller.state.ok)
+ notifyBuildsChange(state)
+end
+
+------------------------------------------------------------------------ Network
+
+local function onBuildsFetched(response: HttpResponse)
+ if not response.ok then
+ notifyError(K.poller.errors.request_failed)
+ return
+ end
+ local builds, error = noctalia.json.decode(response.body)
+ if error or builds == nil then
+ notifyError(K.poller.errors.bad_response)
+ return
+ end
+ if response.status == 400 then
+ if type(builds.errors) == "table" then
+ for _, error_response in ipairs(builds.errors) do
+ if ErrorUtils.isUnauthorized(error_response) then
+ notifyError(K.poller.errors.unauthorized)
+ return
+ end
+ end
+ end
+ notifyError(K.poller.errors.bad_response)
+ return
+ end
+ parseBuilds(builds)
+end
+
+local function fetchBuilds()
+ local request: HttpRequest = {
+ url = K.sourcehut.builds_url,
+ method = "GET",
+ body = K.sourcehut.query,
+ headers = {
+ "Content-Type: application/json",
+ "Authorization: Bearer " .. noctalia.getConfig(K.sourcehut.token_configuration_key)
+ },
+ }
+ noctalia.http(request, onBuildsFetched)
+end
+
+---------------------------------------------------------------------- Lifecycle
+
+local function run()
+ noctalia.setUpdateInterval(K.poller.update_interval)
+ update()
+end
+
+--------------------------------------------------------------------------------
+-- Public API
+--------------------------------------------------------------------------------
+function update()
+ fetchBuilds()
+end
+
+function onConfigChanged()
+ fetchBuilds()
+end
+
+run()
diff --git a/sourcehut-builds/string_utils.luau b/sourcehut-builds/string_utils.luau
new file mode 100644
index 0000000..1072bef
--- /dev/null
+++ b/sourcehut-builds/string_utils.luau
@@ -0,0 +1,5 @@
+return {
+ capitalize = function(str: string): string
+ return str:lower():gsub("^%l", string.upper)
+ end
+}
diff --git a/sourcehut-builds/table_utils.luau b/sourcehut-builds/table_utils.luau
new file mode 100644
index 0000000..47feafa
--- /dev/null
+++ b/sourcehut-builds/table_utils.luau
@@ -0,0 +1,11 @@
+return {
+ trim = function<T>(table: {[number]: T}, count: number): {[number]: T}
+ local sliced: {[number]: T} = {}
+
+ for i = 1, count or #table, 1 do
+ sliced[#sliced+1] = table[i]
+ end
+
+ return sliced
+ end
+}
diff --git a/sourcehut-builds/translations/en.json b/sourcehut-builds/translations/en.json
new file mode 100644
index 0000000..c56cafa
--- /dev/null
+++ b/sourcehut-builds/translations/en.json
@@ -0,0 +1,59 @@
+{
+ "bar": {
+ "tooltip": "Sourcehut Builds."
+ },
+ "panel": {
+ "title": "Sourcehut Builds",
+ "loading": {
+ "label": "Fetching builds."
+ },
+ "error": {
+ "label": "Woops: {error}"
+ },
+ "empty": {
+ "label": "No builds were found."
+ },
+ "build": {
+ "label": "{user}/{repo} (#{id})",
+ "tooltip": "{status}"
+ },
+ "task": {
+ "tooltip": "{task}: {status}"
+ }
+ },
+ "settings": {
+ "sourcehut_api_token": {
+ "label": "Sourcehut API token",
+ "description": "Your sourcehut API token, with access to read builds"
+ },
+ "build_count": {
+ "label": "Build Count",
+ "description": "Number of builds to show."
+ },
+ "recent_cutoff": {
+ "label": "Cutoff for Recent Builds",
+ "description": "The amount of time in seconds to consider a build 'recent' for the glyph display."
+ },
+ "glyph": {
+ "label": "Glyph",
+ "description": "Glyph to show when there aren't recent builds."
+ },
+ "ok_glyph": {
+ "label": "OK Glyph",
+ "description": "Glyph to show when recent builds are OK."
+ },
+ "failed_glyph": {
+ "label": "Failed Glyph",
+ "description": "Glyph to show when recent builds have failed"
+ },
+ "in_progress_glyph": {
+ "label": "In Progress Glyph",
+ "description": "Glyph to show when a build is in progress"
+ }
+ },
+ "error": {
+ "request_failed": "We could not reach sourcehut. Make sure your network is connected and sourcehut is reachable.",
+ "bad_response": "We weren't able to understand the response. Make sure sourcehut is operating well, or reach out to the maintainer.",
+ "unauthorized": "Sourcehut did not authorize the request. Make sure you have set a valid API Token."
+ }
+}
diff --git a/sourcehut_builds.d.luau b/sourcehut_builds.d.luau
new file mode 100644
index 0000000..100e388
--- /dev/null
+++ b/sourcehut_builds.d.luau
@@ -0,0 +1,47 @@
+export type SourcehutBuildsState = {
+ successful: number,
+ failed: number,
+ inProgress: number,
+ user: string,
+ builds: { SourcehutBuild }
+}
+
+export type SourcehutBuild = {
+ id: number,
+ created: string,
+ updated: string,
+ status: SourcehutBuildStatus,
+ note: string,
+ tags: {[number]: string},
+ tasks: {[number]: SourcehutBuildTask}
+}
+
+export type SourcehutBuildStatus = "PENDING" | "QUEUED" | "RUNNING" | "FAILED" | "SUCCESS" | "TIMEOUT" | "CANCELLED"
+
+export type SourcehutBuildTask = {
+ name: string,
+ status: SourcehutBuildTaskStatus
+}
+export type SourcehutBuildTaskStatus = "PENDING" | "RUNNING" | "FAILED" | "SUCCESS" | "SKIPPED"
+
+type Err<E> = {type: "error", error: string}
+type Pending = {type: "pending"}
+type Ok = {type: "ok"}
+export type PollerStatus = Err<string> | Pending | Ok
+
+export type SourcehutBuildResponse = {
+ data: SourcehutBuildData
+}
+
+export type SourcehutBuildData = {
+ me: SourcehutUser,
+}
+
+export type SourcehutUser = {
+ canonicalName: string,
+ jobs: SourcehutUserJobs
+}
+
+export type SourcehutUserJobs = {
+ results: {[number]: SourcehutBuild},
+}