mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-09-21 16:00:57 +00:00
Initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/target/
|
||||
.DS_Store
|
||||
Generated
+9757
File diff suppressed because it is too large
Load Diff
+44
@@ -0,0 +1,44 @@
|
||||
[package]
|
||||
name = "ashell"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
authors = ["ashell contributors"]
|
||||
description = "A GPUI Component based SSH and local terminal client"
|
||||
license = "GPL-3.0-or-later"
|
||||
rust-version = "1.85.0"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "fcf32feacb367b75ec84dd40f041e4fd411d3cc1" }
|
||||
async-trait = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
directories = "5"
|
||||
flate2 = "1"
|
||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "runtime_shaders"] }
|
||||
gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "ca85b5588f5feea021f1ee86cb13703dc6f30ec4" }
|
||||
gpui-component-assets = { git = "https://github.com/longbridge/gpui-component", rev = "ca85b5588f5feea021f1ee86cb13703dc6f30ec4" }
|
||||
menu = { git = "https://github.com/zed-industries/zed" }
|
||||
portable-pty = "0.8"
|
||||
rfd = "0.15"
|
||||
russh = "0.49"
|
||||
russh-keys = "0.49"
|
||||
russh-sftp = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
ssh-key = "0.6"
|
||||
sysinfo = "0.33"
|
||||
tar = "0.4"
|
||||
thiserror = "1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "net", "time", "fs"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
walkdir = "2"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 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 General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is 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. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
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.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
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 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. Use with the GNU Affero General Public License.
|
||||
|
||||
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 Affero 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 special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU 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 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 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 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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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 General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU 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 the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
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 GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,41 @@
|
||||
[English](README.md) | [中文](README.zh-CN.md)
|
||||
|
||||
# ashell
|
||||
|
||||
`ashell` is a modern, GPUI Component-based desktop terminal client written in Rust.
|
||||
|
||||
This project focuses on providing a high-performance and visually appealing shell workspace by combining local and remote environments with a rich set of built-in features.
|
||||
|
||||
## Features
|
||||
|
||||
The current version provides a fully-featured GPUI-native workspace:
|
||||
|
||||
- **Local & Remote Sessions:** Open local terminal tabs or connect to remote servers via SSH.
|
||||
- **Advanced SSH Authentication:** Supports both password-based and key-based (file path or inline) SSH connections.
|
||||
- **Session Management:** Easily save, reopen, edit, and remove your SSH sessions.
|
||||
- **SFTP Integration:** Built-in SFTP file manager to browse, upload, download, and manage remote files.
|
||||
- **Robust Terminal Emulator:** Parses terminal output with `alacritty_terminal`, supporting rich ANSI color spans, fast rendering, and complete keyboard input forwarding.
|
||||
- **System Telemetry:** Real-time visualization of CPU, memory, swap, network, and disk metrics in the left cockpit sidebar.
|
||||
- **Theming System:** Switch between multiple GPUI Component themes directly from the top toolbar.
|
||||
- **Embedded Fonts:** Uses embedded Maple Mono NF CN fonts out-of-the-box for excellent CJK character and Nerd Font icon support.
|
||||
|
||||
## Run
|
||||
|
||||
To run the application locally:
|
||||
|
||||
```bash
|
||||
cargo run --release
|
||||
```
|
||||
|
||||
## Package macOS App
|
||||
|
||||
```bash
|
||||
./scripts/package-macos-app.sh
|
||||
open target/release/ashell.app
|
||||
```
|
||||
|
||||
The packaging script creates a standard `.app` bundle. It does not attach an entitlements file, and after signing, it verifies that `com.apple.security.app-sandbox` is not present (meaning it runs non-sandboxed).
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [GPL-3.0-or-later License](LICENSE).
|
||||
@@ -0,0 +1,41 @@
|
||||
[English](README.md) | [中文](README.zh-CN.md)
|
||||
|
||||
# ashell
|
||||
|
||||
`ashell` 是一款现代化的、基于 GPUI Component 构建的 Rust 桌面终端客户端。
|
||||
|
||||
该项目旨在通过结合本地和远程环境,并内置丰富的核心功能,提供一个高性能且美观的 Shell 工作区。
|
||||
|
||||
## 功能特性
|
||||
|
||||
当前版本提供了一个功能完备的 GPUI 原生工作区:
|
||||
|
||||
- **本地与远程会话**:支持打开本地终端标签页或通过 SSH 连接到远程服务器。
|
||||
- **高级 SSH 认证**:支持基于密码和基于密钥(文件路径或内联内容)的 SSH 连接方式。
|
||||
- **会话管理**:可以轻松地保存、重新打开、编辑和删除您的 SSH 会话。
|
||||
- **SFTP 集成**:内置 SFTP 文件管理器,可以浏览、上传、下载以及管理远程文件。
|
||||
- **强大的终端模拟器**:使用 `alacritty_terminal` 解析终端输出,支持丰富的 ANSI 颜色代码、极速渲染和完整的键盘输入转发。
|
||||
- **系统遥测**:在左侧边栏实时可视化显示 CPU、内存、Swap、网络和磁盘的使用指标。
|
||||
- **主题系统**:支持直接从顶部工具栏切换多种 GPUI Component 颜色主题。
|
||||
- **内置字体**:开箱即用,内置 Maple Mono NF CN 字体,提供卓越的中日韩(CJK)字符与 Nerd Font 图标支持。
|
||||
|
||||
## 运行
|
||||
|
||||
在本地运行该应用:
|
||||
|
||||
```bash
|
||||
cargo run --release
|
||||
```
|
||||
|
||||
## 打包 macOS 应用
|
||||
|
||||
```bash
|
||||
./scripts/package-macos-app.sh
|
||||
open target/release/ashell.app
|
||||
```
|
||||
|
||||
该打包脚本会创建一个标准的 `.app` 应用程序包。它没有附加 entitlements 文件,并且在签名后会验证是否不存在 `com.apple.security.app-sandbox`(这意味着它在非沙盒模式下运行)。
|
||||
|
||||
## 协议
|
||||
|
||||
本项目采用 [GPL-3.0-or-later 协议](LICENSE) 开源。
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Maple Mono Project Authors (https://github.com/subframe7536/maple-font)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,302 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-component/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Gruvbox",
|
||||
"author": "Pavel Pertsev",
|
||||
"url": "https://github.com/morhetz/gruvbox",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Gruvbox Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#e6d19e",
|
||||
"accent.foreground": "#3c3836",
|
||||
"background": "#fbf1c7",
|
||||
"border": "#d5c4a1",
|
||||
"danger.background": "#fb4934",
|
||||
"danger.active.background": "#cc241d",
|
||||
"danger.foreground": "#fbf1c7",
|
||||
"foreground": "#3c3836",
|
||||
"input.border": "#d5c4a1",
|
||||
"link.active.foreground": "#458588",
|
||||
"link.foreground": "#458588",
|
||||
"link.hover.foreground": "#458588",
|
||||
"list.active.background": "#d7992122",
|
||||
"list.hover.background": "#ebdbb2",
|
||||
"list.active.border": "#d79921",
|
||||
"list.even.background": "#f9ecba",
|
||||
"muted.background": "#d5c4a166",
|
||||
"muted.foreground": "#928374",
|
||||
"panel.background": "#ebdbb2",
|
||||
"popover.background": "#fbf1c7",
|
||||
"popover.foreground": "#3c3836",
|
||||
"primary.active.background": "#b7841f",
|
||||
"primary.background": "#d79921",
|
||||
"primary.foreground": "#fbf1c7",
|
||||
"primary.hover.background": "#d79921ee",
|
||||
"ring": "#d79921",
|
||||
"scrollbar.background": "#fbf1c700",
|
||||
"scrollbar.thumb.background": "#d5c4a1",
|
||||
"secondary.background": "#EBDBB2",
|
||||
"secondary.active.background": "#d5c4a1",
|
||||
"secondary.foreground": "#3c3836",
|
||||
"secondary.hover.background": "#e8d5a6",
|
||||
"tab.active.background": "#fbf1c7",
|
||||
"tab.active.foreground": "#3c3836",
|
||||
"tab.background": "#ebdbb200",
|
||||
"tab.foreground": "#928374",
|
||||
"tab_bar.background": "#ebdbb2",
|
||||
"title_bar.background": "#ebdbb2",
|
||||
"title_bar.border": "#d5c4a1",
|
||||
"base.magenta.light": "#D3869B66",
|
||||
"base.red": "#fb4934",
|
||||
"base.green": "#67a64f",
|
||||
"base.yellow": "#b57614",
|
||||
"base.blue": "#076678",
|
||||
"base.magenta": "#8f3f71",
|
||||
"base.cyan": "#427b58"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#3c3836",
|
||||
"editor.background": "#fbf1c7",
|
||||
"editor.active_line.background": "#ebdbb2",
|
||||
"editor.line_number": "#928374",
|
||||
"editor.active_line_number": "#3c3836",
|
||||
"editor.invisible": "#92837466",
|
||||
"conflict": "#cc241d",
|
||||
"created": "#79740e",
|
||||
"deleted": "#9d0006",
|
||||
"error": "#cc241d",
|
||||
"hidden": "#928374",
|
||||
"hint": "#458588",
|
||||
"ignored": "#928374",
|
||||
"info": "#458588",
|
||||
"modified": "#b57614",
|
||||
"predictive": "#928374",
|
||||
"renamed": "#b57614",
|
||||
"success": "#79740e",
|
||||
"unreachable": "#928374",
|
||||
"warning": "#d79921",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#b57614"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#928374",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#928374",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b57614"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#3c3836"
|
||||
},
|
||||
"function": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#cc241d"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#458588",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#076678",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#cc241d"
|
||||
},
|
||||
"string": {
|
||||
"color": "#79740e"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#79740e"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#79740e"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b57614"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#d79921"
|
||||
},
|
||||
"title": {
|
||||
"color": "#b57614",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#b57614"
|
||||
},
|
||||
"property": {
|
||||
"color": "#3c3836"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#cc241d"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Gruvbox Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#303030",
|
||||
"accent.foreground": "#ebdbb2",
|
||||
"background": "#1d2021",
|
||||
"border": "#3e3936",
|
||||
"danger.active.background": "#fb4934",
|
||||
"foreground": "#ebdbb2",
|
||||
"input.border": "#504945",
|
||||
"link.active.foreground": "#83a598",
|
||||
"link.foreground": "#83a598",
|
||||
"link.hover.foreground": "#83a598",
|
||||
"list.active.background": "#d7992111",
|
||||
"list.active.border": "#b17f1b",
|
||||
"list.even.background": "#282828",
|
||||
"muted.background": "#3c383655",
|
||||
"muted.foreground": "#928374",
|
||||
"panel.background": "#282828",
|
||||
"popover.background": "#1d2021",
|
||||
"popover.foreground": "#ebdbb2",
|
||||
"primary.background": "#d79921",
|
||||
"primary.foreground": "#282828",
|
||||
"primary.hover.background": "#fabd2f",
|
||||
"ring": "#b17f1b",
|
||||
"scrollbar.background": "#1d202100",
|
||||
"scrollbar.thumb.background": "#504945",
|
||||
"secondary.active.background": "#333332",
|
||||
"secondary.background": "#282828",
|
||||
"secondary.foreground": "#ebdbb2",
|
||||
"secondary.hover.background": "#33333299",
|
||||
"tab.active.background": "#1d2021",
|
||||
"tab.active.foreground": "#ebdbb2",
|
||||
"tab.background": "#28282800",
|
||||
"tab.foreground": "#928374",
|
||||
"tab_bar.background": "#28282899",
|
||||
"title_bar.background": "#252525",
|
||||
"title_bar.border": "#3e3936",
|
||||
"base.magenta.light": "#D3869B66",
|
||||
"base.red": "#f06555",
|
||||
"base.green": "#98971a",
|
||||
"base.yellow": "#d79921",
|
||||
"base.blue": "#458588",
|
||||
"base.magenta": "#b16286",
|
||||
"base.cyan": "#689d6a"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#1d2021",
|
||||
"editor.active_line.background": "#131313",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#92837466",
|
||||
"conflict": "#f06555",
|
||||
"created": "#3f72e2",
|
||||
"deleted": "#9d0006",
|
||||
"error": "#cc241d",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"ignored": "#928374",
|
||||
"info": "#458588",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"renamed": "#b57614",
|
||||
"success": "#79740e",
|
||||
"unreachable": "#928374",
|
||||
"warning": "#d79921",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-component/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Matrix",
|
||||
"author": "iruzo",
|
||||
"url": "https://github.com/iruzo/matrix-nvim",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Matrix",
|
||||
"mode": "dark",
|
||||
"radius": 0,
|
||||
"radius.lg": 0,
|
||||
"colors": {
|
||||
"accent.background": "#002d00",
|
||||
"accent.foreground": "#00FF00",
|
||||
"background": "#020D02",
|
||||
"border": "#12410E",
|
||||
"window_border": "#156B12",
|
||||
"ring": "#00FF00",
|
||||
"danger.background": "#FF0000",
|
||||
"danger.active.background": "#FF0000",
|
||||
"danger.foreground": "#0D0208",
|
||||
"danger.hover.background": "#FF0000",
|
||||
"foreground": "#88FF88",
|
||||
"input.border": "#003200",
|
||||
"link.active.foreground": "#00FF00",
|
||||
"link.foreground": "#00FF00",
|
||||
"link.hover.foreground": "#00FF00",
|
||||
"list.active.background": "#00FF0011",
|
||||
"list.active.border": "#00FF00",
|
||||
"list.even.background": "#00190066",
|
||||
"muted.background": "#001900",
|
||||
"muted.foreground": "#007700",
|
||||
"panel.background": "#001900",
|
||||
"popover.background": "#020D02",
|
||||
"popover.foreground": "#00FF00",
|
||||
"primary.active.background": "#00FF0099",
|
||||
"primary.background": "#00FF00",
|
||||
"primary.foreground": "#0D0208",
|
||||
"primary.hover.background": "#00FF00AA",
|
||||
"scrollbar.background": "#0D020800",
|
||||
"scrollbar.thumb.background": "#003d00",
|
||||
"secondary.active.background": "#001B00",
|
||||
"secondary.background": "#002900",
|
||||
"secondary.foreground": "#3fd43a",
|
||||
"secondary.hover.background": "#003300",
|
||||
"tab.active.foreground": "#00FF00",
|
||||
"tab.background": "#0D020800",
|
||||
"tab.foreground": "#88FF88",
|
||||
"title_bar.background": "#020d02",
|
||||
"title_bar.border": "#12410E",
|
||||
"base.red": "#FF0000",
|
||||
"base.green": "#00FF00",
|
||||
"base.yellow": "#ffea00",
|
||||
"base.blue": "#0000ff",
|
||||
"base.magenta": "#FF00FF",
|
||||
"base.cyan": "#00ffd5"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#88FF88",
|
||||
"editor.background": "#020d02",
|
||||
"editor.active_line.background": "#001900",
|
||||
"editor.line_number": "#007700",
|
||||
"editor.active_line_number": "#00FF00",
|
||||
"editor.invisible": "#00770066",
|
||||
"conflict": "#FF0000",
|
||||
"created": "#82d967",
|
||||
"deleted": "#FF0000",
|
||||
"error": "#FF0000",
|
||||
"hidden": "#007700",
|
||||
"hint": "#3fd43a",
|
||||
"ignored": "#007700",
|
||||
"info": "#3fd43a",
|
||||
"modified": "#82d967",
|
||||
"predictive": "#007700",
|
||||
"renamed": "#82d967",
|
||||
"success": "#82d967",
|
||||
"unreachable": "#007700",
|
||||
"warning": "#ffd700",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#82d967"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#ffd700"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#007700",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#007700",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#ffd700"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#82d967"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#88FF88"
|
||||
},
|
||||
"function": {
|
||||
"color": "#ffd700"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#FF0000"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#3fd43a",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#00FF00",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#FF0000"
|
||||
},
|
||||
"string": {
|
||||
"color": "#82d967"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#82d967"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#82d967"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#ffd700"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#ffd700"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#82d967"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#ffd700"
|
||||
},
|
||||
"title": {
|
||||
"color": "#82d967",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#82d967"
|
||||
},
|
||||
"property": {
|
||||
"color": "#88FF88"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#FF0000"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-component/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Solarized",
|
||||
"author": "Ethan Schoonover",
|
||||
"url": "https://ethanschoonover.com/solarized",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Solarized Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#ebe4ce",
|
||||
"accent.foreground": "#073642",
|
||||
"background": "#FDF6E3",
|
||||
"border": "#DCD4BC",
|
||||
"foreground": "#586E75",
|
||||
"input.border": "#DCD4BC",
|
||||
"link.foreground": "#587573",
|
||||
"list.active.background": "#586E75",
|
||||
"list.active.border": "#586E75",
|
||||
"list.even.background": "#EEE8D599",
|
||||
"muted.background": "#eee8d5",
|
||||
"muted.foreground": "#93a1a1",
|
||||
"panel.background": "#fdf6e3",
|
||||
"popover.background": "#fdf6e3",
|
||||
"popover.foreground": "#073642",
|
||||
"primary.background": "#586E75",
|
||||
"primary.foreground": "#fdf6e3",
|
||||
"scrollbar.background": "#EEE8D500",
|
||||
"scrollbar.thumb.background": "#d5cbaf",
|
||||
"secondary.background": "#EEE8D5",
|
||||
"secondary.active.background": "#DCD4BC",
|
||||
"secondary.foreground": "#073642",
|
||||
"secondary.hover.background": "#DCD4BC88",
|
||||
"tab.active.background": "#fdf6e3",
|
||||
"tab.active.foreground": "#073642",
|
||||
"tab.background": "#fdf6e300",
|
||||
"tab.foreground": "#657b83",
|
||||
"tab_bar.background": "#eee8d5",
|
||||
"title_bar.background": "#f4ecd7",
|
||||
"title_bar.border": "#dcd4bc",
|
||||
"chart.grid": "#ebe5d4",
|
||||
"base.red": "#755858",
|
||||
"base.green": "#717558",
|
||||
"base.yellow": "#756e58",
|
||||
"base.blue": "#586975",
|
||||
"base.magenta": "#755866",
|
||||
"base.cyan": "#587573"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#586E75",
|
||||
"editor.background": "#FDF6E3",
|
||||
"editor.active_line.background": "#EEE8D5",
|
||||
"editor.line_number": "#93A1A1",
|
||||
"editor.active_line_number": "#073642",
|
||||
"editor.invisible": "#93A1A166",
|
||||
"conflict": "#DC322F",
|
||||
"created": "#859900",
|
||||
"deleted": "#DC322F",
|
||||
"error": "#DC322F",
|
||||
"hidden": "#93A1A1",
|
||||
"hint": "#2AA198",
|
||||
"ignored": "#93A1A1",
|
||||
"info": "#268BD2",
|
||||
"modified": "#B58900",
|
||||
"predictive": "#586E75",
|
||||
"renamed": "#2AA198",
|
||||
"success": "#859900",
|
||||
"unreachable": "#DC322F",
|
||||
"warning": "#B58900",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#B58900"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#CB4B16"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#93A1A1",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#93A1A1",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#268BD2"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#859900"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#2AA198"
|
||||
},
|
||||
"function": {
|
||||
"color": "#268BD2"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#DC322F"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#268BD2",
|
||||
"font_style": "underline"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#2AA198",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#D33682"
|
||||
},
|
||||
"string": {
|
||||
"color": "#859900"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#2AA198"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#D33682"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#B58900"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#B58900"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#268BD2"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#586E75"
|
||||
},
|
||||
"title": {
|
||||
"color": "#073642",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#2AA198"
|
||||
},
|
||||
"property": {
|
||||
"color": "#586E75"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#DC322F"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Solarized Dark",
|
||||
"author": "Ethan Schoonover",
|
||||
"url": "https://ethanschoonover.com/solarized",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#073945",
|
||||
"accent.foreground": "#fdf6e3",
|
||||
"background": "#002B36",
|
||||
"border": "#103a44",
|
||||
"foreground": "#fdf6e3",
|
||||
"input.border": "#083e4c",
|
||||
"list.active.background": "#0d667d25",
|
||||
"list.active.border": "#0d667d",
|
||||
"list.even.background": "#15333A99",
|
||||
"muted.background": "#073642",
|
||||
"muted.foreground": "#839496",
|
||||
"panel.background": "#002b36",
|
||||
"popover.background": "#002b36",
|
||||
"popover.foreground": "#fdf6e3",
|
||||
"primary.background": "#0d667d",
|
||||
"primary.foreground": "#fdf6e3",
|
||||
"scrollbar.background": "#002b3600",
|
||||
"scrollbar.thumb.background": "#586e75",
|
||||
"secondary.background": "#073642",
|
||||
"secondary.active.background": "#083e4b",
|
||||
"secondary.foreground": "#fdf6e3",
|
||||
"secondary.hover.background": "#073945",
|
||||
"tab.active.background": "#002b36",
|
||||
"tab.active.foreground": "#fdf6e3",
|
||||
"tab.foreground": "#93a1a1",
|
||||
"tab_bar.background": "#07364266",
|
||||
"title_bar.background": "#09323d",
|
||||
"title_bar.border": "#16404b",
|
||||
"base.red": "#7d2f0d",
|
||||
"base.green": "#0d7d3f",
|
||||
"base.yellow": "#7d650d",
|
||||
"base.blue": "#0d507d",
|
||||
"base.magenta": "#7d0d43",
|
||||
"base.cyan": "#0d7d70"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#DDDDDD",
|
||||
"editor.background": "#002B36",
|
||||
"editor.active_line.background": "#073642",
|
||||
"editor.line_number": "#8F8F8F",
|
||||
"editor.active_line_number": "#DDDDDD",
|
||||
"editor.invisible": "#93A1A166",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"created.background": "#0C4619",
|
||||
"deleted.background": "#46190C",
|
||||
"error.background": "#46190C",
|
||||
"error.border": "#802207",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"hint.background": "#250c4b",
|
||||
"hint.border": "#3f0891",
|
||||
"info.background": "#0C194D",
|
||||
"info.border": "#082190",
|
||||
"modified": "#B0A878",
|
||||
"modified.background": "#3A310E",
|
||||
"predictive": "#5D5945",
|
||||
"success.background": "#0C4619",
|
||||
"warning.background": "#3A310E",
|
||||
"warning.border": "#7B6508",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
{
|
||||
"name": "Tokyo",
|
||||
"author": "Folke Lemaitre",
|
||||
"url": "https://github.com/folke/tokyonight.nvim",
|
||||
"$schema": "https://github.com/longbridge/gpui-component/raw/refs/heads/main/.theme-schema.json",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Tokyo Night",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#2C3045",
|
||||
"accent.foreground": "#c0caf5",
|
||||
"background": "#1a1b26",
|
||||
"border": "#292e42",
|
||||
"window_border": "#3d4462",
|
||||
"ring": "#7aa2f7",
|
||||
"foreground": "#c0caf5",
|
||||
"link.active.foreground": "#7aa2f722",
|
||||
"link.foreground": "#7aa2f7",
|
||||
"link.hover.foreground": "#7aa2f7",
|
||||
"list.active.background": "#7aa2f722",
|
||||
"list.active.border": "#7aa2f7",
|
||||
"list.even.background": "#2C304599",
|
||||
"muted.background": "#2C304544",
|
||||
"muted.foreground": "#565f89",
|
||||
"panel.background": "#292e42",
|
||||
"popover.background": "#1a1b26",
|
||||
"popover.foreground": "#c0caf5",
|
||||
"primary.background": "#7aa2f7",
|
||||
"primary.foreground": "#1a1b26",
|
||||
"scrollbar.background": "#1a1b2600",
|
||||
"scrollbar.thumb.background": "#414868",
|
||||
"secondary.active.background": "#292e42",
|
||||
"secondary.background": "#292e42",
|
||||
"secondary.foreground": "#C0CAF5",
|
||||
"secondary.hover.background": "#2d3349",
|
||||
"title_bar.background": "#161720",
|
||||
"title_bar.border": "#292e42",
|
||||
"chart.grid": "#222332",
|
||||
"base.red": "#f7768e",
|
||||
"base.green": "#9ece6a",
|
||||
"base.yellow": "#e0af68",
|
||||
"base.blue": "#7aa2f7",
|
||||
"base.magenta": "#565f89",
|
||||
"base.cyan": "#7dcfff"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#c0caf5",
|
||||
"editor.background": "#1a1b26",
|
||||
"editor.active_line.background": "#292e42",
|
||||
"editor.line_number": "#565f89",
|
||||
"editor.active_line_number": "#c0caf5",
|
||||
"editor.invisible": "#565f8966",
|
||||
"conflict": "#f7768e",
|
||||
"created": "#9ece6a",
|
||||
"deleted": "#f7768e",
|
||||
"error": "#f7768e",
|
||||
"hidden": "#565f89",
|
||||
"hint": "#7dcfff",
|
||||
"ignored": "#565f89",
|
||||
"info": "#7aa2f7",
|
||||
"modified": "#e0af68",
|
||||
"predictive": "#565f89",
|
||||
"renamed": "#7aa2f7",
|
||||
"success": "#9ece6a",
|
||||
"unreachable": "#565f89",
|
||||
"warning": "#e0af68",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#e0af68"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#9ece6a"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#565f89",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#565f89",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#7aa2f7"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#e0af68"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"function": {
|
||||
"color": "#7aa2f7"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#f7768e"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#7dcfff",
|
||||
"font_style": "underline"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#7aa2f7",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#e0af68"
|
||||
},
|
||||
"string": {
|
||||
"color": "#9ece6a"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#7dcfff"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#9ece6a"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#e0af68"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#e0af68"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#f7768e"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"title": {
|
||||
"color": "#7aa2f7",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#7dcfff"
|
||||
},
|
||||
"property": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#f7768e"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Tokyo Storm",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#414868",
|
||||
"accent.foreground": "#c0caf5",
|
||||
"background": "#24283b",
|
||||
"border": "#3d4462",
|
||||
"window_border": "#3d4462",
|
||||
"ring": "#7aa2f7",
|
||||
"foreground": "#c0caf5",
|
||||
"input.border": "#414868",
|
||||
"link.active.foreground": "#7aa2f7",
|
||||
"link.foreground": "#7aa2f7",
|
||||
"link.hover.foreground": "#7aa2f7",
|
||||
"list.active.background": "#7aa2f711",
|
||||
"list.active.border": "#7aa2f7",
|
||||
"list.even.background": "#41486844",
|
||||
"muted.background": "#292e42",
|
||||
"muted.foreground": "#565f89",
|
||||
"panel.background": "#292e42",
|
||||
"popover.background": "#24283b",
|
||||
"popover.foreground": "#c0caf5",
|
||||
"primary.background": "#7aa2f7",
|
||||
"primary.foreground": "#24283b",
|
||||
"scrollbar.background": "#24283b00",
|
||||
"scrollbar.thumb.background": "#414868",
|
||||
"secondary.active.background": "#323750",
|
||||
"secondary.background": "#292e42",
|
||||
"secondary.foreground": "#c0caf5",
|
||||
"secondary.hover.background": "#32375099",
|
||||
"title_bar.background": "#202435",
|
||||
"title_bar.border": "#3d4462",
|
||||
"base.red": "#f7768e",
|
||||
"base.green": "#9ece6a",
|
||||
"base.yellow": "#e0af68",
|
||||
"base.blue": "#7aa2f7",
|
||||
"base.magenta": "#b283f8",
|
||||
"base.cyan": "#7dcfff"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#B0B9E2",
|
||||
"editor.background": "#24283B",
|
||||
"editor.active_line.background": "#292E42",
|
||||
"editor.line_number": "#363C58",
|
||||
"editor.active_line_number": "#B0B9E2",
|
||||
"editor.invisible": "#565f8966",
|
||||
"conflict": "#D2602D",
|
||||
"created": "#3f72e2",
|
||||
"deleted": "#f7768e",
|
||||
"error": "#f7768e",
|
||||
"hidden": "#9E9E9E",
|
||||
"hint": "#b283f8",
|
||||
"modified": "#B0A878",
|
||||
"predictive": "#5D5945",
|
||||
"success": "#9ece6a",
|
||||
"warning": "#e0af68",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#be9a52"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#9E9E9E"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#A86D3B",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#6F6D66",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#E19773"
|
||||
},
|
||||
"string": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#76BA53"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#b5af9a"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#E1D797"
|
||||
},
|
||||
"title": {
|
||||
"color": "#A76D3B",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#A86D3B"
|
||||
},
|
||||
"property": {
|
||||
"color": "#CACCCA"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E19773"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Tokyo Moon",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#32364E",
|
||||
"accent.foreground": "#c0caf5",
|
||||
"background": "#222436",
|
||||
"border": "#323752",
|
||||
"window_border": "#404668",
|
||||
"ring": "#82aaff",
|
||||
"foreground": "#c0caf5",
|
||||
"input.border": "#444b6a",
|
||||
"link.active.foreground": "#82aaff",
|
||||
"link.foreground": "#82aaff",
|
||||
"link.hover.foreground": "#82aaff",
|
||||
"list.active.background": "#82aaff22",
|
||||
"list.active.border": "#82aaff",
|
||||
"list.background": "#222436",
|
||||
"list.even.background": "#282a40",
|
||||
"muted.background": "#2d3149",
|
||||
"muted.foreground": "#6e738d",
|
||||
"panel.background": "#2d3149",
|
||||
"popover.background": "#222436",
|
||||
"popover.foreground": "#c0caf5",
|
||||
"primary.background": "#82aaff",
|
||||
"primary.foreground": "#222436",
|
||||
"scrollbar.background": "#22243600",
|
||||
"scrollbar.thumb.background": "#444b6a",
|
||||
"secondary.active.background": "#2f334c",
|
||||
"secondary.background": "#2d3149",
|
||||
"secondary.foreground": "#8c94b5",
|
||||
"secondary.hover.background": "#31354f",
|
||||
"table.row.border": "#2d3149",
|
||||
"title_bar.background": "#1e2030",
|
||||
"title_bar.border": "#323752",
|
||||
"base.red": "#ff757f",
|
||||
"base.green": "#c3e88d",
|
||||
"base.yellow": "#ffc777",
|
||||
"base.blue": "#82aaff",
|
||||
"base.magenta": "#6e738d",
|
||||
"base.cyan": "#86e1fc"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#c0caf5",
|
||||
"editor.background": "#222436",
|
||||
"editor.active_line.background": "#2d3149",
|
||||
"editor.line_number": "#6e738d",
|
||||
"editor.active_line_number": "#c0caf5",
|
||||
"editor.invisible": "#565f8966",
|
||||
"conflict": "#ed8796",
|
||||
"created": "#c3e88d",
|
||||
"deleted": "#ed8796",
|
||||
"error": "#ed8796",
|
||||
"hidden": "#6e738d",
|
||||
"hint": "#86e1fc",
|
||||
"ignored": "#6e738d",
|
||||
"info": "#82aaff",
|
||||
"modified": "#ffc777",
|
||||
"predictive": "#6e738d",
|
||||
"renamed": "#82aaff",
|
||||
"success": "#c3e88d",
|
||||
"unreachable": "#6e738d",
|
||||
"warning": "#ffc777",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#ffc777"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#c3e88d"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#6e738d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#6e738d",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#82aaff"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#ffc777"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"function": {
|
||||
"color": "#82aaff"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#ed8796"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#86e1fc",
|
||||
"font_style": "underline"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#82aaff",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#ffc777"
|
||||
},
|
||||
"string": {
|
||||
"color": "#c3e88d"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#86e1fc"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#c3e88d"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#ffc777"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#ffc777"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#ed8796"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"title": {
|
||||
"color": "#82aaff",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#86e1fc"
|
||||
},
|
||||
"property": {
|
||||
"color": "#c0caf5"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#ed8796"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
APP_NAME="ashell"
|
||||
BUNDLE_ID="dev.ashell.app"
|
||||
APP_DIR="$ROOT_DIR/target/release/${APP_NAME}.app"
|
||||
CONTENTS_DIR="$APP_DIR/Contents"
|
||||
MACOS_DIR="$CONTENTS_DIR/MacOS"
|
||||
RESOURCES_DIR="$CONTENTS_DIR/Resources"
|
||||
SIGN_IDENTITY="${SIGN_IDENTITY:--}"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
cargo build --release
|
||||
|
||||
rm -rf "$APP_DIR"
|
||||
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
|
||||
cp "$ROOT_DIR/target/release/$APP_NAME" "$MACOS_DIR/$APP_NAME"
|
||||
|
||||
cat > "$CONTENTS_DIR/Info.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$APP_NAME</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$BUNDLE_ID</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$APP_NAME</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
printf 'APPL????' > "$CONTENTS_DIR/PkgInfo"
|
||||
|
||||
if command -v codesign >/dev/null 2>&1; then
|
||||
# Important: do not pass an entitlements file here.
|
||||
# A sandboxed macOS app carries the `com.apple.security.app-sandbox` entitlement,
|
||||
# which would prevent the file access behavior this app needs.
|
||||
codesign --force --deep --sign "$SIGN_IDENTITY" "$APP_DIR" >/dev/null
|
||||
|
||||
ENTITLEMENTS_XML="$(codesign -d --entitlements :- "$APP_DIR" 2>/dev/null || true)"
|
||||
if printf '%s' "$ENTITLEMENTS_XML" | grep -q "com.apple.security.app-sandbox"; then
|
||||
echo "error: app bundle is sandboxed; remove app sandbox entitlements before packaging" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$APP_DIR"
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuthMethod {
|
||||
Password,
|
||||
Key,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub user: String,
|
||||
pub auth: AuthMethod,
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
#[serde(default)]
|
||||
pub private_key_path: String,
|
||||
#[serde(default)]
|
||||
pub private_key_inline: String,
|
||||
#[serde(default)]
|
||||
pub last_used: Option<String>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn password(host: String, port: u16, user: String, password: String) -> Self {
|
||||
let name = format!("{user}@{host}");
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
auth: AuthMethod::Password,
|
||||
password,
|
||||
private_key_path: String::new(),
|
||||
private_key_inline: String::new(),
|
||||
last_used: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(
|
||||
host: String,
|
||||
port: u16,
|
||||
user: String,
|
||||
private_key_path: String,
|
||||
private_key_inline: String,
|
||||
) -> Self {
|
||||
let name = format!("{user}@{host}");
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
auth: AuthMethod::Key,
|
||||
password: String::new(),
|
||||
private_key_path,
|
||||
private_key_inline,
|
||||
last_used: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ConfigFile {
|
||||
#[serde(default)]
|
||||
pub follow_system_theme: bool,
|
||||
#[serde(default)]
|
||||
pub light_theme_name: String,
|
||||
#[serde(default)]
|
||||
pub dark_theme_name: String,
|
||||
#[serde(default = "default_terminal_font_size")]
|
||||
pub terminal_font_size: f32,
|
||||
#[serde(default)]
|
||||
pub sessions: Vec<Session>,
|
||||
}
|
||||
|
||||
fn default_terminal_font_size() -> f32 {
|
||||
13.0
|
||||
}
|
||||
|
||||
pub struct ConfigStore {
|
||||
path: PathBuf,
|
||||
cache: ConfigFile,
|
||||
}
|
||||
|
||||
impl ConfigStore {
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = Self::config_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create config dir {}", parent.display()))?;
|
||||
}
|
||||
|
||||
let cache = if path.exists() {
|
||||
let raw = fs::read_to_string(&path)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
serde_json::from_str::<ConfigFile>(&raw).unwrap_or_default()
|
||||
} else {
|
||||
ConfigFile::default()
|
||||
};
|
||||
|
||||
Ok(Self { path, cache })
|
||||
}
|
||||
|
||||
pub fn in_memory() -> Self {
|
||||
Self {
|
||||
path: PathBuf::new(),
|
||||
cache: ConfigFile::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn config_path() -> Result<PathBuf> {
|
||||
let dirs = BaseDirs::new().context("could not determine user home directory")?;
|
||||
Ok(dirs
|
||||
.home_dir()
|
||||
.join(".config")
|
||||
.join("ashell")
|
||||
.join("sessions.json"))
|
||||
}
|
||||
|
||||
pub fn sessions(&self) -> &[Session] {
|
||||
&self.cache.sessions
|
||||
}
|
||||
|
||||
pub fn follow_system_theme(&self) -> bool {
|
||||
self.cache.follow_system_theme
|
||||
}
|
||||
|
||||
pub fn light_theme_name(&self) -> &str {
|
||||
&self.cache.light_theme_name
|
||||
}
|
||||
|
||||
pub fn dark_theme_name(&self) -> &str {
|
||||
&self.cache.dark_theme_name
|
||||
}
|
||||
|
||||
pub fn terminal_font_size(&self) -> f32 {
|
||||
if self.cache.terminal_font_size <= 0.0 {
|
||||
default_terminal_font_size()
|
||||
} else {
|
||||
self.cache.terminal_font_size
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_theme_preferences(
|
||||
&mut self,
|
||||
follow_system_theme: bool,
|
||||
light_theme_name: impl Into<String>,
|
||||
dark_theme_name: impl Into<String>,
|
||||
) {
|
||||
self.cache.follow_system_theme = follow_system_theme;
|
||||
self.cache.light_theme_name = light_theme_name.into();
|
||||
self.cache.dark_theme_name = dark_theme_name.into();
|
||||
}
|
||||
|
||||
pub fn set_terminal_font_size(&mut self, terminal_font_size: f32) {
|
||||
self.cache.terminal_font_size = terminal_font_size.max(10.0);
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&Session> {
|
||||
self.cache.sessions.iter().find(|s| s.id == id)
|
||||
}
|
||||
|
||||
pub fn upsert(&mut self, session: Session) {
|
||||
if let Some(existing) = self.cache.sessions.iter_mut().find(|s| s.id == session.id) {
|
||||
*existing = session;
|
||||
} else {
|
||||
self.cache.sessions.push(session);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: &str) {
|
||||
self.cache.sessions.retain(|s| s.id != id);
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
if self.path.as_os_str().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let raw = serde_json::to_string_pretty(&self.cache)?;
|
||||
fs::write(&self.path, raw).with_context(|| format!("failed to write {}", self.path.display()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
sync::mpsc::{self, Sender},
|
||||
thread,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
||||
|
||||
use crate::terminal::{BackendCommand, BackendEvent, BackendTx};
|
||||
|
||||
pub fn spawn_local_terminal(
|
||||
tab_id: String,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
events: Sender<BackendEvent>,
|
||||
) -> Result<BackendTx> {
|
||||
let pty_system = native_pty_system();
|
||||
let pair = pty_system
|
||||
.openpty(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.context("open local PTY")?;
|
||||
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| {
|
||||
if cfg!(windows) {
|
||||
"powershell.exe".into()
|
||||
} else {
|
||||
"/bin/zsh".into()
|
||||
}
|
||||
});
|
||||
|
||||
let mut cmd = CommandBuilder::new(&shell);
|
||||
cmd.env("TERM", std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".into()));
|
||||
cmd.env(
|
||||
"COLORTERM",
|
||||
std::env::var("COLORTERM").unwrap_or_else(|_| "truecolor".into()),
|
||||
);
|
||||
cmd.env("TERM_PROGRAM", "ashell");
|
||||
if let Ok(path) = std::env::var("PATH") {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
if let Ok(lang) = std::env::var("LANG") {
|
||||
cmd.env("LANG", lang);
|
||||
} else {
|
||||
cmd.env("LANG", "en_US.UTF-8");
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
cmd.env("HOME", home);
|
||||
}
|
||||
cmd.env("SHELL", shell);
|
||||
let mut child = pair.slave.spawn_command(cmd).context("spawn local shell")?;
|
||||
drop(pair.slave);
|
||||
|
||||
let master = pair.master;
|
||||
let mut reader = master.try_clone_reader().context("clone PTY reader")?;
|
||||
let mut writer = master.take_writer().context("take PTY writer")?;
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel::<BackendCommand>();
|
||||
|
||||
let read_tab = tab_id.clone();
|
||||
let read_events = events.clone();
|
||||
thread::spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
let _ = read_events.send(BackendEvent::Output {
|
||||
tab_id: read_tab.clone(),
|
||||
bytes: buf[..n].to_vec(),
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = read_events.send(BackendEvent::Closed {
|
||||
tab_id: read_tab.clone(),
|
||||
reason: format!("local read error: {err}"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = read_events.send(BackendEvent::Closed {
|
||||
tab_id: read_tab,
|
||||
reason: "local shell closed".into(),
|
||||
});
|
||||
});
|
||||
|
||||
let write_tab = tab_id.clone();
|
||||
let write_events = events.clone();
|
||||
thread::spawn(move || {
|
||||
while let Ok(command) = cmd_rx.recv() {
|
||||
match command {
|
||||
BackendCommand::Input(bytes) => {
|
||||
if let Err(err) = writer.write_all(&bytes) {
|
||||
let _ = write_events.send(BackendEvent::Closed {
|
||||
tab_id: write_tab.clone(),
|
||||
reason: format!("local write error: {err}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
let _ = writer.flush();
|
||||
}
|
||||
BackendCommand::Resize { cols, rows } => {
|
||||
let _ = master.resize(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
});
|
||||
}
|
||||
BackendCommand::Close => break,
|
||||
}
|
||||
}
|
||||
let _ = child.kill();
|
||||
});
|
||||
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id,
|
||||
text: "local shell ready".into(),
|
||||
});
|
||||
|
||||
Ok(BackendTx::Local(cmd_tx))
|
||||
}
|
||||
+3278
File diff suppressed because it is too large
Load Diff
+909
@@ -0,0 +1,909 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use directories::BaseDirs;
|
||||
use flate2::read::GzDecoder;
|
||||
use russh::{
|
||||
client::{self, Handler},
|
||||
keys::{decode_secret_key, key::PrivateKeyWithHashAlg, load_secret_key, HashAlg, PrivateKey},
|
||||
Disconnect,
|
||||
};
|
||||
use russh_sftp::client::SftpSession;
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
|
||||
task::JoinHandle,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
use walkdir::WalkDir;
|
||||
use zip::read::ZipArchive;
|
||||
|
||||
use crate::{
|
||||
config::{AuthMethod, Session},
|
||||
terminal::BackendEvent,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RemoteEntry {
|
||||
pub name: String,
|
||||
pub full_path: String,
|
||||
pub is_dir: bool,
|
||||
pub size: u64,
|
||||
pub modified: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreviewData {
|
||||
pub path: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub is_binary: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum SftpCommand {
|
||||
ListDir(String),
|
||||
Preview(String),
|
||||
Download { remote: String, local_dir: String },
|
||||
UploadPaths { locals: Vec<String>, remote_dir: String },
|
||||
Close,
|
||||
}
|
||||
|
||||
pub struct SftpHandle {
|
||||
commands: UnboundedSender<SftpCommand>,
|
||||
#[allow(dead_code)]
|
||||
join: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Clone for SftpHandle {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
commands: self.commands.clone(),
|
||||
join: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SftpHandle {
|
||||
pub fn list_dir(&self, path: String) {
|
||||
let _ = self.commands.send(SftpCommand::ListDir(path));
|
||||
}
|
||||
|
||||
pub fn preview(&self, path: String) {
|
||||
let _ = self.commands.send(SftpCommand::Preview(path));
|
||||
}
|
||||
|
||||
pub fn download(&self, remote: String, local_dir: String) {
|
||||
let _ = self.commands.send(SftpCommand::Download { remote, local_dir });
|
||||
}
|
||||
|
||||
pub fn upload_paths(&self, locals: Vec<String>, remote_dir: String) {
|
||||
let _ = self.commands.send(SftpCommand::UploadPaths { locals, remote_dir });
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
let _ = self.commands.send(SftpCommand::Close);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_sftp(
|
||||
runtime: &tokio::runtime::Handle,
|
||||
tab_id: String,
|
||||
session: Session,
|
||||
events: std::sync::mpsc::Sender<BackendEvent>,
|
||||
) -> SftpHandle {
|
||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
||||
let join = runtime.spawn(async move {
|
||||
if let Err(err) = run_sftp(tab_id.clone(), session, cmd_rx, events.clone()).await {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id,
|
||||
text: format!("sftp error: {err:#}"),
|
||||
});
|
||||
}
|
||||
});
|
||||
SftpHandle {
|
||||
commands: cmd_tx,
|
||||
join: Some(join),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_sftp(
|
||||
tab_id: String,
|
||||
session: Session,
|
||||
mut commands: UnboundedReceiver<SftpCommand>,
|
||||
events: std::sync::mpsc::Sender<BackendEvent>,
|
||||
) -> Result<()> {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: "sftp connecting...".into(),
|
||||
});
|
||||
|
||||
let handle = connect_and_authenticate(&session).await?;
|
||||
let channel = handle
|
||||
.channel_open_session()
|
||||
.await
|
||||
.context("open sftp channel")?;
|
||||
channel
|
||||
.request_subsystem(true, "sftp")
|
||||
.await
|
||||
.context("request sftp subsystem")?;
|
||||
let sftp = SftpSession::new(channel.into_stream())
|
||||
.await
|
||||
.context("sftp handshake")?;
|
||||
|
||||
let home = sftp.canonicalize(".").await.unwrap_or_else(|_| "/".to_string());
|
||||
emit_entries(&events, &tab_id, &sftp, &home).await?;
|
||||
|
||||
while let Some(command) = commands.recv().await {
|
||||
match command {
|
||||
SftpCommand::Close => break,
|
||||
SftpCommand::ListDir(path) => {
|
||||
if let Err(err) = emit_entries(&events, &tab_id, &sftp, &path).await {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: format!("list failed: {err:#}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
SftpCommand::Preview(path) => match preview_impl(&sftp, &path).await {
|
||||
Ok(preview) => {
|
||||
let _ = events.send(BackendEvent::SftpPreview {
|
||||
tab_id: tab_id.clone(),
|
||||
preview,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: format!("preview failed: {err:#}"),
|
||||
});
|
||||
}
|
||||
},
|
||||
SftpCommand::Download { remote, local_dir } => {
|
||||
let base = base_name(&remote);
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: format!("downloading {base}..."),
|
||||
});
|
||||
match download_path_impl(&handle, &sftp, &remote, Path::new(&local_dir)).await {
|
||||
Ok(summary) => {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: summary,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: format!("download failed: {err:#}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
SftpCommand::UploadPaths { locals, remote_dir } => {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: "uploading...".into(),
|
||||
});
|
||||
match upload_paths_impl(&sftp, &locals, &remote_dir).await {
|
||||
Ok(summary) => {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: summary,
|
||||
});
|
||||
let _ = emit_entries(&events, &tab_id, &sftp, &remote_dir).await;
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: format!("upload failed: {err:#}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = handle.disconnect(Disconnect::ByApplication, "bye", "").await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn emit_entries(
|
||||
events: &std::sync::mpsc::Sender<BackendEvent>,
|
||||
tab_id: &str,
|
||||
sftp: &SftpSession,
|
||||
path: &str,
|
||||
) -> Result<()> {
|
||||
let entries = list_dir_impl(sftp, path).await?;
|
||||
let _ = events.send(BackendEvent::SftpEntries {
|
||||
tab_id: tab_id.to_string(),
|
||||
path: path.to_string(),
|
||||
entries,
|
||||
});
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: path.to_string(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn connect_and_authenticate(session: &Session) -> Result<russh::client::Handle<SftpClientHandler>> {
|
||||
let config = Arc::new(client::Config {
|
||||
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
|
||||
..Default::default()
|
||||
});
|
||||
let addr = format!("{}:{}", session.host, session.port);
|
||||
let mut handle = client::connect(config, addr.as_str(), SftpClientHandler)
|
||||
.await
|
||||
.with_context(|| format!("connect {addr} failed"))?;
|
||||
|
||||
let authed = match session.auth {
|
||||
AuthMethod::Password => handle
|
||||
.authenticate_password(&session.user, &session.password)
|
||||
.await
|
||||
.context("password authentication failed")?,
|
||||
AuthMethod::Key => {
|
||||
let keypair = load_session_private_key(session)?;
|
||||
let key = private_key_with_alg(keypair).context("invalid private key")?;
|
||||
handle
|
||||
.authenticate_publickey(&session.user, key)
|
||||
.await
|
||||
.context("public key authentication failed")?
|
||||
}
|
||||
};
|
||||
|
||||
if !authed {
|
||||
let _ = handle
|
||||
.disconnect(Disconnect::ByApplication, "auth failed", "")
|
||||
.await;
|
||||
return Err(anyhow!(
|
||||
"authentication failed: server rejected {} authentication for {}@{}:{}",
|
||||
match session.auth {
|
||||
AuthMethod::Password => "password",
|
||||
AuthMethod::Key => "public key",
|
||||
},
|
||||
session.user,
|
||||
session.host,
|
||||
session.port
|
||||
));
|
||||
}
|
||||
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
|
||||
let inline_key = normalize_inline_private_key(&session.private_key_inline);
|
||||
let key_path = expand_key_path(session.private_key_path.trim());
|
||||
let has_inline = !inline_key.is_empty();
|
||||
let has_path = key_path.is_some();
|
||||
|
||||
if !has_inline && !has_path {
|
||||
return Err(anyhow!("private key content or path is required"));
|
||||
}
|
||||
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if has_inline {
|
||||
match decode_secret_key(&inline_key, None) {
|
||||
Ok(key) => return Ok(key),
|
||||
Err(err) => errors.push(format!("decode private key content: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(path) = key_path {
|
||||
match load_secret_key(path.as_path(), None) {
|
||||
Ok(key) => return Ok(key),
|
||||
Err(err) => errors.push(format!("load key {}: {err}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(errors.join("; ")))
|
||||
}
|
||||
|
||||
fn private_key_with_alg(keypair: PrivateKey) -> Result<PrivateKeyWithHashAlg> {
|
||||
let hash_alg = if keypair.algorithm().is_rsa() {
|
||||
Some(HashAlg::Sha512)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(
|
||||
PrivateKeyWithHashAlg::new(Arc::new(keypair.clone()), hash_alg)
|
||||
.or_else(|_| PrivateKeyWithHashAlg::new(Arc::new(keypair), Some(HashAlg::Sha256)))?,
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_inline_private_key(value: &str) -> String {
|
||||
let mut normalized = value
|
||||
.trim()
|
||||
.replace("\\r\\n", "\n")
|
||||
.replace("\\n", "\n")
|
||||
.replace("\r\n", "\n");
|
||||
if !normalized.ends_with('\n') {
|
||||
normalized.push('\n');
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn expand_key_path(value: &str) -> Option<PathBuf> {
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if value == "~" {
|
||||
return BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf());
|
||||
}
|
||||
if let Some(rest) = value.strip_prefix("~/") {
|
||||
return BaseDirs::new().map(|dirs| dirs.home_dir().join(rest));
|
||||
}
|
||||
Some(Path::new(value).to_path_buf())
|
||||
}
|
||||
|
||||
fn base_name(path: &str) -> String {
|
||||
let sep = |c: char| c == '/' || c == '\\';
|
||||
path.trim_end_matches(sep)
|
||||
.rsplit(sep)
|
||||
.next()
|
||||
.unwrap_or(path)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn join_remote(parent: &str, child: &str) -> String {
|
||||
if parent == "/" {
|
||||
format!("/{child}")
|
||||
} else {
|
||||
format!("{}/{}", parent.trim_end_matches('/'), child)
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_archive_suffix(name: &str) -> &str {
|
||||
for suffix in [".tar.gz", ".tgz", ".zip", ".tar"] {
|
||||
if let Some(stripped) = name.strip_suffix(suffix) {
|
||||
return stripped;
|
||||
}
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
fn format_bytes(bytes: u64) -> String {
|
||||
if bytes < 1024 {
|
||||
format!("{bytes} B")
|
||||
} else if bytes < 1024 * 1024 {
|
||||
format!("{:.1} KB", bytes as f64 / 1024.0)
|
||||
} else if bytes < 1024 * 1024 * 1024 {
|
||||
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||
} else {
|
||||
format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_mtime(ts: u32) -> String {
|
||||
let dt: DateTime<Utc> = Utc
|
||||
.timestamp_opt(ts as i64, 0)
|
||||
.single()
|
||||
.unwrap_or_else(Utc::now);
|
||||
dt.format("%Y-%m-%d %H:%M").to_string()
|
||||
}
|
||||
|
||||
async fn list_dir_impl(sftp: &SftpSession, path: &str) -> Result<Vec<RemoteEntry>> {
|
||||
let raw = sftp
|
||||
.read_dir(path)
|
||||
.await
|
||||
.with_context(|| format!("read_dir {path} failed"))?;
|
||||
|
||||
let mut entries = raw
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
let name = entry.file_name();
|
||||
name != "." && name != ".."
|
||||
})
|
||||
.map(|entry| {
|
||||
let name = entry.file_name().to_string();
|
||||
let full_path = join_remote(path, &name);
|
||||
let meta = entry.metadata();
|
||||
let permissions = meta.permissions.unwrap_or(0);
|
||||
let is_dir = (permissions & 0o170_000) == 0o040_000;
|
||||
let size = meta.size.unwrap_or(0);
|
||||
let modified = meta.mtime.unwrap_or(0);
|
||||
RemoteEntry {
|
||||
name,
|
||||
full_path,
|
||||
is_dir,
|
||||
size,
|
||||
modified,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
async fn preview_impl(sftp: &SftpSession, path: &str) -> Result<PreviewData> {
|
||||
let metadata = sftp.metadata(path).await.with_context(|| format!("metadata {path}"))?;
|
||||
let is_dir = metadata
|
||||
.permissions
|
||||
.map(|mode| (mode & 0o170_000) == 0o040_000)
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_dir {
|
||||
let entries = list_dir_impl(sftp, path).await?;
|
||||
let mut lines = vec![format!("Directory: {path}"), String::new()];
|
||||
for entry in entries.into_iter().take(200) {
|
||||
let kind = if entry.is_dir { "dir " } else { "file" };
|
||||
lines.push(format!("{kind} {}", entry.name));
|
||||
}
|
||||
return Ok(PreviewData {
|
||||
path: path.to_string(),
|
||||
title: base_name(path),
|
||||
body: lines.join("\n"),
|
||||
is_binary: false,
|
||||
});
|
||||
}
|
||||
|
||||
let mut remote_file = sftp.open(path).await.with_context(|| format!("open remote {path}"))?;
|
||||
let mut buffer = vec![0u8; 128 * 1024];
|
||||
let read = remote_file.read(&mut buffer).await.context("read preview bytes")?;
|
||||
buffer.truncate(read);
|
||||
|
||||
let nul_ratio = if buffer.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
buffer.iter().filter(|byte| **byte == 0).count() as f32 / buffer.len() as f32
|
||||
};
|
||||
let is_binary = nul_ratio > 0.01;
|
||||
let body = if is_binary {
|
||||
format!(
|
||||
"Binary file\npath: {path}\nsize: {}\npreview: unavailable in-app",
|
||||
format_bytes(metadata.size.unwrap_or(0)),
|
||||
)
|
||||
} else {
|
||||
String::from_utf8_lossy(&buffer).into_owned()
|
||||
};
|
||||
|
||||
Ok(PreviewData {
|
||||
path: path.to_string(),
|
||||
title: base_name(path),
|
||||
body,
|
||||
is_binary,
|
||||
})
|
||||
}
|
||||
|
||||
async fn download_path_impl(
|
||||
handle: &russh::client::Handle<SftpClientHandler>,
|
||||
sftp: &SftpSession,
|
||||
remote: &str,
|
||||
local_dir: &Path,
|
||||
) -> Result<String> {
|
||||
tokio::fs::create_dir_all(local_dir)
|
||||
.await
|
||||
.with_context(|| format!("create {}", local_dir.display()))?;
|
||||
|
||||
let metadata = sftp.metadata(remote).await.with_context(|| format!("metadata {remote}"))?;
|
||||
let is_dir = metadata
|
||||
.permissions
|
||||
.map(|mode| (mode & 0o170_000) == 0o040_000)
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_dir {
|
||||
let local_archive = local_dir.join(format!(
|
||||
".ashell-{}-{}.tar.gz",
|
||||
base_name(remote),
|
||||
Uuid::new_v4()
|
||||
));
|
||||
let extracted_to = download_remote_directory_archive(handle, sftp, remote, &local_archive).await?;
|
||||
return Ok(format!("downloaded folder to {}", extracted_to.display()));
|
||||
}
|
||||
|
||||
let local_path = local_dir.join(base_name(remote));
|
||||
download_file_impl(sftp, remote, &local_path).await?;
|
||||
Ok(format!("downloaded file to {}", local_path.display()))
|
||||
}
|
||||
|
||||
async fn download_dir_recursive(sftp: &SftpSession, remote_dir: &str, local_dir: &Path) -> Result<()> {
|
||||
tokio::fs::create_dir_all(local_dir)
|
||||
.await
|
||||
.with_context(|| format!("create {}", local_dir.display()))?;
|
||||
let entries = list_dir_impl(sftp, remote_dir).await?;
|
||||
for entry in entries {
|
||||
let local_path = local_dir.join(&entry.name);
|
||||
if entry.is_dir {
|
||||
Box::pin(download_dir_recursive(sftp, &entry.full_path, &local_path)).await?;
|
||||
} else {
|
||||
download_file_impl(sftp, &entry.full_path, &local_path).await?;
|
||||
let _ = maybe_extract_archive(&local_path).await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_remote_directory_archive(
|
||||
handle: &russh::client::Handle<SftpClientHandler>,
|
||||
sftp: &SftpSession,
|
||||
remote_dir: &str,
|
||||
local_archive: &Path,
|
||||
) -> Result<PathBuf> {
|
||||
let remote_archive = format!("/tmp/ashell-{}-{}.tar.gz", base_name(remote_dir), Uuid::new_v4());
|
||||
create_remote_archive(handle, remote_dir, &remote_archive).await?;
|
||||
let local_extract_root = local_archive
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join(base_name(remote_dir));
|
||||
|
||||
let archive_download = async {
|
||||
download_file_impl(sftp, &remote_archive, local_archive).await?;
|
||||
extract_archive_to(local_archive, local_archive.parent().unwrap_or_else(|| Path::new("."))).await?;
|
||||
tokio::fs::remove_file(local_archive)
|
||||
.await
|
||||
.with_context(|| format!("remove {}", local_archive.display()))?;
|
||||
Ok::<PathBuf, anyhow::Error>(local_extract_root)
|
||||
}
|
||||
.await;
|
||||
|
||||
let cleanup_result = remove_remote_path(handle, &remote_archive).await;
|
||||
|
||||
let extracted_to = archive_download?;
|
||||
if let Err(err) = cleanup_result {
|
||||
tracing::warn!("failed to clean remote archive {remote_archive}: {err:#}");
|
||||
}
|
||||
|
||||
Ok(extracted_to)
|
||||
}
|
||||
|
||||
async fn download_file_impl(sftp: &SftpSession, remote: &str, local: &Path) -> Result<()> {
|
||||
let mut remote_file = sftp.open(remote).await.with_context(|| format!("open remote {remote}"))?;
|
||||
let mut local_file = tokio::fs::File::create(local)
|
||||
.await
|
||||
.with_context(|| format!("create local {}", local.display()))?;
|
||||
|
||||
let mut buffer = vec![0u8; 64 * 1024];
|
||||
loop {
|
||||
let read = remote_file.read(&mut buffer).await.context("read remote file")?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
local_file
|
||||
.write_all(&buffer[..read])
|
||||
.await
|
||||
.with_context(|| format!("write {}", local.display()))?;
|
||||
}
|
||||
local_file.flush().await.context("flush local file")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_paths_impl(sftp: &SftpSession, locals: &[String], remote_dir: &str) -> Result<String> {
|
||||
create_remote_dir_all(sftp, remote_dir).await?;
|
||||
let mut file_count = 0usize;
|
||||
let mut folder_count = 0usize;
|
||||
for local in locals {
|
||||
let path = PathBuf::from(local);
|
||||
if path.is_dir() {
|
||||
upload_directory_impl(sftp, &path, remote_dir).await?;
|
||||
folder_count += 1;
|
||||
} else {
|
||||
upload_file_to_dir_impl(sftp, &path, remote_dir).await?;
|
||||
file_count += 1;
|
||||
}
|
||||
}
|
||||
let summary = match (file_count, folder_count) {
|
||||
(1, 0) => "uploaded file".to_string(),
|
||||
(0, 1) => "uploaded folder".to_string(),
|
||||
(files, 0) => format!("uploaded {files} files"),
|
||||
(0, folders) => format!("uploaded {folders} folders"),
|
||||
(files, folders) => format!("uploaded {files} files and {folders} folders"),
|
||||
};
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
async fn upload_directory_impl(sftp: &SftpSession, local_dir: &Path, remote_parent: &str) -> Result<()> {
|
||||
let root_name = local_dir
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| anyhow!("invalid folder name: {}", local_dir.display()))?;
|
||||
let remote_root = join_remote(remote_parent, root_name);
|
||||
create_remote_dir_all(sftp, &remote_root).await?;
|
||||
|
||||
for entry in WalkDir::new(local_dir) {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path == local_dir {
|
||||
continue;
|
||||
}
|
||||
let relative = path.strip_prefix(local_dir)?;
|
||||
let remote_path = if relative.as_os_str().is_empty() {
|
||||
remote_root.clone()
|
||||
} else {
|
||||
let rel = relative
|
||||
.components()
|
||||
.map(|component| component.as_os_str().to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
join_remote(&remote_root, &rel)
|
||||
};
|
||||
if path.is_dir() {
|
||||
create_remote_dir_all(sftp, &remote_path).await?;
|
||||
} else {
|
||||
if let Some(parent) = Path::new(&remote_path).parent() {
|
||||
let parent_remote = parent.to_string_lossy().replace('\\', "/");
|
||||
create_remote_dir_all(sftp, &parent_remote).await?;
|
||||
}
|
||||
upload_file_impl(sftp, path, &remote_path).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_file_to_dir_impl(sftp: &SftpSession, local_file: &Path, remote_dir: &str) -> Result<()> {
|
||||
let file_name = local_file
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| anyhow!("invalid file name: {}", local_file.display()))?;
|
||||
let remote_path = join_remote(remote_dir, file_name);
|
||||
upload_file_impl(sftp, local_file, &remote_path).await
|
||||
}
|
||||
|
||||
async fn upload_file_impl(sftp: &SftpSession, local_file: &Path, remote_path: &str) -> Result<()> {
|
||||
let mut local = tokio::fs::File::open(local_file)
|
||||
.await
|
||||
.with_context(|| format!("open local {}", local_file.display()))?;
|
||||
let mut remote = sftp
|
||||
.create(remote_path)
|
||||
.await
|
||||
.with_context(|| format!("create remote {remote_path}"))?;
|
||||
|
||||
let mut buffer = vec![0u8; 64 * 1024];
|
||||
loop {
|
||||
let read = local.read(&mut buffer).await.context("read local file")?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
remote
|
||||
.write_all(&buffer[..read])
|
||||
.await
|
||||
.with_context(|| format!("write remote {remote_path}"))?;
|
||||
}
|
||||
remote.flush().await.context("flush remote file")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_remote_dir_all(sftp: &SftpSession, remote_dir: &str) -> Result<()> {
|
||||
if remote_dir.is_empty() || remote_dir == "/" {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut current = String::from("/");
|
||||
for segment in remote_dir.split('/').filter(|segment| !segment.is_empty()) {
|
||||
current = join_remote(¤t, segment);
|
||||
let _ = sftp.create_dir(¤t).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_remote_archive(
|
||||
handle: &russh::client::Handle<SftpClientHandler>,
|
||||
remote_dir: &str,
|
||||
remote_archive: &str,
|
||||
) -> Result<()> {
|
||||
let remote_dir = remote_dir.trim_end_matches('/');
|
||||
let parent = remote_parent(remote_dir);
|
||||
let name = base_name(remote_dir);
|
||||
let command = format!(
|
||||
"tar -C {} -czf {} {}",
|
||||
shell_quote(&parent),
|
||||
shell_quote(remote_archive),
|
||||
shell_quote(&name),
|
||||
);
|
||||
exec_remote_command(handle, &command)
|
||||
.await
|
||||
.with_context(|| format!("archive remote directory {remote_dir}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_remote_path(
|
||||
handle: &russh::client::Handle<SftpClientHandler>,
|
||||
remote_path: &str,
|
||||
) -> Result<()> {
|
||||
let command = format!("rm -f {}", shell_quote(remote_path));
|
||||
exec_remote_command(handle, &command)
|
||||
.await
|
||||
.with_context(|| format!("remove remote temporary file {remote_path}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn exec_remote_command(
|
||||
handle: &russh::client::Handle<SftpClientHandler>,
|
||||
command: &str,
|
||||
) -> Result<()> {
|
||||
let mut channel = handle
|
||||
.channel_open_session()
|
||||
.await
|
||||
.context("open remote exec session")?;
|
||||
channel
|
||||
.exec(true, command)
|
||||
.await
|
||||
.with_context(|| format!("exec remote command: {command}"))?;
|
||||
|
||||
let mut stderr = Vec::new();
|
||||
let mut stdout = Vec::new();
|
||||
let mut exit_status = None;
|
||||
while let Some(msg) = channel.wait().await {
|
||||
match msg {
|
||||
russh::ChannelMsg::Data { data } => stdout.extend_from_slice(&data),
|
||||
russh::ChannelMsg::ExtendedData { data, .. } => stderr.extend_from_slice(&data),
|
||||
russh::ChannelMsg::ExitStatus { exit_status: code } => exit_status = Some(code),
|
||||
russh::ChannelMsg::Close => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
match exit_status.unwrap_or(0) {
|
||||
0 => Ok(()),
|
||||
code => {
|
||||
let stderr = String::from_utf8_lossy(&stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&stdout).trim().to_string();
|
||||
Err(anyhow!(
|
||||
"remote command exited with {code}: {}",
|
||||
if !stderr.is_empty() { stderr } else { stdout }
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_parent(path: &str) -> String {
|
||||
if path == "/" {
|
||||
"/".to_string()
|
||||
} else {
|
||||
path.rsplit_once('/')
|
||||
.map(|(parent, _)| {
|
||||
if parent.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
parent.to_string()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "/".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
||||
}
|
||||
|
||||
async fn maybe_extract_archive(path: &Path) -> Result<Option<PathBuf>> {
|
||||
let Some(file_name) = path.file_name().and_then(|name| name.to_str()).map(|name| name.to_string()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let is_archive = [".zip", ".tar", ".tar.gz", ".tgz"]
|
||||
.iter()
|
||||
.any(|suffix| file_name.ends_with(suffix));
|
||||
if !is_archive {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let extract_root = path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join(strip_archive_suffix(&file_name));
|
||||
let archive_path = path.to_path_buf();
|
||||
let target_dir = extract_root.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
fs::create_dir_all(&target_dir)
|
||||
.with_context(|| format!("create {}", target_dir.display()))?;
|
||||
|
||||
if file_name.ends_with(".zip") {
|
||||
let file = fs::File::open(&archive_path)
|
||||
.with_context(|| format!("open {}", archive_path.display()))?;
|
||||
let mut zip = ZipArchive::new(file).context("read zip archive")?;
|
||||
for index in 0..zip.len() {
|
||||
let mut entry = zip.by_index(index).context("read zip entry")?;
|
||||
let Some(name) = entry.enclosed_name().map(|name| name.to_path_buf()) else {
|
||||
continue;
|
||||
};
|
||||
let output = target_dir.join(name);
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&output)?;
|
||||
} else {
|
||||
if let Some(parent) = output.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut output_file = fs::File::create(&output)?;
|
||||
std::io::copy(&mut entry, &mut output_file)?;
|
||||
}
|
||||
}
|
||||
} else if file_name.ends_with(".tar.gz") || file_name.ends_with(".tgz") {
|
||||
let file = fs::File::open(&archive_path)
|
||||
.with_context(|| format!("open {}", archive_path.display()))?;
|
||||
let decoder = GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive.unpack(&target_dir).context("unpack tar.gz archive")?;
|
||||
} else if file_name.ends_with(".tar") {
|
||||
let file = fs::File::open(&archive_path)
|
||||
.with_context(|| format!("open {}", archive_path.display()))?;
|
||||
let mut archive = tar::Archive::new(file);
|
||||
archive.unpack(&target_dir).context("unpack tar archive")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.context("extract archive task join failure")??;
|
||||
|
||||
Ok(Some(extract_root))
|
||||
}
|
||||
|
||||
async fn extract_archive_to(path: &Path, target_dir: &Path) -> Result<()> {
|
||||
let Some(file_name) = path.file_name().and_then(|name| name.to_str()).map(|name| name.to_string()) else {
|
||||
return Ok(());
|
||||
};
|
||||
let archive_path = path.to_path_buf();
|
||||
let target_dir = target_dir.to_path_buf();
|
||||
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
fs::create_dir_all(&target_dir)
|
||||
.with_context(|| format!("create {}", target_dir.display()))?;
|
||||
|
||||
if file_name.ends_with(".zip") {
|
||||
let file = fs::File::open(&archive_path)
|
||||
.with_context(|| format!("open {}", archive_path.display()))?;
|
||||
let mut zip = ZipArchive::new(file).context("read zip archive")?;
|
||||
for index in 0..zip.len() {
|
||||
let mut entry = zip.by_index(index).context("read zip entry")?;
|
||||
let Some(name) = entry.enclosed_name().map(|name| name.to_path_buf()) else {
|
||||
continue;
|
||||
};
|
||||
let output = target_dir.join(name);
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&output)?;
|
||||
} else {
|
||||
if let Some(parent) = output.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut output_file = fs::File::create(&output)?;
|
||||
std::io::copy(&mut entry, &mut output_file)?;
|
||||
}
|
||||
}
|
||||
} else if file_name.ends_with(".tar.gz") || file_name.ends_with(".tgz") {
|
||||
let file = fs::File::open(&archive_path)
|
||||
.with_context(|| format!("open {}", archive_path.display()))?;
|
||||
let decoder = GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive.unpack(&target_dir).context("unpack tar.gz archive")?;
|
||||
} else if file_name.ends_with(".tar") {
|
||||
let file = fs::File::open(&archive_path)
|
||||
.with_context(|| format!("open {}", archive_path.display()))?;
|
||||
let mut archive = tar::Archive::new(file);
|
||||
archive.unpack(&target_dir).context("unpack tar archive")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.context("extract archive task join failure")??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct SftpClientHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl Handler for SftpClientHandler {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
async fn check_server_key(
|
||||
&mut self,
|
||||
_server_public_key: &russh::keys::ssh_key::PublicKey,
|
||||
) -> Result<bool, Self::Error> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use directories::BaseDirs;
|
||||
use russh::{
|
||||
client::{self, Handler},
|
||||
keys::{decode_secret_key, key::PrivateKeyWithHashAlg, load_secret_key, HashAlg, PrivateKey},
|
||||
ChannelMsg, Disconnect,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::{
|
||||
config::{AuthMethod, Session},
|
||||
system::{remote_snapshot_from_kv, SystemSnapshot},
|
||||
terminal::{BackendCommand, BackendEvent, BackendTx},
|
||||
};
|
||||
|
||||
pub fn spawn_ssh_terminal(
|
||||
runtime: &tokio::runtime::Handle,
|
||||
tab_id: String,
|
||||
session: Session,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
events: std::sync::mpsc::Sender<BackendEvent>,
|
||||
) -> BackendTx {
|
||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<BackendCommand>();
|
||||
let task_tab = tab_id.clone();
|
||||
runtime.spawn(async move {
|
||||
if let Err(err) = run_ssh(
|
||||
task_tab.clone(),
|
||||
session,
|
||||
cols,
|
||||
rows,
|
||||
cmd_rx,
|
||||
events.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = events.send(BackendEvent::Closed {
|
||||
tab_id: task_tab,
|
||||
reason: format!("{err:#}"),
|
||||
});
|
||||
}
|
||||
});
|
||||
BackendTx::Ssh(cmd_tx)
|
||||
}
|
||||
|
||||
pub async fn sample_remote_system(session: Session) -> Result<SystemSnapshot> {
|
||||
let (events_tx, _events_rx) = std::sync::mpsc::channel();
|
||||
let handle = connect_and_authenticate("remote-metrics", &session, &events_tx).await?;
|
||||
let mut channel = handle
|
||||
.channel_open_session()
|
||||
.await
|
||||
.context("open metrics session")?;
|
||||
channel
|
||||
.exec(true, REMOTE_SYSTEM_PROBE)
|
||||
.await
|
||||
.context("exec remote metrics probe")?;
|
||||
|
||||
let mut stdout = Vec::new();
|
||||
while let Some(msg) = channel.wait().await {
|
||||
match msg {
|
||||
ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, ext: _ } => {
|
||||
stdout.extend_from_slice(&data);
|
||||
}
|
||||
ChannelMsg::Close => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = handle
|
||||
.disconnect(Disconnect::ByApplication, "metrics done", "")
|
||||
.await;
|
||||
let output = String::from_utf8_lossy(&stdout);
|
||||
remote_snapshot_from_kv(&output)
|
||||
}
|
||||
|
||||
async fn run_ssh(
|
||||
tab_id: String,
|
||||
session: Session,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
mut commands: mpsc::UnboundedReceiver<BackendCommand>,
|
||||
events: std::sync::mpsc::Sender<BackendEvent>,
|
||||
) -> Result<()> {
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.clone(),
|
||||
text: format!(
|
||||
"connecting {}@{}:{}...",
|
||||
session.user, session.host, session.port
|
||||
),
|
||||
});
|
||||
|
||||
let handle = connect_and_authenticate(&tab_id, &session, &events).await?;
|
||||
|
||||
let mut channel = handle
|
||||
.channel_open_session()
|
||||
.await
|
||||
.context("open session")?;
|
||||
channel
|
||||
.request_pty(true, "xterm-256color", cols.into(), rows.into(), 0, 0, &[])
|
||||
.await
|
||||
.context("request pty")?;
|
||||
channel.request_shell(true).await.context("request shell")?;
|
||||
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.clone(),
|
||||
text: format!("connected {}@{}", session.user, session.host),
|
||||
});
|
||||
let _ = events.send(BackendEvent::Connected {
|
||||
tab_id: tab_id.clone(),
|
||||
});
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
command = commands.recv() => {
|
||||
match command {
|
||||
Some(BackendCommand::Input(bytes)) => {
|
||||
if let Err(err) = channel.data(bytes.as_slice()).await {
|
||||
let _ = events.send(BackendEvent::Closed {
|
||||
tab_id: tab_id.clone(),
|
||||
reason: format!("ssh write error: {err}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(BackendCommand::Resize { cols, rows }) => {
|
||||
let _ = channel.window_change(cols.into(), rows.into(), 0, 0).await;
|
||||
}
|
||||
Some(BackendCommand::Close) | None => {
|
||||
let _ = channel.eof().await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
msg = channel.wait() => {
|
||||
match msg {
|
||||
Some(ChannelMsg::Data { data }) | Some(ChannelMsg::ExtendedData { data, ext: _ }) => {
|
||||
let _ = events.send(BackendEvent::Output {
|
||||
tab_id: tab_id.clone(),
|
||||
bytes: data.to_vec(),
|
||||
});
|
||||
}
|
||||
Some(ChannelMsg::Close) | None => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = handle
|
||||
.disconnect(Disconnect::ByApplication, "bye", "")
|
||||
.await;
|
||||
let _ = events.send(BackendEvent::Closed {
|
||||
tab_id,
|
||||
reason: "ssh session closed".into(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn connect_and_authenticate(
|
||||
tab_id: &str,
|
||||
session: &Session,
|
||||
events: &std::sync::mpsc::Sender<BackendEvent>,
|
||||
) -> Result<russh::client::Handle<ClientHandler>> {
|
||||
let config = Arc::new(client::Config {
|
||||
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
|
||||
..Default::default()
|
||||
});
|
||||
let addr = format!("{}:{}", session.host, session.port);
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!("opening tcp connection to {addr}"),
|
||||
});
|
||||
let mut handle = client::connect(config, addr.as_str(), ClientHandler)
|
||||
.await
|
||||
.with_context(|| format!("connect {addr} failed"))?;
|
||||
|
||||
let authed = match session.auth {
|
||||
AuthMethod::Password => {
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!(
|
||||
"connected to {addr}, sending password authentication for {}",
|
||||
session.user
|
||||
),
|
||||
});
|
||||
handle
|
||||
.authenticate_password(&session.user, &session.password)
|
||||
.await
|
||||
.context("password authentication failed")?
|
||||
}
|
||||
AuthMethod::Key => {
|
||||
let source = key_source_label(session);
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!("connected to {addr}, loading private key from {source}"),
|
||||
});
|
||||
let keypair = load_session_private_key(session)?;
|
||||
let algorithm = format!("{:?}", keypair.algorithm());
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!("private key loaded from {source}, algorithm {algorithm}, sending public key authentication for {}", session.user),
|
||||
});
|
||||
let key = private_key_with_alg(keypair).context("invalid private key")?;
|
||||
handle
|
||||
.authenticate_publickey(&session.user, key)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"public key authentication failed for {}@{}:{} using {} ({})",
|
||||
session.user, session.host, session.port, source, algorithm
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
if !authed {
|
||||
let _ = handle
|
||||
.disconnect(Disconnect::ByApplication, "auth failed", "")
|
||||
.await;
|
||||
return Err(anyhow!(
|
||||
"{}",
|
||||
match session.auth {
|
||||
AuthMethod::Password => format!(
|
||||
"authentication failed: server rejected password authentication for {}@{}:{}",
|
||||
session.user, session.host, session.port
|
||||
),
|
||||
AuthMethod::Key => format!(
|
||||
"authentication failed: server rejected public key authentication for {}@{}:{} using {}",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port,
|
||||
key_source_label(session)
|
||||
),
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!(
|
||||
"authentication accepted, opening shell for {}@{}",
|
||||
session.user, session.host
|
||||
),
|
||||
});
|
||||
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
|
||||
let inline_key = normalize_inline_private_key(&session.private_key_inline);
|
||||
let key_path = expand_key_path(session.private_key_path.trim());
|
||||
let has_inline = !inline_key.is_empty();
|
||||
let has_path = key_path.is_some();
|
||||
|
||||
if !has_inline && !has_path {
|
||||
return Err(anyhow!("private key content or path is required"));
|
||||
}
|
||||
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if has_inline {
|
||||
match decode_secret_key(&inline_key, None) {
|
||||
Ok(key) => return Ok(key),
|
||||
Err(err) => errors.push(format!("decode private key content: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(path) = key_path {
|
||||
match load_secret_key(path.as_path(), None) {
|
||||
Ok(key) => return Ok(key),
|
||||
Err(err) => errors.push(format!("load key {}: {err}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(errors.join("; ")))
|
||||
}
|
||||
|
||||
fn private_key_with_alg(keypair: PrivateKey) -> Result<PrivateKeyWithHashAlg> {
|
||||
let hash_alg = if keypair.algorithm().is_rsa() {
|
||||
Some(HashAlg::Sha512)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(
|
||||
PrivateKeyWithHashAlg::new(Arc::new(keypair.clone()), hash_alg)
|
||||
.or_else(|_| PrivateKeyWithHashAlg::new(Arc::new(keypair), Some(HashAlg::Sha256)))?,
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_inline_private_key(value: &str) -> String {
|
||||
let mut normalized = value
|
||||
.trim()
|
||||
.replace("\\r\\n", "\n")
|
||||
.replace("\\n", "\n")
|
||||
.replace("\r\n", "\n");
|
||||
if !normalized.ends_with('\n') {
|
||||
normalized.push('\n');
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn expand_key_path(value: &str) -> Option<PathBuf> {
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if value == "~" {
|
||||
return BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf());
|
||||
}
|
||||
if let Some(rest) = value.strip_prefix("~/") {
|
||||
return BaseDirs::new().map(|dirs| dirs.home_dir().join(rest));
|
||||
}
|
||||
Some(Path::new(value).to_path_buf())
|
||||
}
|
||||
|
||||
fn key_source_label(session: &Session) -> String {
|
||||
let path = session.private_key_path.trim();
|
||||
let has_inline = !session.private_key_inline.trim().is_empty();
|
||||
match (!path.is_empty(), has_inline) {
|
||||
(true, true) => format!("inline key or {}", path),
|
||||
(true, false) => path.to_string(),
|
||||
(false, true) => "inline key text".to_string(),
|
||||
(false, false) => "unknown key source".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
const REMOTE_SYSTEM_PROBE: &str = r#"sh -lc '
|
||||
os=$(uname -s 2>/dev/null || echo unknown)
|
||||
|
||||
if [ "$os" = "Linux" ] && [ -r /proc/stat ]; then
|
||||
cpu_stat() { awk '"'"'/^cpu / { print ($2+$3+$4+$5+$6+$7+$8), $5 }'"'"' /proc/stat 2>/dev/null; }
|
||||
net_stat() { awk -F"[: ]+" '"'"'/:/ && $1!="Inter" && $1!="face" { rx += $3; tx += $11 } END { print rx+0, tx+0 }'"'"' /proc/net/dev 2>/dev/null; }
|
||||
|
||||
read cpu_total_1 cpu_idle_1 <<EOF
|
||||
$(cpu_stat)
|
||||
EOF
|
||||
read net_rx_1 net_tx_1 <<EOF
|
||||
$(net_stat)
|
||||
EOF
|
||||
sleep 1
|
||||
read cpu_total_2 cpu_idle_2 <<EOF
|
||||
$(cpu_stat)
|
||||
EOF
|
||||
read net_rx_2 net_tx_2 <<EOF
|
||||
$(net_stat)
|
||||
EOF
|
||||
|
||||
cpu_delta=$((cpu_total_2 - cpu_total_1))
|
||||
idle_delta=$((cpu_idle_2 - cpu_idle_1))
|
||||
cpu_percent=$(awk -v total="$cpu_delta" -v idle="$idle_delta" '"'"'BEGIN { if (total <= 0) print "0.00"; else printf "%.2f", ((total-idle)/total)*100 }'"'"')
|
||||
mem_total=$(awk '"'"'/^MemTotal:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
|
||||
mem_available=$(awk '"'"'/^MemAvailable:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
|
||||
swap_total=$(awk '"'"'/^SwapTotal:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
|
||||
swap_free=$(awk '"'"'/^SwapFree:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
|
||||
|
||||
echo "CPU_PERCENT=${cpu_percent:-0.00}"
|
||||
echo "MEM_TOTAL=${mem_total:-0}"
|
||||
echo "MEM_USED=$(( ${mem_total:-0} - ${mem_available:-0} ))"
|
||||
echo "SWAP_TOTAL=${swap_total:-0}"
|
||||
echo "SWAP_USED=$(( ${swap_total:-0} - ${swap_free:-0} ))"
|
||||
echo "NET_RX=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))"
|
||||
echo "NET_TX=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))"
|
||||
df -kP 2>/dev/null | awk "NR > 1 { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 4
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$os" = "Darwin" ]; then
|
||||
net_stat() { netstat -ibn 2>/dev/null | awk '"'"'NR > 1 && $7 ~ /^[0-9]+$/ && $10 ~ /^[0-9]+$/ { rx += $7; tx += $10 } END { print rx+0, tx+0 }'"'"'; }
|
||||
|
||||
read net_rx_1 net_tx_1 <<EOF
|
||||
$(net_stat)
|
||||
EOF
|
||||
sleep 1
|
||||
read net_rx_2 net_tx_2 <<EOF
|
||||
$(net_stat)
|
||||
EOF
|
||||
|
||||
cpu_percent=$(top -l 2 -n 0 -s 1 2>/dev/null | awk -F"[:,% ]+" '"'"'/CPU usage:/ { user=$3; sys=$5 } END { if (user == "" && sys == "") print "0.00"; else printf "%.2f", user + sys }'"'"')
|
||||
mem_total=$(sysctl -n hw.memsize 2>/dev/null || echo 0)
|
||||
pagesize=$(sysctl -n hw.pagesize 2>/dev/null || echo 4096)
|
||||
vm_output=$(vm_stat 2>/dev/null)
|
||||
pages_active=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages active/ { gsub("\\.","",$3); print $3+0 }'"'"')
|
||||
pages_wired=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages wired down/ { gsub("\\.","",$4); print $4+0 }'"'"')
|
||||
pages_compressed=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages occupied by compressor/ { gsub("\\.","",$5); print $5+0 }'"'"')
|
||||
pages_speculative=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages speculative/ { gsub("\\.","",$3); print $3+0 }'"'"')
|
||||
mem_used=$(( (${pages_active:-0} + ${pages_wired:-0} + ${pages_compressed:-0} + ${pages_speculative:-0}) * ${pagesize:-4096} ))
|
||||
swap_line=$(sysctl vm.swapusage 2>/dev/null || true)
|
||||
swap_used=$(printf "%s\n" "$swap_line" | awk -F"[= ,]+" '"'"'
|
||||
function mult(unit) { return unit=="K"?1024:(unit=="M"?1048576:(unit=="G"?1073741824:(unit=="T"?1099511627776:1))) }
|
||||
/used/ { value=$4; unit=substr(value, length(value), 1); sub(/[A-Za-z]+$/, "", value); printf "%.0f", value * mult(unit) }'"'"')
|
||||
swap_total=$(printf "%s\n" "$swap_line" | awk -F"[= ,]+" '"'"'
|
||||
function mult(unit) { return unit=="K"?1024:(unit=="M"?1048576:(unit=="G"?1073741824:(unit=="T"?1099511627776:1))) }
|
||||
/used/ && /free/ { used=$4; free=$8; unit1=substr(used, length(used), 1); unit2=substr(free, length(free), 1); sub(/[A-Za-z]+$/, "", used); sub(/[A-Za-z]+$/, "", free); printf "%.0f", (used * mult(unit1)) + (free * mult(unit2)) }'"'"')
|
||||
|
||||
echo "CPU_PERCENT=${cpu_percent:-0.00}"
|
||||
echo "MEM_TOTAL=${mem_total:-0}"
|
||||
echo "MEM_USED=${mem_used:-0}"
|
||||
echo "SWAP_TOTAL=${swap_total:-0}"
|
||||
echo "SWAP_USED=${swap_used:-0}"
|
||||
echo "NET_RX=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))"
|
||||
echo "NET_TX=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))"
|
||||
df -kP 2>/dev/null | awk "NR > 1 { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 4
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "CPU_PERCENT=0.00"
|
||||
echo "MEM_TOTAL=0"
|
||||
echo "MEM_USED=0"
|
||||
echo "SWAP_TOTAL=0"
|
||||
echo "SWAP_USED=0"
|
||||
echo "NET_RX=0"
|
||||
echo "NET_TX=0"
|
||||
'"#;
|
||||
|
||||
struct ClientHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl Handler for ClientHandler {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
async fn check_server_key(
|
||||
&mut self,
|
||||
_server_public_key: &russh::keys::ssh_key::PublicKey,
|
||||
) -> Result<bool, Self::Error> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
use std::{collections::BTreeMap, time::{Duration, Instant}};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use sysinfo::{Disks, Networks, System};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DiskSample {
|
||||
pub mount: String,
|
||||
pub available_bytes: u64,
|
||||
pub total_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SystemSnapshot {
|
||||
pub cpu_percent: f32,
|
||||
pub mem_percent: f32,
|
||||
pub swap_percent: f32,
|
||||
pub mem_detail: String,
|
||||
pub swap_detail: String,
|
||||
pub net_rx: String,
|
||||
pub net_tx: String,
|
||||
pub disks: Vec<DiskSample>,
|
||||
}
|
||||
|
||||
pub struct SystemSampler {
|
||||
sys: System,
|
||||
nets: Networks,
|
||||
disks: Disks,
|
||||
last_rx_total: u64,
|
||||
last_tx_total: u64,
|
||||
last_instant: Instant,
|
||||
}
|
||||
|
||||
impl SystemSampler {
|
||||
pub fn new() -> Self {
|
||||
let mut sys = System::new_all();
|
||||
sys.refresh_all();
|
||||
let nets = Networks::new_with_refreshed_list();
|
||||
let disks = Disks::new_with_refreshed_list();
|
||||
let last_rx_total = nets.iter().map(|(_, d)| d.total_received()).sum();
|
||||
let last_tx_total = nets.iter().map(|(_, d)| d.total_transmitted()).sum();
|
||||
|
||||
Self {
|
||||
sys,
|
||||
nets,
|
||||
disks,
|
||||
last_rx_total,
|
||||
last_tx_total,
|
||||
last_instant: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn interval() -> Duration {
|
||||
Duration::from_millis(1000)
|
||||
}
|
||||
|
||||
pub fn sample(&mut self) -> SystemSnapshot {
|
||||
self.sys.refresh_cpu_usage();
|
||||
self.sys.refresh_memory();
|
||||
self.nets.refresh(true);
|
||||
self.disks.refresh(true);
|
||||
|
||||
let cpu_percent = self.sys.global_cpu_usage() / 100.0;
|
||||
let mem_total = self.sys.total_memory();
|
||||
let mem_used = self.sys.used_memory();
|
||||
let swap_total = self.sys.total_swap();
|
||||
let swap_used = self.sys.used_swap();
|
||||
|
||||
let rx_total: u64 = self.nets.iter().map(|(_, d)| d.total_received()).sum();
|
||||
let tx_total: u64 = self.nets.iter().map(|(_, d)| d.total_transmitted()).sum();
|
||||
let now = Instant::now();
|
||||
let elapsed = now.duration_since(self.last_instant).as_secs_f64().max(0.001);
|
||||
let rx_rate = (rx_total.saturating_sub(self.last_rx_total) as f64 / elapsed) as u64;
|
||||
let tx_rate = (tx_total.saturating_sub(self.last_tx_total) as f64 / elapsed) as u64;
|
||||
self.last_rx_total = rx_total;
|
||||
self.last_tx_total = tx_total;
|
||||
self.last_instant = now;
|
||||
|
||||
let disks = self
|
||||
.disks
|
||||
.iter()
|
||||
.filter(|disk| disk.total_space() > 0)
|
||||
.take(4)
|
||||
.map(|disk| DiskSample {
|
||||
mount: disk.mount_point().to_string_lossy().to_string(),
|
||||
available_bytes: disk.available_space(),
|
||||
total_bytes: disk.total_space(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
SystemSnapshot {
|
||||
cpu_percent,
|
||||
mem_percent: ratio(mem_used, mem_total),
|
||||
swap_percent: ratio(swap_used, swap_total),
|
||||
mem_detail: format!(
|
||||
"{}/{}",
|
||||
format_bytes(mem_used),
|
||||
format_bytes(mem_total)
|
||||
),
|
||||
swap_detail: format!(
|
||||
"{}/{}",
|
||||
format_bytes(swap_used),
|
||||
format_bytes(swap_total)
|
||||
),
|
||||
net_rx: format!("{}/s", format_bytes(rx_rate)),
|
||||
net_tx: format!("{}/s", format_bytes(tx_rate)),
|
||||
disks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ratio(used: u64, total: u64) -> f32 {
|
||||
if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(used as f32 / total as f32).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_bytes(bytes: u64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
|
||||
let mut value = bytes as f64;
|
||||
let mut unit = 0;
|
||||
while value >= 1024.0 && unit < UNITS.len() - 1 {
|
||||
value /= 1024.0;
|
||||
unit += 1;
|
||||
}
|
||||
if unit == 0 {
|
||||
format!("{bytes} {}", UNITS[unit])
|
||||
} else {
|
||||
format!("{value:.1} {}", UNITS[unit])
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
|
||||
let mut kv = BTreeMap::new();
|
||||
let mut disks = Vec::new();
|
||||
|
||||
for line in raw.lines().map(str::trim).filter(|line| !line.is_empty()) {
|
||||
if let Some(rest) = line.strip_prefix("DISK=") {
|
||||
let mut parts = rest.split('\t');
|
||||
let mount = parts.next().unwrap_or_default().to_string();
|
||||
let available_bytes = parts
|
||||
.next()
|
||||
.unwrap_or("0")
|
||||
.parse::<u64>()
|
||||
.unwrap_or_default();
|
||||
let total_bytes = parts
|
||||
.next()
|
||||
.unwrap_or("0")
|
||||
.parse::<u64>()
|
||||
.unwrap_or_default();
|
||||
disks.push(DiskSample {
|
||||
mount,
|
||||
available_bytes,
|
||||
total_bytes,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
kv.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
|
||||
let cpu_percent = kv
|
||||
.get("CPU_PERCENT")
|
||||
.ok_or_else(|| anyhow!("missing CPU_PERCENT"))?
|
||||
.parse::<f32>()
|
||||
.unwrap_or_default()
|
||||
/ 100.0;
|
||||
|
||||
let mem_used = parse_u64(&kv, "MEM_USED");
|
||||
let mem_total = parse_u64(&kv, "MEM_TOTAL");
|
||||
let swap_used = parse_u64(&kv, "SWAP_USED");
|
||||
let swap_total = parse_u64(&kv, "SWAP_TOTAL");
|
||||
let rx_rate = parse_u64(&kv, "NET_RX");
|
||||
let tx_rate = parse_u64(&kv, "NET_TX");
|
||||
|
||||
Ok(SystemSnapshot {
|
||||
cpu_percent: cpu_percent.clamp(0.0, 1.0),
|
||||
mem_percent: ratio(mem_used, mem_total),
|
||||
swap_percent: ratio(swap_used, swap_total),
|
||||
mem_detail: format!("{}/{}", format_bytes(mem_used), format_bytes(mem_total)),
|
||||
swap_detail: format!("{}/{}", format_bytes(swap_used), format_bytes(swap_total)),
|
||||
net_rx: format!("{}/s", format_bytes(rx_rate)),
|
||||
net_tx: format!("{}/s", format_bytes(tx_rate)),
|
||||
disks,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_u64(kv: &BTreeMap<String, String>, key: &str) -> u64 {
|
||||
kv.get(key)
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
+557
@@ -0,0 +1,557 @@
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use alacritty_terminal::{
|
||||
event::{Event, EventListener},
|
||||
grid::{Dimensions, Scroll},
|
||||
index::{Column, Point, Side},
|
||||
selection::{Selection, SelectionRange, SelectionType},
|
||||
term::{
|
||||
cell::Cell,
|
||||
point_to_viewport, viewport_to_point, Config, Term, TermMode,
|
||||
},
|
||||
vte::ansi::{CursorShape, Processor},
|
||||
};
|
||||
use gpui::Keystroke;
|
||||
|
||||
use crate::config::Session;
|
||||
use crate::sftp::{PreviewData, RemoteEntry};
|
||||
use crate::system::SystemSnapshot;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TabKind {
|
||||
Local,
|
||||
Ssh,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BackendCommand {
|
||||
Input(Vec<u8>),
|
||||
Resize { cols: u16, rows: u16 },
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BackendEvent {
|
||||
Output { tab_id: String, bytes: Vec<u8> },
|
||||
Status { tab_id: String, text: String },
|
||||
Connected { tab_id: String },
|
||||
SftpEntries { tab_id: String, path: String, entries: Vec<RemoteEntry> },
|
||||
SftpPreview { tab_id: String, preview: PreviewData },
|
||||
SftpStatus { tab_id: String, text: String },
|
||||
RemoteSystem { tab_id: String, snapshot: SystemSnapshot },
|
||||
RemoteSystemUnavailable { tab_id: String, reason: String },
|
||||
Closed { tab_id: String, reason: String },
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum BackendTx {
|
||||
Local(Sender<BackendCommand>),
|
||||
Ssh(tokio::sync::mpsc::UnboundedSender<BackendCommand>),
|
||||
}
|
||||
|
||||
impl BackendTx {
|
||||
pub fn send(&self, command: BackendCommand) {
|
||||
match self {
|
||||
Self::Local(tx) => {
|
||||
let _ = tx.send(command);
|
||||
}
|
||||
Self::Ssh(tx) => {
|
||||
let _ = tx.send(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TerminalTab {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub kind: TabKind,
|
||||
pub status: String,
|
||||
pub connected: bool,
|
||||
pub session: Option<Session>,
|
||||
pub sftp: Option<SftpUiState>,
|
||||
processor: Processor,
|
||||
term: Term<TerminalListener>,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
pub backend: BackendTx,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct CursorState {
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
pub shape: CursorShape,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RenderCell {
|
||||
pub row: i32,
|
||||
pub col: i32,
|
||||
pub cell: Cell,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RenderSnapshot {
|
||||
pub cells: Vec<RenderCell>,
|
||||
pub cursor: Option<CursorState>,
|
||||
pub selection: Option<ViewportSelection>,
|
||||
pub display_offset: usize,
|
||||
pub history_size: usize,
|
||||
pub rows: usize,
|
||||
pub cols: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ViewportSelection {
|
||||
pub start_row: usize,
|
||||
pub start_col: usize,
|
||||
pub end_row: usize,
|
||||
pub end_col: usize,
|
||||
pub is_block: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SftpUiState {
|
||||
pub current_path: String,
|
||||
pub status: String,
|
||||
pub entries: Vec<RemoteEntry>,
|
||||
pub selected_path: Option<String>,
|
||||
pub preview: Option<PreviewData>,
|
||||
}
|
||||
|
||||
impl TerminalTab {
|
||||
pub fn new_local(id: String, title: String, backend: BackendTx) -> Self {
|
||||
Self::new(id, title, TabKind::Local, "local shell".into(), backend)
|
||||
}
|
||||
|
||||
pub fn new_ssh(id: String, session: &Session, backend: BackendTx) -> Self {
|
||||
let mut tab = Self::new(
|
||||
id,
|
||||
session.name.clone(),
|
||||
TabKind::Ssh,
|
||||
format!("connecting {}@{}:{}", session.user, session.host, session.port),
|
||||
backend,
|
||||
);
|
||||
tab.session = Some(session.clone());
|
||||
tab.connected = false;
|
||||
tab.sftp = Some(SftpUiState {
|
||||
current_path: "/".into(),
|
||||
status: "sftp connecting...".into(),
|
||||
entries: Vec::new(),
|
||||
selected_path: None,
|
||||
preview: None,
|
||||
});
|
||||
tab
|
||||
}
|
||||
|
||||
fn new(id: String, title: String, kind: TabKind, status: String, backend: BackendTx) -> Self {
|
||||
Self {
|
||||
id,
|
||||
title,
|
||||
kind,
|
||||
status,
|
||||
connected: matches!(kind, TabKind::Local),
|
||||
session: None,
|
||||
sftp: None,
|
||||
processor: Processor::new(),
|
||||
term: new_term(100, 30, backend.clone()),
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
backend,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn feed(&mut self, bytes: &[u8]) {
|
||||
self.processor.advance(&mut self.term, bytes);
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, cols: u16, rows: u16) {
|
||||
self.cols = cols.max(1);
|
||||
self.rows = rows.max(1);
|
||||
self.term.resize(TerminalSize::new(self.cols, self.rows));
|
||||
self.backend.send(BackendCommand::Resize { cols, rows });
|
||||
}
|
||||
|
||||
pub fn cursor_state(&self) -> Option<CursorState> {
|
||||
let content = self.term.renderable_content();
|
||||
if matches!(content.cursor.shape, CursorShape::Hidden) || content.display_offset > 0 {
|
||||
return None;
|
||||
}
|
||||
let row = content.cursor.point.line.0;
|
||||
if row < 0 {
|
||||
return None;
|
||||
}
|
||||
let row = row as usize;
|
||||
if row >= self.rows as usize {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(CursorState {
|
||||
row,
|
||||
col: content.cursor.point.column.0,
|
||||
shape: content.cursor.shape,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn app_cursor_mode(&self) -> bool {
|
||||
self.term.mode().contains(TermMode::APP_CURSOR)
|
||||
}
|
||||
|
||||
pub fn render_snapshot(&self) -> RenderSnapshot {
|
||||
let rows = self.rows;
|
||||
let cols = self.cols;
|
||||
let content = self.term.renderable_content();
|
||||
let display_offset = content.display_offset as i32;
|
||||
let mut cells = Vec::with_capacity((rows as usize) * (cols as usize));
|
||||
|
||||
for indexed in content.display_iter {
|
||||
let line = indexed.point.line.0;
|
||||
let row = line + display_offset;
|
||||
if row < 0 {
|
||||
continue;
|
||||
}
|
||||
if row >= rows as i32 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let col = indexed.point.column.0 as i32;
|
||||
if col >= cols as i32 {
|
||||
continue;
|
||||
}
|
||||
|
||||
cells.push(RenderCell {
|
||||
row,
|
||||
col,
|
||||
cell: indexed.cell.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
RenderSnapshot {
|
||||
cells,
|
||||
cursor: self.cursor_state(),
|
||||
selection: viewport_selection_from_range(content.display_offset, &content.selection),
|
||||
display_offset: content.display_offset,
|
||||
history_size: self.term.grid().history_size(),
|
||||
rows: self.rows as usize,
|
||||
cols: self.cols as usize,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_history(&mut self, delta: i32) {
|
||||
if delta != 0 {
|
||||
self.term.scroll_display(Scroll::Delta(delta));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_up_by(&mut self, lines: usize) {
|
||||
if lines != 0 {
|
||||
self.term.scroll_display(Scroll::Delta(lines as i32));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_down_by(&mut self, lines: usize) {
|
||||
if lines != 0 {
|
||||
self.term.scroll_display(Scroll::Delta(-(lines as i32)));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_to_bottom(&mut self) {
|
||||
self.term.scroll_display(Scroll::Bottom);
|
||||
}
|
||||
|
||||
pub fn has_selection(&self) -> bool {
|
||||
self.term.selection_to_string().is_some_and(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
pub fn clear_selection(&mut self) {
|
||||
self.term.selection = None;
|
||||
}
|
||||
|
||||
pub fn selection_text(&self) -> Option<String> {
|
||||
self.term.selection_to_string().filter(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
pub fn begin_selection(
|
||||
&mut self,
|
||||
row: usize,
|
||||
col: usize,
|
||||
side: Side,
|
||||
selection_type: SelectionType,
|
||||
) {
|
||||
let point = viewport_to_point(
|
||||
self.term.grid().display_offset(),
|
||||
Point::new(row, Column(col)),
|
||||
);
|
||||
self.term.selection = Some(Selection::new(selection_type, point, side));
|
||||
}
|
||||
|
||||
pub fn update_selection(&mut self, row: usize, col: usize, side: Side) {
|
||||
let point = viewport_to_point(
|
||||
self.term.grid().display_offset(),
|
||||
Point::new(row, Column(col)),
|
||||
);
|
||||
if let Some(selection) = self.term.selection.as_mut() {
|
||||
selection.update(point, side);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paste_text(&mut self, text: &str) {
|
||||
let paste_text = text.replace('\x1b', "").replace("\r\n", "\r").replace('\n', "\r");
|
||||
|
||||
self.backend
|
||||
.send(BackendCommand::Input(paste_text.into_bytes()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn viewport_selection_from_range(
|
||||
display_offset: usize,
|
||||
selection: &Option<SelectionRange>,
|
||||
) -> Option<ViewportSelection> {
|
||||
let SelectionRange { start, end, is_block } = selection.as_ref().copied()?;
|
||||
let start = point_to_viewport(display_offset, start)?;
|
||||
let end = point_to_viewport(display_offset, end)?;
|
||||
|
||||
Some(ViewportSelection {
|
||||
start_row: start.line,
|
||||
start_col: start.column.0,
|
||||
end_row: end.line,
|
||||
end_col: end.column.0,
|
||||
is_block,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TerminalListener {
|
||||
backend: BackendTx,
|
||||
}
|
||||
|
||||
impl EventListener for TerminalListener {
|
||||
fn send_event(&self, event: Event) {
|
||||
match event {
|
||||
Event::PtyWrite(output) => self.backend.send(BackendCommand::Input(output.into_bytes())),
|
||||
Event::TextAreaSizeRequest(format) => {
|
||||
let size = alacritty_terminal::event::WindowSize {
|
||||
num_lines: 30,
|
||||
num_cols: 100,
|
||||
cell_width: 8,
|
||||
cell_height: 16,
|
||||
};
|
||||
self.backend
|
||||
.send(BackendCommand::Input(format(size).into_bytes()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_term(cols: u16, rows: u16, backend: BackendTx) -> Term<TerminalListener> {
|
||||
Term::new(
|
||||
Config {
|
||||
scrolling_history: 2000,
|
||||
..Config::default()
|
||||
},
|
||||
&TerminalSize::new(cols, rows),
|
||||
TerminalListener { backend },
|
||||
)
|
||||
}
|
||||
|
||||
struct TerminalSize {
|
||||
cols: usize,
|
||||
rows: usize,
|
||||
}
|
||||
|
||||
impl TerminalSize {
|
||||
fn new(cols: u16, rows: u16) -> Self {
|
||||
Self {
|
||||
cols: cols.max(1) as usize,
|
||||
rows: rows.max(1) as usize,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dimensions for TerminalSize {
|
||||
fn total_lines(&self) -> usize {
|
||||
self.rows
|
||||
}
|
||||
|
||||
fn screen_lines(&self) -> usize {
|
||||
self.rows
|
||||
}
|
||||
|
||||
fn columns(&self) -> usize {
|
||||
self.cols
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_key(keystroke: &Keystroke, app_cursor_mode: bool, option_as_meta: bool) -> Option<Vec<u8>> {
|
||||
zed_like_to_esc_str(keystroke, app_cursor_mode, option_as_meta)
|
||||
.map(|text| text.into_owned().into_bytes())
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum TerminalModifiers {
|
||||
None,
|
||||
Alt,
|
||||
Ctrl,
|
||||
Shift,
|
||||
CtrlShift,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl TerminalModifiers {
|
||||
fn new(ks: &Keystroke) -> Self {
|
||||
match (
|
||||
ks.modifiers.alt,
|
||||
ks.modifiers.control,
|
||||
ks.modifiers.shift,
|
||||
ks.modifiers.platform,
|
||||
) {
|
||||
(false, false, false, false) => Self::None,
|
||||
(true, false, false, false) => Self::Alt,
|
||||
(false, true, false, false) => Self::Ctrl,
|
||||
(false, false, true, false) => Self::Shift,
|
||||
(false, true, true, false) => Self::CtrlShift,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
|
||||
fn any(&self) -> bool {
|
||||
!matches!(self, Self::None)
|
||||
}
|
||||
}
|
||||
|
||||
fn zed_like_to_esc_str(
|
||||
keystroke: &Keystroke,
|
||||
app_cursor_mode: bool,
|
||||
option_as_meta: bool,
|
||||
) -> Option<std::borrow::Cow<'static, str>> {
|
||||
let modifiers = TerminalModifiers::new(keystroke);
|
||||
let key = keystroke.key.to_ascii_lowercase();
|
||||
|
||||
let manual_esc_str = match (key.as_str(), &modifiers) {
|
||||
("tab", TerminalModifiers::None) => Some("\x09"),
|
||||
("tab", TerminalModifiers::Shift) => Some("\x1b[Z"),
|
||||
("escape", TerminalModifiers::None) => Some("\x1b"),
|
||||
("enter", TerminalModifiers::None) => Some("\x0d"),
|
||||
("enter", TerminalModifiers::Shift) => Some("\x0a"),
|
||||
("enter", TerminalModifiers::Alt) => Some("\x1b\x0d"),
|
||||
("backspace", TerminalModifiers::None) => Some("\x7f"),
|
||||
("backspace", TerminalModifiers::Ctrl) => Some("\x08"),
|
||||
("backspace", TerminalModifiers::Alt) => Some("\x1b\x7f"),
|
||||
("backspace", TerminalModifiers::Shift) => Some("\x7f"),
|
||||
("space", TerminalModifiers::Ctrl) => Some("\x00"),
|
||||
("home", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOH"),
|
||||
("home", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[H"),
|
||||
("end", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOF"),
|
||||
("end", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[F"),
|
||||
("up", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOA"),
|
||||
("up", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[A"),
|
||||
("down", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOB"),
|
||||
("down", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[B"),
|
||||
("right", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOC"),
|
||||
("right", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[C"),
|
||||
("left", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOD"),
|
||||
("left", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[D"),
|
||||
("insert", TerminalModifiers::None) => Some("\x1b[2~"),
|
||||
("delete", TerminalModifiers::None) => Some("\x1b[3~"),
|
||||
("pageup", TerminalModifiers::None) => Some("\x1b[5~"),
|
||||
("pagedown", TerminalModifiers::None) => Some("\x1b[6~"),
|
||||
("a", TerminalModifiers::Ctrl) | ("A", TerminalModifiers::CtrlShift) => Some("\x01"),
|
||||
("b", TerminalModifiers::Ctrl) | ("B", TerminalModifiers::CtrlShift) => Some("\x02"),
|
||||
("c", TerminalModifiers::Ctrl) | ("C", TerminalModifiers::CtrlShift) => Some("\x03"),
|
||||
("d", TerminalModifiers::Ctrl) | ("D", TerminalModifiers::CtrlShift) => Some("\x04"),
|
||||
("e", TerminalModifiers::Ctrl) | ("E", TerminalModifiers::CtrlShift) => Some("\x05"),
|
||||
("f", TerminalModifiers::Ctrl) | ("F", TerminalModifiers::CtrlShift) => Some("\x06"),
|
||||
("g", TerminalModifiers::Ctrl) | ("G", TerminalModifiers::CtrlShift) => Some("\x07"),
|
||||
("h", TerminalModifiers::Ctrl) | ("H", TerminalModifiers::CtrlShift) => Some("\x08"),
|
||||
("i", TerminalModifiers::Ctrl) | ("I", TerminalModifiers::CtrlShift) => Some("\x09"),
|
||||
("j", TerminalModifiers::Ctrl) | ("J", TerminalModifiers::CtrlShift) => Some("\x0a"),
|
||||
("k", TerminalModifiers::Ctrl) | ("K", TerminalModifiers::CtrlShift) => Some("\x0b"),
|
||||
("l", TerminalModifiers::Ctrl) | ("L", TerminalModifiers::CtrlShift) => Some("\x0c"),
|
||||
("m", TerminalModifiers::Ctrl) | ("M", TerminalModifiers::CtrlShift) => Some("\x0d"),
|
||||
("n", TerminalModifiers::Ctrl) | ("N", TerminalModifiers::CtrlShift) => Some("\x0e"),
|
||||
("o", TerminalModifiers::Ctrl) | ("O", TerminalModifiers::CtrlShift) => Some("\x0f"),
|
||||
("p", TerminalModifiers::Ctrl) | ("P", TerminalModifiers::CtrlShift) => Some("\x10"),
|
||||
("q", TerminalModifiers::Ctrl) | ("Q", TerminalModifiers::CtrlShift) => Some("\x11"),
|
||||
("r", TerminalModifiers::Ctrl) | ("R", TerminalModifiers::CtrlShift) => Some("\x12"),
|
||||
("s", TerminalModifiers::Ctrl) | ("S", TerminalModifiers::CtrlShift) => Some("\x13"),
|
||||
("t", TerminalModifiers::Ctrl) | ("T", TerminalModifiers::CtrlShift) => Some("\x14"),
|
||||
("u", TerminalModifiers::Ctrl) | ("U", TerminalModifiers::CtrlShift) => Some("\x15"),
|
||||
("v", TerminalModifiers::Ctrl) | ("V", TerminalModifiers::CtrlShift) => Some("\x16"),
|
||||
("w", TerminalModifiers::Ctrl) | ("W", TerminalModifiers::CtrlShift) => Some("\x17"),
|
||||
("x", TerminalModifiers::Ctrl) | ("X", TerminalModifiers::CtrlShift) => Some("\x18"),
|
||||
("y", TerminalModifiers::Ctrl) | ("Y", TerminalModifiers::CtrlShift) => Some("\x19"),
|
||||
("z", TerminalModifiers::Ctrl) | ("Z", TerminalModifiers::CtrlShift) => Some("\x1a"),
|
||||
("@", TerminalModifiers::Ctrl) => Some("\x00"),
|
||||
("[", TerminalModifiers::Ctrl) => Some("\x1b"),
|
||||
("\\", TerminalModifiers::Ctrl) => Some("\x1c"),
|
||||
("]", TerminalModifiers::Ctrl) => Some("\x1d"),
|
||||
("^", TerminalModifiers::Ctrl) => Some("\x1e"),
|
||||
("_", TerminalModifiers::Ctrl) => Some("\x1f"),
|
||||
("?", TerminalModifiers::Ctrl) => Some("\x7f"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(esc) = manual_esc_str {
|
||||
return Some(esc.into());
|
||||
}
|
||||
|
||||
if modifiers.any() {
|
||||
let modifier_code = modifier_code(keystroke);
|
||||
let modified = match key.as_str() {
|
||||
"up" => Some(format!("\x1b[1;{}A", modifier_code)),
|
||||
"down" => Some(format!("\x1b[1;{}B", modifier_code)),
|
||||
"right" => Some(format!("\x1b[1;{}C", modifier_code)),
|
||||
"left" => Some(format!("\x1b[1;{}D", modifier_code)),
|
||||
"insert" => Some(format!("\x1b[2;{}~", modifier_code)),
|
||||
"pageup" => Some(format!("\x1b[5;{}~", modifier_code)),
|
||||
"pagedown" => Some(format!("\x1b[6;{}~", modifier_code)),
|
||||
"end" => Some(format!("\x1b[1;{}F", modifier_code)),
|
||||
"home" => Some(format!("\x1b[1;{}H", modifier_code)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(esc) = modified {
|
||||
return Some(esc.into());
|
||||
}
|
||||
}
|
||||
|
||||
if !cfg!(target_os = "macos") || option_as_meta {
|
||||
let is_alt_lowercase_ascii =
|
||||
modifiers == TerminalModifiers::Alt && keystroke.key.is_ascii();
|
||||
let is_alt_uppercase_ascii =
|
||||
keystroke.modifiers.alt && keystroke.modifiers.shift && keystroke.key.is_ascii();
|
||||
if is_alt_lowercase_ascii || is_alt_uppercase_ascii {
|
||||
let key = if is_alt_uppercase_ascii {
|
||||
keystroke.key.to_ascii_uppercase()
|
||||
} else {
|
||||
keystroke.key.clone()
|
||||
};
|
||||
return Some(format!("\x1b{}", key).into());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(text) = &keystroke.key_char {
|
||||
return Some(text.clone().into());
|
||||
}
|
||||
|
||||
if keystroke.key.len() == 1 {
|
||||
return Some(keystroke.key.clone().into());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn modifier_code(keystroke: &Keystroke) -> u32 {
|
||||
let mut modifier_code = 0;
|
||||
if keystroke.modifiers.shift {
|
||||
modifier_code |= 1;
|
||||
}
|
||||
if keystroke.modifiers.alt {
|
||||
modifier_code |= 1 << 1;
|
||||
}
|
||||
if keystroke.modifiers.control {
|
||||
modifier_code |= 1 << 2;
|
||||
}
|
||||
modifier_code + 1
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
use alacritty_terminal::{
|
||||
term::cell::Flags,
|
||||
vte::ansi::{Color as AnsiColor, CursorShape, NamedColor},
|
||||
};
|
||||
use gpui::{
|
||||
App, Bounds, Element, ElementId, Entity, FocusHandle, Font, FontStyle, FontWeight, GlobalElementId, Hsla, InputHandler, IntoElement,
|
||||
LayoutId, Pixels, Point, Rgba, StrikethroughStyle, TextRun, TextStyle, UTF16Selection, UnderlineStyle, Window, fill, point, px,
|
||||
relative, rgb,
|
||||
};
|
||||
use gpui_component::ActiveTheme as _;
|
||||
|
||||
use crate::Ashell;
|
||||
use crate::terminal::{RenderSnapshot, ViewportSelection};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct TerminalMetrics {
|
||||
cell_width: Pixels,
|
||||
line_height: Pixels,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LayoutRect {
|
||||
row: i32,
|
||||
col: i32,
|
||||
cells: usize,
|
||||
color: Hsla,
|
||||
}
|
||||
|
||||
impl LayoutRect {
|
||||
fn paint(&self, origin: Point<Pixels>, metrics: TerminalMetrics, window: &mut Window) {
|
||||
let position = point(
|
||||
origin.x + metrics.cell_width * self.col as f32,
|
||||
origin.y + metrics.line_height * self.row as f32,
|
||||
);
|
||||
let size = gpui::size(metrics.cell_width * self.cells as f32, metrics.line_height);
|
||||
window.paint_quad(fill(Bounds::new(position, size), self.color));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct BatchedTextRun {
|
||||
row: i32,
|
||||
col: i32,
|
||||
cell_count: usize,
|
||||
text: String,
|
||||
style: TextRun,
|
||||
font_size: Pixels,
|
||||
}
|
||||
|
||||
impl BatchedTextRun {
|
||||
fn new(row: i32, col: i32, ch: char, style: TextRun, font_size: Pixels) -> Self {
|
||||
Self {
|
||||
row,
|
||||
col,
|
||||
cell_count: 1,
|
||||
text: ch.to_string(),
|
||||
style,
|
||||
font_size,
|
||||
}
|
||||
}
|
||||
|
||||
fn can_append(&self, other: &TextRun, row: i32, col: i32) -> bool {
|
||||
self.row == row
|
||||
&& self.col + self.cell_count as i32 == col
|
||||
&& self.style.font == other.font
|
||||
&& self.style.color == other.color
|
||||
&& self.style.background_color == other.background_color
|
||||
&& self.style.underline == other.underline
|
||||
&& self.style.strikethrough == other.strikethrough
|
||||
}
|
||||
|
||||
fn append(&mut self, ch: char, zerowidth: Option<&[char]>) {
|
||||
self.text.push(ch);
|
||||
self.cell_count += 1;
|
||||
self.style.len += ch.len_utf8();
|
||||
if let Some(chars) = zerowidth {
|
||||
for c in chars {
|
||||
self.text.push(*c);
|
||||
self.style.len += c.len_utf8();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(&self, origin: Point<Pixels>, metrics: TerminalMetrics, window: &mut Window, cx: &mut App) {
|
||||
let pos = point(
|
||||
origin.x + metrics.cell_width * self.col as f32,
|
||||
origin.y + metrics.line_height * self.row as f32,
|
||||
);
|
||||
|
||||
window
|
||||
.text_system()
|
||||
.shape_line(
|
||||
self.text.clone().into(),
|
||||
self.font_size,
|
||||
std::slice::from_ref(&self.style),
|
||||
Some(metrics.cell_width),
|
||||
)
|
||||
.paint(
|
||||
pos,
|
||||
metrics.line_height,
|
||||
gpui::TextAlign::Left,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct CursorLayout {
|
||||
row: usize,
|
||||
col: usize,
|
||||
shape: CursorShape,
|
||||
color: Hsla,
|
||||
}
|
||||
|
||||
pub struct TerminalElement {
|
||||
view: Entity<Ashell>,
|
||||
focus_handle: FocusHandle,
|
||||
snapshot: RenderSnapshot,
|
||||
marked_text: Option<String>,
|
||||
font_family: &'static str,
|
||||
font_size: Pixels,
|
||||
line_height: Pixels,
|
||||
cell_width: Pixels,
|
||||
}
|
||||
|
||||
pub struct PrepaintState {
|
||||
bounds: Bounds<Pixels>,
|
||||
metrics: TerminalMetrics,
|
||||
rects: Vec<LayoutRect>,
|
||||
runs: Vec<BatchedTextRun>,
|
||||
cursor: Option<CursorLayout>,
|
||||
}
|
||||
|
||||
struct TerminalInputHandler {
|
||||
view: Entity<Ashell>,
|
||||
element_bounds: Bounds<Pixels>,
|
||||
}
|
||||
|
||||
impl InputHandler for TerminalInputHandler {
|
||||
fn selected_text_range(
|
||||
&mut self,
|
||||
_ignore_disabled_input: bool,
|
||||
_window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<UTF16Selection> {
|
||||
self.view.read(cx).terminal_accepts_text_input().then_some(UTF16Selection {
|
||||
range: 0..0,
|
||||
reversed: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn marked_text_range(
|
||||
&mut self,
|
||||
_window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<std::ops::Range<usize>> {
|
||||
self.view.read(cx).terminal_marked_text_range()
|
||||
}
|
||||
|
||||
fn text_for_range(
|
||||
&mut self,
|
||||
_range_utf16: std::ops::Range<usize>,
|
||||
_adjusted_range: &mut Option<std::ops::Range<usize>>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut App,
|
||||
) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn replace_text_in_range(
|
||||
&mut self,
|
||||
_replacement_range: Option<std::ops::Range<usize>>,
|
||||
text: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
self.view.update(cx, |view, cx| {
|
||||
view.commit_terminal_ime_text(text, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn replace_and_mark_text_in_range(
|
||||
&mut self,
|
||||
_range_utf16: Option<std::ops::Range<usize>>,
|
||||
new_text: &str,
|
||||
_new_selected_range: Option<std::ops::Range<usize>>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
self.view.update(cx, |view, cx| {
|
||||
view.set_terminal_marked_text(new_text.to_string(), window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn unmark_text(&mut self, window: &mut Window, cx: &mut App) {
|
||||
self.view.update(cx, |view, cx| {
|
||||
view.clear_terminal_marked_text(window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn bounds_for_range(
|
||||
&mut self,
|
||||
range_utf16: std::ops::Range<usize>,
|
||||
_window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<Bounds<Pixels>> {
|
||||
self.view
|
||||
.read(cx)
|
||||
.terminal_ime_bounds_for_range(range_utf16, self.element_bounds)
|
||||
}
|
||||
|
||||
fn character_index_for_point(
|
||||
&mut self,
|
||||
_point: Point<Pixels>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut App,
|
||||
) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
|
||||
fn accepts_text_input(&mut self, _window: &mut Window, cx: &mut App) -> bool {
|
||||
self.view.read(cx).terminal_accepts_text_input()
|
||||
}
|
||||
|
||||
fn apple_press_and_hold_enabled(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn prefers_ime_for_printable_keys(&mut self, _window: &mut Window, cx: &mut App) -> bool {
|
||||
self.view.read(cx).terminal_accepts_text_input()
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalElement {
|
||||
pub fn new(
|
||||
view: Entity<Ashell>,
|
||||
focus_handle: FocusHandle,
|
||||
snapshot: RenderSnapshot,
|
||||
marked_text: Option<String>,
|
||||
font_family: &'static str,
|
||||
font_size: Pixels,
|
||||
line_height: Pixels,
|
||||
cell_width: Pixels,
|
||||
) -> Self {
|
||||
Self {
|
||||
view,
|
||||
focus_handle,
|
||||
snapshot,
|
||||
marked_text,
|
||||
font_family,
|
||||
font_size,
|
||||
line_height,
|
||||
cell_width,
|
||||
}
|
||||
}
|
||||
|
||||
fn base_text_style(&self, cx: &App) -> TextStyle {
|
||||
TextStyle {
|
||||
color: cx.theme().foreground,
|
||||
font_family: self.font_family.into(),
|
||||
font_size: self.font_size.into(),
|
||||
line_height: self.line_height.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn cell_run_style(&self, cell: &alacritty_terminal::term::cell::Cell, cx: &App) -> TextRun {
|
||||
let mut fg = color_to_hsla(cell.fg, true, cx);
|
||||
let mut bg = color_to_hsla(cell.bg, false, cx);
|
||||
if cell.flags.contains(Flags::INVERSE) {
|
||||
std::mem::swap(&mut fg, &mut bg);
|
||||
}
|
||||
if cell.flags.contains(Flags::DIM) {
|
||||
fg.a *= 0.7;
|
||||
}
|
||||
|
||||
let underline = cell.flags.intersects(Flags::ALL_UNDERLINES).then(|| UnderlineStyle {
|
||||
color: Some(fg),
|
||||
thickness: px(1.0),
|
||||
wavy: cell.flags.contains(Flags::UNDERCURL),
|
||||
});
|
||||
let strikethrough = cell.flags.contains(Flags::STRIKEOUT).then(|| StrikethroughStyle {
|
||||
color: Some(fg),
|
||||
thickness: px(1.0),
|
||||
});
|
||||
|
||||
let weight = if cell.flags.intersects(Flags::BOLD | Flags::DIM_BOLD) {
|
||||
FontWeight::BOLD
|
||||
} else {
|
||||
FontWeight::NORMAL
|
||||
};
|
||||
let style = if cell.flags.intersects(Flags::ITALIC | Flags::BOLD_ITALIC) {
|
||||
FontStyle::Italic
|
||||
} else {
|
||||
FontStyle::Normal
|
||||
};
|
||||
|
||||
TextRun {
|
||||
len: cell.c.len_utf8(),
|
||||
color: fg,
|
||||
background_color: None,
|
||||
font: Font {
|
||||
family: self.font_family.into(),
|
||||
weight,
|
||||
style,
|
||||
..Font::default()
|
||||
},
|
||||
underline,
|
||||
strikethrough,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_grid(&self, cx: &App) -> (Vec<LayoutRect>, Vec<BatchedTextRun>) {
|
||||
let mut rects = Vec::new();
|
||||
let mut runs = Vec::new();
|
||||
let mut current_run: Option<BatchedTextRun> = None;
|
||||
|
||||
for render_cell in &self.snapshot.cells {
|
||||
let cell = &render_cell.cell;
|
||||
if cell.flags.intersects(
|
||||
Flags::HIDDEN | Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let selected = self
|
||||
.snapshot
|
||||
.selection
|
||||
.is_some_and(|selection| selection_contains(selection, render_cell.row, render_cell.col));
|
||||
let bg = color_to_hsla(cell.bg, false, cx);
|
||||
if selected || !is_default_bg(cell.bg) {
|
||||
rects.push(LayoutRect {
|
||||
row: render_cell.row,
|
||||
col: render_cell.col,
|
||||
cells: 1,
|
||||
color: if selected {
|
||||
cx.theme().selection
|
||||
} else if cell.flags.contains(Flags::INVERSE) {
|
||||
color_to_hsla(cell.fg, true, cx)
|
||||
} else {
|
||||
bg
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if is_blank(cell) {
|
||||
if let Some(run) = current_run.take() {
|
||||
runs.push(run);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let style = self.cell_run_style(cell, cx);
|
||||
if let Some(run) = current_run.as_mut() {
|
||||
if run.can_append(&style, render_cell.row, render_cell.col) {
|
||||
run.append(cell.c, cell.zerowidth());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(run) = current_run.take() {
|
||||
runs.push(run);
|
||||
}
|
||||
|
||||
let mut run = BatchedTextRun::new(
|
||||
render_cell.row,
|
||||
render_cell.col,
|
||||
cell.c,
|
||||
style,
|
||||
self.font_size,
|
||||
);
|
||||
if let Some(chars) = cell.zerowidth() {
|
||||
for ch in chars {
|
||||
run.text.push(*ch);
|
||||
run.style.len += ch.len_utf8();
|
||||
}
|
||||
}
|
||||
current_run = Some(run);
|
||||
}
|
||||
|
||||
if let Some(run) = current_run {
|
||||
runs.push(run);
|
||||
}
|
||||
|
||||
(merge_rects(rects), runs)
|
||||
}
|
||||
|
||||
fn cursor_layout(&self, cx: &App) -> Option<CursorLayout> {
|
||||
self.snapshot.cursor.map(|cursor| CursorLayout {
|
||||
row: cursor.row,
|
||||
col: cursor.col,
|
||||
shape: cursor.shape,
|
||||
color: cx.theme().primary,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for TerminalElement {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for TerminalElement {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = PrepaintState;
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&gpui::InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
let mut style = gpui::Style::default();
|
||||
style.size.width = relative(1.).into();
|
||||
style.size.height = relative(1.).into();
|
||||
(window.request_layout(style, None, cx), ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&gpui::InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
_window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
let _ = self.base_text_style(cx);
|
||||
let (rects, runs) = self.layout_grid(cx);
|
||||
PrepaintState {
|
||||
bounds,
|
||||
metrics: TerminalMetrics {
|
||||
cell_width: self.cell_width,
|
||||
line_height: self.line_height,
|
||||
},
|
||||
rects,
|
||||
runs,
|
||||
cursor: self.cursor_layout(cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&gpui::InspectorElementId>,
|
||||
_bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
prepaint: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
for rect in &prepaint.rects {
|
||||
rect.paint(prepaint.bounds.origin, prepaint.metrics, window);
|
||||
}
|
||||
|
||||
for run in &prepaint.runs {
|
||||
run.paint(prepaint.bounds.origin, prepaint.metrics, window, cx);
|
||||
}
|
||||
|
||||
window.handle_input(
|
||||
&self.focus_handle,
|
||||
TerminalInputHandler {
|
||||
view: self.view.clone(),
|
||||
element_bounds: prepaint.bounds,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
|
||||
if let Some(marked_text) = self.marked_text.as_ref().filter(|text| !text.is_empty()) {
|
||||
if let Some(cursor) = prepaint.cursor {
|
||||
let pos = point(
|
||||
prepaint.bounds.origin.x + prepaint.metrics.cell_width * cursor.col as f32,
|
||||
prepaint.bounds.origin.y + prepaint.metrics.line_height * cursor.row as f32,
|
||||
);
|
||||
let mut base_style = self.base_text_style(cx);
|
||||
base_style.underline = Some(UnderlineStyle {
|
||||
color: Some(base_style.color),
|
||||
thickness: px(1.0),
|
||||
wavy: false,
|
||||
});
|
||||
let shaped = window.text_system().shape_line(
|
||||
marked_text.clone().into(),
|
||||
self.font_size,
|
||||
&[TextRun {
|
||||
len: marked_text.len(),
|
||||
font: Font {
|
||||
family: self.font_family.into(),
|
||||
..Font::default()
|
||||
},
|
||||
color: base_style.color,
|
||||
underline: base_style.underline,
|
||||
..Default::default()
|
||||
}],
|
||||
None,
|
||||
);
|
||||
let bg_bounds =
|
||||
Bounds::new(pos, gpui::size(shaped.width, prepaint.metrics.line_height));
|
||||
window.paint_quad(fill(bg_bounds, cx.theme().background));
|
||||
shaped
|
||||
.paint(
|
||||
pos,
|
||||
prepaint.metrics.line_height,
|
||||
gpui::TextAlign::Left,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(cursor) = prepaint.cursor {
|
||||
if self.marked_text.as_ref().is_some_and(|text| !text.is_empty()) {
|
||||
return;
|
||||
}
|
||||
let x = prepaint.bounds.origin.x + prepaint.metrics.cell_width * cursor.col as f32;
|
||||
let y = prepaint.bounds.origin.y + prepaint.metrics.line_height * cursor.row as f32;
|
||||
match cursor.shape {
|
||||
CursorShape::Hidden => {}
|
||||
CursorShape::Beam => {
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(point(x, y), gpui::size(px(2.), prepaint.metrics.line_height)),
|
||||
cursor.color,
|
||||
));
|
||||
}
|
||||
CursorShape::Underline => {
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(
|
||||
point(x, y + prepaint.metrics.line_height - px(2.)),
|
||||
gpui::size(prepaint.metrics.cell_width, px(2.)),
|
||||
),
|
||||
cursor.color,
|
||||
));
|
||||
}
|
||||
CursorShape::Block | CursorShape::HollowBlock => {
|
||||
let alpha = if matches!(cursor.shape, CursorShape::HollowBlock) {
|
||||
0.18
|
||||
} else {
|
||||
0.32
|
||||
};
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(
|
||||
point(x, y),
|
||||
gpui::size(prepaint.metrics.cell_width, prepaint.metrics.line_height),
|
||||
),
|
||||
cursor.color.opacity(alpha),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_rects(mut rects: Vec<LayoutRect>) -> Vec<LayoutRect> {
|
||||
rects.sort_by_key(|rect| (rect.row, rect.col));
|
||||
let mut merged: Vec<LayoutRect> = Vec::with_capacity(rects.len());
|
||||
|
||||
for rect in rects {
|
||||
if let Some(last) = merged.last_mut() {
|
||||
if last.row == rect.row && last.color == rect.color && last.col + last.cells as i32 == rect.col {
|
||||
last.cells += rect.cells;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
merged.push(rect);
|
||||
}
|
||||
|
||||
merged
|
||||
}
|
||||
|
||||
fn selection_contains(selection: ViewportSelection, row: i32, col: i32) -> bool {
|
||||
let row = row.max(0) as usize;
|
||||
let col = col.max(0) as usize;
|
||||
|
||||
if row < selection.start_row || row > selection.end_row {
|
||||
return false;
|
||||
}
|
||||
|
||||
if selection.is_block {
|
||||
return col >= selection.start_col && col <= selection.end_col;
|
||||
}
|
||||
|
||||
let after_start = row > selection.start_row || col >= selection.start_col;
|
||||
let before_end = row < selection.end_row || col <= selection.end_col;
|
||||
after_start && before_end
|
||||
}
|
||||
|
||||
fn is_blank(cell: &alacritty_terminal::term::cell::Cell) -> bool {
|
||||
cell.c == ' ' && cell.zerowidth().is_none() && !cell.flags.intersects(Flags::ALL_UNDERLINES | Flags::STRIKEOUT)
|
||||
}
|
||||
|
||||
fn is_default_bg(color: AnsiColor) -> bool {
|
||||
matches!(color, AnsiColor::Named(NamedColor::Background))
|
||||
}
|
||||
|
||||
fn color_to_hsla(color: AnsiColor, foreground: bool, cx: &App) -> Hsla {
|
||||
match color {
|
||||
AnsiColor::Spec(rgb) => Hsla::from(Rgba {
|
||||
r: rgb.r as f32 / 255.0,
|
||||
g: rgb.g as f32 / 255.0,
|
||||
b: rgb.b as f32 / 255.0,
|
||||
a: 1.0,
|
||||
}),
|
||||
AnsiColor::Indexed(index) => ansi_index_color(index, cx),
|
||||
AnsiColor::Named(named) => named_color(named, foreground, cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn ansi_index_color(index: u8, _cx: &App) -> Hsla {
|
||||
const ANSI_16: [u32; 16] = [
|
||||
0x1f2430, 0xff5c57, 0x5af78e, 0xf3f99d, 0x57c7ff, 0xff6ac1, 0x9aedfe, 0xf1f1f0,
|
||||
0x686868, 0xff5c57, 0x5af78e, 0xf3f99d, 0x57c7ff, 0xff6ac1, 0x9aedfe, 0xffffff,
|
||||
];
|
||||
|
||||
if (index as usize) < ANSI_16.len() {
|
||||
return Hsla::from(rgb(ANSI_16[index as usize]));
|
||||
}
|
||||
|
||||
if index >= 232 {
|
||||
let gray = 8 + (index - 232) * 10;
|
||||
return Hsla::from(Rgba {
|
||||
r: gray as f32 / 255.0,
|
||||
g: gray as f32 / 255.0,
|
||||
b: gray as f32 / 255.0,
|
||||
a: 1.0,
|
||||
});
|
||||
}
|
||||
|
||||
let i = index - 16;
|
||||
let r = i / 36;
|
||||
let g = (i % 36) / 6;
|
||||
let b = i % 6;
|
||||
let conv = |v: u8| if v == 0 { 0 } else { 55 + v * 40 };
|
||||
Hsla::from(Rgba {
|
||||
r: conv(r) as f32 / 255.0,
|
||||
g: conv(g) as f32 / 255.0,
|
||||
b: conv(b) as f32 / 255.0,
|
||||
a: 1.0,
|
||||
})
|
||||
}
|
||||
|
||||
fn named_color(named: NamedColor, _foreground: bool, cx: &App) -> Hsla {
|
||||
match named {
|
||||
NamedColor::Foreground => cx.theme().foreground,
|
||||
NamedColor::Background => cx.theme().background,
|
||||
NamedColor::Black => Hsla::from(rgb(0x1f2430)),
|
||||
NamedColor::Red => Hsla::from(rgb(0xff5c57)),
|
||||
NamedColor::Green => Hsla::from(rgb(0x5af78e)),
|
||||
NamedColor::Yellow => Hsla::from(rgb(0xf3f99d)),
|
||||
NamedColor::Blue => Hsla::from(rgb(0x57c7ff)),
|
||||
NamedColor::Magenta => Hsla::from(rgb(0xff6ac1)),
|
||||
NamedColor::Cyan => Hsla::from(rgb(0x9aedfe)),
|
||||
NamedColor::White => Hsla::from(rgb(0xf1f1f0)),
|
||||
NamedColor::BrightBlack => Hsla::from(rgb(0x686868)),
|
||||
NamedColor::BrightRed => Hsla::from(rgb(0xff5c57)),
|
||||
NamedColor::BrightGreen => Hsla::from(rgb(0x5af78e)),
|
||||
NamedColor::BrightYellow => Hsla::from(rgb(0xf3f99d)),
|
||||
NamedColor::BrightBlue => Hsla::from(rgb(0x57c7ff)),
|
||||
NamedColor::BrightMagenta => Hsla::from(rgb(0xff6ac1)),
|
||||
NamedColor::BrightCyan => Hsla::from(rgb(0x9aedfe)),
|
||||
NamedColor::BrightWhite => Hsla::from(rgb(0xffffff)),
|
||||
NamedColor::Cursor => cx.theme().primary,
|
||||
NamedColor::DimForeground => cx.theme().muted_foreground,
|
||||
NamedColor::BrightForeground => cx.theme().foreground,
|
||||
NamedColor::DimBlack => Hsla::from(rgb(0x3b4252)),
|
||||
NamedColor::DimRed => Hsla::from(rgb(0xbf616a)),
|
||||
NamedColor::DimGreen => Hsla::from(rgb(0xa3be8c)),
|
||||
NamedColor::DimYellow => Hsla::from(rgb(0xebcb8b)),
|
||||
NamedColor::DimBlue => Hsla::from(rgb(0x81a1c1)),
|
||||
NamedColor::DimMagenta => Hsla::from(rgb(0xb48ead)),
|
||||
NamedColor::DimCyan => Hsla::from(rgb(0x88c0d0)),
|
||||
NamedColor::DimWhite => Hsla::from(rgb(0xe5e9f0)),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user