Compare commits
43 Commits
alpha-v202
...
stable-v20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cec1627a81 | ||
|
|
b00183419e | ||
|
|
ed9ceff1d2 | ||
|
|
e18491bcd3 | ||
|
|
fd0cfc7fb6 | ||
|
|
6541ce5755 | ||
|
|
4456801526 | ||
|
|
db7aaf1385 | ||
|
|
54b2616fa4 | ||
|
|
6a90e9f770 | ||
|
|
b79c78a5ba | ||
|
|
48a8882970 | ||
|
|
98276702a2 | ||
|
|
857d55a7cd | ||
|
|
b78dfad384 | ||
|
|
9ac51bab9a | ||
|
|
1fee994863 | ||
|
|
764bbf0e4c | ||
|
|
ff05ba0430 | ||
|
|
347df47c62 | ||
|
|
7536169508 | ||
|
|
34de384738 | ||
|
|
954ee42f0b | ||
|
|
f67aafe442 | ||
|
|
b2122ac24c | ||
|
|
6f8810146e | ||
|
|
929ba9241e | ||
|
|
c5e84c8218 | ||
|
|
fb55ed2f6d | ||
|
|
91a0247187 | ||
|
|
ce78abfd57 | ||
|
|
6202892512 | ||
|
|
2eb21356ef | ||
|
|
4cbf359c7b | ||
|
|
4e7069174e | ||
|
|
22cefe4912 | ||
|
|
ce26c57e99 | ||
|
|
8238134435 | ||
|
|
10fe1239bd | ||
|
|
a84f3c93ca | ||
|
|
072591ad59 | ||
|
|
323ef1913b | ||
|
|
2b85f4e45f |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.85
|
||||
AVERAGE_THRESHOLD=9.61
|
||||
HOTSPOT_THRESHOLD=9.86
|
||||
AVERAGE_THRESHOLD=9.7
|
||||
|
||||
661
LICENSE
Normal file
661
LICENSE
Normal file
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<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 Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
11
README.md
11
README.md
@@ -1,3 +1,4 @@
|
||||
[](https://codescene.io/projects/76865) [](https://codescene.io/projects/76865)
|
||||
# Tolaria App
|
||||
|
||||
Personal knowledge and life management desktop app built with Tauri v2 + React + TypeScript + BlockNote.
|
||||
@@ -90,4 +91,12 @@ Husky installs the git hooks from `.husky/` during `pnpm install`. The repo enfo
|
||||
|
||||
## License
|
||||
|
||||
Private repository — not licensed for public use.
|
||||
Tolaria is licensed under the GNU Affero General Public License, version 3 or any later version (AGPL-3.0-or-later).
|
||||
|
||||
See [LICENSE](LICENSE) for the full license text.
|
||||
|
||||
## Trademarks
|
||||
|
||||
The Tolaria name and logo are not licensed under the AGPL.
|
||||
|
||||
See [trademarks.md](trademarks.md) for the trademark policy.
|
||||
|
||||
@@ -386,6 +386,12 @@ interface PulseCommit {
|
||||
- Converts `hasRemote: false` into a local-only commit path
|
||||
- Keeps the normal push path unchanged for vaults that do have a remote
|
||||
|
||||
`useAutoGit` is the checkpoint-time companion to both hooks:
|
||||
- Consumes installation-local AutoGit settings (`autogit_enabled`, idle threshold, inactive threshold)
|
||||
- Tracks the last meaningful editor activity plus app focus/visibility transitions
|
||||
- Triggers `useCommitFlow.runAutomaticCheckpoint()` only when the vault is git-backed, pending changes exist, and no unsaved edits remain
|
||||
- Shares the same deterministic automatic commit message generator with the bottom-bar Commit button, so timer-driven checkpoints and manual quick commits produce the same `Updated N note(s)` / `Updated N file(s)` messages
|
||||
|
||||
### Frontend Integration
|
||||
|
||||
- **Modified file badges**: Orange dots in sidebar
|
||||
@@ -430,9 +436,11 @@ Defined in `src/components/editorSchema.tsx` and styled in `src/components/Edito
|
||||
|
||||
Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tolariaEditorFormattingConfig.ts`:
|
||||
|
||||
- `SingleEditorView` disables BlockNote's default formatting toolbar and `/` menu, then mounts Tolaria-owned controllers so the visible formatting surface matches Tolaria's markdown round-trip guarantees.
|
||||
- `SingleEditorView` disables BlockNote's default formatting toolbar, `/` menu, and side menu, then mounts Tolaria-owned controllers so the visible formatting surface matches Tolaria's markdown round-trip guarantees.
|
||||
- The formatting toolbar only exposes inline controls that persist through `blocksToMarkdownLossy()` in Tolaria's save pipeline: bold, italic, strike, nesting, and link creation. Controls that BlockNote can render temporarily but Tolaria cannot faithfully persist, such as underline, color, alignment, and the block-type dropdown, are hidden instead of appearing to work and later disappearing.
|
||||
- Tolaria's formatting-toolbar controller also keeps file/image actions mounted across the tiny hover gap between an image block and the floating toolbar, and while the toolbar itself is hovered, so image controls remain usable instead of collapsing mid-interaction.
|
||||
- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, and list blocks. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model.
|
||||
- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface.
|
||||
|
||||
### Markdown-to-BlockNote Pipeline
|
||||
|
||||
@@ -580,6 +588,9 @@ App-level settings persisted at `~/.config/com.tolaria.app/settings.json` (reads
|
||||
```typescript
|
||||
interface Settings {
|
||||
auto_pull_interval_minutes: number | null
|
||||
autogit_enabled: boolean | null
|
||||
autogit_idle_threshold_seconds: number | null
|
||||
autogit_inactive_threshold_seconds: number | null
|
||||
telemetry_consent: boolean | null
|
||||
crash_reporting_enabled: boolean | null
|
||||
analytics_enabled: boolean | null
|
||||
@@ -589,7 +600,7 @@ interface Settings {
|
||||
}
|
||||
```
|
||||
|
||||
Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default.
|
||||
Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
|
||||
|
||||
## Telemetry
|
||||
|
||||
|
||||
@@ -553,6 +553,8 @@ flowchart TD
|
||||
|
||||
`useGitRemoteStatus` re-checks `git_remote_status` when the commit dialog opens and again right before submit. If `hasRemote` is false, Tolaria keeps the flow local-only: the status bar shows a neutral `No remote` chip, the dialog copy switches from "Commit & Push" to "Commit", and no `git_push` call is attempted.
|
||||
|
||||
`useCommitFlow` also exposes `runAutomaticCheckpoint()`, a dialog-free commit path shared by AutoGit and the bottom-bar Commit button. `useAutoGit` watches the last editor activity plus app focus/visibility state, and when the vault is git-backed, all saves are flushed, and no unsaved edits remain, it triggers the same deterministic `Updated N note(s)` / `Updated N file(s)` commit message path after the configured idle or inactive thresholds. The bottom-bar quick action reuses that checkpoint flow after forcing a save first, so manual quick commits and scheduled AutoGit commits stay aligned on message generation and push behavior.
|
||||
|
||||
#### Sync States
|
||||
|
||||
| State | Indicator | Color | Trigger |
|
||||
@@ -576,7 +578,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `rename.rs` | `rename_note` — renames files, updates `title` frontmatter, and updates wikilinks across the vault |
|
||||
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
|
||||
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
|
||||
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, repairs missing config files |
|
||||
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, repairs missing root config/type files such as `config.md` and `note.md` |
|
||||
| `getting_started.rs` | Creates the Getting Started demo vault |
|
||||
|
||||
## Rust Backend Modules
|
||||
@@ -613,7 +615,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
|
||||
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
|
||||
| `check_vault_exists` | Check if vault path exists |
|
||||
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, and `config.md` defaults |
|
||||
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, `config.md`, and `note.md` defaults |
|
||||
| `create_getting_started_vault` | Clone the public Getting Started vault, refresh Tolaria-managed guidance/config defaults, and keep the cloned repo clean |
|
||||
| `get_vault_ai_guidance_status` | Report whether `AGENTS.md` and the `CLAUDE.md` shim are managed, missing, broken, or custom |
|
||||
| `restore_vault_ai_guidance` | Restore any missing/broken Tolaria-managed guidance files without overwriting custom ones |
|
||||
@@ -657,7 +659,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
|---------|-------------|
|
||||
| `get_vault_settings` | Read `.laputa/settings.json` |
|
||||
| `save_vault_settings` | Write vault settings |
|
||||
| `repair_vault` | Flatten vault structure, migrate legacy frontmatter, restore config |
|
||||
| `repair_vault` | Flatten vault structure, migrate legacy frontmatter, restore root config/type defaults including `note.md` |
|
||||
|
||||
### AI & MCP
|
||||
|
||||
@@ -705,6 +707,8 @@ if (isTauri()) {
|
||||
|
||||
The mock layer includes sample entries across all entity types, full markdown content with realistic frontmatter, mock git history, mock AI responses, and mock pulse commits.
|
||||
|
||||
Browser smoke tests can also override `window.__mockHandlers` before the app boots. The AutoGit smoke bridge uses that path directly for seeded saves so the mocked git dirty-state stays synchronized even when the optional browser vault API is serving note content.
|
||||
|
||||
## State Management
|
||||
|
||||
No Redux or global context. State lives in the root `App.tsx` and custom hooks:
|
||||
@@ -722,9 +726,11 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
|
||||
| `useTheme` | Editor theme CSS vars | Editor typography theme |
|
||||
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation |
|
||||
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
|
||||
| `useAutoGit` | Last activity timestamp, idle/inactive checkpoint triggers | Automatic commit/push checkpoints |
|
||||
| `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration |
|
||||
| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI |
|
||||
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
|
||||
| `useSettings` | App settings (telemetry, release channel, auto-sync interval) | Persistent settings |
|
||||
| `useSettings` | App settings (telemetry, release channel, auto-sync interval, AutoGit thresholds, default AI agent) | Persistent settings |
|
||||
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
|
||||
| `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands |
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
type: ADR
|
||||
id: "0055"
|
||||
title: "H1 is the only editor title surface"
|
||||
status: active
|
||||
status: superseded
|
||||
date: 2026-04-11
|
||||
supersedes: "0044"
|
||||
superseded_by: "0068"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
type: ADR
|
||||
id: "0057"
|
||||
title: "Alpha/stable release channels with PostHog beta cohorts"
|
||||
status: active
|
||||
status: superseded
|
||||
date: 2026-04-12
|
||||
superseded_by: "0066"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
39
docs/adr/0067-autogit-idle-and-inactive-checkpoints.md
Normal file
39
docs/adr/0067-autogit-idle-and-inactive-checkpoints.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0067"
|
||||
title: "AutoGit idle and inactive checkpoints"
|
||||
status: active
|
||||
date: 2026-04-17
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria already had explicit git actions in the status bar (ADR-0032) and a remote-aware manual commit flow (ADR-0059), but git-backed vaults still depended on the user remembering to create checkpoints. That worked for deliberate commits, yet it left a gap for ordinary writing sessions where the app had already saved all note content but no git checkpoint had been recorded.
|
||||
|
||||
The new checkpointing behavior needed to stay conservative:
|
||||
|
||||
- never run for non-git vaults
|
||||
- never commit unsaved editor buffers
|
||||
- reuse the same remote detection and local-only fallback as the manual commit flow
|
||||
- avoid drift between timer-driven checkpoints and the status-bar quick commit action
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria introduces installation-local AutoGit settings plus a dedicated `useAutoGit` hook that triggers a shared `useCommitFlow.runAutomaticCheckpoint()` path after configurable idle or inactive thresholds.** The checkpoint runs only when the current vault is git-backed, there are pending saved changes (or local commits waiting to push), and no unsaved edits remain.
|
||||
|
||||
`useCommitFlow.runAutomaticCheckpoint()` is now the single checkpoint runner for both AutoGit and the status-bar quick commit action. That shared path generates deterministic automatic commit messages (`Updated N note(s)` / `Updated N file(s)`), commits locally when no remote exists, and can also do a push-only retry when commits already exist locally.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): A shared checkpoint runner used by both AutoGit timers and the quick commit action. Pros: one git policy, one message generator, one remote-handling path. Cons: adds another cross-cutting settings-driven hook.
|
||||
- **Option B**: A separate background AutoGit implementation. Pros: could evolve independently from the manual commit flow. Cons: high risk of drift in commit messages, push behavior, and remote handling.
|
||||
- **Option C**: Commit on every save. Pros: simplest trigger model. Cons: far too noisy for git history, especially with Tolaria's autosave model.
|
||||
|
||||
## Consequences
|
||||
|
||||
- App settings now persist `autogit_enabled`, `autogit_idle_threshold_seconds`, and `autogit_inactive_threshold_seconds` in installation-local settings storage.
|
||||
- `useAutoGit` tracks editor activity plus app focus/visibility state and triggers checkpoints after the configured thresholds.
|
||||
- Automatic checkpoints are blocked while unsaved edits exist, so AutoGit only records content that is already flushed through the normal save pipeline.
|
||||
- The bottom-bar quick commit action now reuses the same checkpoint runner after forcing a save, keeping manual and automatic checkpoint behavior aligned.
|
||||
- Vaults without a remote still benefit: AutoGit uses the existing local-only commit behavior from ADR-0059 instead of treating missing remotes as an error.
|
||||
- Re-evaluate if users need per-vault policy instead of installation-local policy, or if timer-driven checkpoints create too much git noise in real-world use.
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0068"
|
||||
title: "H1-only title surface with optional untitled auto-rename"
|
||||
status: active
|
||||
date: 2026-04-17
|
||||
supersedes: "0055"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0055 removed the legacy title row and made the editor body the only title surface. That ADR also kept one strong filename behavior from ADR-0044: untitled notes would auto-rename from their first H1 on save.
|
||||
|
||||
That always-on rename rule turned out to be too rigid. Some users want the H1 to drive the displayed title immediately, but prefer to keep the synthetic `untitled-*` filename stable until they explicitly rename it from the breadcrumb bar. The product needed to preserve the H1-only editing model without forcing every installation into automatic filename changes.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria keeps the editor body as the only title surface, but untitled-note auto-rename from the first H1 becomes an installation-local setting (`initial_h1_auto_rename_enabled`) that defaults to enabled.**
|
||||
|
||||
When the setting is enabled, untitled notes continue to auto-rename on save as soon as a real H1 title exists. When disabled, Tolaria still treats the H1 as the canonical display title, but it leaves the filename unchanged until the user explicitly renames it through the breadcrumb controls.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Keep the current auto-rename behavior as the default, but make it an installation-local preference. Pros: preserves the fast path for most users while allowing opt-out for users who want stable temporary filenames. Cons: different installs can behave differently.
|
||||
- **Option B**: Keep auto-rename mandatory, as assumed by ADR-0055. Pros: one simple filename policy. Cons: surprises users who want title editing without immediate file renames.
|
||||
- **Option C**: Turn auto-rename off for everyone. Pros: filenames only change on explicit user action. Cons: leaves more `untitled-*` files around and adds friction to the common case.
|
||||
|
||||
## Consequences
|
||||
|
||||
- App settings now persist `initial_h1_auto_rename_enabled` in installation-local settings storage.
|
||||
- The save pipeline consults that setting before scheduling untitled-file renames.
|
||||
- Disabling untitled auto-rename does not restore any legacy title field or alternate title UI. H1 remains the only editor title surface.
|
||||
- When the setting is off, display title and filename can diverge for longer: the note may show a human H1 while the file remains `untitled-*` until explicit rename.
|
||||
- Settings UI and command/search affordances now expose this filename policy as a user preference rather than a hardcoded rule.
|
||||
- Re-evaluate if users later need this policy to be per-vault instead of installation-local, or if longer-lived untitled filenames create too much Finder/git noise.
|
||||
@@ -110,9 +110,9 @@ proposed → active → superseded
|
||||
| [0052](0052-renderer-first-shortcut-execution-with-native-menu-dedupe.md) | Renderer-first shortcut execution with native-menu dedupe | active |
|
||||
| [0053](0053-webview-init-prevention-for-browser-reserved-shortcuts.md) | Webview-init prevention for browser-reserved shortcuts | active |
|
||||
| [0054](0054-deterministic-shortcut-qa-matrix.md) | Deterministic shortcut QA matrix | active |
|
||||
| [0055](0055-h1-is-the-only-editor-title-surface.md) | H1 is the only editor title surface | active |
|
||||
| [0055](0055-h1-is-the-only-editor-title-surface.md) | H1 is the only editor title surface | superseded → [0068](0068-h1-only-title-surface-with-optional-untitled-auto-rename.md) |
|
||||
| [0056](0056-system-git-cli-auth-no-provider-oauth.md) | System git auth only — no provider-specific OAuth or repo APIs | active |
|
||||
| [0057](0057-alpha-stable-release-channels-and-beta-cohorts.md) | Alpha/stable release channels with PostHog beta cohorts | active |
|
||||
| [0057](0057-alpha-stable-release-channels-and-beta-cohorts.md) | Alpha/stable release channels with PostHog beta cohorts | superseded → [0066](0066-calendar-semver-versioning-for-alpha-and-stable-releases.md) |
|
||||
| [0058](0058-claude-code-first-launch-onboarding-gate.md) | Claude Code first-launch onboarding gate | superseded → [0062](0062-selectable-cli-ai-agents.md) |
|
||||
| [0059](0059-local-only-git-commits-without-remote.md) | Local-only git commits for vaults without a remote | active |
|
||||
| [0060](0060-network-aware-ui-gating-for-remote-features.md) | Network-aware UI gating for remote-dependent features | active |
|
||||
@@ -121,3 +121,6 @@ proposed → active → superseded
|
||||
| [0063](0063-blocknote-code-block-package-for-editor-highlighting.md) | BlockNote code-block package for editor syntax highlighting | active |
|
||||
| [0064](0064-ratcheted-codescene-thresholds.md) | Ratcheted CodeScene thresholds as the quality gate baseline | active |
|
||||
| [0065](0065-root-managed-ai-guidance-files.md) | Root-managed AI guidance files with Claude shim | active |
|
||||
| [0066](0066-calendar-semver-versioning-for-alpha-and-stable-releases.md) | Calendar-semver versioning for alpha and stable releases | active |
|
||||
| [0067](0067-autogit-idle-and-inactive-checkpoints.md) | AutoGit idle and inactive checkpoints | active |
|
||||
| [0068](0068-h1-only-title-surface-with-optional-untitled-auto-rename.md) | H1-only title surface with optional untitled auto-rename | active |
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "tolaria",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
name = "tolaria"
|
||||
version = "0.1.0"
|
||||
description = "Personal knowledge and life management app"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
authors = ["Luca Rossi"]
|
||||
license = "AGPL-3.0-or-later"
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
@@ -8,6 +8,9 @@ const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Settings {
|
||||
pub auto_pull_interval_minutes: Option<u32>,
|
||||
pub autogit_enabled: Option<bool>,
|
||||
pub autogit_idle_threshold_seconds: Option<u32>,
|
||||
pub autogit_inactive_threshold_seconds: Option<u32>,
|
||||
pub telemetry_consent: Option<bool>,
|
||||
pub crash_reporting_enabled: Option<bool>,
|
||||
pub analytics_enabled: Option<bool>,
|
||||
@@ -23,6 +26,10 @@ fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
.filter(|candidate| !candidate.is_empty())
|
||||
}
|
||||
|
||||
fn normalize_optional_positive_u32(value: Option<u32>) -> Option<u32> {
|
||||
value.filter(|candidate| *candidate > 0)
|
||||
}
|
||||
|
||||
pub fn normalize_release_channel(value: Option<&str>) -> Option<String> {
|
||||
match value.map(|candidate| candidate.trim().to_ascii_lowercase()) {
|
||||
Some(channel) if channel == "alpha" => Some(channel),
|
||||
@@ -48,6 +55,13 @@ pub fn normalize_default_ai_agent(value: Option<&str>) -> Option<String> {
|
||||
fn normalize_settings(settings: Settings) -> Settings {
|
||||
Settings {
|
||||
auto_pull_interval_minutes: settings.auto_pull_interval_minutes,
|
||||
autogit_enabled: settings.autogit_enabled,
|
||||
autogit_idle_threshold_seconds: normalize_optional_positive_u32(
|
||||
settings.autogit_idle_threshold_seconds,
|
||||
),
|
||||
autogit_inactive_threshold_seconds: normalize_optional_positive_u32(
|
||||
settings.autogit_inactive_threshold_seconds,
|
||||
),
|
||||
telemetry_consent: settings.telemetry_consent,
|
||||
crash_reporting_enabled: settings.crash_reporting_enabled,
|
||||
analytics_enabled: settings.analytics_enabled,
|
||||
@@ -151,6 +165,9 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
type SettingsSnapshot<'a> = (
|
||||
Option<u32>,
|
||||
Option<bool>,
|
||||
Option<u32>,
|
||||
Option<u32>,
|
||||
Option<bool>,
|
||||
Option<bool>,
|
||||
@@ -164,6 +181,9 @@ mod tests {
|
||||
fn settings_snapshot(settings: &Settings) -> SettingsSnapshot<'_> {
|
||||
(
|
||||
settings.auto_pull_interval_minutes,
|
||||
settings.autogit_enabled,
|
||||
settings.autogit_idle_threshold_seconds,
|
||||
settings.autogit_inactive_threshold_seconds,
|
||||
settings.telemetry_consent,
|
||||
settings.crash_reporting_enabled,
|
||||
settings.analytics_enabled,
|
||||
@@ -177,7 +197,7 @@ mod tests {
|
||||
fn assert_empty_settings(settings: &Settings) {
|
||||
assert_eq!(
|
||||
settings_snapshot(settings),
|
||||
(None, None, None, None, None, None, None, None)
|
||||
(None, None, None, None, None, None, None, None, None, None, None)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -211,6 +231,9 @@ mod tests {
|
||||
fn test_settings_json_roundtrip() {
|
||||
let settings = Settings {
|
||||
auto_pull_interval_minutes: Some(10),
|
||||
autogit_enabled: Some(true),
|
||||
autogit_idle_threshold_seconds: Some(90),
|
||||
autogit_inactive_threshold_seconds: Some(30),
|
||||
telemetry_consent: Some(true),
|
||||
crash_reporting_enabled: Some(true),
|
||||
analytics_enabled: Some(false),
|
||||
@@ -236,12 +259,18 @@ mod tests {
|
||||
fn test_save_and_load_preserves_values() {
|
||||
let loaded = save_and_reload(Settings {
|
||||
auto_pull_interval_minutes: Some(10),
|
||||
autogit_enabled: Some(true),
|
||||
autogit_idle_threshold_seconds: Some(90),
|
||||
autogit_inactive_threshold_seconds: Some(30),
|
||||
release_channel: Some("alpha".to_string()),
|
||||
initial_h1_auto_rename_enabled: Some(false),
|
||||
default_ai_agent: Some("codex".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(loaded.auto_pull_interval_minutes, Some(10));
|
||||
assert_eq!(loaded.autogit_enabled, Some(true));
|
||||
assert_eq!(loaded.autogit_idle_threshold_seconds, Some(90));
|
||||
assert_eq!(loaded.autogit_inactive_threshold_seconds, Some(30));
|
||||
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
|
||||
assert_eq!(loaded.initial_h1_auto_rename_enabled, Some(false));
|
||||
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
|
||||
@@ -269,6 +298,17 @@ mod tests {
|
||||
assert!(loaded.release_channel.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_positive_autogit_thresholds_are_filtered() {
|
||||
let loaded = save_and_reload(Settings {
|
||||
autogit_idle_threshold_seconds: Some(0),
|
||||
autogit_inactive_threshold_seconds: Some(0),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(loaded.autogit_idle_threshold_seconds.is_none());
|
||||
assert!(loaded.autogit_inactive_threshold_seconds.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_alpha_release_channels_normalize_to_stable() {
|
||||
let loaded = save_and_reload(Settings {
|
||||
@@ -339,6 +379,9 @@ mod tests {
|
||||
assert_eq!(
|
||||
settings_snapshot(&loaded),
|
||||
(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(true),
|
||||
Some(true),
|
||||
|
||||
@@ -19,6 +19,17 @@ sidebar label: Config
|
||||
Vault configuration files. These control how AI agents, tools, and other integrations interact with this vault.
|
||||
";
|
||||
|
||||
/// Content for `note.md` — restores the default Note type definition when missing.
|
||||
const NOTE_TYPE_DEFINITION: &str = "\
|
||||
---
|
||||
type: Type
|
||||
---
|
||||
|
||||
# Note
|
||||
|
||||
A Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type.
|
||||
";
|
||||
|
||||
const CLAUDE_MD_SHIM: &str = "@AGENTS.md
|
||||
|
||||
This file is a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
|
||||
@@ -113,8 +124,8 @@ fn classify_guidance_file(
|
||||
AiGuidanceFileState::Custom
|
||||
}
|
||||
|
||||
fn guidance_paths(vault_path: &str) -> (PathBuf, PathBuf) {
|
||||
let vault = Path::new(vault_path);
|
||||
fn guidance_paths(vault_path: &Path) -> (PathBuf, PathBuf) {
|
||||
let vault = vault_path;
|
||||
(vault.join("AGENTS.md"), vault.join("CLAUDE.md"))
|
||||
}
|
||||
|
||||
@@ -137,7 +148,7 @@ fn guidance_file_needs_restore(state: AiGuidanceFileState) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn build_ai_guidance_status(vault_path: &str) -> VaultAiGuidanceStatus {
|
||||
fn build_ai_guidance_status(vault_path: &Path) -> VaultAiGuidanceStatus {
|
||||
let (agents_path, claude_path) = guidance_paths(vault_path);
|
||||
let agents_state = classify_agents_file(&agents_path);
|
||||
let claude_state = classify_claude_file(&claude_path);
|
||||
@@ -150,12 +161,12 @@ fn build_ai_guidance_status(vault_path: &str) -> VaultAiGuidanceStatus {
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_claude_shim_file(vault_path: &str) -> Result<bool, String> {
|
||||
fn sync_claude_shim_file(vault_path: &Path) -> Result<bool, String> {
|
||||
let (_, claude_path) = guidance_paths(vault_path);
|
||||
sync_managed_file(&claude_path, CLAUDE_MD_SHIM, claude_shim_can_be_replaced)
|
||||
}
|
||||
|
||||
fn sync_ai_guidance_files(vault_path: &str) -> Result<bool, String> {
|
||||
fn sync_ai_guidance_files(vault_path: &Path) -> Result<bool, String> {
|
||||
let wrote_agents = sync_default_agents_file(vault_path)?;
|
||||
let wrote_claude = sync_claude_shim_file(vault_path)?;
|
||||
Ok(wrote_agents || wrote_claude)
|
||||
@@ -203,34 +214,45 @@ fn cleanup_empty_config_dir(vault: &Path) -> Result<bool, String> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(super) fn sync_default_agents_file(vault_path: &str) -> Result<bool, String> {
|
||||
pub(super) fn sync_default_agents_file(vault_path: &Path) -> Result<bool, String> {
|
||||
let (agents_path, _) = guidance_paths(vault_path);
|
||||
sync_managed_file(&agents_path, AGENTS_MD, root_agents_can_be_replaced)
|
||||
}
|
||||
|
||||
pub fn get_ai_guidance_status(vault_path: &str) -> Result<VaultAiGuidanceStatus, String> {
|
||||
pub fn get_ai_guidance_status(
|
||||
vault_path: impl AsRef<str>,
|
||||
) -> Result<VaultAiGuidanceStatus, String> {
|
||||
Ok(build_ai_guidance_status(Path::new(vault_path.as_ref())))
|
||||
}
|
||||
|
||||
pub fn restore_ai_guidance_files(
|
||||
vault_path: impl AsRef<str>,
|
||||
) -> Result<VaultAiGuidanceStatus, String> {
|
||||
let vault_path = Path::new(vault_path.as_ref());
|
||||
sync_ai_guidance_files(vault_path)?;
|
||||
Ok(build_ai_guidance_status(vault_path))
|
||||
}
|
||||
|
||||
pub fn restore_ai_guidance_files(vault_path: &str) -> Result<VaultAiGuidanceStatus, String> {
|
||||
sync_ai_guidance_files(vault_path)?;
|
||||
get_ai_guidance_status(vault_path)
|
||||
}
|
||||
|
||||
/// Seed `AGENTS.md` at vault root if missing or empty (idempotent, per-file).
|
||||
/// Also seeds `config.md` type definition for sidebar visibility.
|
||||
pub fn seed_config_files(vault_path: &str) {
|
||||
/// Also seeds Tolaria-managed root type definitions used by repair/bootstrap flows.
|
||||
pub fn seed_config_files(vault_path: impl AsRef<str>) {
|
||||
let vault_path = Path::new(vault_path.as_ref());
|
||||
if sync_ai_guidance_files(vault_path).unwrap_or(false) {
|
||||
log::info!("Seeded vault AI guidance files at vault root");
|
||||
}
|
||||
|
||||
ensure_config_type_definition(vault_path);
|
||||
ensure_root_type_definitions(vault_path);
|
||||
}
|
||||
|
||||
/// Ensure `config.md` exists at vault root (gives Config type a sidebar icon/color).
|
||||
fn ensure_config_type_definition(vault_path: &str) {
|
||||
let path = Path::new(vault_path).join("config.md");
|
||||
let _ = write_if_missing(&path, CONFIG_TYPE_DEFINITION);
|
||||
fn ensure_root_type_definition(vault_path: &Path, file_name: &str, content: &str) {
|
||||
let path = vault_path.join(file_name);
|
||||
let _ = write_if_missing(&path, content);
|
||||
}
|
||||
|
||||
/// Ensure the default root type definitions exist for opened/repaired vaults.
|
||||
fn ensure_root_type_definitions(vault_path: &Path) {
|
||||
ensure_root_type_definition(vault_path, "config.md", CONFIG_TYPE_DEFINITION);
|
||||
ensure_root_type_definition(vault_path, "note.md", NOTE_TYPE_DEFINITION);
|
||||
}
|
||||
|
||||
/// Migrate legacy `config/agents.md` → root `AGENTS.md` for existing vaults.
|
||||
@@ -241,8 +263,8 @@ fn ensure_config_type_definition(vault_path: &str) {
|
||||
/// - Cleans up empty `config/` directory after migration.
|
||||
///
|
||||
/// Always idempotent and silent.
|
||||
pub fn migrate_agents_md(vault_path: &str) {
|
||||
let vault = Path::new(vault_path);
|
||||
pub fn migrate_agents_md(vault_path: impl AsRef<str>) {
|
||||
let vault = Path::new(vault_path.as_ref());
|
||||
let root_agents = vault.join("AGENTS.md");
|
||||
let config_agents = vault.join("config").join("agents.md");
|
||||
|
||||
@@ -259,22 +281,23 @@ pub fn migrate_agents_md(vault_path: &str) {
|
||||
log::info!("Removed empty config/ directory");
|
||||
}
|
||||
|
||||
let _ = sync_ai_guidance_files(vault_path);
|
||||
let _ = sync_ai_guidance_files(vault);
|
||||
}
|
||||
|
||||
/// Repair config files: ensure `AGENTS.md` at vault root and `config.md` type definition.
|
||||
/// Repair config files: ensure `AGENTS.md` at vault root and root type definitions.
|
||||
/// Migrates legacy `config/agents.md` to root if present.
|
||||
/// Called by the "Repair Vault" command. Returns a status message.
|
||||
pub fn repair_config_files(vault_path: &str) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
pub fn repair_config_files(vault_path: impl AsRef<str>) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path.as_ref());
|
||||
let root_agents = vault.join("AGENTS.md");
|
||||
let config_agents = vault.join("config").join("agents.md");
|
||||
|
||||
migrate_legacy_agents_file(&root_agents, &config_agents)?;
|
||||
let _ = cleanup_empty_config_dir(vault)?;
|
||||
sync_ai_guidance_files(vault_path)?;
|
||||
sync_ai_guidance_files(vault)?;
|
||||
|
||||
write_if_missing(&vault.join("config.md"), CONFIG_TYPE_DEFINITION)?;
|
||||
write_if_missing(&vault.join("note.md"), NOTE_TYPE_DEFINITION)?;
|
||||
|
||||
Ok("Config files repaired".to_string())
|
||||
}
|
||||
@@ -417,15 +440,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_creates_type_definition() {
|
||||
fn test_seed_config_files_creates_type_definitions() {
|
||||
let (_dir, vault) = create_vault();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
assert!(vault.join("config.md").exists());
|
||||
let content = fs::read_to_string(vault.join("config.md")).unwrap();
|
||||
assert!(content.contains("type: Type"));
|
||||
assert!(content.contains("icon: gear-six"));
|
||||
assert!(vault.join("note.md").exists());
|
||||
let config_content = fs::read_to_string(vault.join("config.md")).unwrap();
|
||||
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
|
||||
assert!(config_content.contains("type: Type"));
|
||||
assert!(config_content.contains("icon: gear-six"));
|
||||
assert!(note_content.contains("type: Type"));
|
||||
assert!(note_content.contains("# Note"));
|
||||
assert!(!vault.join("config").exists());
|
||||
}
|
||||
|
||||
@@ -543,10 +570,14 @@ mod tests {
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
assert!(vault.join("CLAUDE.md").exists());
|
||||
assert!(vault.join("config.md").exists());
|
||||
assert!(vault.join("note.md").exists());
|
||||
assert!(!vault.join("config").exists());
|
||||
|
||||
let agents = read_root_agents(&vault);
|
||||
assert!(agents.contains("Tolaria Vault"));
|
||||
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
|
||||
assert!(note_content.contains("type: Type"));
|
||||
assert!(note_content.contains("general-purpose document"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -208,107 +208,161 @@ const OUTDATED_AGENTS_MARKERS: [&str; 3] = [
|
||||
"Tolaria still understands legacy aliases such as `Is A`.",
|
||||
];
|
||||
|
||||
pub(super) fn agents_content_can_be_refreshed(content: &str) -> bool {
|
||||
let is_outdated_managed_template = OUTDATED_AGENTS_MARKERS
|
||||
.iter()
|
||||
.all(|marker| content.contains(marker));
|
||||
let is_stale_title_stub = content.contains("Do not add `title:` frontmatter.");
|
||||
const STALE_TITLE_FRONTMATTER_MARKER: &str = "Do not add `title:` frontmatter.";
|
||||
const LEGACY_VIEWS_SECTION_MARKER: &str = "## Views";
|
||||
const LEGACY_VIEW_FILE_MARKERS: [&str; 2] = [".view.json", "```json"];
|
||||
|
||||
content.trim().is_empty()
|
||||
|| content == PRE_TYPE_AGENTS_MD
|
||||
|| content == LEGACY_AGENTS_MD
|
||||
|| content == STALE_AGENTS_MD
|
||||
|| is_stale_title_stub
|
||||
|| is_outdated_managed_template
|
||||
struct AgentsContent<'a>(&'a str);
|
||||
|
||||
impl<'a> AgentsContent<'a> {
|
||||
fn new(content: &'a str) -> Self {
|
||||
Self(content)
|
||||
}
|
||||
|
||||
fn contains(&self, marker: &str) -> bool {
|
||||
self.0.contains(marker)
|
||||
}
|
||||
|
||||
fn contains_all(&self, markers: &[&str]) -> bool {
|
||||
markers.iter().all(|marker| self.contains(marker))
|
||||
}
|
||||
|
||||
fn is_known_legacy_template(&self) -> bool {
|
||||
self.0.trim().is_empty()
|
||||
|| self.0 == PRE_TYPE_AGENTS_MD
|
||||
|| self.0 == LEGACY_AGENTS_MD
|
||||
|| self.0 == STALE_AGENTS_MD
|
||||
}
|
||||
|
||||
fn has_stale_title_stub(&self) -> bool {
|
||||
self.contains(STALE_TITLE_FRONTMATTER_MARKER)
|
||||
}
|
||||
|
||||
fn has_legacy_json_view_guidance(&self) -> bool {
|
||||
self.contains(LEGACY_VIEWS_SECTION_MARKER)
|
||||
&& LEGACY_VIEW_FILE_MARKERS
|
||||
.iter()
|
||||
.any(|marker| self.contains(marker))
|
||||
}
|
||||
|
||||
fn can_be_refreshed(&self) -> bool {
|
||||
self.is_known_legacy_template()
|
||||
|| self.has_stale_title_stub()
|
||||
|| self.has_legacy_json_view_guidance()
|
||||
|| self.contains_all(&OUTDATED_AGENTS_MARKERS)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn agents_content_can_be_refreshed(content: &str) -> bool {
|
||||
AgentsContent::new(content).can_be_refreshed()
|
||||
}
|
||||
|
||||
/// Default AGENTS.md content — vault instructions for AI agents.
|
||||
/// Describes Tolaria vault mechanics only; no user-specific structure.
|
||||
/// The vault scanner will pick this up as a regular entry.
|
||||
pub(super) const AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault
|
||||
pub(super) const AGENTS_MD: &str = r##"---
|
||||
type: Note
|
||||
_organized: true
|
||||
---
|
||||
|
||||
This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault: a folder of Markdown files with YAML frontmatter forming a personal knowledge graph.
|
||||
# AGENTS.md — Tolaria Vault
|
||||
|
||||
Keep edits compatible with Tolaria's current conventions. Prefer small, human-readable changes over heavy restructuring.
|
||||
This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault, a folder of Markdown files with YAML frontmatter forming a personal knowledge graph.
|
||||
|
||||
Keep edits compatible with this starter vault's current conventions. Prefer small, human-readable changes over heavy restructuring.
|
||||
|
||||
## Core conventions
|
||||
|
||||
- One Markdown note per file.
|
||||
- The first H1 in the body is the preferred display title.
|
||||
- Legacy `title:` frontmatter is still read as a fallback when a note has no H1. Do not add it to new notes unless you are maintaining an older file.
|
||||
- The first H1 in the body is the preferred display title. Legacy `title:` frontmatter is still read as a fallback when a note has no H1, but do not add it to new notes.
|
||||
- Store note type in the `type:` frontmatter field.
|
||||
- Most notes live at the vault root as flat `.md` files. Type definitions live in `type/`. Saved views live in `views/`.
|
||||
- Any frontmatter field containing `[[wikilinks]]` is treated as a relationship. Common names include `Belongs to:`, `Related to:`, `Workspace:`, and custom relationship names.
|
||||
- In this starter vault, type definitions currently live at the vault root, for example `project.md`, `person.md`, `note.md`, and `config.md`. Keep new type files at the vault root unless the user explicitly asks to reorganize them.
|
||||
- Saved views live in `views/*.yml`.
|
||||
- Files in `attachments/` are assets, not notes. Reference them from notes, but do not treat them as notes or types.
|
||||
- Frontmatter properties that start with `_` are usually Tolaria-managed state. Leave them alone unless the user explicitly asks for them to change.
|
||||
|
||||
## Notes
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Project
|
||||
type: Note
|
||||
related_to: "[[tolaria]]"
|
||||
status: Active
|
||||
icon: target
|
||||
Workspace: "[[tolaria]]"
|
||||
Belongs to:
|
||||
- "[[25q2]]"
|
||||
Related to:
|
||||
- "[[person-luca-rossi]]"
|
||||
aliases:
|
||||
- Tolaria work
|
||||
url: https://example.com
|
||||
---
|
||||
|
||||
# Ship Tolaria
|
||||
# Example note
|
||||
|
||||
Body content in Markdown.
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
Type definitions are regular notes stored in `type/`. Use `type: Type` for new ones:
|
||||
In this starter vault, types are regular notes stored at the vault root and use `type: Type`.
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Type
|
||||
icon: shapes
|
||||
color: blue
|
||||
sidebar label: Projects
|
||||
template: |
|
||||
## Outcome
|
||||
|
||||
## Next actions
|
||||
_icon: rocket
|
||||
_color: "#3b82f6"
|
||||
_order: 0
|
||||
_list_properties_display:
|
||||
- related_to
|
||||
_sort: "property:onboarding:asc"
|
||||
---
|
||||
|
||||
# Project
|
||||
```
|
||||
|
||||
Useful type metadata includes `icon`, `color`, `order`, `sidebar label`, `template`, `sort`, `view`, and `visible`.
|
||||
Useful type metadata in this vault includes `icon`/`_icon`, `color`/`_color`, `order`/`_order`, `sidebar label`, `_list_properties_display`, `_sort`, `template`, `view`, and `visible`. When editing an existing file, preserve the key style already used there instead of mass-normalizing underscored keys.
|
||||
|
||||
## Relationships
|
||||
|
||||
Any frontmatter property whose value contains `[[wikilinks]]` is treated as a relationship. In this starter vault, common relationship keys include `related_to` and `belongs_to`, but custom relationship names are valid too.
|
||||
|
||||
Preserve older relationship labels such as `Belongs to:` when editing existing notes that already use them.
|
||||
|
||||
Use quoted wikilinks for scalar frontmatter values and YAML lists for multi-value relationships.
|
||||
|
||||
## Wikilinks
|
||||
|
||||
- `[[filename]]` or `[[Note Title]]` — link by filename or title
|
||||
- `[[filename|display text]]` — with custom display text
|
||||
- `[[filename]]` or `[[Note Title]]` for normal links
|
||||
- `[[filename|display text]]` for custom display text
|
||||
- Works in frontmatter values and Markdown body
|
||||
|
||||
## Views
|
||||
|
||||
Saved views live in `views/*.yml` and are written as YAML:
|
||||
Saved views live in `views/*.yml` and are written as YAML. Tolaria scans every `.yml` file in `views/`, and the filename is the stable view id, so use kebab-case filenames such as `active-projects.yml`.
|
||||
|
||||
A view definition looks like this:
|
||||
|
||||
```yaml
|
||||
name: Active Projects
|
||||
icon: kanban
|
||||
color: blue
|
||||
sort: modified:desc
|
||||
icon: null
|
||||
color: null
|
||||
sort: "property:onboarding:asc"
|
||||
filters:
|
||||
all:
|
||||
any:
|
||||
- field: type
|
||||
op: equals
|
||||
value: Project
|
||||
- field: status
|
||||
op: equals
|
||||
value: Active
|
||||
- field: related_to
|
||||
op: contains
|
||||
value: "[[tolaria]]"
|
||||
```
|
||||
|
||||
View rules that matter when creating or editing files:
|
||||
- `name` is required. `icon`, `color`, and `sort` are optional.
|
||||
- `sort` uses `option:direction`. Built-in options are `modified`, `created`, `title`, and `status`. Custom-property sorts use `property:<Property Name>`, for example `property:onboarding:asc`.
|
||||
- `filters` must be a tree whose root is exactly one `all:` group or one `any:` group.
|
||||
- Each filter condition uses `field`, `op`, and usually `value`.
|
||||
- `field` can target built-ins like `type`, `status`, `title`, `favorite`, and `body`, plus actual frontmatter keys used in this vault such as `related_to`, `belongs_to`, or `url`.
|
||||
- Supported operators are `equals`, `not_equals`, `contains`, `not_contains`, `any_of`, `none_of`, `is_empty`, `is_not_empty`, `before`, and `after`.
|
||||
- `any_of` and `none_of` expect `value` to be a YAML list.
|
||||
- `regex: true` is supported with `equals`, `not_equals`, `contains`, and `not_contains` when pattern matching is needed.
|
||||
- Relationship filters can use wikilinks in `value`, for example `"[[tolaria]]"`.
|
||||
- Do not create JSON view files or `.view.json` filenames.
|
||||
|
||||
## Filenames
|
||||
|
||||
Use kebab-case: `my-note-title.md`. One note per file.
|
||||
@@ -316,16 +370,17 @@ Use kebab-case: `my-note-title.md`. One note per file.
|
||||
## What agents should do
|
||||
|
||||
- Create and edit notes using the frontmatter and H1 conventions above.
|
||||
- Create and edit type documents in `type/`.
|
||||
- Create and edit type documents at the vault root.
|
||||
- Add or modify relationships without breaking existing wikilinks.
|
||||
- Create and edit saved views in `views/`.
|
||||
- Update `AGENTS.md` only when the user asks for vault-level guidance changes.
|
||||
|
||||
## What agents should avoid
|
||||
|
||||
- Do not infer note type from folders other than the dedicated `type/` directory for type definitions.
|
||||
- Do not move existing type files out of the vault root unless the user explicitly asks.
|
||||
- Do not treat files in `attachments/` as notes, types, or view definitions.
|
||||
- Do not silently overwrite an existing custom `AGENTS.md`.
|
||||
- Do not overwrite user-authored config or installation-specific app files unless the user explicitly asks.
|
||||
- Do not rewrite installation-specific app configuration unless the user explicitly asks.
|
||||
"##;
|
||||
|
||||
pub(super) const LEGACY_AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault
|
||||
@@ -413,18 +468,23 @@ Do not modify app configuration files — those are local to each installation.
|
||||
|
||||
/// Clone the public starter vault into the requested path.
|
||||
pub fn create_getting_started_vault(target_path: &str) -> Result<String, String> {
|
||||
create_getting_started_vault_from_repo(target_path, &getting_started_repo_url())
|
||||
let vault_path = create_getting_started_vault_from_repo(
|
||||
Path::new(target_path),
|
||||
&getting_started_repo_url(),
|
||||
)?;
|
||||
Ok(vault_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn create_getting_started_vault_from_repo(
|
||||
target_path: &str,
|
||||
target_path: &Path,
|
||||
repo_url: &str,
|
||||
) -> Result<String, String> {
|
||||
if target_path.trim().is_empty() {
|
||||
) -> Result<PathBuf, String> {
|
||||
let target_path_str = target_path.to_string_lossy();
|
||||
if target_path_str.trim().is_empty() {
|
||||
return Err("Target path is required".to_string());
|
||||
}
|
||||
|
||||
crate::git::clone_repo(repo_url, target_path)?;
|
||||
crate::git::clone_repo(repo_url, &target_path_str)?;
|
||||
let vault_path = canonical_vault_path(target_path)?;
|
||||
refresh_cloned_vault_config_files(&vault_path)?;
|
||||
Ok(vault_path)
|
||||
@@ -436,16 +496,23 @@ fn getting_started_repo_url() -> String {
|
||||
.unwrap_or_else(|_| GETTING_STARTED_REPO_URL.to_string())
|
||||
}
|
||||
|
||||
fn canonical_vault_path(target_path: &str) -> Result<String, String> {
|
||||
let path = Path::new(target_path);
|
||||
let canonical = path
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Failed to resolve vault path '{}': {}", target_path, e))?;
|
||||
Ok(canonical.to_string_lossy().to_string())
|
||||
fn canonical_vault_path(target_path: &Path) -> Result<PathBuf, String> {
|
||||
target_path.canonicalize().map_err(|e| {
|
||||
format!(
|
||||
"Failed to resolve vault path '{}': {}",
|
||||
target_path.display(),
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn refresh_cloned_vault_config_files(vault_path: &str) -> Result<(), String> {
|
||||
let agents_path = Path::new(vault_path).join("AGENTS.md");
|
||||
fn path_to_utf8<'a>(path: &'a Path, context: &str) -> Result<&'a str, String> {
|
||||
path.to_str()
|
||||
.ok_or_else(|| format!("{context} '{}' is not valid UTF-8", path.display()))
|
||||
}
|
||||
|
||||
fn refresh_cloned_vault_config_files(vault_path: &Path) -> Result<(), String> {
|
||||
let agents_path = vault_path.join("AGENTS.md");
|
||||
let refresh_agents = if !agents_path.exists() {
|
||||
true
|
||||
} else {
|
||||
@@ -459,18 +526,21 @@ fn refresh_cloned_vault_config_files(vault_path: &str) -> Result<(), String> {
|
||||
.map_err(|e| format!("Failed to write {}: {e}", agents_path.display()))?;
|
||||
}
|
||||
|
||||
crate::vault::repair_config_files(vault_path)?;
|
||||
crate::vault::repair_config_files(path_to_utf8(vault_path, "Vault path")?)?;
|
||||
|
||||
if !vault_has_pending_changes(vault_path)? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ensure_commit_identity(vault_path)?;
|
||||
crate::git::git_commit(vault_path, "Initialize Tolaria config files")?;
|
||||
crate::git::git_commit(
|
||||
path_to_utf8(vault_path, "Vault path")?,
|
||||
"Initialize Tolaria config files",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn vault_has_pending_changes(vault_path: &str) -> Result<bool, String> {
|
||||
fn vault_has_pending_changes(vault_path: &Path) -> Result<bool, String> {
|
||||
let output = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(vault_path)
|
||||
@@ -487,7 +557,7 @@ fn vault_has_pending_changes(vault_path: &str) -> Result<bool, String> {
|
||||
))
|
||||
}
|
||||
|
||||
fn ensure_commit_identity(vault_path: &str) -> Result<(), String> {
|
||||
fn ensure_commit_identity(vault_path: &Path) -> Result<(), String> {
|
||||
for (key, fallback) in [
|
||||
("user.name", "Tolaria"),
|
||||
("user.email", "vault@tolaria.app"),
|
||||
@@ -575,8 +645,7 @@ mod tests {
|
||||
let dest = dir.path().join("Getting Started");
|
||||
init_source_repo(&source, Some(agents_content));
|
||||
|
||||
create_getting_started_vault_from_repo(dest.to_str().unwrap(), source.to_str().unwrap())
|
||||
.unwrap();
|
||||
create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap()).unwrap();
|
||||
|
||||
let content = fs::read_to_string(dest.join("AGENTS.md")).unwrap();
|
||||
assert_eq!(content, AGENTS_MD);
|
||||
@@ -605,13 +674,11 @@ mod tests {
|
||||
let dest = dir.path().join("Getting Started");
|
||||
init_source_repo(&source, None);
|
||||
|
||||
let result = create_getting_started_vault_from_repo(
|
||||
dest.to_str().unwrap(),
|
||||
source.to_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let result =
|
||||
create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, dest.canonicalize().unwrap().to_string_lossy());
|
||||
assert_eq!(result, dest.canonicalize().unwrap());
|
||||
assert!(dest.join("welcome.md").exists());
|
||||
assert!(dest.join("views").join("active-projects.yml").exists());
|
||||
assert!(dest.join(".git").exists());
|
||||
@@ -631,11 +698,8 @@ mod tests {
|
||||
fs::create_dir_all(&dest).unwrap();
|
||||
fs::write(dest.join("existing.md"), "# Existing\n").unwrap();
|
||||
|
||||
let err = create_getting_started_vault_from_repo(
|
||||
dest.to_str().unwrap(),
|
||||
source.to_str().unwrap(),
|
||||
)
|
||||
.unwrap_err();
|
||||
let err = create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap())
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("already exists and is not empty"));
|
||||
}
|
||||
@@ -646,11 +710,9 @@ mod tests {
|
||||
let missing_repo = dir.path().join("missing");
|
||||
let dest = dir.path().join("Getting Started");
|
||||
|
||||
let err = create_getting_started_vault_from_repo(
|
||||
dest.to_str().unwrap(),
|
||||
missing_repo.to_str().unwrap(),
|
||||
)
|
||||
.unwrap_err();
|
||||
let err =
|
||||
create_getting_started_vault_from_repo(dest.as_path(), missing_repo.to_str().unwrap())
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("git clone failed"));
|
||||
assert!(!dest.exists());
|
||||
@@ -663,8 +725,7 @@ mod tests {
|
||||
let dest = dir.path().join("Getting Started");
|
||||
init_source_repo(&source, None);
|
||||
|
||||
create_getting_started_vault_from_repo(dest.to_str().unwrap(), source.to_str().unwrap())
|
||||
.unwrap();
|
||||
create_getting_started_vault_from_repo(dest.as_path(), source.to_str().unwrap()).unwrap();
|
||||
|
||||
let output = StdCommand::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
@@ -689,16 +750,35 @@ mod tests {
|
||||
assert!(agents_content_can_be_refreshed(PRE_TYPE_AGENTS_MD));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agents_refresh_detection_accepts_legacy_json_view_guidance() {
|
||||
let stale = r#"# AGENTS.md — Tolaria Vault
|
||||
|
||||
## Views
|
||||
|
||||
Saved filters live in `views/` as `.view.json` files:
|
||||
|
||||
```json
|
||||
{"title":"Active Notes"}
|
||||
```
|
||||
"#;
|
||||
assert!(agents_content_can_be_refreshed(stale));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agents_template_matches_current_tolaria_vault_conventions() {
|
||||
assert!(AGENTS_MD.starts_with("---\ntype: Note\n_organized: true\n---\n"));
|
||||
assert!(AGENTS_MD.contains("# AGENTS.md — Tolaria Vault"));
|
||||
assert!(AGENTS_MD.contains("type/"));
|
||||
assert!(AGENTS_MD.contains("views/"));
|
||||
assert!(AGENTS_MD.contains("sidebar label"));
|
||||
assert!(AGENTS_MD.contains("Legacy `title:` frontmatter is still read as a fallback"));
|
||||
assert!(AGENTS_MD.contains("Store note type in the `type:` frontmatter field."));
|
||||
assert!(AGENTS_MD.contains("type definitions currently live at the vault root"));
|
||||
assert!(AGENTS_MD.contains("attachments/"));
|
||||
assert!(AGENTS_MD.contains("views/*.yml"));
|
||||
assert!(AGENTS_MD.contains("option:direction"));
|
||||
assert!(AGENTS_MD.contains("property:<Property Name>"));
|
||||
assert!(AGENTS_MD.contains("actual frontmatter keys used in this vault such as `related_to`, `belongs_to`, or `url`."));
|
||||
assert!(AGENTS_MD.contains("Belongs to:"));
|
||||
assert!(AGENTS_MD.contains("Do not create JSON view files or `.view.json` filenames."));
|
||||
assert!(!AGENTS_MD.contains("Laputa"));
|
||||
assert!(!AGENTS_MD.contains("Is A"));
|
||||
assert!(!AGENTS_MD.contains("is_a"));
|
||||
|
||||
133
src/App.test.tsx
133
src/App.test.tsx
@@ -1,4 +1,5 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Provide a localStorage mock that supports all methods (jsdom's may be incomplete)
|
||||
@@ -75,8 +76,14 @@ const mockAllContent: Record<string, string> = {
|
||||
'/vault/topic/dev.md': '---\ntitle: Software Development\nis_a: Topic\n---\n\n# Software Development\n',
|
||||
}
|
||||
|
||||
const mockVaultList = {
|
||||
vaults: [{ label: 'Test Vault', path: '/vault' }],
|
||||
active_vault: '/vault',
|
||||
hidden_defaults: [],
|
||||
}
|
||||
|
||||
const mockCommandResults: Record<string, unknown> = {
|
||||
load_vault_list: { vaults: [], active_vault: null, hidden_defaults: [] },
|
||||
load_vault_list: mockVaultList,
|
||||
list_vault: mockEntries,
|
||||
list_vault_folders: [],
|
||||
list_views: [],
|
||||
@@ -93,9 +100,42 @@ const mockCommandResults: Record<string, unknown> = {
|
||||
get_vault_settings: { theme: null },
|
||||
}
|
||||
|
||||
function resetMockCommandResults() {
|
||||
Object.assign(mockCommandResults, {
|
||||
load_vault_list: mockVaultList,
|
||||
list_vault: mockEntries,
|
||||
list_vault_folders: [],
|
||||
list_views: [],
|
||||
get_all_content: mockAllContent,
|
||||
get_modified_files: [],
|
||||
get_note_content: mockAllContent['/vault/project/test.md'] || '',
|
||||
get_file_history: [],
|
||||
get_settings: {
|
||||
auto_pull_interval_minutes: null,
|
||||
telemetry_consent: true,
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
release_channel: null,
|
||||
},
|
||||
save_settings: null,
|
||||
check_vault_exists: true,
|
||||
get_default_vault_path: '/Users/mock/Documents/Getting Started',
|
||||
list_themes: [],
|
||||
get_vault_settings: { theme: null },
|
||||
})
|
||||
}
|
||||
|
||||
function resolveMockCommandResult(cmd: string, args?: unknown) {
|
||||
const result = mockCommandResults[cmd]
|
||||
return typeof result === 'function'
|
||||
? (result as (input?: unknown) => unknown)(args)
|
||||
: result ?? null
|
||||
}
|
||||
|
||||
vi.mock('./mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: vi.fn(async (cmd: string) => mockCommandResults[cmd] ?? null),
|
||||
mockInvoke: vi.fn(async (cmd: string, args?: unknown) => resolveMockCommandResult(cmd, args)),
|
||||
addMockEntry: vi.fn(),
|
||||
updateMockContent: vi.fn(),
|
||||
}))
|
||||
@@ -125,6 +165,12 @@ vi.mock('@blocknote/core/extensions', () => ({
|
||||
|
||||
vi.mock('@blocknote/react', () => ({
|
||||
createReactInlineContentSpec: () => ({ render: () => null }),
|
||||
BlockNoteViewRaw: ({ children }: { children?: ReactNode }) => (
|
||||
<div data-testid="blocknote-view">{children}</div>
|
||||
),
|
||||
ComponentsContext: {
|
||||
Provider: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
},
|
||||
useCreateBlockNote: () => ({
|
||||
tryParseMarkdownToBlocks: async () => [],
|
||||
replaceBlocks: () => {},
|
||||
@@ -132,6 +178,7 @@ vi.mock('@blocknote/react', () => ({
|
||||
insertInlineContent: () => {},
|
||||
onMount: (cb: () => void) => { cb(); return () => {} },
|
||||
}),
|
||||
SideMenuController: () => null,
|
||||
SuggestionMenuController: () => null,
|
||||
}))
|
||||
|
||||
@@ -148,6 +195,7 @@ const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dis
|
||||
describe('App', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetMockCommandResults()
|
||||
localStorage.clear()
|
||||
localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, '1')
|
||||
})
|
||||
@@ -196,6 +244,68 @@ describe('App', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows onboarding after telemetry consent when no active vault is configured', async () => {
|
||||
mockCommandResults.get_settings = {
|
||||
auto_pull_interval_minutes: null,
|
||||
telemetry_consent: null,
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
release_channel: null,
|
||||
}
|
||||
mockCommandResults.load_vault_list = { vaults: [], active_vault: null, hidden_defaults: [] }
|
||||
mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === '/Users/mock/Documents/Getting Started'
|
||||
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Help improve Tolaria')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('telemetry-accept'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('welcome-screen')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['telemetry-accept', 'Allow anonymous reporting'],
|
||||
['telemetry-decline', 'No thanks'],
|
||||
])('ignores a remembered default vault after %s when onboarding was never completed', async (buttonTestId) => {
|
||||
const rememberedDefaultVaultPath = '/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2'
|
||||
localStorage.setItem('tolaria_welcome_dismissed', '1')
|
||||
mockCommandResults.get_default_vault_path = rememberedDefaultVaultPath
|
||||
mockCommandResults.get_settings = {
|
||||
auto_pull_interval_minutes: null,
|
||||
telemetry_consent: null,
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
release_channel: null,
|
||||
}
|
||||
mockCommandResults.load_vault_list = {
|
||||
vaults: [],
|
||||
active_vault: rememberedDefaultVaultPath,
|
||||
hidden_defaults: [],
|
||||
}
|
||||
mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === rememberedDefaultVaultPath
|
||||
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Help improve Tolaria')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId(buttonTestId))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('welcome-screen')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
|
||||
})
|
||||
|
||||
it('renders sidebar with correct default selection (All Notes)', async () => {
|
||||
render(<App />)
|
||||
await waitFor(() => {
|
||||
@@ -206,6 +316,11 @@ describe('App', () => {
|
||||
})
|
||||
|
||||
it('defaults to All Notes when explicit organization is disabled in vault config', async () => {
|
||||
mockCommandResults.load_vault_list = {
|
||||
vaults: [{ label: 'Getting Started', path: '/Users/mock/Documents/Getting Started' }],
|
||||
active_vault: '/Users/mock/Documents/Getting Started',
|
||||
hidden_defaults: [],
|
||||
}
|
||||
const disabledWorkflowConfig = JSON.stringify({
|
||||
zoom: null,
|
||||
view_mode: null,
|
||||
@@ -215,13 +330,19 @@ describe('App', () => {
|
||||
property_display_modes: null,
|
||||
inbox: { noteListProperties: null, explicitOrganization: false },
|
||||
})
|
||||
localStorage.setItem('laputa:vault-config:/Users/mock/Documents/Getting Started', disabledWorkflowConfig)
|
||||
localStorage.setItem('laputa:vault-config:/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2', disabledWorkflowConfig)
|
||||
const vaultPaths = [
|
||||
'/Users/mock/Documents/Getting Started',
|
||||
'/Users/mock/demo-vault-v2',
|
||||
'/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2',
|
||||
]
|
||||
for (const path of vaultPaths) {
|
||||
localStorage.setItem(`laputa:vault-config:${path}`, disabledWorkflowConfig)
|
||||
}
|
||||
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Inbox')).not.toBeInTheDocument()
|
||||
expect(within(screen.getByTestId('sidebar-top-nav')).queryByText('Inbox')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
230
src/App.tsx
230
src/App.tsx
@@ -24,18 +24,21 @@ import { useMcpStatus } from './hooks/useMcpStatus'
|
||||
import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding'
|
||||
import { useAiAgentsStatus } from './hooks/useAiAgentsStatus'
|
||||
import { useVaultAiGuidanceStatus } from './hooks/useVaultAiGuidanceStatus'
|
||||
import { useAutoGit } from './hooks/useAutoGit'
|
||||
import { useVaultLoader } from './hooks/useVaultLoader'
|
||||
import { useAiAgentPreferences } from './hooks/useAiAgentPreferences'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useNoteActions } from './hooks/useNoteActions'
|
||||
import { slugify } from './hooks/useNoteCreation'
|
||||
import { useCommitFlow } from './hooks/useCommitFlow'
|
||||
import { useGitRemoteStatus } from './hooks/useGitRemoteStatus'
|
||||
import { useViewMode, type ViewMode } from './hooks/useViewMode'
|
||||
import { useEntryActions } from './hooks/useEntryActions'
|
||||
import { useAppCommands } from './hooks/useAppCommands'
|
||||
import { triggerCommitEntryAction } from './utils/commitEntryAction'
|
||||
import { generateCommitMessage } from './utils/commitMessage'
|
||||
import { useDialogs } from './hooks/useDialogs'
|
||||
import { GETTING_STARTED_LABEL, useVaultSwitcher } from './hooks/useVaultSwitcher'
|
||||
import { useVaultSwitcher } from './hooks/useVaultSwitcher'
|
||||
import { useGitHistory } from './hooks/useGitHistory'
|
||||
import { useUpdater, restartApp } from './hooks/useUpdater'
|
||||
import { useAutoSync } from './hooks/useAutoSync'
|
||||
@@ -204,22 +207,40 @@ function App() {
|
||||
onSwitch: () => { handleSetSelection(DEFAULT_SELECTION); notes.closeAllTabs() },
|
||||
onToast: (msg) => setToastMessage(msg),
|
||||
})
|
||||
const { handleVaultCloned } = vaultSwitcher
|
||||
const { allVaults, defaultPath, handleVaultCloned, selectedVaultPath, switchVault } = vaultSwitcher
|
||||
|
||||
const handleGettingStartedVaultReady = useCallback((vaultPath: string, label: string) => {
|
||||
const rememberOnboardingVaultChoice = useCallback((vaultPath: string) => {
|
||||
if (!vaultPath) return
|
||||
|
||||
if (allVaults.some((vault) => vault.path === vaultPath)) {
|
||||
switchVault(vaultPath)
|
||||
return
|
||||
}
|
||||
|
||||
const label = vaultPath.split('/').filter(Boolean).pop() || 'Local Vault'
|
||||
handleVaultCloned(vaultPath, label)
|
||||
}, [allVaults, handleVaultCloned, switchVault])
|
||||
|
||||
const handleGettingStartedVaultReady = useCallback((vaultPath: string) => {
|
||||
rememberOnboardingVaultChoice(vaultPath)
|
||||
setToastMessage(`Getting Started vault cloned and opened at ${vaultPath}`)
|
||||
}, [handleVaultCloned])
|
||||
}, [rememberOnboardingVaultChoice])
|
||||
const cloneGettingStartedVault = useGettingStartedClone({
|
||||
onError: (message) => setToastMessage(message),
|
||||
onSuccess: handleGettingStartedVaultReady,
|
||||
})
|
||||
const onboarding = useOnboarding(vaultSwitcher.vaultPath, (vaultPath) => {
|
||||
handleGettingStartedVaultReady(vaultPath, GETTING_STARTED_LABEL)
|
||||
handleGettingStartedVaultReady(vaultPath)
|
||||
})
|
||||
const aiAgentsStatus = useAiAgentsStatus()
|
||||
const aiAgentsOnboarding = useAiAgentsOnboarding(onboarding.state.status === 'ready' && !noteWindowParams)
|
||||
|
||||
useEffect(() => {
|
||||
if (onboarding.state.status !== 'ready') return
|
||||
if (!onboarding.state.vaultPath || onboarding.state.vaultPath === vaultSwitcher.vaultPath) return
|
||||
rememberOnboardingVaultChoice(onboarding.state.vaultPath)
|
||||
}, [onboarding.state, rememberOnboardingVaultChoice, vaultSwitcher.vaultPath])
|
||||
|
||||
// The active vault path can temporarily come from onboarding before the
|
||||
// persisted vault switcher catches up to the newly cloned starter vault.
|
||||
const resolvedPath = noteWindowParams?.vaultPath ?? (onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath)
|
||||
@@ -334,12 +355,20 @@ function App() {
|
||||
onToast: (msg) => setToastMessage(msg),
|
||||
onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath),
|
||||
})
|
||||
const flushPendingRawContentRef = useRef<((path: string) => void) | null>(null)
|
||||
|
||||
const notes = useNoteActions({
|
||||
addEntry: vault.addEntry,
|
||||
removeEntry: vault.removeEntry,
|
||||
entries: vault.entries,
|
||||
flushBeforePathRename: (path) => appSave.flushBeforeAction(path),
|
||||
flushBeforeNoteSwitch: async (path) => {
|
||||
flushPendingRawContentRef.current?.(path)
|
||||
await appSave.flushBeforeAction(path)
|
||||
},
|
||||
flushBeforePathRename: async (path) => {
|
||||
flushPendingRawContentRef.current?.(path)
|
||||
await appSave.flushBeforeAction(path)
|
||||
},
|
||||
reloadVault: vault.reloadVault,
|
||||
setToastMessage,
|
||||
updateEntry: vault.updateEntry,
|
||||
@@ -616,6 +645,89 @@ function App() {
|
||||
vaultPath: resolvedPath,
|
||||
})
|
||||
const suggestedCommitMessage = useMemo(() => generateCommitMessage(vault.modifiedFiles), [vault.modifiedFiles])
|
||||
const isGitVault = !vault.modifiedFilesError
|
||||
const modifiedFilesSignature = useMemo(
|
||||
() => vault.modifiedFiles.map((file) => `${file.relativePath}:${file.status}`).sort().join('|'),
|
||||
[vault.modifiedFiles],
|
||||
)
|
||||
const autoGit = useAutoGit({
|
||||
enabled: settings.autogit_enabled === true,
|
||||
idleThresholdSeconds: settings.autogit_idle_threshold_seconds ?? 90,
|
||||
inactiveThresholdSeconds: settings.autogit_inactive_threshold_seconds ?? 30,
|
||||
isGitVault,
|
||||
hasPendingChanges: vault.modifiedFiles.length > 0
|
||||
|| ((autoSync.remoteStatus?.hasRemote ?? false) && (autoSync.remoteStatus?.ahead ?? 0) > 0),
|
||||
hasUnsavedChanges: vault.unsavedPaths.size > 0,
|
||||
onCheckpoint: () => commitFlow.runAutomaticCheckpoint(),
|
||||
})
|
||||
const recordAutoGitActivity = autoGit.recordActivity
|
||||
const openCommitDialog = commitFlow.openCommitDialog
|
||||
const runAutomaticCheckpoint = commitFlow.runAutomaticCheckpoint
|
||||
const handleAppContentChange = appSave.handleContentChange
|
||||
const handleAppSave = appSave.handleSave
|
||||
const loadModifiedFiles = vault.loadModifiedFiles
|
||||
|
||||
useEffect(() => {
|
||||
if (modifiedFilesSignature.length === 0) return
|
||||
recordAutoGitActivity()
|
||||
}, [modifiedFilesSignature, recordAutoGitActivity])
|
||||
|
||||
const handleCommitPush = useCallback(() => {
|
||||
triggerCommitEntryAction({
|
||||
autoGitEnabled: settings.autogit_enabled === true,
|
||||
openCommitDialog,
|
||||
runAutomaticCheckpoint,
|
||||
})
|
||||
}, [openCommitDialog, runAutomaticCheckpoint, settings.autogit_enabled])
|
||||
|
||||
const handleTrackedContentChange = useCallback((path: string, content: string) => {
|
||||
recordAutoGitActivity()
|
||||
handleAppContentChange(path, content)
|
||||
}, [handleAppContentChange, recordAutoGitActivity])
|
||||
|
||||
const handleTrackedSave = useCallback(async (...args: Parameters<typeof handleAppSave>) => {
|
||||
const result = await handleAppSave(...args)
|
||||
recordAutoGitActivity()
|
||||
return result
|
||||
}, [handleAppSave, recordAutoGitActivity])
|
||||
|
||||
const seedAutoGitSavedChange = useCallback(async () => {
|
||||
if (isTauri()) {
|
||||
throw new Error('seedAutoGitSavedChange is only available in browser smoke tests')
|
||||
}
|
||||
|
||||
const activePath = notes.activeTabPath
|
||||
const activeTab = activePath
|
||||
? notes.tabs.find((tab) => tab.entry.path === activePath)
|
||||
: null
|
||||
|
||||
if (!activePath || !activeTab) {
|
||||
throw new Error('No active note is available for the AutoGit test bridge')
|
||||
}
|
||||
|
||||
const saveNoteContent = window.__mockHandlers?.save_note_content
|
||||
if (typeof saveNoteContent === 'function') {
|
||||
await Promise.resolve(saveNoteContent({ path: activePath, content: activeTab.content }))
|
||||
} else {
|
||||
await mockInvoke('save_note_content', { path: activePath, content: activeTab.content })
|
||||
}
|
||||
|
||||
await loadModifiedFiles()
|
||||
recordAutoGitActivity()
|
||||
}, [loadModifiedFiles, notes.activeTabPath, notes.tabs, recordAutoGitActivity])
|
||||
|
||||
useEffect(() => {
|
||||
window.__laputaTest = {
|
||||
...window.__laputaTest,
|
||||
seedAutoGitSavedChange,
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (window.__laputaTest?.seedAutoGitSavedChange === seedAutoGitSavedChange) {
|
||||
delete window.__laputaTest.seedAutoGitSavedChange
|
||||
}
|
||||
}
|
||||
}, [seedAutoGitSavedChange])
|
||||
|
||||
const entryActions = useEntryActions({
|
||||
entries: vault.entries, updateEntry: vault.updateEntry,
|
||||
@@ -639,7 +751,34 @@ function App() {
|
||||
const handleCreateType = useCallback((name: string) => {
|
||||
notes.handleCreateType(name)
|
||||
setToastMessage(`Type "${name}" created`)
|
||||
}, [notes])
|
||||
}, [notes, setToastMessage])
|
||||
|
||||
const handleCreateMissingType = useCallback(async (path: string, missingType: string, nextTypeName: string) => {
|
||||
const trimmed = nextTypeName.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
const targetFilename = `${slugify(trimmed)}.md`
|
||||
const exactType = vault.entries.find((entry) => entry.isA === 'Type' && entry.title === trimmed)
|
||||
const slugMatch = vault.entries.find((entry) => entry.isA === 'Type' && slugify(entry.title) === slugify(trimmed))
|
||||
const filenameCollision = vault.entries.find((entry) => entry.filename.toLowerCase() === targetFilename)
|
||||
const resolvedTypeName = exactType?.title ?? slugMatch?.title ?? trimmed
|
||||
|
||||
if (filenameCollision && filenameCollision.isA !== 'Type') {
|
||||
setToastMessage(`Cannot create type "${trimmed}" because ${targetFilename} already exists`)
|
||||
throw new Error(`Type filename collision for ${targetFilename}`)
|
||||
}
|
||||
|
||||
if (!exactType && !slugMatch) {
|
||||
await notes.createTypeEntrySilent(trimmed)
|
||||
}
|
||||
|
||||
await notes.handleUpdateFrontmatter(path, 'type', resolvedTypeName)
|
||||
setToastMessage(
|
||||
resolvedTypeName === missingType
|
||||
? `Type "${resolvedTypeName}" created`
|
||||
: `Type set to "${resolvedTypeName}"`,
|
||||
)
|
||||
}, [notes, setToastMessage, vault.entries])
|
||||
|
||||
const handleCreateOrUpdateView = useCallback(async (definition: import('./types').ViewDefinition) => {
|
||||
const editing = dialogs.editingView
|
||||
@@ -792,6 +931,17 @@ function App() {
|
||||
) ?? null
|
||||
}, [notes.activeTabPath, vault.modifiedFiles])
|
||||
|
||||
const activeCommandEntry = useMemo(() => {
|
||||
if (!notes.activeTabPath) return null
|
||||
return notes.tabs.find((tab) => tab.entry.path === notes.activeTabPath)?.entry
|
||||
?? vault.entries.find((entry) => entry.path === notes.activeTabPath)
|
||||
?? null
|
||||
}, [notes.activeTabPath, notes.tabs, vault.entries])
|
||||
|
||||
const canToggleRichEditor = !!activeCommandEntry
|
||||
&& activeCommandEntry.filename.toLowerCase().endsWith('.md')
|
||||
&& !activeDeletedFile
|
||||
|
||||
const commands = useAppCommands({
|
||||
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
||||
entries: vault.entries,
|
||||
@@ -809,13 +959,13 @@ function App() {
|
||||
onOpenFeedback: openFeedback,
|
||||
onDeleteNote: deleteActions.handleDeleteNote,
|
||||
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
|
||||
onCommitPush: commitFlow.openCommitDialog,
|
||||
onCommitPush: handleCommitPush,
|
||||
onPull: autoSync.triggerSync,
|
||||
onResolveConflicts: conflictFlow.handleOpenConflictResolver,
|
||||
onSetViewMode: handleSetViewMode,
|
||||
onToggleInspector: handleToggleInspector,
|
||||
onToggleDiff: () => diffToggleRef.current(),
|
||||
onToggleRawEditor: activeDeletedFile ? undefined : () => rawToggleRef.current(),
|
||||
onToggleRawEditor: canToggleRichEditor ? () => rawToggleRef.current() : undefined,
|
||||
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
|
||||
zoomLevel: zoom.zoomLevel,
|
||||
onSelect: handleSetSelection,
|
||||
@@ -880,16 +1030,45 @@ function App() {
|
||||
return { type: null, query: '' }
|
||||
}, [effectiveSelection])
|
||||
|
||||
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
|
||||
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing')) {
|
||||
return <WelcomeView onboarding={onboarding} isOffline={networkStatus.isOffline} />
|
||||
}
|
||||
const shouldResumeFreshStartOnboarding = useMemo(() => {
|
||||
if (onboarding.state.status !== 'ready' || !vaultSwitcher.loaded) return false
|
||||
const remembersOnlyDefaultVault = selectedVaultPath === null || selectedVaultPath === defaultPath
|
||||
|
||||
return remembersOnlyDefaultVault
|
||||
&& vaultSwitcher.allVaults.length === 1
|
||||
&& vaultSwitcher.allVaults[0]?.path === vaultSwitcher.vaultPath
|
||||
&& onboarding.state.vaultPath === vaultSwitcher.vaultPath
|
||||
}, [defaultPath, onboarding.state, selectedVaultPath, vaultSwitcher.allVaults, vaultSwitcher.loaded, vaultSwitcher.vaultPath])
|
||||
|
||||
// Show loading spinner while checking vault (skip for note windows)
|
||||
if (!noteWindowParams && onboarding.state.status === 'loading') {
|
||||
return <LoadingView />
|
||||
}
|
||||
|
||||
// Show telemetry consent dialog on first launch (skip for note windows).
|
||||
// After the user answers, the next render can continue into onboarding.
|
||||
if (!noteWindowParams && settingsLoaded && settings.telemetry_consent === null) {
|
||||
return (
|
||||
<TelemetryConsentDialog
|
||||
onAccept={() => {
|
||||
const id = crypto.randomUUID()
|
||||
saveSettings({ ...settings, telemetry_consent: true, crash_reporting_enabled: true, analytics_enabled: true, anonymous_id: id })
|
||||
}}
|
||||
onDecline={() => {
|
||||
saveSettings({ ...settings, telemetry_consent: false, crash_reporting_enabled: false, analytics_enabled: false, anonymous_id: null })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
|
||||
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing' || shouldResumeFreshStartOnboarding)) {
|
||||
const welcomeOnboarding = shouldResumeFreshStartOnboarding
|
||||
? { ...onboarding, state: { status: 'welcome' as const, defaultPath: vaultSwitcher.vaultPath } }
|
||||
: onboarding
|
||||
return <WelcomeView onboarding={welcomeOnboarding} isOffline={networkStatus.isOffline} />
|
||||
}
|
||||
|
||||
if (!noteWindowParams && onboarding.state.status === 'ready' && aiAgentsOnboarding.showPrompt) {
|
||||
return (
|
||||
<>
|
||||
@@ -919,21 +1098,6 @@ function App() {
|
||||
return <LoadingView />
|
||||
}
|
||||
|
||||
// Show telemetry consent dialog on first launch (skip for note windows)
|
||||
if (!noteWindowParams && settingsLoaded && settings.telemetry_consent === null) {
|
||||
return (
|
||||
<TelemetryConsentDialog
|
||||
onAccept={() => {
|
||||
const id = crypto.randomUUID()
|
||||
saveSettings({ ...settings, telemetry_consent: true, crash_reporting_enabled: true, analytics_enabled: true, anonymous_id: id })
|
||||
}}
|
||||
onDecline={() => {
|
||||
saveSettings({ ...settings, telemetry_consent: false, crash_reporting_enabled: false, analytics_enabled: false, anonymous_id: null })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className="app">
|
||||
@@ -981,6 +1145,7 @@ function App() {
|
||||
onUpdateFrontmatter={notes.handleUpdateFrontmatter}
|
||||
onDeleteProperty={notes.handleDeleteProperty}
|
||||
onAddProperty={notes.handleAddProperty}
|
||||
onCreateMissingType={handleCreateMissingType}
|
||||
onCreateAndOpenNote={notes.handleCreateNoteForRelationship}
|
||||
onInitializeProperties={handleInitializeProperties}
|
||||
showAIChat={dialogs.showAIChat}
|
||||
@@ -993,8 +1158,8 @@ function App() {
|
||||
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
|
||||
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
|
||||
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
|
||||
onContentChange={appSave.handleContentChange}
|
||||
onSave={appSave.handleSave}
|
||||
onContentChange={handleTrackedContentChange}
|
||||
onSave={handleTrackedSave}
|
||||
onRenameFilename={activeDeletedFile ? undefined : appSave.handleFilenameRename}
|
||||
rawToggleRef={rawToggleRef}
|
||||
diffToggleRef={diffToggleRef}
|
||||
@@ -1009,12 +1174,13 @@ function App() {
|
||||
isConflicted={conflictFlow.isConflicted}
|
||||
onKeepMine={conflictFlow.handleKeepMine}
|
||||
onKeepTheirs={conflictFlow.handleKeepTheirs}
|
||||
flushPendingRawContentRef={flushPendingRawContentRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isOffline={networkStatus.isOffline} isGitVault={!vault.modifiedFilesError} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
|
||||
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
@@ -1048,7 +1214,7 @@ function App() {
|
||||
onCommit={conflictResolver.commitResolution}
|
||||
onClose={conflictFlow.handleCloseConflictResolver}
|
||||
/>
|
||||
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
|
||||
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
|
||||
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
|
||||
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
|
||||
{deleteActions.confirmDelete && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { BreadcrumbBar } from './BreadcrumbBar'
|
||||
import type { VaultEntry } from '../types'
|
||||
@@ -49,6 +49,19 @@ const defaultProps = {
|
||||
onToggleDiff: vi.fn(),
|
||||
}
|
||||
|
||||
async function expectTooltip(trigger: HTMLElement, ...parts: string[]) {
|
||||
act(() => {
|
||||
fireEvent.focus(trigger)
|
||||
})
|
||||
const tooltip = await screen.findByRole('tooltip')
|
||||
for (const part of parts) {
|
||||
expect(tooltip).toHaveTextContent(part)
|
||||
}
|
||||
act(() => {
|
||||
fireEvent.blur(trigger)
|
||||
})
|
||||
}
|
||||
|
||||
describe('BreadcrumbBar — drag region', () => {
|
||||
it('has data-tauri-drag-region on the container', () => {
|
||||
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
@@ -67,13 +80,13 @@ describe('BreadcrumbBar — drag region', () => {
|
||||
describe('BreadcrumbBar — delete', () => {
|
||||
it('shows delete button', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onDelete={vi.fn()} />)
|
||||
expect(screen.getByTitle('Delete (Cmd+Delete)')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Delete this note' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onDelete when delete button is clicked', () => {
|
||||
const onDelete = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onDelete={onDelete} />)
|
||||
fireEvent.click(screen.getByTitle('Delete (Cmd+Delete)'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete this note' }))
|
||||
expect(onDelete).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -81,40 +94,40 @@ describe('BreadcrumbBar — delete', () => {
|
||||
describe('BreadcrumbBar — archive/unarchive', () => {
|
||||
it('shows archive button for non-archived note', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
|
||||
expect(screen.getByTitle('Archive')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Unarchive')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Archive this note' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Restore this archived note' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows unarchive button for archived note', () => {
|
||||
render(<BreadcrumbBar entry={archivedEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
|
||||
expect(screen.getByTitle('Unarchive')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Archive')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Restore this archived note' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Archive this note' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onArchive when archive button is clicked', () => {
|
||||
const onArchive = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onArchive={onArchive} />)
|
||||
fireEvent.click(screen.getByTitle('Archive'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Archive this note' }))
|
||||
expect(onArchive).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onUnarchive when unarchive button is clicked', () => {
|
||||
const onUnarchive = vi.fn()
|
||||
render(<BreadcrumbBar entry={archivedEntry} {...defaultProps} onUnarchive={onUnarchive} />)
|
||||
fireEvent.click(screen.getByTitle('Unarchive'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Restore this archived note' }))
|
||||
expect(onUnarchive).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — organized shortcut hint', () => {
|
||||
it('shows Cmd+E on the organized toggle tooltip', () => {
|
||||
it('shows Cmd+E on the organized toggle tooltip', async () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onToggleOrganized={vi.fn()} />)
|
||||
expect(screen.getByTitle('Mark as organized (remove from Inbox) (Cmd+E)')).toBeInTheDocument()
|
||||
await expectTooltip(screen.getByRole('button', { name: 'Set note as organized' }), 'Set note as organized', '⌘E')
|
||||
})
|
||||
|
||||
it('hides the organized toggle when the workflow is disabled', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
expect(screen.queryByTitle('Mark as organized (remove from Inbox) (Cmd+E)')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Set note as organized' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -238,32 +251,32 @@ describe('BreadcrumbBar — raw editor toggle', () => {
|
||||
it('shows Raw editor button with tooltip "Raw editor" when rawMode is off', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={false} onToggleRaw={onToggleRaw} />)
|
||||
expect(screen.getByTitle('Raw editor')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Open the raw editor' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Back to editor" tooltip when rawMode is on', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={true} onToggleRaw={onToggleRaw} />)
|
||||
expect(screen.getByTitle('Back to editor')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Return to the editor' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onToggleRaw when raw button is clicked', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={false} onToggleRaw={onToggleRaw} />)
|
||||
fireEvent.click(screen.getByTitle('Raw editor'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open the raw editor' }))
|
||||
expect(onToggleRaw).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides raw toggle when forceRawMode is true (non-markdown file)', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={true} onToggleRaw={onToggleRaw} forceRawMode={true} />)
|
||||
expect(screen.queryByTitle('Raw editor')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Back to editor')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Open the raw editor' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Return to the editor' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows raw toggle when forceRawMode is false (markdown file)', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={false} onToggleRaw={onToggleRaw} forceRawMode={false} />)
|
||||
expect(screen.getByTitle('Raw editor')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Open the raw editor' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { VaultEntry } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ActionTooltip, type ActionTooltipCopy } from '@/components/ui/action-tooltip'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import {
|
||||
MagnifyingGlass,
|
||||
GitBranch,
|
||||
Code,
|
||||
CursorText,
|
||||
@@ -96,46 +96,49 @@ function handleFilenameInputKeyDown(
|
||||
}
|
||||
|
||||
function IconActionButton({
|
||||
title,
|
||||
copy,
|
||||
onClick,
|
||||
className,
|
||||
style,
|
||||
disabled,
|
||||
tabIndex,
|
||||
children,
|
||||
testId,
|
||||
tooltipAlign,
|
||||
}: {
|
||||
title: string
|
||||
copy: ActionTooltipCopy
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
disabled?: boolean
|
||||
tabIndex?: number
|
||||
children: ReactNode
|
||||
testId?: string
|
||||
tooltipAlign?: 'start' | 'center' | 'end'
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={cn('text-muted-foreground [&_svg:not([class*=size-])]:size-4', className)}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
tabIndex={tabIndex}
|
||||
title={title}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
<ActionTooltip copy={copy} side="bottom" align={tooltipAlign}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={cn('text-muted-foreground [&_svg:not([class*=size-])]:size-4', className)}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
aria-label={copy.label}
|
||||
aria-disabled={onClick ? undefined : true}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</ActionTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) {
|
||||
const copy: ActionTooltipCopy = {
|
||||
label: rawMode ? 'Return to the editor' : 'Open the raw editor',
|
||||
shortcut: '⌘\\',
|
||||
}
|
||||
return (
|
||||
<IconActionButton
|
||||
title={rawMode ? 'Back to editor' : 'Raw editor'}
|
||||
copy={copy}
|
||||
onClick={onToggleRaw}
|
||||
className={cn(rawMode ? 'text-foreground' : 'hover:text-foreground')}
|
||||
>
|
||||
@@ -145,9 +148,13 @@ function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggle
|
||||
}
|
||||
|
||||
function FavoriteAction({ favorite, onToggleFavorite }: { favorite: boolean; onToggleFavorite?: () => void }) {
|
||||
const copy: ActionTooltipCopy = {
|
||||
label: favorite ? 'Remove from favorites' : 'Add to favorites',
|
||||
shortcut: '⌘D',
|
||||
}
|
||||
return (
|
||||
<IconActionButton
|
||||
title={favorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
copy={copy}
|
||||
onClick={onToggleFavorite}
|
||||
className={cn(favorite ? 'text-yellow-500' : 'hover:text-foreground')}
|
||||
>
|
||||
@@ -164,9 +171,13 @@ function OrganizedAction({
|
||||
onToggleOrganized?: () => void
|
||||
}) {
|
||||
if (!onToggleOrganized) return null
|
||||
const copy: ActionTooltipCopy = {
|
||||
label: organized ? 'Set note as not organized' : 'Set note as organized',
|
||||
shortcut: '⌘E',
|
||||
}
|
||||
return (
|
||||
<IconActionButton
|
||||
title={organized ? 'Mark as unorganized (back to Inbox) (Cmd+E)' : 'Mark as organized (remove from Inbox) (Cmd+E)'}
|
||||
copy={copy}
|
||||
onClick={onToggleOrganized}
|
||||
className={cn(organized ? 'text-green-600' : 'hover:text-foreground')}
|
||||
>
|
||||
@@ -175,14 +186,6 @@ function OrganizedAction({
|
||||
)
|
||||
}
|
||||
|
||||
function SearchAction() {
|
||||
return (
|
||||
<IconActionButton title="Search in file" className="hover:text-foreground">
|
||||
<MagnifyingGlass size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function DiffAction({
|
||||
showDiffToggle,
|
||||
diffMode,
|
||||
@@ -191,17 +194,19 @@ function DiffAction({
|
||||
}: Pick<BreadcrumbBarProps, 'showDiffToggle' | 'diffMode' | 'diffLoading' | 'onToggleDiff'>) {
|
||||
if (!showDiffToggle) {
|
||||
return (
|
||||
<IconActionButton title="No changes" style={DISABLED_ICON_STYLE} tabIndex={-1}>
|
||||
<IconActionButton copy={{ label: 'No diff is available yet' }} style={DISABLED_ICON_STYLE}>
|
||||
<GitBranch size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
const copy: ActionTooltipCopy = diffLoading
|
||||
? { label: 'Loading the diff' }
|
||||
: { label: diffMode ? 'Return to the editor' : 'Show the current diff' }
|
||||
return (
|
||||
<IconActionButton
|
||||
title={diffLoading ? 'Loading diff...' : diffMode ? 'Back to editor' : 'Show diff'}
|
||||
copy={copy}
|
||||
onClick={onToggleDiff}
|
||||
disabled={diffLoading}
|
||||
className={cn(diffMode ? 'text-foreground' : 'hover:text-foreground')}
|
||||
>
|
||||
<GitBranch size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
@@ -209,18 +214,22 @@ function DiffAction({
|
||||
)
|
||||
}
|
||||
|
||||
function PlaceholderAction({ title, children }: { title: string; children: ReactNode }) {
|
||||
function PlaceholderAction({ copy, children }: { copy: ActionTooltipCopy; children: ReactNode }) {
|
||||
return (
|
||||
<IconActionButton title={title} style={DISABLED_ICON_STYLE} tabIndex={-1}>
|
||||
<IconActionButton copy={copy} style={DISABLED_ICON_STYLE}>
|
||||
{children}
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function AIChatAction({ showAIChat, onToggleAIChat }: Pick<BreadcrumbBarProps, 'showAIChat' | 'onToggleAIChat'>) {
|
||||
const copy: ActionTooltipCopy = {
|
||||
label: showAIChat ? 'Close the AI panel' : 'Open the AI panel',
|
||||
shortcut: '⇧⌘L',
|
||||
}
|
||||
return (
|
||||
<IconActionButton
|
||||
title={showAIChat ? 'Close AI Chat' : 'Open AI Chat'}
|
||||
copy={copy}
|
||||
onClick={onToggleAIChat}
|
||||
className={cn(showAIChat ? 'text-primary' : 'hover:text-foreground')}
|
||||
>
|
||||
@@ -236,14 +245,14 @@ function ArchiveAction({
|
||||
}: Pick<VaultEntry, 'archived'> & Pick<BreadcrumbBarProps, 'onArchive' | 'onUnarchive'>) {
|
||||
if (archived) {
|
||||
return (
|
||||
<IconActionButton title="Unarchive" onClick={onUnarchive} className="hover:text-foreground">
|
||||
<IconActionButton copy={{ label: 'Restore this archived note' }} onClick={onUnarchive} className="hover:text-foreground">
|
||||
<ArrowUUpLeft size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<IconActionButton title="Archive" onClick={onArchive} className="hover:text-foreground">
|
||||
<IconActionButton copy={{ label: 'Archive this note' }} onClick={onArchive} className="hover:text-foreground">
|
||||
<Archive size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
@@ -251,7 +260,7 @@ function ArchiveAction({
|
||||
|
||||
function DeleteAction({ onDelete }: Pick<BreadcrumbBarProps, 'onDelete'>) {
|
||||
return (
|
||||
<IconActionButton title="Delete (Cmd+Delete)" onClick={onDelete} className="hover:text-destructive">
|
||||
<IconActionButton copy={{ label: 'Delete this note', shortcut: '⌘⌫' }} onClick={onDelete} className="hover:text-destructive">
|
||||
<Trash size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
@@ -263,7 +272,7 @@ function InspectorAction({
|
||||
}: Pick<BreadcrumbBarProps, 'inspectorCollapsed' | 'onToggleInspector'>) {
|
||||
if (!inspectorCollapsed) return null
|
||||
return (
|
||||
<IconActionButton title="Properties (⌘⇧I)" onClick={onToggleInspector} className="hover:text-foreground">
|
||||
<IconActionButton copy={{ label: 'Open the properties panel', shortcut: '⌘⇧I' }} onClick={onToggleInspector} className="hover:text-foreground" tooltipAlign="end">
|
||||
<SlidersHorizontal size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
@@ -351,26 +360,19 @@ function SyncFilenameButton({
|
||||
}) {
|
||||
if (!syncStem || !onRenameFilename) return null
|
||||
return (
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onRenameFilename(entryPath, syncStem)}
|
||||
data-testid="breadcrumb-sync-button"
|
||||
aria-label="Rename file to match title"
|
||||
>
|
||||
<ArrowsClockwise size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Rename file to match title
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<ActionTooltip copy={{ label: 'Rename the file to match the title' }} side="bottom">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onRenameFilename(entryPath, syncStem)}
|
||||
data-testid="breadcrumb-sync-button"
|
||||
aria-label="Rename the file to match the title"
|
||||
>
|
||||
<ArrowsClockwise size={14} />
|
||||
</Button>
|
||||
</ActionTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -472,7 +474,6 @@ function BreadcrumbActions({
|
||||
<div className="breadcrumb-bar__actions ml-auto flex items-center" style={{ gap: 12 }}>
|
||||
<FavoriteAction favorite={entry.favorite} onToggleFavorite={onToggleFavorite} />
|
||||
<OrganizedAction organized={entry.organized} onToggleOrganized={onToggleOrganized} />
|
||||
<SearchAction />
|
||||
<DiffAction
|
||||
showDiffToggle={showDiffToggle}
|
||||
diffMode={diffMode}
|
||||
@@ -480,14 +481,14 @@ function BreadcrumbActions({
|
||||
onToggleDiff={onToggleDiff}
|
||||
/>
|
||||
{!forceRawMode && <RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />}
|
||||
<PlaceholderAction title="Coming soon">
|
||||
<PlaceholderAction copy={{ label: 'Backlinks are coming soon' }}>
|
||||
<CursorText size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</PlaceholderAction>
|
||||
<AIChatAction showAIChat={showAIChat} onToggleAIChat={onToggleAIChat} />
|
||||
<ArchiveAction archived={entry.archived} onArchive={onArchive} onUnarchive={onUnarchive} />
|
||||
<DeleteAction onDelete={onDelete} />
|
||||
<InspectorAction inspectorCollapsed={inspectorCollapsed} onToggleInspector={onToggleInspector} />
|
||||
<PlaceholderAction title="Coming soon">
|
||||
<PlaceholderAction copy={{ label: 'More note actions are coming soon' }}>
|
||||
<DotsThree size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</PlaceholderAction>
|
||||
</div>
|
||||
@@ -517,27 +518,29 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
...actionProps
|
||||
}: BreadcrumbBarProps) {
|
||||
return (
|
||||
<div
|
||||
ref={barRef}
|
||||
data-tauri-drag-region
|
||||
data-title-hidden=""
|
||||
className="breadcrumb-bar flex shrink-0 items-center border-b border-transparent"
|
||||
style={{
|
||||
height: 52,
|
||||
background: 'var(--background)',
|
||||
padding: '6px 16px',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<div className="breadcrumb-bar__title min-w-0">
|
||||
<BreadcrumbTitle entry={entry} onRenameFilename={onRenameFilename} />
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
ref={barRef}
|
||||
data-tauri-drag-region
|
||||
className="breadcrumb-bar__drag-spacer min-w-0 flex-1"
|
||||
/>
|
||||
<BreadcrumbActions entry={entry} {...actionProps} />
|
||||
</div>
|
||||
data-title-hidden=""
|
||||
className="breadcrumb-bar flex shrink-0 items-center border-b border-transparent"
|
||||
style={{
|
||||
height: 52,
|
||||
background: 'var(--background)',
|
||||
padding: '6px 16px',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<div className="breadcrumb-bar__title min-w-0">
|
||||
<BreadcrumbTitle entry={entry} onRenameFilename={onRenameFilename} />
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-tauri-drag-region
|
||||
className="breadcrumb-bar__drag-spacer min-w-0 flex-1"
|
||||
/>
|
||||
<BreadcrumbActions entry={entry} {...actionProps} />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { CreateTypeDialog } from './CreateTypeDialog'
|
||||
|
||||
@@ -34,7 +34,16 @@ describe('CreateTypeDialog', () => {
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Recipe, Book, Habit...'), { target: { value: ' Recipe ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
expect(onCreate).toHaveBeenCalledWith('Recipe')
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes after create completes', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(<CreateTypeDialog open={true} onClose={onClose} onCreate={() => Promise.resolve()} />)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Recipe, Book, Habit...'), { target: { value: 'Recipe' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
@@ -60,4 +69,10 @@ describe('CreateTypeDialog', () => {
|
||||
fireEvent.submit(screen.getByPlaceholderText('e.g. Recipe, Book, Habit...').closest('form')!)
|
||||
expect(onCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prefills the name when initialName is provided', () => {
|
||||
render(<CreateTypeDialog open={true} onClose={() => {}} onCreate={() => {}} initialName="Hotel" />)
|
||||
|
||||
expect(screen.getByDisplayValue('Hotel')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,63 +1,77 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
interface CreateTypeDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreate: (name: string) => void
|
||||
onCreate: (name: string) => void | Promise<void>
|
||||
initialName?: string
|
||||
}
|
||||
|
||||
export function CreateTypeDialog({ open, onClose, onCreate }: CreateTypeDialogProps) {
|
||||
const [name, setName] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
interface CreateTypeDialogFormProps {
|
||||
initialName: string
|
||||
onClose: () => void
|
||||
onCreate: (name: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [open])
|
||||
function CreateTypeDialogForm({ initialName, onClose, onCreate }: CreateTypeDialogFormProps) {
|
||||
const [name, setName] = useState(initialName)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
onCreate(trimmed)
|
||||
await onCreate(trimmed)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Type Name
|
||||
</label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="e.g. Recipe, Book, Habit..."
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Creates a type document. Its properties become defaults for new docs of this type.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim()}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export function CreateTypeDialog({ open, onClose, onCreate, initialName = '' }: CreateTypeDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-[380px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Type</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a type document so notes of this type can inherit templates and metadata.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Type Name
|
||||
</label>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder="e.g. Recipe, Book, Habit..."
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Creates a type document. Its properties become defaults for new docs of this type.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim()}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
<CreateTypeDialogForm
|
||||
key={initialName}
|
||||
initialName={initialName}
|
||||
onClose={onClose}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react'
|
||||
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
|
||||
import { FOCUS_NOTE_ICON_PROPERTY_EVENT } from './noteIconPropertyEvents'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
beforeAll(() => {
|
||||
global.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} }
|
||||
@@ -54,6 +56,10 @@ function hasSuggestedSlot(label: string): boolean {
|
||||
.some((node) => node.textContent?.includes(label))
|
||||
}
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(ui, { wrapper: TooltipProvider })
|
||||
}
|
||||
|
||||
describe('DynamicPropertiesPanel system metadata', () => {
|
||||
const onAddProperty = vi.fn()
|
||||
|
||||
@@ -61,7 +67,7 @@ describe('DynamicPropertiesPanel system metadata', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('hides underscored and legacy system metadata while keeping user properties visible', () => {
|
||||
it('keeps the icon visible while hiding the other system metadata', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
@@ -80,7 +86,9 @@ describe('DynamicPropertiesPanel system metadata', () => {
|
||||
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument()
|
||||
expect(screen.getByText('Luca')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Icon')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('icon-editable-display')).toHaveTextContent('rocket')
|
||||
expect(screen.queryByDisplayValue('legacy')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Order')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Sort')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Sidebar label')).not.toBeInTheDocument()
|
||||
@@ -124,4 +132,18 @@ describe('DynamicPropertiesPanel system metadata', () => {
|
||||
|
||||
expect(onAddProperty).toHaveBeenCalledWith('_icon', 'rocket')
|
||||
})
|
||||
|
||||
it('renders an existing underscored icon property with the icon picker UI', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ _icon: 'megaphone' }}
|
||||
onAddProperty={onAddProperty}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('icon-editable-display')).toHaveTextContent('megaphone')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DynamicPropertiesPanel, containsWikilinks } from './DynamicPropertiesPa
|
||||
import type { VaultEntry } from '../types'
|
||||
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore'
|
||||
import { initDisplayModeOverrides } from '../utils/propertyTypes'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
// Radix Select needs ResizeObserver and pointer/scroll APIs in JSDOM
|
||||
beforeAll(() => {
|
||||
@@ -57,12 +58,14 @@ const renderPanel = ({
|
||||
...props
|
||||
}: RenderPanelOptions = {}) =>
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={entry}
|
||||
content={content}
|
||||
frontmatter={frontmatter}
|
||||
{...props}
|
||||
/>,
|
||||
<TooltipProvider>
|
||||
<DynamicPropertiesPanel
|
||||
entry={entry}
|
||||
content={content}
|
||||
frontmatter={frontmatter}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
)
|
||||
|
||||
describe('containsWikilinks', () => {
|
||||
@@ -258,12 +261,14 @@ describe('DynamicPropertiesPanel', () => {
|
||||
|
||||
it('handles navigating to type via click in read-only mode', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry({ isA: 'Project' })}
|
||||
content=""
|
||||
frontmatter={{}}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry({ isA: 'Project' })}
|
||||
content=""
|
||||
frontmatter={{}}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
)
|
||||
fireEvent.click(screen.getByText('Project'))
|
||||
expect(onNavigate).toHaveBeenCalledWith('project')
|
||||
@@ -322,6 +327,41 @@ describe('DynamicPropertiesPanel', () => {
|
||||
expect(screen.getByRole('option', { name: 'CustomType' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a missing-type warning with keyboard-accessible help and creation flow', async () => {
|
||||
const onCreateMissingType = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
renderPanel({
|
||||
entry: makeEntry({ isA: 'Hotel' }),
|
||||
entries: typeEntries,
|
||||
onUpdateProperty,
|
||||
onCreateMissingType,
|
||||
})
|
||||
|
||||
const warningButton = screen.getByTestId('missing-type-warning')
|
||||
expect(warningButton).toBeInTheDocument()
|
||||
|
||||
fireEvent.focus(warningButton)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('Missing type')
|
||||
})
|
||||
|
||||
fireEvent.click(warningButton)
|
||||
expect(screen.getByDisplayValue('Hotel')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
await waitFor(() => expect(onCreateMissingType).toHaveBeenCalledWith('Hotel'))
|
||||
})
|
||||
|
||||
it('does not show a missing-type warning when the type already exists', () => {
|
||||
renderPanel({
|
||||
entry: makeEntry({ isA: 'Project' }),
|
||||
entries: typeEntries,
|
||||
onUpdateProperty,
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('missing-type-warning')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows None placeholder when entry has no type', () => {
|
||||
renderPanel({
|
||||
entry: makeEntry({ isA: null }),
|
||||
@@ -657,21 +697,20 @@ describe('DynamicPropertiesPanel', () => {
|
||||
})
|
||||
|
||||
describe('system property filtering', () => {
|
||||
it('hides archived, archived_at, and legacy icon metadata from the properties panel', () => {
|
||||
it('hides archived metadata but keeps the note icon visible in the properties panel', () => {
|
||||
renderEditablePanel({ archived: false, archived_at: '', icon: '📝', cadence: 'Weekly' })
|
||||
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Archived at')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Icon')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('📝')).not.toBeInTheDocument()
|
||||
// Custom property still visible
|
||||
expect(screen.getByText('Icon')).toBeInTheDocument()
|
||||
expect(screen.getByText('📝')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cadence')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides legacy icon metadata even when cased differently', () => {
|
||||
it('keeps the note icon visible even when cased differently', () => {
|
||||
renderEditablePanel({ Archived: false, Icon: '🎯', cadence: 'Daily' })
|
||||
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Icon')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('🎯')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Icon')).toBeInTheDocument()
|
||||
expect(screen.getByText('🎯')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cadence')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -111,6 +111,12 @@ function getSuggestedDisplayMode(key: string): PropertyDisplayMode {
|
||||
return SUGGESTED_PROPERTY_MODES[key] ?? 'text'
|
||||
}
|
||||
|
||||
function resolveMissingTypeName(entryIsA: string | null | undefined, availableTypes: string[]): string | null {
|
||||
const trimmed = entryIsA?.trim()
|
||||
if (!trimmed) return null
|
||||
return availableTypes.includes(trimmed) ? null : trimmed
|
||||
}
|
||||
|
||||
function SuggestedPropertySlot({ label, displayMode, onAdd }: {
|
||||
label: string
|
||||
displayMode: PropertyDisplayMode
|
||||
@@ -223,7 +229,7 @@ function useSuggestedPropertyActions({
|
||||
|
||||
export function DynamicPropertiesPanel({
|
||||
entry, frontmatter, entries,
|
||||
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate,
|
||||
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate, onCreateMissingType,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
content?: string | null
|
||||
@@ -233,6 +239,7 @@ export function DynamicPropertiesPanel({
|
||||
onDeleteProperty?: (key: string) => void
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onNavigate?: (target: string) => void
|
||||
onCreateMissingType?: (typeName: string) => void | Promise<void>
|
||||
}) {
|
||||
const {
|
||||
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
|
||||
@@ -240,6 +247,7 @@ export function DynamicPropertiesPanel({
|
||||
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
|
||||
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty })
|
||||
const [pendingSuggestedKey, setPendingSuggestedKey] = useState<string | null>(null)
|
||||
const missingTypeName = useMemo(() => resolveMissingTypeName(entry.isA, availableTypes), [entry.isA, availableTypes])
|
||||
|
||||
const existingKeys = useMemo(() => getExistingPropertyKeys(propertyEntries, frontmatter), [propertyEntries, frontmatter])
|
||||
const missingSuggested = useMemo(
|
||||
@@ -261,7 +269,17 @@ export function DynamicPropertiesPanel({
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid min-w-0 gap-x-2 gap-y-1.5" style={PROPERTY_PANEL_GRID_STYLE}>
|
||||
<TypeSelector isA={entry.isA} customColorKey={customColorKey} availableTypes={availableTypes} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} onUpdateProperty={onUpdateProperty} onNavigate={onNavigate} />
|
||||
<TypeSelector
|
||||
isA={entry.isA}
|
||||
customColorKey={customColorKey}
|
||||
availableTypes={availableTypes}
|
||||
typeColorKeys={typeColorKeys}
|
||||
typeIconKeys={typeIconKeys}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
onNavigate={onNavigate}
|
||||
missingTypeName={missingTypeName}
|
||||
onCreateMissingType={onCreateMissingType}
|
||||
/>
|
||||
{propertyEntries.map(([key, value]) => (
|
||||
<PropertyRow
|
||||
key={key} propKey={key} value={value}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import type { ComponentProps, PropsWithChildren } from 'react'
|
||||
import { render as rtlRender, screen, fireEvent, act } from '@testing-library/react'
|
||||
import type { ComponentProps, PropsWithChildren, ReactElement } from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
@@ -70,6 +70,7 @@ vi.mock('@blocknote/react', () => ({
|
||||
</div>
|
||||
),
|
||||
FormattingToolbarController: () => null,
|
||||
SideMenuController: () => null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
|
||||
SuggestionMenuController: (props: any) => {
|
||||
capturedGetItemsByTrigger[props.triggerCharacter] = props.getItems
|
||||
@@ -90,12 +91,21 @@ vi.mock('./tolariaEditorFormatting', () => ({
|
||||
}))
|
||||
|
||||
import { Editor } from './Editor'
|
||||
import { applyPendingRawExitContent } from './editorRawModeSync'
|
||||
import {
|
||||
applyPendingRawExitContent,
|
||||
rememberPendingRawExitContent,
|
||||
syncActiveTabIntoRawBuffer,
|
||||
} from './editorRawModeSync'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { bindVaultConfigStore, resetVaultConfigStore } from '../utils/vaultConfigStore'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
type EditorComponentProps = ComponentProps<typeof Editor>
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(ui, { wrapper: TooltipProvider })
|
||||
}
|
||||
|
||||
const mockEntry: VaultEntry = {
|
||||
path: '/vault/project/test.md',
|
||||
filename: 'test.md',
|
||||
@@ -203,7 +213,8 @@ describe('Editor', () => {
|
||||
activeTabPath: mockEntry.path,
|
||||
})
|
||||
|
||||
expect(screen.getByTitle('Search in file')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Open the raw editor' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Delete this note' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the legacy title field for untitled draft notes', () => {
|
||||
@@ -243,7 +254,7 @@ describe('Editor', () => {
|
||||
onLoadDiff={async () => '+ added line'}
|
||||
/>
|
||||
)
|
||||
const diffBtn = screen.getByTitle('Show diff')
|
||||
const diffBtn = screen.getByRole('button', { name: 'Show the current diff' })
|
||||
expect(diffBtn).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -410,6 +421,73 @@ describe('applyPendingRawExitContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('raw-mode sync content guards', () => {
|
||||
it('does not emit a content change when entering raw mode normalizes markdown', () => {
|
||||
const onContentChange = vi.fn()
|
||||
const rawLatestContentRef = { current: null as string | null }
|
||||
|
||||
const result = syncActiveTabIntoRawBuffer({
|
||||
editor: mockEditor as never,
|
||||
activeTabPath: mockEntry.path,
|
||||
activeTabContent: mockContent,
|
||||
rawLatestContentRef,
|
||||
})
|
||||
|
||||
expect(result).toBe('---\ntitle: Test Project\nis_a: Project\nStatus: Active\n---\n# Test Project\n\nThis is a test note with some words to count.\n')
|
||||
expect(rawLatestContentRef.current).toBe(result)
|
||||
expect(onContentChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('captures the latest serialized markdown when entering raw mode', () => {
|
||||
const rawLatestContentRef = { current: null as string | null }
|
||||
|
||||
mockEditor.blocksToMarkdownLossy.mockReturnValueOnce('# Test Project\n\nUpdated body\n')
|
||||
|
||||
const result = syncActiveTabIntoRawBuffer({
|
||||
editor: mockEditor as never,
|
||||
activeTabPath: mockEntry.path,
|
||||
activeTabContent: mockContent,
|
||||
rawLatestContentRef,
|
||||
})
|
||||
|
||||
expect(result).toBe('---\ntitle: Test Project\nis_a: Project\nStatus: Active\n---\n# Test Project\n\nUpdated body\n')
|
||||
expect(rawLatestContentRef.current).toBe(result)
|
||||
})
|
||||
|
||||
it('does not emit a content change when leaving raw mode without user edits', () => {
|
||||
const onContentChange = vi.fn()
|
||||
const normalizedContent = '---\ntitle: Test Project\nis_a: Project\nStatus: Active\n---\n# Test Project\n\nThis is a test note with some words to count.\n'
|
||||
|
||||
const result = rememberPendingRawExitContent({
|
||||
activeTabPath: mockEntry.path,
|
||||
activeTabContent: mockContent,
|
||||
rawInitialContent: normalizedContent,
|
||||
rawLatestContentRef: { current: normalizedContent },
|
||||
onContentChange,
|
||||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(onContentChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits a content change when leaving raw mode with edited markdown', () => {
|
||||
const onContentChange = vi.fn()
|
||||
const normalizedContent = '---\ntitle: Test Project\nis_a: Project\nStatus: Active\n---\n# Test Project\n\nThis is a test note with some words to count.\n'
|
||||
const editedContent = `${normalizedContent}\nUpdated in raw mode\n`
|
||||
|
||||
const result = rememberPendingRawExitContent({
|
||||
activeTabPath: mockEntry.path,
|
||||
activeTabContent: mockContent,
|
||||
rawInitialContent: normalizedContent,
|
||||
rawLatestContentRef: { current: editedContent },
|
||||
onContentChange,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ path: mockEntry.path, content: editedContent })
|
||||
expect(onContentChange).toHaveBeenCalledWith(mockEntry.path, editedContent)
|
||||
})
|
||||
})
|
||||
|
||||
describe('click empty editor space', () => {
|
||||
it('focuses editor at end of last block when clicking empty space below content', () => {
|
||||
mockEditor.focus.mockClear()
|
||||
@@ -580,7 +658,7 @@ describe('wikilink autocomplete', () => {
|
||||
mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
|
||||
})
|
||||
|
||||
it('shows correct noteType and color for typed entries, neutral for untyped', async () => {
|
||||
it('shows Note chips and icons for explicit Note entries while keeping untyped entries neutral', async () => {
|
||||
const mixedEntries: VaultEntry[] = [
|
||||
{ ...mockEntry, title: 'Test Project', filename: 'proj.md', path: '/vault/proj.md', isA: 'Project', aliases: [] },
|
||||
{ ...mockEntry, title: 'Test Plain', filename: 'plain.md', path: '/vault/plain.md', isA: null, aliases: [] },
|
||||
@@ -597,20 +675,24 @@ describe('wikilink autocomplete', () => {
|
||||
/>
|
||||
)
|
||||
const items = await capturedGetItems!('Test')
|
||||
// Typed entries should have noteType and color
|
||||
// Typed entries should have noteType, color, and a left-side icon
|
||||
const project = items.find((i: { title: string }) => i.title === 'Test Project')
|
||||
expect(project).toBeDefined()
|
||||
expect(project!.noteType).toBe('Project')
|
||||
expect(project!.typeColor).toBeTruthy()
|
||||
// Untyped entries (isA: null or 'Note') should have no noteType (grey/neutral)
|
||||
expect(project!.TypeIcon).toBeTruthy()
|
||||
|
||||
const explicitNote = items.find((i: { title: string }) => i.title === 'Test Explicit')
|
||||
expect(explicitNote).toBeDefined()
|
||||
expect(explicitNote!.noteType).toBe('Note')
|
||||
expect(explicitNote!.typeColor).toBeTruthy()
|
||||
expect(explicitNote!.TypeIcon).toBeTruthy()
|
||||
|
||||
// Untyped entries should remain neutral
|
||||
const plainNote = items.find((i: { title: string }) => i.title === 'Test Plain')
|
||||
expect(plainNote).toBeDefined()
|
||||
expect(plainNote!.noteType).toBeUndefined()
|
||||
expect(plainNote!.typeColor).toBeUndefined()
|
||||
const explicitNote = items.find((i: { title: string }) => i.title === 'Test Explicit')
|
||||
expect(explicitNote).toBeDefined()
|
||||
expect(explicitNote!.noteType).toBeUndefined()
|
||||
expect(explicitNote!.typeColor).toBeUndefined()
|
||||
mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
|
||||
})
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
resolveRawModeContent,
|
||||
} from './editorRawModeSync'
|
||||
import { useRawModeWithFlush } from './useRawModeWithFlush'
|
||||
import { useFilenameAutolinkGuard } from './useFilenameAutolinkGuard'
|
||||
import './Editor.css'
|
||||
import './EditorTheme.css'
|
||||
|
||||
@@ -51,6 +52,7 @@ interface EditorProps {
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
onInitializeProperties?: (path: string) => void
|
||||
showAIChat?: boolean
|
||||
@@ -85,6 +87,8 @@ interface EditorProps {
|
||||
onKeepMine?: (path: string) => void
|
||||
/** Resolve conflict by keeping the remote version. */
|
||||
onKeepTheirs?: (path: string) => void
|
||||
/** Registers a hook that flushes the raw editor buffer into app state before external actions. */
|
||||
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
|
||||
}
|
||||
|
||||
function useEditorModeExclusion({
|
||||
@@ -166,6 +170,7 @@ function useEditorSetup({
|
||||
schema,
|
||||
uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current),
|
||||
})
|
||||
useFilenameAutolinkGuard(editor)
|
||||
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null
|
||||
const {
|
||||
rawMode,
|
||||
@@ -221,39 +226,147 @@ function useEditorSetup({
|
||||
}
|
||||
}
|
||||
|
||||
export const Editor = memo(function Editor(props: EditorProps) {
|
||||
const {
|
||||
tabs, activeTabPath, entries, onNavigateWikilink,
|
||||
getNoteStatus,
|
||||
inspectorCollapsed, onToggleInspector, inspectorWidth,
|
||||
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
|
||||
onInspectorResize,
|
||||
inspectorEntry, inspectorContent, gitHistory,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote, onInitializeProperties,
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath, noteList, noteListFilter,
|
||||
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onContentChange, onSave, onRenameFilename,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
isConflicted, onKeepMine, onKeepTheirs,
|
||||
} = props
|
||||
function useRegisterRawContentFlush({
|
||||
activeTab,
|
||||
rawLatestContentRef,
|
||||
rawMode,
|
||||
onContentChange,
|
||||
flushPendingRawContentRef,
|
||||
}: {
|
||||
activeTab: Tab | null
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
rawMode: boolean
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
|
||||
}) {
|
||||
const flushPendingRawContent = useCallback((path: string) => {
|
||||
if (!rawMode || !activeTab || activeTab.entry.path !== path) return
|
||||
|
||||
const {
|
||||
editor, activeTab, rawLatestContentRef, rawModeContent,
|
||||
rawMode, diffMode, diffContent, diffLoading,
|
||||
handleToggleDiffExclusive, handleToggleRawExclusive,
|
||||
handleEditorChange, handleViewCommitDiff,
|
||||
isLoadingNewTab, activeStatus, showDiffToggle,
|
||||
} = useEditorSetup({
|
||||
tabs, activeTabPath, vaultPath, onContentChange,
|
||||
onLoadDiff: props.onLoadDiff,
|
||||
onLoadDiffAtCommit: props.onLoadDiffAtCommit,
|
||||
pendingCommitDiffRequest: props.pendingCommitDiffRequest,
|
||||
onPendingCommitDiffHandled: props.onPendingCommitDiffHandled,
|
||||
getNoteStatus,
|
||||
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
|
||||
})
|
||||
const latestContent = rawLatestContentRef.current
|
||||
if (latestContent === null || latestContent === activeTab.content) return
|
||||
|
||||
onContentChange?.(path, latestContent)
|
||||
}, [activeTab, onContentChange, rawLatestContentRef, rawMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!flushPendingRawContentRef) return
|
||||
|
||||
flushPendingRawContentRef.current = flushPendingRawContent
|
||||
return () => {
|
||||
if (flushPendingRawContentRef.current === flushPendingRawContent) {
|
||||
flushPendingRawContentRef.current = null
|
||||
}
|
||||
}
|
||||
}, [flushPendingRawContent, flushPendingRawContentRef])
|
||||
}
|
||||
|
||||
function EditorLayout({
|
||||
tabs,
|
||||
activeTab,
|
||||
isLoadingNewTab,
|
||||
entries,
|
||||
editor,
|
||||
diffMode,
|
||||
diffContent,
|
||||
diffLoading,
|
||||
handleToggleDiffExclusive,
|
||||
rawMode,
|
||||
handleToggleRawExclusive,
|
||||
onContentChange,
|
||||
onSave,
|
||||
activeStatus,
|
||||
showDiffToggle,
|
||||
showAIChat,
|
||||
onToggleAIChat,
|
||||
inspectorCollapsed,
|
||||
onToggleInspector,
|
||||
onNavigateWikilink,
|
||||
handleEditorChange,
|
||||
onToggleFavorite,
|
||||
onToggleOrganized,
|
||||
onDeleteNote,
|
||||
onArchiveNote,
|
||||
onUnarchiveNote,
|
||||
vaultPath,
|
||||
rawModeContent,
|
||||
rawLatestContentRef,
|
||||
onRenameFilename,
|
||||
isConflicted,
|
||||
onKeepMine,
|
||||
onKeepTheirs,
|
||||
onInspectorResize,
|
||||
inspectorWidth,
|
||||
defaultAiAgent,
|
||||
defaultAiAgentReady,
|
||||
inspectorEntry,
|
||||
inspectorContent,
|
||||
gitHistory,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
handleViewCommitDiff,
|
||||
onUpdateFrontmatter,
|
||||
onDeleteProperty,
|
||||
onAddProperty,
|
||||
onCreateMissingType,
|
||||
onCreateAndOpenNote,
|
||||
onInitializeProperties,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}: {
|
||||
tabs: Tab[]
|
||||
activeTab: Tab | null
|
||||
isLoadingNewTab: boolean
|
||||
entries: VaultEntry[]
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
diffMode: boolean
|
||||
diffContent: string | null
|
||||
diffLoading: boolean
|
||||
handleToggleDiffExclusive: () => void | Promise<void>
|
||||
rawMode: boolean
|
||||
handleToggleRawExclusive: () => void
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
onSave?: () => void
|
||||
activeStatus: NoteStatus
|
||||
showDiffToggle: boolean
|
||||
showAIChat?: boolean
|
||||
onToggleAIChat?: () => void
|
||||
inspectorCollapsed: boolean
|
||||
onToggleInspector: () => void
|
||||
onNavigateWikilink: (target: string) => void
|
||||
handleEditorChange: () => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onToggleOrganized?: (path: string) => void
|
||||
onDeleteNote?: (path: string) => void
|
||||
onArchiveNote?: (path: string) => void
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
vaultPath?: string
|
||||
rawModeContent: string | null
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
isConflicted?: boolean
|
||||
onKeepMine?: (path: string) => void
|
||||
onKeepTheirs?: (path: string) => void
|
||||
onInspectorResize: (delta: number) => void
|
||||
inspectorWidth: number
|
||||
defaultAiAgent: AiAgentId
|
||||
defaultAiAgentReady: boolean
|
||||
inspectorEntry: VaultEntry | null
|
||||
inspectorContent: string | null
|
||||
gitHistory: GitCommit[]
|
||||
noteList?: NoteListItem[]
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
handleViewCommitDiff: (commitHash: string) => Promise<void>
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
onInitializeProperties?: (path: string) => void
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
onFileModified?: (relativePath: string) => void
|
||||
onVaultChanged?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="editor flex flex-col min-h-0 overflow-hidden bg-background text-foreground">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
@@ -315,6 +428,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onAddProperty={onAddProperty}
|
||||
onCreateMissingType={onCreateMissingType}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
onInitializeProperties={onInitializeProperties}
|
||||
onToggleRawEditor={handleToggleRawExclusive}
|
||||
@@ -326,4 +440,103 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Editor = memo(function Editor(props: EditorProps) {
|
||||
const {
|
||||
tabs, activeTabPath, entries, onNavigateWikilink,
|
||||
getNoteStatus,
|
||||
inspectorCollapsed, onToggleInspector, inspectorWidth,
|
||||
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
|
||||
onInspectorResize,
|
||||
inspectorEntry, inspectorContent, gitHistory,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath, noteList, noteListFilter,
|
||||
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onContentChange, onSave, onRenameFilename,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
isConflicted, onKeepMine, onKeepTheirs,
|
||||
flushPendingRawContentRef,
|
||||
} = props
|
||||
|
||||
const {
|
||||
editor, activeTab, rawLatestContentRef, rawModeContent,
|
||||
rawMode, diffMode, diffContent, diffLoading,
|
||||
handleToggleDiffExclusive, handleToggleRawExclusive,
|
||||
handleEditorChange, handleViewCommitDiff,
|
||||
isLoadingNewTab, activeStatus, showDiffToggle,
|
||||
} = useEditorSetup({
|
||||
tabs, activeTabPath, vaultPath, onContentChange,
|
||||
onLoadDiff: props.onLoadDiff,
|
||||
onLoadDiffAtCommit: props.onLoadDiffAtCommit,
|
||||
pendingCommitDiffRequest: props.pendingCommitDiffRequest,
|
||||
onPendingCommitDiffHandled: props.onPendingCommitDiffHandled,
|
||||
getNoteStatus,
|
||||
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
|
||||
})
|
||||
useRegisterRawContentFlush({
|
||||
activeTab,
|
||||
rawLatestContentRef,
|
||||
rawMode,
|
||||
onContentChange,
|
||||
flushPendingRawContentRef,
|
||||
})
|
||||
|
||||
return (
|
||||
<EditorLayout
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
isLoadingNewTab={isLoadingNewTab}
|
||||
entries={entries}
|
||||
editor={editor}
|
||||
diffMode={diffMode}
|
||||
diffContent={diffContent}
|
||||
diffLoading={diffLoading}
|
||||
handleToggleDiffExclusive={handleToggleDiffExclusive}
|
||||
rawMode={rawMode}
|
||||
handleToggleRawExclusive={handleToggleRawExclusive}
|
||||
onContentChange={onContentChange}
|
||||
onSave={onSave}
|
||||
activeStatus={activeStatus}
|
||||
showDiffToggle={showDiffToggle}
|
||||
showAIChat={showAIChat}
|
||||
onToggleAIChat={onToggleAIChat}
|
||||
inspectorCollapsed={inspectorCollapsed}
|
||||
onToggleInspector={onToggleInspector}
|
||||
onNavigateWikilink={onNavigateWikilink}
|
||||
handleEditorChange={handleEditorChange}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleOrganized={onToggleOrganized}
|
||||
onDeleteNote={onDeleteNote}
|
||||
onArchiveNote={onArchiveNote}
|
||||
onUnarchiveNote={onUnarchiveNote}
|
||||
vaultPath={vaultPath}
|
||||
rawModeContent={rawModeContent}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
onRenameFilename={onRenameFilename}
|
||||
isConflicted={isConflicted}
|
||||
onKeepMine={onKeepMine}
|
||||
onKeepTheirs={onKeepTheirs}
|
||||
onInspectorResize={onInspectorResize}
|
||||
inspectorWidth={inspectorWidth}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiAgentReady={defaultAiAgentReady}
|
||||
inspectorEntry={inspectorEntry}
|
||||
inspectorContent={inspectorContent}
|
||||
gitHistory={gitHistory}
|
||||
noteList={noteList}
|
||||
noteListFilter={noteListFilter}
|
||||
handleViewCommitDiff={handleViewCommitDiff}
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onAddProperty={onAddProperty}
|
||||
onCreateMissingType={onCreateMissingType}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
onInitializeProperties={onInitializeProperties}
|
||||
onFileCreated={onFileCreated}
|
||||
onFileModified={onFileModified}
|
||||
onVaultChanged={onVaultChanged}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ interface EditorRightPanelProps {
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
onInitializeProperties?: (path: string) => void
|
||||
onToggleRawEditor?: () => void
|
||||
@@ -39,7 +40,7 @@ export function EditorRightPanel({
|
||||
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath,
|
||||
noteList, noteListFilter,
|
||||
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
}: EditorRightPanelProps) {
|
||||
if (showAIChat) {
|
||||
@@ -87,6 +88,7 @@ export function EditorRightPanel({
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onAddProperty={onAddProperty}
|
||||
onCreateMissingType={onCreateMissingType}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
onInitializeProperties={onInitializeProperties}
|
||||
onToggleRawEditor={onToggleRawEditor}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import type { ComponentProps, ReactElement } from 'react'
|
||||
import { render as rtlRender, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { Inspector } from './Inspector'
|
||||
import type { VaultEntry, GitCommit } from '../types'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(ui, { wrapper: TooltipProvider })
|
||||
}
|
||||
|
||||
const mockEntry: VaultEntry = {
|
||||
path: '/vault/project/test.md',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useCallback } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import type { VaultEntry, GitCommit } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Separator } from './ui/separator'
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
InstancesPanel,
|
||||
NoteInfoPanel,
|
||||
} from './InspectorPanels'
|
||||
import type { ReferencedByItem } from './InspectorPanels'
|
||||
import { EmptyInspector, InitializePropertiesPrompt, InspectorHeader, InvalidFrontmatterNotice } from './inspector/InspectorChrome'
|
||||
import { useBacklinks, useReferencedBy } from './inspector/useInspectorData'
|
||||
import { useInspectorPropertyActions } from './inspector/useInspectorPropertyActions'
|
||||
|
||||
export type FrontmatterValue = string | number | boolean | string[] | null
|
||||
|
||||
@@ -30,11 +32,136 @@ interface InspectorProps {
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
onInitializeProperties?: (path: string) => void
|
||||
onToggleRawEditor?: () => void
|
||||
}
|
||||
|
||||
function buildTypeEntryMap(entries: VaultEntry[]): Record<string, VaultEntry> {
|
||||
const map: Record<string, VaultEntry> = {}
|
||||
for (const candidate of entries) {
|
||||
if (candidate.isA === 'Type') map[candidate.title] = candidate
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function ValidFrontmatterPanels({
|
||||
entry,
|
||||
entries,
|
||||
frontmatter,
|
||||
typeEntryMap,
|
||||
vaultPath,
|
||||
referencedBy,
|
||||
onNavigate,
|
||||
onCreateAndOpenNote,
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
onAddProperty,
|
||||
onCreateMissingType,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
entries: VaultEntry[]
|
||||
frontmatter: ReturnType<typeof parseFrontmatter>
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
vaultPath?: string
|
||||
referencedBy: ReferencedByItem[]
|
||||
onNavigate: (target: string) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onDeleteProperty?: (key: string) => void
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onCreateMissingType?: (typeName: string) => Promise<void>
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DynamicPropertiesPanel
|
||||
entry={entry}
|
||||
frontmatter={frontmatter}
|
||||
entries={entries}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onAddProperty={onAddProperty}
|
||||
onNavigate={onNavigate}
|
||||
onCreateMissingType={onCreateMissingType}
|
||||
/>
|
||||
<Separator data-testid="inspector-properties-relationships-separator" />
|
||||
<DynamicRelationshipsPanel
|
||||
frontmatter={frontmatter}
|
||||
entries={entries}
|
||||
typeEntryMap={typeEntryMap}
|
||||
vaultPath={vaultPath}
|
||||
onNavigate={onNavigate}
|
||||
onAddProperty={onAddProperty}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
<InstancesPanel entry={entry} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
<ReferencedByPanel items={referencedBy} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PrimaryInspectorPanel({
|
||||
entry,
|
||||
frontmatterState,
|
||||
frontmatter,
|
||||
entries,
|
||||
typeEntryMap,
|
||||
vaultPath,
|
||||
referencedBy,
|
||||
onNavigate,
|
||||
onToggleRawEditor,
|
||||
onInitializeProperties,
|
||||
onCreateAndOpenNote,
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
onAddProperty,
|
||||
onCreateMissingType,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
frontmatterState: ReturnType<typeof detectFrontmatterState>
|
||||
frontmatter: ReturnType<typeof parseFrontmatter>
|
||||
entries: VaultEntry[]
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
vaultPath?: string
|
||||
referencedBy: ReferencedByItem[]
|
||||
onNavigate: (target: string) => void
|
||||
onToggleRawEditor?: () => void
|
||||
onInitializeProperties?: (path: string) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onDeleteProperty?: (key: string) => void
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onCreateMissingType?: (typeName: string) => Promise<void>
|
||||
}) {
|
||||
if (frontmatterState === 'valid') {
|
||||
return (
|
||||
<ValidFrontmatterPanels
|
||||
entry={entry}
|
||||
entries={entries}
|
||||
frontmatter={frontmatter}
|
||||
typeEntryMap={typeEntryMap}
|
||||
vaultPath={vaultPath}
|
||||
referencedBy={referencedBy}
|
||||
onNavigate={onNavigate}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onAddProperty={onAddProperty}
|
||||
onCreateMissingType={onCreateMissingType}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (frontmatterState === 'invalid') {
|
||||
return onToggleRawEditor ? <InvalidFrontmatterNotice onFix={onToggleRawEditor} /> : null
|
||||
}
|
||||
|
||||
return onInitializeProperties ? <InitializePropertiesPrompt onClick={() => onInitializeProperties(entry.path)} /> : null
|
||||
}
|
||||
|
||||
export function Inspector({
|
||||
collapsed,
|
||||
onToggle,
|
||||
@@ -48,6 +175,7 @@ export function Inspector({
|
||||
onUpdateFrontmatter,
|
||||
onDeleteProperty,
|
||||
onAddProperty,
|
||||
onCreateMissingType,
|
||||
onCreateAndOpenNote,
|
||||
onInitializeProperties,
|
||||
onToggleRawEditor,
|
||||
@@ -56,25 +184,19 @@ export function Inspector({
|
||||
const backlinks = useBacklinks(entry, entries, referencedBy)
|
||||
const frontmatter = useMemo(() => parseFrontmatter(content), [content])
|
||||
const frontmatterState = useMemo(() => detectFrontmatterState(content), [content])
|
||||
const typeEntryMap = useMemo(() => {
|
||||
const map: Record<string, VaultEntry> = {}
|
||||
for (const candidate of entries) {
|
||||
if (candidate.isA === 'Type') map[candidate.title] = candidate
|
||||
}
|
||||
return map
|
||||
}, [entries])
|
||||
|
||||
const handleUpdateProperty = useCallback((key: string, value: FrontmatterValue) => {
|
||||
if (entry && onUpdateFrontmatter) onUpdateFrontmatter(entry.path, key, value)
|
||||
}, [entry, onUpdateFrontmatter])
|
||||
|
||||
const handleDeleteProperty = useCallback((key: string) => {
|
||||
if (entry && onDeleteProperty) onDeleteProperty(entry.path, key)
|
||||
}, [entry, onDeleteProperty])
|
||||
|
||||
const handleAddProperty = useCallback((key: string, value: FrontmatterValue) => {
|
||||
if (entry && onAddProperty) onAddProperty(entry.path, key, value)
|
||||
}, [entry, onAddProperty])
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const {
|
||||
handleUpdateProperty,
|
||||
handleDeleteProperty,
|
||||
handleAddProperty,
|
||||
handleCreateMissingType,
|
||||
} = useInspectorPropertyActions({
|
||||
entry,
|
||||
onUpdateFrontmatter,
|
||||
onDeleteProperty,
|
||||
onAddProperty,
|
||||
onCreateMissingType,
|
||||
})
|
||||
|
||||
return (
|
||||
<aside className={cn('flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground transition-[width] duration-200', collapsed && '!w-10 !min-w-10')}>
|
||||
@@ -83,37 +205,23 @@ export function Inspector({
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-3">
|
||||
{entry ? (
|
||||
<>
|
||||
{frontmatterState === 'valid' ? (
|
||||
<>
|
||||
<DynamicPropertiesPanel
|
||||
entry={entry}
|
||||
frontmatter={frontmatter}
|
||||
entries={entries}
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
||||
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
<Separator data-testid="inspector-properties-relationships-separator" />
|
||||
<DynamicRelationshipsPanel
|
||||
frontmatter={frontmatter}
|
||||
entries={entries}
|
||||
typeEntryMap={typeEntryMap}
|
||||
vaultPath={vaultPath}
|
||||
onNavigate={onNavigate}
|
||||
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
<InstancesPanel entry={entry} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
<ReferencedByPanel items={referencedBy} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
</>
|
||||
) : frontmatterState === 'invalid' ? (
|
||||
onToggleRawEditor && <InvalidFrontmatterNotice onFix={onToggleRawEditor} />
|
||||
) : (
|
||||
onInitializeProperties && <InitializePropertiesPrompt onClick={() => onInitializeProperties(entry.path)} />
|
||||
)}
|
||||
<PrimaryInspectorPanel
|
||||
entry={entry}
|
||||
frontmatterState={frontmatterState}
|
||||
frontmatter={frontmatter}
|
||||
entries={entries}
|
||||
typeEntryMap={typeEntryMap}
|
||||
vaultPath={vaultPath}
|
||||
referencedBy={referencedBy}
|
||||
onNavigate={onNavigate}
|
||||
onToggleRawEditor={onToggleRawEditor}
|
||||
onInitializeProperties={onInitializeProperties}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
||||
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
||||
onCreateMissingType={onCreateMissingType ? handleCreateMissingType : undefined}
|
||||
/>
|
||||
{backlinks.length > 0 && <Separator />}
|
||||
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
|
||||
<Separator />
|
||||
|
||||
@@ -262,6 +262,23 @@ describe('NoteList filter pills', () => {
|
||||
expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows only explicit Note entries for the Notes type filter', () => {
|
||||
const noteEntries = [
|
||||
makeEntry({ title: 'Note Type', isA: 'Type', path: '/types/note.md', filename: 'note.md' }),
|
||||
makeEntry({ title: 'Explicit Note', isA: 'Note', path: '/explicit-note.md', filename: 'explicit-note.md' }),
|
||||
makeEntry({ title: 'Untyped Note', isA: null, path: '/untyped-note.md', filename: 'untyped-note.md' }),
|
||||
makeEntry({ title: 'Archived Explicit Note', isA: 'Note', archived: true, path: '/archived-note.md', filename: 'archived-note.md' }),
|
||||
]
|
||||
|
||||
renderNoteList({ entries: noteEntries, selection: { kind: 'sectionGroup', type: 'Note' } })
|
||||
|
||||
expect(screen.getByText('Explicit Note')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Untyped Note')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Archived Explicit Note')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-pill-open')).toHaveTextContent('1')
|
||||
expect(screen.getByTestId('filter-pill-archived')).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('shows the archived empty state when a section has no archived notes', () => {
|
||||
renderNoteList({
|
||||
entries: projectEntries.filter((entry) => !entry.archived),
|
||||
|
||||
@@ -26,6 +26,50 @@ function makeBookTypeEntries(
|
||||
]
|
||||
}
|
||||
|
||||
const noop = () => undefined
|
||||
|
||||
function searchNoteList(query: string) {
|
||||
const searchInput = screen.queryByPlaceholderText('Search notes...')
|
||||
if (!searchInput) fireEvent.click(screen.getByTitle('Search notes'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: query } })
|
||||
}
|
||||
|
||||
function renderBookNoteList({
|
||||
displayProps = ['Priority'],
|
||||
entryOverrides = {},
|
||||
selection = allSelection,
|
||||
allNotesNoteListProperties,
|
||||
onUpdateAllNotesNoteListProperties = noop,
|
||||
inboxNoteListProperties,
|
||||
onUpdateInboxNoteListProperties = noop,
|
||||
}: {
|
||||
displayProps?: string[]
|
||||
entryOverrides?: Parameters<typeof makeEntry>[0]
|
||||
selection?: Parameters<typeof renderNoteList>[0]['selection']
|
||||
allNotesNoteListProperties?: string[] | null
|
||||
onUpdateAllNotesNoteListProperties?: () => void
|
||||
inboxNoteListProperties?: string[] | null
|
||||
onUpdateInboxNoteListProperties?: () => void
|
||||
} = {}) {
|
||||
return renderNoteList({
|
||||
entries: makeBookTypeEntries(displayProps, entryOverrides),
|
||||
selection,
|
||||
allNotesNoteListProperties,
|
||||
onUpdateAllNotesNoteListProperties,
|
||||
inboxNoteListProperties,
|
||||
onUpdateInboxNoteListProperties,
|
||||
})
|
||||
}
|
||||
|
||||
function expectOnlySearchMatch(title: string, matchingQuery: string, hiddenQuery: string) {
|
||||
searchNoteList(matchingQuery)
|
||||
expect(screen.getByText(title)).toBeInTheDocument()
|
||||
|
||||
searchNoteList(hiddenQuery)
|
||||
expect(screen.queryByText(title)).not.toBeInTheDocument()
|
||||
expect(screen.getByText('No matching notes')).toBeInTheDocument()
|
||||
}
|
||||
|
||||
describe('NoteList rendering', () => {
|
||||
it('shows an empty state when there are no entries', () => {
|
||||
renderNoteList({ entries: [] })
|
||||
@@ -93,12 +137,49 @@ describe('NoteList rendering', () => {
|
||||
|
||||
it('filters by a case-insensitive search query', () => {
|
||||
renderNoteList()
|
||||
fireEvent.click(screen.getByTitle('Search notes'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'facebook' } })
|
||||
searchNoteList('facebook')
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by snippet text when the title does not match', () => {
|
||||
renderNoteList({
|
||||
entries: [
|
||||
makeEntry({ path: '/vault/a.md', filename: 'a.md', title: 'Alpha Note', snippet: 'Routine body copy.' }),
|
||||
makeEntry({ path: '/vault/b.md', filename: 'b.md', title: 'Beta Note', snippet: 'Nebula-only snippet token.' }),
|
||||
],
|
||||
})
|
||||
|
||||
searchNoteList('nebula-only')
|
||||
|
||||
expect(screen.getByText('Beta Note')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Alpha Note')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by visible property values and ignores hidden properties', () => {
|
||||
renderBookNoteList({
|
||||
entryOverrides: {
|
||||
title: 'Property Search Note',
|
||||
properties: { Priority: 'Boarding Window', Owner: 'Hidden Owner Value' },
|
||||
},
|
||||
allNotesNoteListProperties: null,
|
||||
})
|
||||
|
||||
expectOnlySearchMatch('Property Search Note', 'boarding window', 'hidden owner value')
|
||||
})
|
||||
|
||||
it('uses the active all-notes columns when filtering by visible property values', () => {
|
||||
renderBookNoteList({
|
||||
entryOverrides: {
|
||||
title: 'Override Search Note',
|
||||
properties: { Priority: 'Hidden Priority', Owner: 'Visible Owner Value' },
|
||||
},
|
||||
allNotesNoteListProperties: ['Owner'],
|
||||
})
|
||||
|
||||
expectOnlySearchMatch('Override Search Note', 'visible owner value', 'hidden priority')
|
||||
})
|
||||
|
||||
it('sorts entries by last modified descending by default', () => {
|
||||
renderNoteList({
|
||||
entries: [
|
||||
@@ -124,11 +205,10 @@ describe('NoteList rendering', () => {
|
||||
})
|
||||
|
||||
it('uses breadcrumbs-like button styling for note-list header actions', () => {
|
||||
renderNoteList({
|
||||
entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High' } }),
|
||||
renderBookNoteList({
|
||||
entryOverrides: { properties: { Priority: 'High' } },
|
||||
selection: { kind: 'filter', filter: 'inbox' },
|
||||
inboxNoteListProperties: null,
|
||||
onUpdateInboxNoteListProperties: () => undefined,
|
||||
})
|
||||
|
||||
const buttons = [
|
||||
@@ -184,11 +264,10 @@ describe('NoteList rendering', () => {
|
||||
})
|
||||
|
||||
it('shows the inbox customize-columns action and falls back to type-defined chips', () => {
|
||||
renderNoteList({
|
||||
entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }),
|
||||
renderBookNoteList({
|
||||
entryOverrides: { properties: { Priority: 'High', Owner: 'Luca' } },
|
||||
selection: { kind: 'filter', filter: 'inbox' },
|
||||
inboxNoteListProperties: null,
|
||||
onUpdateInboxNoteListProperties: () => undefined,
|
||||
})
|
||||
|
||||
expect(screen.getByTitle('Customize Inbox columns')).toBeInTheDocument()
|
||||
@@ -197,11 +276,9 @@ describe('NoteList rendering', () => {
|
||||
})
|
||||
|
||||
it('shows the all-notes customize-columns action and falls back to type-defined chips', () => {
|
||||
renderNoteList({
|
||||
entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }),
|
||||
selection: allSelection,
|
||||
renderBookNoteList({
|
||||
entryOverrides: { properties: { Priority: 'High', Owner: 'Luca' } },
|
||||
allNotesNoteListProperties: null,
|
||||
onUpdateAllNotesNoteListProperties: () => undefined,
|
||||
})
|
||||
|
||||
expect(screen.getByTitle('Customize All Notes columns')).toBeInTheDocument()
|
||||
|
||||
@@ -22,6 +22,7 @@ import { getTagStyle } from '../utils/tagStyles'
|
||||
import { ColorEditableValue } from './ColorInput'
|
||||
import { IconEditableValue } from './IconEditableValue'
|
||||
import { PROPERTY_CHIP_STYLE } from './propertyChipStyles'
|
||||
import { canonicalSystemMetadataKey } from '../utils/systemMetadata'
|
||||
|
||||
function parseDateValue(value: string): Date | undefined {
|
||||
const iso = toISODate(value)
|
||||
@@ -307,7 +308,7 @@ function toBooleanValue(value: FrontmatterValue): boolean {
|
||||
}
|
||||
|
||||
function autoDetectFromValue(propKey: string, value: FrontmatterValue): PropertyDisplayMode {
|
||||
if (propKey.toLowerCase() === 'icon') return 'text'
|
||||
if (canonicalSystemMetadataKey(propKey) === '_icon') return 'text'
|
||||
if (typeof value === 'boolean') return 'boolean'
|
||||
if (typeof value === 'string' && isUrlValue(value)) return 'url'
|
||||
if (typeof value === 'string' && isValidCssColor(value) && value.startsWith('#')) return 'color'
|
||||
@@ -405,7 +406,7 @@ function ScalarValueCell(props: SmartCellProps) {
|
||||
onSave,
|
||||
})
|
||||
|
||||
if (propKey.toLowerCase() === 'icon') {
|
||||
if (canonicalSystemMetadataKey(propKey) === '_icon') {
|
||||
return <IconEditableValue {...editProps} />
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import type { Settings } from '../types'
|
||||
|
||||
const emptySettings: Settings = {
|
||||
auto_pull_interval_minutes: null,
|
||||
autogit_enabled: null,
|
||||
autogit_idle_threshold_seconds: null,
|
||||
autogit_inactive_threshold_seconds: null,
|
||||
telemetry_consent: null,
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
@@ -45,7 +48,7 @@ describe('SettingsPanel', () => {
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
expect(screen.getByText('Sync')).toBeInTheDocument()
|
||||
expect(screen.getByText('Sync & Updates')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSave with stable defaults on save', () => {
|
||||
@@ -57,6 +60,9 @@ describe('SettingsPanel', () => {
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
auto_pull_interval_minutes: 5,
|
||||
autogit_enabled: false,
|
||||
autogit_idle_threshold_seconds: 90,
|
||||
autogit_inactive_threshold_seconds: 30,
|
||||
release_channel: null,
|
||||
}))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
@@ -116,6 +122,49 @@ describe('SettingsPanel', () => {
|
||||
expect(screen.getByRole('switch', { name: 'Auto-rename untitled notes from first H1' })).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('defaults AutoGit to off with recommended thresholds', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
expect(screen.getByRole('switch', { name: 'Enable AutoGit' })).toHaveAttribute('aria-checked', 'false')
|
||||
expect(screen.getByTestId('settings-autogit-idle-threshold')).toHaveValue(90)
|
||||
expect(screen.getByTestId('settings-autogit-inactive-threshold')).toHaveValue(30)
|
||||
})
|
||||
|
||||
it('saves AutoGit preferences when toggled and edited', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Enable AutoGit' }))
|
||||
fireEvent.change(screen.getByTestId('settings-autogit-idle-threshold'), { target: { value: '120' } })
|
||||
fireEvent.change(screen.getByTestId('settings-autogit-inactive-threshold'), { target: { value: '45' } })
|
||||
fireEvent.click(screen.getByTestId('settings-save'))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
autogit_enabled: true,
|
||||
autogit_idle_threshold_seconds: 120,
|
||||
autogit_inactive_threshold_seconds: 45,
|
||||
}))
|
||||
})
|
||||
|
||||
it('disables AutoGit controls when the current vault is not git-enabled', () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
open={true}
|
||||
settings={emptySettings}
|
||||
isGitVault={false}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('switch', { name: 'Enable AutoGit' })).toBeDisabled()
|
||||
expect(screen.getByTestId('settings-autogit-idle-threshold')).toBeDisabled()
|
||||
expect(screen.getByTestId('settings-autogit-inactive-threshold')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('saves the initial H1 auto-rename preference when toggled off', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useEffect,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { X } from '@phosphor-icons/react'
|
||||
import type { Settings } from '../types'
|
||||
@@ -20,6 +21,7 @@ import { normalizeReleaseChannel, serializeReleaseChannel, type ReleaseChannel }
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { Button } from './ui/button'
|
||||
import { Checkbox, type CheckedState } from './ui/checkbox'
|
||||
import { Input } from './ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -34,6 +36,7 @@ interface SettingsPanelProps {
|
||||
settings: Settings
|
||||
aiAgentsStatus?: AiAgentsStatus
|
||||
onSave: (settings: Settings) => void
|
||||
isGitVault?: boolean
|
||||
explicitOrganizationEnabled?: boolean
|
||||
onSaveExplicitOrganization?: (enabled: boolean) => void
|
||||
onClose: () => void
|
||||
@@ -41,6 +44,9 @@ interface SettingsPanelProps {
|
||||
|
||||
interface SettingsDraft {
|
||||
pullInterval: number
|
||||
autoGitEnabled: boolean
|
||||
autoGitIdleThresholdSeconds: number
|
||||
autoGitInactiveThresholdSeconds: number
|
||||
defaultAiAgent: AiAgentId
|
||||
releaseChannel: ReleaseChannel
|
||||
initialH1AutoRename: boolean
|
||||
@@ -52,6 +58,13 @@ interface SettingsDraft {
|
||||
interface SettingsBodyProps {
|
||||
pullInterval: number
|
||||
setPullInterval: (value: number) => void
|
||||
isGitVault: boolean
|
||||
autoGitEnabled: boolean
|
||||
setAutoGitEnabled: (value: boolean) => void
|
||||
autoGitIdleThresholdSeconds: number
|
||||
setAutoGitIdleThresholdSeconds: (value: number) => void
|
||||
autoGitInactiveThresholdSeconds: number
|
||||
setAutoGitInactiveThresholdSeconds: (value: number) => void
|
||||
aiAgentsStatus: AiAgentsStatus
|
||||
defaultAiAgent: AiAgentId
|
||||
setDefaultAiAgent: (value: AiAgentId) => void
|
||||
@@ -68,6 +81,8 @@ interface SettingsBodyProps {
|
||||
}
|
||||
|
||||
const PULL_INTERVAL_OPTIONS = [1, 2, 5, 10, 15, 30] as const
|
||||
const DEFAULT_AUTOGIT_IDLE_THRESHOLD_SECONDS = 90
|
||||
const DEFAULT_AUTOGIT_INACTIVE_THRESHOLD_SECONDS = 30
|
||||
|
||||
function isSaveShortcut(event: ReactKeyboardEvent): boolean {
|
||||
return event.key === 'Enter' && (event.metaKey || event.ctrlKey)
|
||||
@@ -79,6 +94,15 @@ function createSettingsDraft(
|
||||
): SettingsDraft {
|
||||
return {
|
||||
pullInterval: settings.auto_pull_interval_minutes ?? 5,
|
||||
autoGitEnabled: settings.autogit_enabled ?? false,
|
||||
autoGitIdleThresholdSeconds: sanitizePositiveInteger(
|
||||
settings.autogit_idle_threshold_seconds,
|
||||
DEFAULT_AUTOGIT_IDLE_THRESHOLD_SECONDS,
|
||||
),
|
||||
autoGitInactiveThresholdSeconds: sanitizePositiveInteger(
|
||||
settings.autogit_inactive_threshold_seconds,
|
||||
DEFAULT_AUTOGIT_INACTIVE_THRESHOLD_SECONDS,
|
||||
),
|
||||
defaultAiAgent: resolveDefaultAiAgent(settings.default_ai_agent),
|
||||
releaseChannel: normalizeReleaseChannel(settings.release_channel),
|
||||
initialH1AutoRename: settings.initial_h1_auto_rename_enabled ?? true,
|
||||
@@ -104,6 +128,9 @@ function resolveAnonymousId(settings: Settings, draft: SettingsDraft): string |
|
||||
function buildSettingsFromDraft(settings: Settings, draft: SettingsDraft): Settings {
|
||||
return {
|
||||
auto_pull_interval_minutes: draft.pullInterval,
|
||||
autogit_enabled: draft.autoGitEnabled,
|
||||
autogit_idle_threshold_seconds: draft.autoGitIdleThresholdSeconds,
|
||||
autogit_inactive_threshold_seconds: draft.autoGitInactiveThresholdSeconds,
|
||||
telemetry_consent: resolveTelemetryConsent(settings, draft),
|
||||
crash_reporting_enabled: draft.crashReporting,
|
||||
analytics_enabled: draft.analytics,
|
||||
@@ -123,11 +150,17 @@ function isChecked(checked: CheckedState): boolean {
|
||||
return checked === true
|
||||
}
|
||||
|
||||
function sanitizePositiveInteger(value: number | null | undefined, fallback: number): number {
|
||||
if (value === null || value === undefined || !Number.isFinite(value) || value < 1) return fallback
|
||||
return Math.round(value)
|
||||
}
|
||||
|
||||
export function SettingsPanel({
|
||||
open,
|
||||
settings,
|
||||
aiAgentsStatus = createMissingAiAgentsStatus(),
|
||||
onSave,
|
||||
isGitVault = true,
|
||||
explicitOrganizationEnabled = true,
|
||||
onSaveExplicitOrganization,
|
||||
onClose,
|
||||
@@ -139,6 +172,7 @@ export function SettingsPanel({
|
||||
settings={settings}
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
onSave={onSave}
|
||||
isGitVault={isGitVault}
|
||||
explicitOrganizationEnabled={explicitOrganizationEnabled}
|
||||
onSaveExplicitOrganization={onSaveExplicitOrganization}
|
||||
onClose={onClose}
|
||||
@@ -146,8 +180,9 @@ export function SettingsPanel({
|
||||
)
|
||||
}
|
||||
|
||||
type SettingsPanelInnerProps = Omit<SettingsPanelProps, 'open' | 'explicitOrganizationEnabled' | 'aiAgentsStatus'> & {
|
||||
type SettingsPanelInnerProps = Omit<SettingsPanelProps, 'open' | 'explicitOrganizationEnabled' | 'aiAgentsStatus' | 'isGitVault'> & {
|
||||
aiAgentsStatus: AiAgentsStatus
|
||||
isGitVault: boolean
|
||||
explicitOrganizationEnabled: boolean
|
||||
}
|
||||
|
||||
@@ -155,6 +190,7 @@ function SettingsPanelInner({
|
||||
settings,
|
||||
aiAgentsStatus,
|
||||
onSave,
|
||||
isGitVault,
|
||||
explicitOrganizationEnabled,
|
||||
onSaveExplicitOrganization,
|
||||
onClose,
|
||||
@@ -224,6 +260,13 @@ function SettingsPanelInner({
|
||||
<SettingsBody
|
||||
pullInterval={draft.pullInterval}
|
||||
setPullInterval={(value) => updateDraft('pullInterval', value)}
|
||||
isGitVault={isGitVault}
|
||||
autoGitEnabled={draft.autoGitEnabled}
|
||||
setAutoGitEnabled={(value) => updateDraft('autoGitEnabled', value)}
|
||||
autoGitIdleThresholdSeconds={draft.autoGitIdleThresholdSeconds}
|
||||
setAutoGitIdleThresholdSeconds={(value) => updateDraft('autoGitIdleThresholdSeconds', value)}
|
||||
autoGitInactiveThresholdSeconds={draft.autoGitInactiveThresholdSeconds}
|
||||
setAutoGitInactiveThresholdSeconds={(value) => updateDraft('autoGitInactiveThresholdSeconds', value)}
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
defaultAiAgent={draft.defaultAiAgent}
|
||||
setDefaultAiAgent={(value) => updateDraft('defaultAiAgent', value)}
|
||||
@@ -267,6 +310,13 @@ function SettingsHeader({ onClose }: { onClose: () => void }) {
|
||||
function SettingsBody({
|
||||
pullInterval,
|
||||
setPullInterval,
|
||||
isGitVault,
|
||||
autoGitEnabled,
|
||||
setAutoGitEnabled,
|
||||
autoGitIdleThresholdSeconds,
|
||||
setAutoGitIdleThresholdSeconds,
|
||||
autoGitInactiveThresholdSeconds,
|
||||
setAutoGitInactiveThresholdSeconds,
|
||||
aiAgentsStatus,
|
||||
defaultAiAgent,
|
||||
setDefaultAiAgent,
|
||||
@@ -282,10 +332,73 @@ function SettingsBody({
|
||||
setAnalytics,
|
||||
}: SettingsBodyProps) {
|
||||
return (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20, overflow: 'auto' }}>
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 0, overflow: 'auto' }}>
|
||||
<SettingsSection showDivider={false}>
|
||||
<SyncAndUpdatesSection
|
||||
pullInterval={pullInterval}
|
||||
setPullInterval={setPullInterval}
|
||||
releaseChannel={releaseChannel}
|
||||
setReleaseChannel={setReleaseChannel}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<AutoGitSettingsSection
|
||||
isGitVault={isGitVault}
|
||||
autoGitEnabled={autoGitEnabled}
|
||||
setAutoGitEnabled={setAutoGitEnabled}
|
||||
autoGitIdleThresholdSeconds={autoGitIdleThresholdSeconds}
|
||||
setAutoGitIdleThresholdSeconds={setAutoGitIdleThresholdSeconds}
|
||||
autoGitInactiveThresholdSeconds={autoGitInactiveThresholdSeconds}
|
||||
setAutoGitInactiveThresholdSeconds={setAutoGitInactiveThresholdSeconds}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<TitleSettingsSection
|
||||
initialH1AutoRename={initialH1AutoRename}
|
||||
setInitialH1AutoRename={setInitialH1AutoRename}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<AiAgentSettingsSection
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
setDefaultAiAgent={setDefaultAiAgent}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<OrganizationWorkflowSection
|
||||
checked={explicitOrganization}
|
||||
onChange={setExplicitOrganization}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<PrivacySettingsSection
|
||||
crashReporting={crashReporting}
|
||||
setCrashReporting={setCrashReporting}
|
||||
analytics={analytics}
|
||||
setAnalytics={setAnalytics}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SyncAndUpdatesSection({
|
||||
pullInterval,
|
||||
setPullInterval,
|
||||
releaseChannel,
|
||||
setReleaseChannel,
|
||||
}: Pick<SettingsBodyProps, 'pullInterval' | 'setPullInterval' | 'releaseChannel' | 'setReleaseChannel'>) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeading
|
||||
title="Sync"
|
||||
description="Automatically pull vault changes from Git in the background."
|
||||
title="Sync & Updates"
|
||||
description="Configure background pulling and which update feed Tolaria follows. Stable only receives manually promoted releases, while Alpha follows every push to main."
|
||||
/>
|
||||
|
||||
<LabeledSelect
|
||||
@@ -300,8 +413,85 @@ function SettingsBody({
|
||||
autoFocus={true}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
<LabeledSelect
|
||||
label="Release channel"
|
||||
value={releaseChannel}
|
||||
onValueChange={(value) => setReleaseChannel(value as ReleaseChannel)}
|
||||
options={[
|
||||
{ value: 'stable', label: 'Stable' },
|
||||
{ value: 'alpha', label: 'Alpha' },
|
||||
]}
|
||||
testId="settings-release-channel"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function autoGitSectionDescription(isGitVault: boolean): string {
|
||||
return isGitVault
|
||||
? 'Automatically create conservative Git checkpoints after editing pauses or when the app is no longer active.'
|
||||
: 'AutoGit is unavailable until the current vault is Git-enabled. Initialize Git for this vault first.'
|
||||
}
|
||||
|
||||
function AutoGitSettingsSection({
|
||||
isGitVault,
|
||||
autoGitEnabled,
|
||||
setAutoGitEnabled,
|
||||
autoGitIdleThresholdSeconds,
|
||||
setAutoGitIdleThresholdSeconds,
|
||||
autoGitInactiveThresholdSeconds,
|
||||
setAutoGitInactiveThresholdSeconds,
|
||||
}: Pick<
|
||||
SettingsBodyProps,
|
||||
| 'isGitVault'
|
||||
| 'autoGitEnabled'
|
||||
| 'setAutoGitEnabled'
|
||||
| 'autoGitIdleThresholdSeconds'
|
||||
| 'setAutoGitIdleThresholdSeconds'
|
||||
| 'autoGitInactiveThresholdSeconds'
|
||||
| 'setAutoGitInactiveThresholdSeconds'
|
||||
>) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeading
|
||||
title="AutoGit"
|
||||
description={autoGitSectionDescription(isGitVault)}
|
||||
/>
|
||||
|
||||
<SettingsSwitchRow
|
||||
label="Enable AutoGit"
|
||||
description="When enabled, Tolaria will commit and push saved local changes automatically after an idle pause or after the app becomes inactive."
|
||||
checked={autoGitEnabled}
|
||||
onChange={setAutoGitEnabled}
|
||||
disabled={!isGitVault}
|
||||
testId="settings-autogit-enabled"
|
||||
/>
|
||||
|
||||
<LabeledNumberInput
|
||||
label="Idle threshold (seconds)"
|
||||
value={autoGitIdleThresholdSeconds}
|
||||
onValueChange={setAutoGitIdleThresholdSeconds}
|
||||
testId="settings-autogit-idle-threshold"
|
||||
disabled={!isGitVault}
|
||||
/>
|
||||
|
||||
<LabeledNumberInput
|
||||
label="Inactive-app grace period (seconds)"
|
||||
value={autoGitInactiveThresholdSeconds}
|
||||
onValueChange={setAutoGitInactiveThresholdSeconds}
|
||||
testId="settings-autogit-inactive-threshold"
|
||||
disabled={!isGitVault}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TitleSettingsSection({
|
||||
initialH1AutoRename,
|
||||
setInitialH1AutoRename,
|
||||
}: Pick<SettingsBodyProps, 'initialH1AutoRename' | 'setInitialH1AutoRename'>) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeading
|
||||
title="Titles & Filenames"
|
||||
description="Choose whether Tolaria automatically syncs untitled note filenames from the first H1 title."
|
||||
@@ -314,9 +504,30 @@ function SettingsBody({
|
||||
onChange={setInitialH1AutoRename}
|
||||
testId="settings-initial-h1-auto-rename"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
<Divider />
|
||||
function buildDefaultAiAgentOptions(aiAgentsStatus: AiAgentsStatus): Array<{ value: string; label: string }> {
|
||||
return AI_AGENT_DEFINITIONS.map((definition) => {
|
||||
const status = aiAgentsStatus[definition.id]
|
||||
const suffix = status.status === 'installed'
|
||||
? ` (installed${status.version ? ` ${status.version}` : ''})`
|
||||
: ' (missing)'
|
||||
return {
|
||||
value: definition.id,
|
||||
label: `${definition.label}${suffix}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function AiAgentSettingsSection({
|
||||
aiAgentsStatus,
|
||||
defaultAiAgent,
|
||||
setDefaultAiAgent,
|
||||
}: Pick<SettingsBodyProps, 'aiAgentsStatus' | 'defaultAiAgent' | 'setDefaultAiAgent'>) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeading
|
||||
title="AI Agents"
|
||||
description="Choose which CLI AI agent Tolaria uses in the AI panel and command palette."
|
||||
@@ -326,50 +537,25 @@ function SettingsBody({
|
||||
label="Default AI agent"
|
||||
value={defaultAiAgent}
|
||||
onValueChange={(value) => setDefaultAiAgent(value as AiAgentId)}
|
||||
options={AI_AGENT_DEFINITIONS.map((definition) => {
|
||||
const status = aiAgentsStatus[definition.id]
|
||||
const suffix = status.status === 'installed'
|
||||
? ` (installed${status.version ? ` ${status.version}` : ''})`
|
||||
: ' (missing)'
|
||||
return {
|
||||
value: definition.id,
|
||||
label: `${definition.label}${suffix}`,
|
||||
}
|
||||
})}
|
||||
options={buildDefaultAiAgentOptions(aiAgentsStatus)}
|
||||
testId="settings-default-ai-agent"
|
||||
/>
|
||||
|
||||
<div style={{ fontSize: 11, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
|
||||
{renderDefaultAiAgentSummary(defaultAiAgent, aiAgentsStatus)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
<Divider />
|
||||
|
||||
<SectionHeading
|
||||
title="Release Channel"
|
||||
description="Controls which update feed Tolaria polls. Stable only receives manually promoted releases. Alpha follows every push to main."
|
||||
/>
|
||||
|
||||
<LabeledSelect
|
||||
label="Release channel"
|
||||
value={releaseChannel}
|
||||
onValueChange={(value) => setReleaseChannel(value as ReleaseChannel)}
|
||||
options={[
|
||||
{ value: 'stable', label: 'Stable' },
|
||||
{ value: 'alpha', label: 'Alpha' },
|
||||
]}
|
||||
testId="settings-release-channel"
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<OrganizationWorkflowSection
|
||||
checked={explicitOrganization}
|
||||
onChange={setExplicitOrganization}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
function PrivacySettingsSection({
|
||||
crashReporting,
|
||||
setCrashReporting,
|
||||
analytics,
|
||||
setAnalytics,
|
||||
}: Pick<SettingsBodyProps, 'crashReporting' | 'setCrashReporting' | 'analytics' | 'setAnalytics'>) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeading
|
||||
title="Privacy & Telemetry"
|
||||
description="Anonymous data helps us fix bugs and improve Tolaria. No vault content, note titles, or file paths are ever sent."
|
||||
@@ -389,6 +575,21 @@ function SettingsBody({
|
||||
onChange={setAnalytics}
|
||||
testId="settings-analytics"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsSection({
|
||||
children,
|
||||
showDivider = true,
|
||||
}: {
|
||||
children: ReactNode
|
||||
showDivider?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, padding: '18px 0' }}>
|
||||
{showDivider ? <Divider /> : null}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -401,9 +602,19 @@ function SectionHeading({
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>{title}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--muted-foreground)',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.55, maxWidth: 420 }}>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
@@ -411,7 +622,7 @@ function SectionHeading({
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <div style={{ height: 1, background: 'var(--border)' }} />
|
||||
return <div style={{ height: 1, background: 'color-mix(in srgb, var(--border) 82%, transparent)' }} />
|
||||
}
|
||||
|
||||
function renderDefaultAiAgentSummary(defaultAiAgent: AiAgentId, aiAgentsStatus: AiAgentsStatus): string {
|
||||
@@ -462,6 +673,37 @@ function LabeledSelect({
|
||||
)
|
||||
}
|
||||
|
||||
function LabeledNumberInput({
|
||||
label,
|
||||
value,
|
||||
onValueChange,
|
||||
testId,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
onValueChange: (value: number) => void
|
||||
testId: string
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--foreground)' }} htmlFor={testId}>{label}</label>
|
||||
<Input
|
||||
id={testId}
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onValueChange(sanitizePositiveInteger(Number(event.target.value), value))}
|
||||
data-testid={testId}
|
||||
className="w-full bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OrganizationWorkflowSection({
|
||||
checked,
|
||||
onChange,
|
||||
@@ -492,25 +734,27 @@ function SettingsSwitchRow({
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
testId,
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
disabled?: boolean
|
||||
testId?: string
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className="flex items-start justify-between gap-3"
|
||||
style={{ cursor: 'pointer' }}
|
||||
style={{ cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.6 : 1 }}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--foreground)' }}>{label}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted-foreground)' }}>{description}</div>
|
||||
</div>
|
||||
<Switch checked={checked} onCheckedChange={onChange} aria-label={label} />
|
||||
<Switch checked={checked} onCheckedChange={onChange} aria-label={label} disabled={disabled} />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -780,6 +780,14 @@ describe('Sidebar', () => {
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/binary-note.pdf', filename: 'binary-note.pdf', title: 'Binary Note',
|
||||
isA: 'Note', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 150, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
|
||||
outgoingLinks: [], properties: {}, fileKind: 'binary',
|
||||
},
|
||||
]
|
||||
|
||||
it('shows Notes section when Note entries exist', () => {
|
||||
@@ -787,13 +795,40 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByText('Notes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('counts both explicit and untyped notes in Notes section chip', () => {
|
||||
it('counts only explicit Note entries in the Notes section chip', () => {
|
||||
render(<Sidebar entries={noteEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
const notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
|
||||
expect(notesHeader.textContent).toContain('1')
|
||||
})
|
||||
|
||||
it('ignores non-markdown Note entries in the Notes section chip', () => {
|
||||
render(<Sidebar entries={noteEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
const notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
|
||||
expect(notesHeader.textContent).toContain('1')
|
||||
expect(notesHeader.textContent).not.toContain('2')
|
||||
})
|
||||
|
||||
it('keeps the Notes section count aligned when an entry changes to or from Note', () => {
|
||||
const { rerender } = render(<Sidebar entries={noteEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
let notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
|
||||
expect(notesHeader.textContent).toContain('1')
|
||||
|
||||
const withoutExplicitNote = noteEntries.map((entry) =>
|
||||
entry.path === '/vault/explicit-note.md' ? { ...entry, isA: null } : entry,
|
||||
)
|
||||
rerender(<Sidebar entries={withoutExplicitNote} selection={defaultSelection} onSelect={() => {}} />)
|
||||
notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
|
||||
expect(notesHeader.textContent).toBe('Notes')
|
||||
|
||||
const withNewExplicitNote = noteEntries.map((entry) =>
|
||||
entry.path === '/vault/untyped-note.md' ? { ...entry, isA: 'Note' } : entry,
|
||||
)
|
||||
rerender(<Sidebar entries={withNewExplicitNote} selection={defaultSelection} onSelect={() => {}} />)
|
||||
notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
|
||||
expect(notesHeader.textContent).toContain('2')
|
||||
})
|
||||
|
||||
it('shows Notes section for untyped entries even without explicit Note entries', () => {
|
||||
it('does not show Notes section for untyped entries without explicit Note entries', () => {
|
||||
const untypedOnly: VaultEntry[] = [
|
||||
{
|
||||
path: '/vault/plain.md', filename: 'plain.md', title: 'Plain Note',
|
||||
@@ -805,7 +840,7 @@ describe('Sidebar', () => {
|
||||
},
|
||||
]
|
||||
render(<Sidebar entries={untypedOnly} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('Notes')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Notes')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -874,6 +909,88 @@ describe('Sidebar', () => {
|
||||
expect(topNav.children[0].textContent).toContain('All Notes')
|
||||
})
|
||||
|
||||
it('excludes attachments-folder markdown from top-nav note totals', () => {
|
||||
const entries: VaultEntry[] = [
|
||||
{
|
||||
path: '/vault/note/real-note.md',
|
||||
filename: 'real-note.md',
|
||||
title: 'Real Note',
|
||||
isA: 'Note',
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, owner: null, cadence: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 310, snippet: '', wordCount: 120,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, sort: null, view: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/attachments/reference.md',
|
||||
filename: 'reference.md',
|
||||
title: 'Attachment Markdown',
|
||||
isA: 'Note',
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, owner: null, cadence: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 220, snippet: '', wordCount: 50,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, sort: null, view: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/attachments/nested/archive.md',
|
||||
filename: 'archive.md',
|
||||
title: 'Attachment Archive',
|
||||
isA: 'Note',
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, owner: null, cadence: null,
|
||||
archived: true,
|
||||
modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 180, snippet: '', wordCount: 25,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, sort: null, view: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/archive/real-archive.md',
|
||||
filename: 'real-archive.md',
|
||||
title: 'Real Archive',
|
||||
isA: 'Note',
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, owner: null, cadence: null,
|
||||
archived: true,
|
||||
modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 280, snippet: '', wordCount: 90,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, sort: null, view: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/attachments/image.png',
|
||||
filename: 'image.png',
|
||||
title: 'image.png',
|
||||
isA: null,
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, owner: null, cadence: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 1024, snippet: '', wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, sort: null, view: null,
|
||||
fileKind: 'binary',
|
||||
outgoingLinks: [], properties: {},
|
||||
},
|
||||
]
|
||||
|
||||
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
|
||||
const topNav = screen.getByTestId('sidebar-top-nav')
|
||||
expect(topNav.children[1].textContent).toContain('All Notes1')
|
||||
expect(topNav.children[2].textContent).toContain('Archive1')
|
||||
})
|
||||
|
||||
it('does not show inline entries — no child items in type sections', () => {
|
||||
const entriesWithEmoji: VaultEntry[] = [
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
SuggestionMenuController,
|
||||
BlockNoteViewRaw,
|
||||
ComponentsContext,
|
||||
SideMenuController,
|
||||
} from '@blocknote/react'
|
||||
import { components } from '@blocknote/mantine'
|
||||
import { MantineContext, MantineProvider } from '@mantine/core'
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
TolariaFormattingToolbar,
|
||||
TolariaFormattingToolbarController,
|
||||
} from './tolariaEditorFormatting'
|
||||
import { TolariaSideMenu } from './tolariaBlockNoteSideMenu'
|
||||
import { useEditorLinkActivation } from './useEditorLinkActivation'
|
||||
|
||||
const TEST_TABLE_MARKDOWN = `| Head 1 | Head 2 | Head 3 |
|
||||
@@ -177,6 +179,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
title: entry.title,
|
||||
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
|
||||
group: entry.isA || 'Note',
|
||||
entryType: entry.isA,
|
||||
entryTitle: entry.title,
|
||||
path: entry.path,
|
||||
}))),
|
||||
@@ -223,7 +226,9 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
editable={editable}
|
||||
formattingToolbar={false}
|
||||
slashMenu={false}
|
||||
sideMenu={false}
|
||||
>
|
||||
<SideMenuController sideMenu={TolariaSideMenu} />
|
||||
<TolariaFormattingToolbarController
|
||||
formattingToolbar={TolariaFormattingToolbar}
|
||||
floatingUIOptions={{
|
||||
|
||||
@@ -19,6 +19,19 @@ const installedAiAgentsStatus = {
|
||||
codex: { status: 'installed' as const, version: '0.37.0' },
|
||||
}
|
||||
|
||||
async function expectTooltip(trigger: HTMLElement, ...parts: string[]) {
|
||||
act(() => {
|
||||
fireEvent.focus(trigger)
|
||||
})
|
||||
const tooltip = await screen.findByRole('tooltip')
|
||||
for (const part of parts) {
|
||||
expect(tooltip).toHaveTextContent(part)
|
||||
}
|
||||
act(() => {
|
||||
fireEvent.blur(trigger)
|
||||
})
|
||||
}
|
||||
|
||||
describe('StatusBar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -46,9 +59,9 @@ describe('StatusBar', () => {
|
||||
expect(onCheckForUpdates).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('build number has "Check for updates" title', () => {
|
||||
it('build number shows the update tooltip on focus', async () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} buildNumber="b281" onCheckForUpdates={vi.fn()} />)
|
||||
expect(screen.getByTitle('Check for updates')).toBeInTheDocument()
|
||||
await expectTooltip(screen.getByRole('button', { name: 'Check for updates' }), 'Check for updates')
|
||||
})
|
||||
|
||||
it('does not display branch name', () => {
|
||||
@@ -83,7 +96,7 @@ describe('StatusBar', () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
|
||||
|
||||
// Click the vault button to open menu
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
|
||||
expect(screen.getByText('Work Vault')).toBeInTheDocument()
|
||||
})
|
||||
@@ -92,7 +105,7 @@ describe('StatusBar', () => {
|
||||
const onSwitchVault = vi.fn()
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={onSwitchVault} />)
|
||||
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
// Click "Work Vault"
|
||||
fireEvent.click(screen.getByText('Work Vault'))
|
||||
|
||||
@@ -102,7 +115,7 @@ describe('StatusBar', () => {
|
||||
it('closes vault menu when clicking outside', () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
expect(screen.getByText('Work Vault')).toBeInTheDocument()
|
||||
|
||||
// Click outside the menu
|
||||
@@ -114,7 +127,7 @@ describe('StatusBar', () => {
|
||||
it('toggles vault menu open and closed', () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
|
||||
|
||||
const vaultButton = screen.getByTitle('Switch vault')
|
||||
const vaultButton = screen.getByRole('button', { name: 'Switch vault' })
|
||||
fireEvent.click(vaultButton)
|
||||
expect(screen.getByText('Work Vault')).toBeInTheDocument()
|
||||
|
||||
@@ -127,7 +140,7 @@ describe('StatusBar', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenLocalFolder={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
expect(screen.getByText('Open local folder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -136,7 +149,7 @@ describe('StatusBar', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenLocalFolder={onOpenLocalFolder} />
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
fireEvent.click(screen.getByText('Open local folder'))
|
||||
expect(onOpenLocalFolder).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -152,7 +165,7 @@ describe('StatusBar', () => {
|
||||
onCloneVault={vi.fn()}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
expect(screen.getByText('Open local folder')).toBeInTheDocument()
|
||||
expect(screen.getByText('Clone Git repo')).toBeInTheDocument()
|
||||
})
|
||||
@@ -168,7 +181,7 @@ describe('StatusBar', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
expect(screen.getByText('Clone Getting Started Vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -184,7 +197,7 @@ describe('StatusBar', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
fireEvent.click(screen.getByText('Clone Getting Started Vault'))
|
||||
expect(onCloneGettingStarted).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -210,7 +223,7 @@ describe('StatusBar', () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenLocalFolder={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Switch vault'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
|
||||
fireEvent.click(screen.getByText('Open local folder'))
|
||||
// Menu should close after clicking an action
|
||||
expect(screen.queryByText('Open local folder')).not.toBeInTheDocument()
|
||||
@@ -225,27 +238,27 @@ describe('StatusBar', () => {
|
||||
expect(onClickPending).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('pending count has title for accessibility', () => {
|
||||
it('pending changes tooltip is available on keyboard focus', async () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} modifiedCount={3} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onClickPending={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTitle('View pending changes')).toBeInTheDocument()
|
||||
await expectTooltip(screen.getByRole('button', { name: 'View pending changes' }), 'View pending changes')
|
||||
})
|
||||
|
||||
it('shows MCP warning badge when status is not_installed', () => {
|
||||
it('shows MCP warning badge when status is not_installed', async () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="not_installed" />
|
||||
)
|
||||
expect(screen.getByTestId('status-mcp')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('MCP server not installed — click to install')).toBeInTheDocument()
|
||||
await expectTooltip(screen.getByRole('button', { name: 'MCP server not installed — click to install' }), 'MCP server not installed — click to install')
|
||||
})
|
||||
|
||||
it('shows MCP warning badge when status is no_claude_cli', () => {
|
||||
it('shows MCP warning badge when status is no_claude_cli', async () => {
|
||||
render(
|
||||
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="no_claude_cli" />
|
||||
)
|
||||
expect(screen.getByTestId('status-mcp')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('Claude CLI not found — install it first')).toBeInTheDocument()
|
||||
await expectTooltip(screen.getByRole('button', { name: 'Claude CLI not found — install it first' }), 'Claude CLI not found — install it first')
|
||||
})
|
||||
|
||||
it('hides MCP badge when status is installed', () => {
|
||||
@@ -369,7 +382,16 @@ describe('StatusBar', () => {
|
||||
expect(onCommitPush).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses a local-only tooltip for the commit button when no remote is configured', () => {
|
||||
it('activates the Commit button with the keyboard', () => {
|
||||
const onCommitPush = vi.fn()
|
||||
render(<StatusBar noteCount={100} modifiedCount={5} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCommitPush={onCommitPush} />)
|
||||
const commitButton = screen.getByTestId('status-commit-push')
|
||||
commitButton.focus()
|
||||
fireEvent.keyDown(commitButton, { key: 'Enter' })
|
||||
expect(onCommitPush).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses a local-only tooltip for the commit button when no remote is configured', async () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
@@ -381,7 +403,7 @@ describe('StatusBar', () => {
|
||||
remoteStatus={{ branch: 'main', ahead: 0, behind: 0, hasRemote: false }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('status-commit-push')).toHaveAttribute('title', 'Commit locally (no remote configured)')
|
||||
await expectTooltip(screen.getByRole('button', { name: 'Commit changes locally' }), 'Commit changes locally')
|
||||
})
|
||||
|
||||
it('shows Commit button even when no modified files', () => {
|
||||
@@ -394,20 +416,20 @@ describe('StatusBar', () => {
|
||||
expect(screen.queryByTestId('status-commit-push')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Claude Code badge when installed', () => {
|
||||
it('shows Claude Code badge when installed', async () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} claudeCodeStatus="installed" claudeCodeVersion="1.0.20" />)
|
||||
const badge = screen.getByTestId('status-claude-code')
|
||||
expect(badge).toBeInTheDocument()
|
||||
expect(screen.getByText('Claude Code')).toBeInTheDocument()
|
||||
expect(badge.getAttribute('title')).toBe('Claude Code 1.0.20')
|
||||
await expectTooltip(screen.getByRole('button', { name: 'Claude Code 1.0.20' }), 'Claude Code 1.0.20')
|
||||
})
|
||||
|
||||
it('shows Claude Code missing badge with warning when missing', () => {
|
||||
it('shows Claude Code missing badge with warning when missing', async () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} claudeCodeStatus="missing" />)
|
||||
const badge = screen.getByTestId('status-claude-code')
|
||||
expect(badge).toBeInTheDocument()
|
||||
expect(screen.getByText('Claude Code missing')).toBeInTheDocument()
|
||||
expect(badge.getAttribute('title')).toBe('Claude Code not found — click to install')
|
||||
await expectTooltip(screen.getByRole('button', { name: 'Claude Code not found — click to install' }), 'Claude Code not found — click to install')
|
||||
})
|
||||
|
||||
it('opens install URL when clicking missing Claude Code badge', () => {
|
||||
@@ -445,7 +467,7 @@ describe('StatusBar', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('status-ai-agents')).toHaveTextContent('AI: Claude')
|
||||
expect(screen.getByTestId('status-ai-agents')).toHaveTextContent('Claude')
|
||||
})
|
||||
|
||||
it('opens the AI agent switcher from the keyboard and switches agents', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
||||
import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus'
|
||||
import type { McpStatus } from '../hooks/useMcpStatus'
|
||||
import type { GitRemoteStatus, SyncStatus } from '../types'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import {
|
||||
StatusBarPrimarySection,
|
||||
StatusBarSecondarySection,
|
||||
@@ -97,62 +98,64 @@ export function StatusBar({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<footer
|
||||
style={{
|
||||
height: 30,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: 'var(--sidebar)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
padding: '0 8px',
|
||||
fontSize: 11,
|
||||
color: 'var(--muted-foreground)',
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<StatusBarPrimarySection
|
||||
modifiedCount={modifiedCount}
|
||||
vaultPath={vaultPath}
|
||||
vaults={vaults}
|
||||
onSwitchVault={onSwitchVault}
|
||||
onOpenLocalFolder={onOpenLocalFolder}
|
||||
onCloneVault={onCloneVault}
|
||||
onCloneGettingStarted={onCloneGettingStarted}
|
||||
onClickPending={onClickPending}
|
||||
onClickPulse={onClickPulse}
|
||||
onCommitPush={onCommitPush}
|
||||
isOffline={isOffline}
|
||||
isGitVault={isGitVault}
|
||||
syncStatus={syncStatus}
|
||||
lastSyncTime={lastSyncTime}
|
||||
conflictCount={conflictCount}
|
||||
remoteStatus={remoteStatus}
|
||||
onTriggerSync={onTriggerSync}
|
||||
onPullAndPush={onPullAndPush}
|
||||
onOpenConflictResolver={onOpenConflictResolver}
|
||||
buildNumber={buildNumber}
|
||||
onCheckForUpdates={onCheckForUpdates}
|
||||
onRemoveVault={onRemoveVault}
|
||||
mcpStatus={mcpStatus}
|
||||
onInstallMcp={onInstallMcp}
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
onSetDefaultAiAgent={onSetDefaultAiAgent}
|
||||
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
|
||||
claudeCodeStatus={claudeCodeStatus}
|
||||
claudeCodeVersion={claudeCodeVersion}
|
||||
/>
|
||||
<StatusBarSecondarySection
|
||||
noteCount={noteCount}
|
||||
zoomLevel={zoomLevel}
|
||||
onZoomReset={onZoomReset}
|
||||
onOpenFeedback={onOpenFeedback}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
</footer>
|
||||
<TooltipProvider>
|
||||
<footer
|
||||
style={{
|
||||
height: 30,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: 'var(--sidebar)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
padding: '0 8px',
|
||||
fontSize: 11,
|
||||
color: 'var(--muted-foreground)',
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<StatusBarPrimarySection
|
||||
modifiedCount={modifiedCount}
|
||||
vaultPath={vaultPath}
|
||||
vaults={vaults}
|
||||
onSwitchVault={onSwitchVault}
|
||||
onOpenLocalFolder={onOpenLocalFolder}
|
||||
onCloneVault={onCloneVault}
|
||||
onCloneGettingStarted={onCloneGettingStarted}
|
||||
onClickPending={onClickPending}
|
||||
onClickPulse={onClickPulse}
|
||||
onCommitPush={onCommitPush}
|
||||
isOffline={isOffline}
|
||||
isGitVault={isGitVault}
|
||||
syncStatus={syncStatus}
|
||||
lastSyncTime={lastSyncTime}
|
||||
conflictCount={conflictCount}
|
||||
remoteStatus={remoteStatus}
|
||||
onTriggerSync={onTriggerSync}
|
||||
onPullAndPush={onPullAndPush}
|
||||
onOpenConflictResolver={onOpenConflictResolver}
|
||||
buildNumber={buildNumber}
|
||||
onCheckForUpdates={onCheckForUpdates}
|
||||
onRemoveVault={onRemoveVault}
|
||||
mcpStatus={mcpStatus}
|
||||
onInstallMcp={onInstallMcp}
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
onSetDefaultAiAgent={onSetDefaultAiAgent}
|
||||
onRestoreVaultAiGuidance={onRestoreVaultAiGuidance}
|
||||
claudeCodeStatus={claudeCodeStatus}
|
||||
claudeCodeVersion={claudeCodeVersion}
|
||||
/>
|
||||
<StatusBarSecondarySection
|
||||
noteCount={noteCount}
|
||||
zoomLevel={zoomLevel}
|
||||
onZoomReset={onZoomReset}
|
||||
onOpenFeedback={onOpenFeedback}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
</footer>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,4 +27,9 @@ describe('TelemetryConsentDialog', () => {
|
||||
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
|
||||
expect(screen.getByText(/no vault content, note titles/i)).toBeDefined()
|
||||
})
|
||||
|
||||
it('focuses the first action for keyboard users', () => {
|
||||
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
|
||||
expect(screen.getByTestId('telemetry-decline')).toHaveFocus()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,6 +46,7 @@ export function TelemetryConsentDialog({ onAccept, onDecline }: TelemetryConsent
|
||||
style={{ flex: 1, fontSize: 13, padding: '10px 16px' }}
|
||||
onClick={onDecline}
|
||||
data-testid="telemetry-decline"
|
||||
autoFocus
|
||||
>
|
||||
No thanks
|
||||
</button>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { CaretUpDown, Check, StackSimple } from '@phosphor-icons/react'
|
||||
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react'
|
||||
import { CaretUpDown, Check, StackSimple, WarningCircle } from '@phosphor-icons/react'
|
||||
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react'
|
||||
import type { FrontmatterValue } from './Inspector'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { getTypeIcon } from './NoteItem'
|
||||
import { CreateTypeDialog } from './CreateTypeDialog'
|
||||
import { PROPERTY_CHIP_STYLE } from './propertyChipStyles'
|
||||
import {
|
||||
PROPERTY_PANEL_LABEL_CLASS_NAME,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
const TYPE_NONE = '__none__'
|
||||
const MIN_POPOVER_WIDTH = 220
|
||||
const OPEN_COMBOBOX_KEYS = new Set(['ArrowDown', 'ArrowUp', 'Enter', ' '])
|
||||
const MISSING_TYPE_TOOLTIP = 'Missing type'
|
||||
|
||||
interface TypeSelectorItemProps {
|
||||
type: string
|
||||
@@ -110,7 +113,86 @@ function TypeRowLabel() {
|
||||
)
|
||||
}
|
||||
|
||||
function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null; customColorKey?: string | null; onNavigate?: (target: string) => void }) {
|
||||
function MissingTypeWarning({
|
||||
missingTypeName,
|
||||
onCreateMissingType,
|
||||
}: {
|
||||
missingTypeName: string
|
||||
onCreateMissingType?: (typeName: string) => void | Promise<void>
|
||||
}) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const canCreateMissingType = Boolean(onCreateMissingType)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={cn(
|
||||
'h-6 w-6 shrink-0 rounded-md border border-amber-300/80 bg-amber-50 p-0 text-amber-700 shadow-none hover:bg-amber-100 hover:text-amber-800',
|
||||
!canCreateMissingType && 'cursor-default',
|
||||
)}
|
||||
data-testid="missing-type-warning"
|
||||
aria-label={`Missing type ${missingTypeName}. Click to create this type.`}
|
||||
onClick={canCreateMissingType ? () => setDialogOpen(true) : undefined}
|
||||
>
|
||||
<WarningCircle size={14} weight="fill" aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{MISSING_TYPE_TOOLTIP}</TooltipContent>
|
||||
</Tooltip>
|
||||
{canCreateMissingType && (
|
||||
<CreateTypeDialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onCreate={async (typeName) => {
|
||||
await onCreateMissingType?.(typeName)
|
||||
}}
|
||||
initialName={missingTypeName}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TypeRowValue({
|
||||
children,
|
||||
missingTypeName,
|
||||
onCreateMissingType,
|
||||
}: {
|
||||
children: ReactNode
|
||||
missingTypeName?: string | null
|
||||
onCreateMissingType?: (typeName: string) => void | Promise<void>
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-start gap-1">
|
||||
<div className="min-w-0">{children}</div>
|
||||
{missingTypeName && (
|
||||
<MissingTypeWarning
|
||||
missingTypeName={missingTypeName}
|
||||
onCreateMissingType={onCreateMissingType}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReadOnlyType({
|
||||
isA,
|
||||
customColorKey,
|
||||
onNavigate,
|
||||
missingTypeName,
|
||||
onCreateMissingType,
|
||||
}: {
|
||||
isA?: string | null
|
||||
customColorKey?: string | null
|
||||
onNavigate?: (target: string) => void
|
||||
missingTypeName?: string | null
|
||||
onCreateMissingType?: (typeName: string) => void | Promise<void>
|
||||
}) {
|
||||
if (!isA) return null
|
||||
return (
|
||||
<div
|
||||
@@ -118,7 +200,7 @@ function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null
|
||||
style={PROPERTY_PANEL_ROW_STYLE}
|
||||
>
|
||||
<TypeRowLabel />
|
||||
<div className="min-w-0">
|
||||
<TypeRowValue missingTypeName={missingTypeName} onCreateMissingType={onCreateMissingType}>
|
||||
{onNavigate ? (
|
||||
<button
|
||||
className="min-w-0 max-w-full truncate border-none cursor-pointer ring-inset hover:ring-1 hover:ring-current"
|
||||
@@ -134,7 +216,7 @@ function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null
|
||||
) : (
|
||||
<span className="text-[12px] text-secondary-foreground">{isA}</span>
|
||||
)}
|
||||
</div>
|
||||
</TypeRowValue>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -147,15 +229,36 @@ interface TypeSelectorProps {
|
||||
typeIconKeys: Record<string, string | null>
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onNavigate?: (target: string) => void
|
||||
missingTypeName?: string | null
|
||||
onCreateMissingType?: (typeName: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
export function TypeSelector({ onUpdateProperty, ...props }: TypeSelectorProps) {
|
||||
if (!onUpdateProperty) return <ReadOnlyType isA={props.isA} customColorKey={props.customColorKey} onNavigate={props.onNavigate} />
|
||||
if (!onUpdateProperty) {
|
||||
return (
|
||||
<ReadOnlyType
|
||||
isA={props.isA}
|
||||
customColorKey={props.customColorKey}
|
||||
onNavigate={props.onNavigate}
|
||||
missingTypeName={props.missingTypeName}
|
||||
onCreateMissingType={props.onCreateMissingType}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <EditableTypeSelector {...props} onUpdateProperty={onUpdateProperty} />
|
||||
}
|
||||
|
||||
function EditableTypeSelector({ isA, customColorKey, availableTypes, typeColorKeys, typeIconKeys, onUpdateProperty }: Omit<TypeSelectorProps, 'onUpdateProperty'> & {
|
||||
function EditableTypeSelector({
|
||||
isA,
|
||||
customColorKey,
|
||||
availableTypes,
|
||||
typeColorKeys,
|
||||
typeIconKeys,
|
||||
missingTypeName,
|
||||
onCreateMissingType,
|
||||
onUpdateProperty,
|
||||
}: Omit<TypeSelectorProps, 'onUpdateProperty'> & {
|
||||
onUpdateProperty: (key: string, value: FrontmatterValue) => void
|
||||
}) {
|
||||
const currentValue = isA ?? TYPE_NONE
|
||||
@@ -279,100 +382,102 @@ function EditableTypeSelector({ isA, customColorKey, availableTypes, typeColorKe
|
||||
data-testid="type-selector"
|
||||
>
|
||||
<TypeRowLabel />
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverAnchor asChild>
|
||||
<div ref={rootRef} className="min-w-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
role="combobox"
|
||||
aria-controls={listboxId}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
className={cn(
|
||||
'h-auto max-w-full justify-between gap-1 border-none px-2 shadow-none ring-inset [&_svg]:text-current',
|
||||
isA ? 'hover:ring-1 hover:ring-current' : 'bg-muted hover:bg-muted/80',
|
||||
<TypeRowValue missingTypeName={missingTypeName} onCreateMissingType={onCreateMissingType}>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverAnchor asChild>
|
||||
<div ref={rootRef} className="min-w-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
role="combobox"
|
||||
aria-controls={listboxId}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
className={cn(
|
||||
'h-auto max-w-full justify-between gap-1 border-none px-2 shadow-none ring-inset [&_svg]:text-current',
|
||||
isA ? 'hover:ring-1 hover:ring-current' : 'bg-muted hover:bg-muted/80',
|
||||
)}
|
||||
style={{
|
||||
...PROPERTY_CHIP_STYLE,
|
||||
background: typeLightColor ?? undefined,
|
||||
color: typeColor ?? undefined,
|
||||
}}
|
||||
onPointerDown={handleTriggerPointerDown}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1 truncate">
|
||||
<TypeSelectorValue isA={isA} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} />
|
||||
</span>
|
||||
<CaretUpDown size={14} aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="left"
|
||||
sideOffset={4}
|
||||
className="overflow-hidden p-1"
|
||||
style={{ width: contentWidth }}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="border-b border-border p-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
placeholder="Search types..."
|
||||
autoComplete="off"
|
||||
aria-label="Search types"
|
||||
className="h-8 text-sm"
|
||||
data-testid="type-selector-search-input"
|
||||
onChange={(event) => handleSearchChange(event.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div ref={listRef} className="max-h-60 overflow-y-auto p-1">
|
||||
{options.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
|
||||
No matching types
|
||||
</div>
|
||||
) : (
|
||||
<div id={listboxId} role="listbox">
|
||||
{options.map((type, index) => {
|
||||
const selected = type === currentValue
|
||||
const highlighted = index === highlightedIndex
|
||||
return (
|
||||
<Button
|
||||
key={type}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
data-index={index}
|
||||
className={cn(
|
||||
'h-auto w-full justify-between px-2 py-1.5 text-left font-normal',
|
||||
highlighted && 'bg-muted',
|
||||
)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onClick={() => selectType(type)}
|
||||
>
|
||||
{type === TYPE_NONE ? (
|
||||
<span className="truncate text-muted-foreground">None</span>
|
||||
) : (
|
||||
<span className="flex min-w-0 items-center gap-2 truncate">
|
||||
<TypeSelectorItem type={type} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} />
|
||||
</span>
|
||||
)}
|
||||
{selected ? <Check size={14} aria-hidden="true" /> : null}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
style={{
|
||||
...PROPERTY_CHIP_STYLE,
|
||||
background: typeLightColor ?? undefined,
|
||||
color: typeColor ?? undefined,
|
||||
}}
|
||||
onPointerDown={handleTriggerPointerDown}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1 truncate">
|
||||
<TypeSelectorValue isA={isA} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} />
|
||||
</span>
|
||||
<CaretUpDown size={14} aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="left"
|
||||
sideOffset={4}
|
||||
className="overflow-hidden p-1"
|
||||
style={{ width: contentWidth }}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="border-b border-border p-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
placeholder="Search types..."
|
||||
autoComplete="off"
|
||||
aria-label="Search types"
|
||||
className="h-8 text-sm"
|
||||
data-testid="type-selector-search-input"
|
||||
onChange={(event) => handleSearchChange(event.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div ref={listRef} className="max-h-60 overflow-y-auto p-1">
|
||||
{options.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
|
||||
No matching types
|
||||
</div>
|
||||
) : (
|
||||
<div id={listboxId} role="listbox">
|
||||
{options.map((type, index) => {
|
||||
const selected = type === currentValue
|
||||
const highlighted = index === highlightedIndex
|
||||
return (
|
||||
<Button
|
||||
key={type}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
data-index={index}
|
||||
className={cn(
|
||||
'h-auto w-full justify-between px-2 py-1.5 text-left font-normal',
|
||||
highlighted && 'bg-muted',
|
||||
)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onClick={() => selectType(type)}
|
||||
>
|
||||
{type === TYPE_NONE ? (
|
||||
<span className="truncate text-muted-foreground">None</span>
|
||||
) : (
|
||||
<span className="flex min-w-0 items-center gap-2 truncate">
|
||||
<TypeSelectorItem type={type} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} />
|
||||
</span>
|
||||
)}
|
||||
{selected ? <Check size={14} aria-hidden="true" /> : null}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</TypeRowValue>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ describe('WelcomeScreen', () => {
|
||||
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Get started with a template')
|
||||
})
|
||||
|
||||
it('focuses the first action for keyboard users', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
expect(screen.getByTestId('welcome-create-new')).toHaveFocus()
|
||||
})
|
||||
|
||||
it('shows default vault path in template option description', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
expect(screen.getByText(/~\/Documents\/Laputa/)).toBeInTheDocument()
|
||||
|
||||
@@ -160,6 +160,7 @@ interface OptionButtonProps {
|
||||
disabled: boolean
|
||||
loading?: boolean
|
||||
testId: string
|
||||
autoFocus?: boolean
|
||||
}
|
||||
|
||||
function OptionButton({
|
||||
@@ -173,6 +174,7 @@ function OptionButton({
|
||||
disabled,
|
||||
loading,
|
||||
testId,
|
||||
autoFocus = false,
|
||||
}: OptionButtonProps) {
|
||||
const [hover, setHover] = useState(false)
|
||||
return (
|
||||
@@ -188,6 +190,7 @@ function OptionButton({
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
data-testid={testId}
|
||||
autoFocus={autoFocus}
|
||||
>
|
||||
<div style={{ ...OPTION_ICON_STYLE, background: iconBg }}>
|
||||
{loading ? <Loader2 size={18} className="animate-spin" style={{ color: 'var(--muted-foreground)' }} /> : icon}
|
||||
@@ -278,6 +281,7 @@ export function WelcomeScreen({
|
||||
disabled={busy}
|
||||
loading={creatingAction === 'empty'}
|
||||
testId="welcome-create-new"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<OptionButton
|
||||
|
||||
114
src/components/blockNoteFormattingToolbarHoverGuard.test.ts
Normal file
114
src/components/blockNoteFormattingToolbarHoverGuard.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isWithinFormattingToolbarHoverBridge,
|
||||
shouldSuppressFormattingToolbarHoverUpdate,
|
||||
} from './blockNoteFormattingToolbarHoverGuard'
|
||||
|
||||
function rect(left: number, top: number, width: number, height: number) {
|
||||
return DOMRect.fromRect({ x: left, y: top, width, height })
|
||||
}
|
||||
|
||||
function setRect(element: HTMLElement, nextRect: DOMRect) {
|
||||
element.getBoundingClientRect = () => nextRect
|
||||
}
|
||||
|
||||
describe('blockNoteFormattingToolbarHoverGuard', () => {
|
||||
it('treats the gap between the selected image block and toolbar as part of the hover bridge', () => {
|
||||
expect(
|
||||
isWithinFormattingToolbarHoverBridge(
|
||||
{ x: 368, y: 104 },
|
||||
rect(300, 130, 140, 90),
|
||||
rect(322, 78, 96, 24),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('suppresses hover updates when the pointer is already over the toolbar', () => {
|
||||
const container = document.createElement('div')
|
||||
const block = document.createElement('div')
|
||||
block.className = 'bn-block'
|
||||
block.dataset.id = 'image-block'
|
||||
const fileBlock = document.createElement('div')
|
||||
fileBlock.dataset.fileBlock = 'true'
|
||||
block.appendChild(fileBlock)
|
||||
container.appendChild(block)
|
||||
document.body.appendChild(container)
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'bn-formatting-toolbar'
|
||||
const toolbarButton = document.createElement('button')
|
||||
toolbar.appendChild(toolbarButton)
|
||||
document.body.appendChild(toolbar)
|
||||
|
||||
setRect(fileBlock, rect(300, 130, 140, 90))
|
||||
setRect(toolbar, rect(322, 78, 96, 24))
|
||||
|
||||
expect(
|
||||
shouldSuppressFormattingToolbarHoverUpdate({
|
||||
eventTarget: toolbarButton,
|
||||
point: { x: 350, y: 90 },
|
||||
container,
|
||||
doc: document,
|
||||
selectedFileBlockId: 'image-block',
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('suppresses hover updates while the pointer crosses the image-toolbar bridge', () => {
|
||||
const container = document.createElement('div')
|
||||
const block = document.createElement('div')
|
||||
block.className = 'bn-block'
|
||||
block.dataset.id = 'image-block'
|
||||
const fileBlock = document.createElement('div')
|
||||
fileBlock.dataset.fileBlock = 'true'
|
||||
block.appendChild(fileBlock)
|
||||
container.appendChild(block)
|
||||
document.body.appendChild(container)
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'bn-formatting-toolbar'
|
||||
document.body.appendChild(toolbar)
|
||||
|
||||
setRect(fileBlock, rect(300, 130, 140, 90))
|
||||
setRect(toolbar, rect(322, 78, 96, 24))
|
||||
|
||||
expect(
|
||||
shouldSuppressFormattingToolbarHoverUpdate({
|
||||
eventTarget: document.body,
|
||||
point: { x: 368, y: 104 },
|
||||
container,
|
||||
doc: document,
|
||||
selectedFileBlockId: 'image-block',
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves unrelated pointer movement alone', () => {
|
||||
const container = document.createElement('div')
|
||||
const block = document.createElement('div')
|
||||
block.className = 'bn-block'
|
||||
block.dataset.id = 'image-block'
|
||||
const fileBlock = document.createElement('div')
|
||||
fileBlock.dataset.fileBlock = 'true'
|
||||
block.appendChild(fileBlock)
|
||||
container.appendChild(block)
|
||||
document.body.appendChild(container)
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.className = 'bn-formatting-toolbar'
|
||||
document.body.appendChild(toolbar)
|
||||
|
||||
setRect(fileBlock, rect(300, 130, 140, 90))
|
||||
setRect(toolbar, rect(322, 78, 96, 24))
|
||||
|
||||
expect(
|
||||
shouldSuppressFormattingToolbarHoverUpdate({
|
||||
eventTarget: document.body,
|
||||
point: { x: 520, y: 220 },
|
||||
container,
|
||||
doc: document,
|
||||
selectedFileBlockId: 'image-block',
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
179
src/components/blockNoteFormattingToolbarHoverGuard.ts
Normal file
179
src/components/blockNoteFormattingToolbarHoverGuard.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
|
||||
type RectLike = Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>
|
||||
|
||||
const HOVER_BRIDGE_PADDING_X = 8
|
||||
const HOVER_BRIDGE_PADDING_Y = 8
|
||||
|
||||
function isVisibleRect(rect: RectLike) {
|
||||
return rect.right > rect.left && rect.bottom > rect.top
|
||||
}
|
||||
|
||||
function getSelectedFileBlockBridgeElement(
|
||||
container: HTMLElement,
|
||||
blockId: string,
|
||||
) {
|
||||
const selectedBlock = container.querySelector<HTMLElement>(
|
||||
`.bn-block[data-id="${blockId}"]`,
|
||||
)
|
||||
|
||||
if (!selectedBlock) return null
|
||||
|
||||
return (
|
||||
selectedBlock.querySelector<HTMLElement>(
|
||||
'[data-file-block] .bn-visual-media-wrapper',
|
||||
) ??
|
||||
selectedBlock.querySelector<HTMLElement>(
|
||||
'[data-file-block] .bn-file-name-with-icon',
|
||||
) ??
|
||||
selectedBlock.querySelector<HTMLElement>('[data-file-block] .bn-add-file-button') ??
|
||||
selectedBlock.querySelector<HTMLElement>('[data-file-block]')
|
||||
)
|
||||
}
|
||||
|
||||
export function isWithinFormattingToolbarHoverBridge(
|
||||
point: { x: number; y: number },
|
||||
fileBlockRect: RectLike,
|
||||
toolbarRect: RectLike,
|
||||
) {
|
||||
if (!isVisibleRect(fileBlockRect) || !isVisibleRect(toolbarRect)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const left = Math.min(fileBlockRect.left, toolbarRect.left) - HOVER_BRIDGE_PADDING_X
|
||||
const right = Math.max(fileBlockRect.right, toolbarRect.right) + HOVER_BRIDGE_PADDING_X
|
||||
const top = Math.min(fileBlockRect.top, toolbarRect.top) - HOVER_BRIDGE_PADDING_Y
|
||||
const bottom = Math.max(fileBlockRect.bottom, toolbarRect.bottom) + HOVER_BRIDGE_PADDING_Y
|
||||
|
||||
return (
|
||||
point.x >= left &&
|
||||
point.x <= right &&
|
||||
point.y >= top &&
|
||||
point.y <= bottom
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldSuppressFormattingToolbarHoverUpdate({
|
||||
eventTarget,
|
||||
point,
|
||||
container,
|
||||
doc,
|
||||
selectedFileBlockId,
|
||||
}: {
|
||||
eventTarget: EventTarget | null
|
||||
point: { x: number; y: number }
|
||||
container: HTMLElement | null
|
||||
doc: Document
|
||||
selectedFileBlockId: string | null
|
||||
}) {
|
||||
if (!container || !selectedFileBlockId) return false
|
||||
|
||||
if (
|
||||
eventTarget instanceof Element &&
|
||||
eventTarget.closest('.bn-formatting-toolbar')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
const selectedFileBlock = getSelectedFileBlockBridgeElement(
|
||||
container,
|
||||
selectedFileBlockId,
|
||||
)
|
||||
const toolbar = doc.querySelector<HTMLElement>('.bn-formatting-toolbar')
|
||||
|
||||
if (!selectedFileBlock || !toolbar) return false
|
||||
|
||||
return isWithinFormattingToolbarHoverBridge(
|
||||
point,
|
||||
selectedFileBlock.getBoundingClientRect(),
|
||||
toolbar.getBoundingClientRect(),
|
||||
)
|
||||
}
|
||||
|
||||
function useLastSelectedFormattingToolbarFileBlockId(
|
||||
selectedFileBlockId: string | null,
|
||||
isOpen: boolean,
|
||||
) {
|
||||
const lastSelectedFileBlockIdRef = useRef<string | null>(selectedFileBlockId)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFileBlockId) {
|
||||
lastSelectedFileBlockIdRef.current = selectedFileBlockId
|
||||
return
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
lastSelectedFileBlockIdRef.current = null
|
||||
}
|
||||
}, [isOpen, selectedFileBlockId])
|
||||
|
||||
return lastSelectedFileBlockIdRef
|
||||
}
|
||||
|
||||
function getFormattingToolbarHoverGuardEnvironment(container: HTMLElement | null) {
|
||||
const doc = container?.ownerDocument
|
||||
const view = doc?.defaultView
|
||||
|
||||
if (!container || !doc || !view) return null
|
||||
|
||||
return { container, doc, view }
|
||||
}
|
||||
|
||||
function createFormattingToolbarHoverGuardHandler({
|
||||
container,
|
||||
doc,
|
||||
selectedFileBlockIdRef,
|
||||
}: {
|
||||
container: HTMLElement
|
||||
doc: Document
|
||||
selectedFileBlockIdRef: RefObject<string | null>
|
||||
}) {
|
||||
return (event: MouseEvent) => {
|
||||
if (
|
||||
!shouldSuppressFormattingToolbarHoverUpdate({
|
||||
eventTarget: event.target,
|
||||
point: { x: event.clientX, y: event.clientY },
|
||||
container,
|
||||
doc,
|
||||
selectedFileBlockId: selectedFileBlockIdRef.current,
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
export function useBlockNoteFormattingToolbarHoverGuard({
|
||||
container,
|
||||
selectedFileBlockId,
|
||||
isOpen,
|
||||
}: {
|
||||
container: HTMLElement | null
|
||||
selectedFileBlockId: string | null
|
||||
isOpen: boolean
|
||||
}) {
|
||||
const lastSelectedFileBlockIdRef = useLastSelectedFormattingToolbarFileBlockId(
|
||||
selectedFileBlockId,
|
||||
isOpen,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const environment = getFormattingToolbarHoverGuardEnvironment(container)
|
||||
if (!environment) return
|
||||
|
||||
const handleMouseMove = createFormattingToolbarHoverGuardHandler({
|
||||
container: environment.container,
|
||||
doc: environment.doc,
|
||||
selectedFileBlockIdRef: lastSelectedFileBlockIdRef,
|
||||
})
|
||||
|
||||
environment.view.addEventListener('mousemove', handleMouseMove, true)
|
||||
return () => {
|
||||
environment.view.removeEventListener('mousemove', handleMouseMove, true)
|
||||
}
|
||||
}, [container, isOpen, lastSelectedFileBlockIdRef])
|
||||
}
|
||||
@@ -54,32 +54,60 @@ export function applyPendingRawExitContent(
|
||||
return changed ? nextTabs : tabs
|
||||
}
|
||||
|
||||
function syncContentIfChanged({
|
||||
currentContent,
|
||||
nextContent,
|
||||
path,
|
||||
onContentChange,
|
||||
}: {
|
||||
currentContent: string | null
|
||||
nextContent: string
|
||||
path: string
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
}) {
|
||||
if (currentContent === nextContent) return
|
||||
onContentChange?.(path, nextContent)
|
||||
}
|
||||
|
||||
export function syncActiveTabIntoRawBuffer(options: {
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
activeTabPath: string | null
|
||||
activeTabContent: string | null
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
}) {
|
||||
const { editor, activeTabPath, activeTabContent, rawLatestContentRef, onContentChange } = options
|
||||
const { editor, activeTabPath, activeTabContent, rawLatestContentRef } = options
|
||||
if (!activeTabPath || activeTabContent === null) return null
|
||||
|
||||
const syncedContent = serializeEditorDocumentToMarkdown(editor, activeTabContent)
|
||||
rawLatestContentRef.current = syncedContent
|
||||
onContentChange?.(activeTabPath, syncedContent)
|
||||
return syncedContent
|
||||
}
|
||||
|
||||
export function rememberPendingRawExitContent(options: {
|
||||
activeTabPath: string | null
|
||||
activeTabContent: string | null
|
||||
rawInitialContent: string | null
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
}) {
|
||||
const { activeTabPath, rawLatestContentRef, onContentChange } = options
|
||||
const {
|
||||
activeTabPath,
|
||||
activeTabContent,
|
||||
rawInitialContent,
|
||||
rawLatestContentRef,
|
||||
onContentChange,
|
||||
} = options
|
||||
const pendingRawExitContent = buildPendingRawExitContent(activeTabPath, rawLatestContentRef.current)
|
||||
if (!pendingRawExitContent) return null
|
||||
if (pendingRawExitContent.content === rawInitialContent) return null
|
||||
if (pendingRawExitContent.content === activeTabContent) return null
|
||||
|
||||
onContentChange?.(pendingRawExitContent.path, pendingRawExitContent.content)
|
||||
syncContentIfChanged({
|
||||
currentContent: activeTabContent,
|
||||
nextContent: pendingRawExitContent.content,
|
||||
path: pendingRawExitContent.path,
|
||||
onContentChange,
|
||||
})
|
||||
return pendingRawExitContent
|
||||
}
|
||||
|
||||
|
||||
61
src/components/inspector/useInspectorPropertyActions.ts
Normal file
61
src/components/inspector/useInspectorPropertyActions.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { VaultEntry } from '../../types'
|
||||
|
||||
type FrontmatterValue = string | number | boolean | string[] | null
|
||||
|
||||
interface InspectorPropertyActionsConfig {
|
||||
entry: VaultEntry | null
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateMissingType?: (path: string, missingType: string, nextTypeName: string) => Promise<void>
|
||||
}
|
||||
|
||||
function bindEntryAction<TArgs extends unknown[], TResult>(
|
||||
entry: VaultEntry | null,
|
||||
action: ((path: string, ...args: TArgs) => TResult) | undefined,
|
||||
) {
|
||||
if (!entry || !action) return undefined
|
||||
return (...args: TArgs) => action(entry.path, ...args)
|
||||
}
|
||||
|
||||
function bindMissingTypeAction(
|
||||
entry: VaultEntry | null,
|
||||
action: ((path: string, missingType: string, nextTypeName: string) => Promise<void>) | undefined,
|
||||
) {
|
||||
const missingType = entry?.isA
|
||||
if (!entry || !missingType || !action) return undefined
|
||||
return (nextTypeName: string) => action(entry.path, missingType, nextTypeName)
|
||||
}
|
||||
|
||||
export function useInspectorPropertyActions({
|
||||
entry,
|
||||
onUpdateFrontmatter,
|
||||
onDeleteProperty,
|
||||
onAddProperty,
|
||||
onCreateMissingType,
|
||||
}: InspectorPropertyActionsConfig) {
|
||||
const handleUpdateProperty = useMemo(
|
||||
() => bindEntryAction<[string, FrontmatterValue], Promise<void>>(entry, onUpdateFrontmatter),
|
||||
[entry, onUpdateFrontmatter],
|
||||
)
|
||||
const handleDeleteProperty = useMemo(
|
||||
() => bindEntryAction<[string], Promise<void>>(entry, onDeleteProperty),
|
||||
[entry, onDeleteProperty],
|
||||
)
|
||||
const handleAddProperty = useMemo(
|
||||
() => bindEntryAction<[string, FrontmatterValue], Promise<void>>(entry, onAddProperty),
|
||||
[entry, onAddProperty],
|
||||
)
|
||||
const handleCreateMissingType = useMemo(
|
||||
() => bindMissingTypeAction(entry, onCreateMissingType),
|
||||
[entry, onCreateMissingType],
|
||||
)
|
||||
|
||||
return {
|
||||
handleUpdateProperty,
|
||||
handleDeleteProperty,
|
||||
handleAddProperty,
|
||||
handleCreateMissingType,
|
||||
}
|
||||
}
|
||||
@@ -1,185 +1,16 @@
|
||||
import { createElement, useMemo, useState, type ComponentType, type CSSProperties, type MouseEvent, type ReactNode, type SVGAttributes } from 'react'
|
||||
import { createElement, useMemo, useState, type ComponentType, type MouseEvent, type ReactNode, type SVGAttributes } from 'react'
|
||||
import { Link } from '@phosphor-icons/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { resolveNoteIcon } from '../../utils/noteIcon'
|
||||
import { detectPropertyType } from '../../utils/propertyTypes'
|
||||
import { getMappedStatusStyle } from '../../utils/statusStyles'
|
||||
import { getTypeColor, getTypeLightColor } from '../../utils/typeColors'
|
||||
import { isUrlValue, normalizeUrl, openExternalUrl } from '../../utils/url'
|
||||
import { resolveEntry, wikilinkDisplay, wikilinkTarget } from '../../utils/wikilink'
|
||||
import { getTypeIcon } from './typeIcon'
|
||||
|
||||
interface PropertyChipValue {
|
||||
label: string
|
||||
noteIcon: string | null
|
||||
typeIcon: ComponentType<SVGAttributes<SVGSVGElement>> | null
|
||||
style?: CSSProperties
|
||||
action?: { kind: 'note'; entry: VaultEntry } | { kind: 'url'; url: string }
|
||||
tone: 'neutral' | 'relationship' | 'status' | 'url'
|
||||
}
|
||||
|
||||
const URL_CHIP_STYLE: CSSProperties = {
|
||||
backgroundColor: 'var(--accent-blue-light)',
|
||||
color: 'var(--accent-blue)',
|
||||
}
|
||||
|
||||
type ChipScalarValue = string | number | boolean | null
|
||||
import { openExternalUrl } from '../../utils/url'
|
||||
import { resolvePropertyChipValues, type PropertyChipValue } from './propertyChipValues'
|
||||
|
||||
function toChipTestId(propName: string, index: number): string {
|
||||
const slug = propName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
return `property-chip-${slug || 'value'}-${index}`
|
||||
}
|
||||
|
||||
function normalizeOpenableUrl(value: string): string | null {
|
||||
if (!isUrlValue(value)) return null
|
||||
const normalized = normalizeUrl(value)
|
||||
try {
|
||||
const url = new URL(normalized)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatChipLabel(value: unknown): string | null {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
const raw = String(value)
|
||||
const openableUrl = normalizeOpenableUrl(raw)
|
||||
if (openableUrl) return new URL(openableUrl).hostname
|
||||
return raw.length > 40 ? `${raw.slice(0, 37)}…` : raw
|
||||
}
|
||||
|
||||
function resolveTargetTypeEntry(targetEntry: VaultEntry, typeEntryMap: Record<string, VaultEntry>): VaultEntry | undefined {
|
||||
return targetEntry.isA ? (typeEntryMap[targetEntry.isA] ?? typeEntryMap[targetEntry.isA.toLowerCase()]) : undefined
|
||||
}
|
||||
|
||||
function findMatchingKey(values: Record<string, unknown>, propName: string): string | undefined {
|
||||
return Object.keys(values).find((key) => key.toLowerCase() === propName.toLowerCase())
|
||||
}
|
||||
|
||||
function resolveRelationshipChipStyle(targetEntry: VaultEntry, typeEntryMap: Record<string, VaultEntry>): CSSProperties | undefined {
|
||||
const typeEntry = resolveTargetTypeEntry(targetEntry, typeEntryMap)
|
||||
const color = getTypeColor(targetEntry.isA, typeEntry?.color)
|
||||
const backgroundColor = getTypeLightColor(targetEntry.isA, typeEntry?.color)
|
||||
if (color === 'var(--muted-foreground)' && backgroundColor === 'var(--muted)') return undefined
|
||||
return { color, backgroundColor }
|
||||
}
|
||||
|
||||
function resolveRelationshipChip(
|
||||
ref: string,
|
||||
allEntries: VaultEntry[],
|
||||
typeEntryMap: Record<string, VaultEntry>,
|
||||
): PropertyChipValue | null {
|
||||
const targetEntry = resolveEntry(allEntries, wikilinkTarget(ref))
|
||||
const displayLabel = wikilinkDisplay(ref)
|
||||
const label = ref.includes('|') ? displayLabel : (targetEntry?.title ?? displayLabel)
|
||||
if (!label) return null
|
||||
if (!targetEntry) {
|
||||
return {
|
||||
label,
|
||||
noteIcon: null,
|
||||
typeIcon: null,
|
||||
tone: 'neutral',
|
||||
}
|
||||
}
|
||||
|
||||
const typeEntry = resolveTargetTypeEntry(targetEntry, typeEntryMap)
|
||||
return {
|
||||
label,
|
||||
noteIcon: targetEntry.icon ?? null,
|
||||
typeIcon: targetEntry.isA ? getTypeIcon(targetEntry.isA, typeEntry?.icon) : null,
|
||||
style: resolveRelationshipChipStyle(targetEntry, typeEntryMap),
|
||||
action: { kind: 'note', entry: targetEntry },
|
||||
tone: 'relationship',
|
||||
}
|
||||
}
|
||||
|
||||
function resolveScalarChip(value: unknown): PropertyChipValue | null {
|
||||
const label = formatChipLabel(value)
|
||||
if (!label) return null
|
||||
|
||||
const openableUrl = typeof value === 'string' ? normalizeOpenableUrl(value) : null
|
||||
if (openableUrl) {
|
||||
return {
|
||||
label,
|
||||
noteIcon: null,
|
||||
typeIcon: null,
|
||||
style: URL_CHIP_STYLE,
|
||||
action: { kind: 'url', url: openableUrl },
|
||||
tone: 'url',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
noteIcon: null,
|
||||
typeIcon: null,
|
||||
tone: 'neutral',
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStatusChip(value: ChipScalarValue): PropertyChipValue | null {
|
||||
const label = formatChipLabel(value)
|
||||
if (!label) return null
|
||||
|
||||
const status = String(value)
|
||||
const style = getMappedStatusStyle(status)
|
||||
return {
|
||||
label: `• ${label}`,
|
||||
noteIcon: null,
|
||||
typeIcon: null,
|
||||
style: style ? { backgroundColor: style.bg, color: style.color } : undefined,
|
||||
tone: 'status',
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePropertyValueChip(propName: string, value: ChipScalarValue | undefined): PropertyChipValue | null {
|
||||
if (value === undefined) return null
|
||||
if (detectPropertyType(propName, value) !== 'status') {
|
||||
return resolveScalarChip(value)
|
||||
}
|
||||
return resolveStatusChip(value)
|
||||
}
|
||||
|
||||
function resolveRelationshipChipValues(
|
||||
entry: VaultEntry,
|
||||
propName: string,
|
||||
allEntries: VaultEntry[],
|
||||
typeEntryMap: Record<string, VaultEntry>,
|
||||
): PropertyChipValue[] | null {
|
||||
const relationshipKey = findMatchingKey(entry.relationships, propName)
|
||||
if (!relationshipKey) return null
|
||||
return entry.relationships[relationshipKey]
|
||||
.map((ref) => resolveRelationshipChip(ref, allEntries, typeEntryMap))
|
||||
.filter((chip): chip is PropertyChipValue => chip !== null)
|
||||
}
|
||||
|
||||
function resolveScalarChipValues(entry: VaultEntry, propName: string): PropertyChipValue[] {
|
||||
const propertyKey = findMatchingKey(entry.properties, propName)
|
||||
if (!propertyKey) return []
|
||||
|
||||
const rawValue = entry.properties[propertyKey]
|
||||
const values = Array.isArray(rawValue) ? rawValue : [rawValue]
|
||||
return values
|
||||
.map((value) => resolvePropertyValueChip(propertyKey, value))
|
||||
.filter((chip): chip is PropertyChipValue => chip !== null)
|
||||
}
|
||||
|
||||
function resolvePropertyChipValues(
|
||||
entry: VaultEntry,
|
||||
propName: string,
|
||||
allEntries: VaultEntry[],
|
||||
typeEntryMap: Record<string, VaultEntry>,
|
||||
): PropertyChipValue[] {
|
||||
if (propName.toLowerCase() === 'status') {
|
||||
const statusChip = resolvePropertyValueChip(propName, entry.status)
|
||||
return statusChip ? [statusChip] : []
|
||||
}
|
||||
|
||||
return resolveRelationshipChipValues(entry, propName, allEntries, typeEntryMap) ?? resolveScalarChipValues(entry, propName)
|
||||
}
|
||||
|
||||
function RelationshipTypeIcon({
|
||||
typeIcon,
|
||||
}: {
|
||||
|
||||
185
src/components/note-item/propertyChipValues.ts
Normal file
185
src/components/note-item/propertyChipValues.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { ComponentType, CSSProperties, SVGAttributes } from 'react'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { detectPropertyType } from '../../utils/propertyTypes'
|
||||
import { getMappedStatusStyle } from '../../utils/statusStyles'
|
||||
import { getTypeColor, getTypeLightColor } from '../../utils/typeColors'
|
||||
import { isUrlValue, normalizeUrl } from '../../utils/url'
|
||||
import { resolveEntry, wikilinkDisplay, wikilinkTarget } from '../../utils/wikilink'
|
||||
import { getTypeIcon } from './typeIcon'
|
||||
|
||||
export interface PropertyChipValue {
|
||||
label: string
|
||||
noteIcon: string | null
|
||||
typeIcon: ComponentType<SVGAttributes<SVGSVGElement>> | null
|
||||
style?: CSSProperties
|
||||
action?: { kind: 'note'; entry: VaultEntry } | { kind: 'url'; url: string }
|
||||
tone: 'neutral' | 'relationship' | 'status' | 'url'
|
||||
}
|
||||
|
||||
const URL_CHIP_STYLE: CSSProperties = {
|
||||
backgroundColor: 'var(--accent-blue-light)',
|
||||
color: 'var(--accent-blue)',
|
||||
}
|
||||
|
||||
type ChipScalarValue = string | number | boolean | null
|
||||
|
||||
function normalizeOpenableUrl(value: string): string | null {
|
||||
if (!isUrlValue(value)) return null
|
||||
const normalized = normalizeUrl(value)
|
||||
try {
|
||||
const url = new URL(normalized)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatChipLabel(value: unknown): string | null {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
const raw = String(value)
|
||||
const openableUrl = normalizeOpenableUrl(raw)
|
||||
if (openableUrl) return new URL(openableUrl).hostname
|
||||
return raw.length > 40 ? `${raw.slice(0, 37)}…` : raw
|
||||
}
|
||||
|
||||
function resolveTargetTypeEntry(targetEntry: VaultEntry, typeEntryMap: Record<string, VaultEntry>): VaultEntry | undefined {
|
||||
return targetEntry.isA ? (typeEntryMap[targetEntry.isA] ?? typeEntryMap[targetEntry.isA.toLowerCase()]) : undefined
|
||||
}
|
||||
|
||||
function findMatchingKey(values: Record<string, unknown>, propName: string): string | undefined {
|
||||
return Object.keys(values).find((key) => key.toLowerCase() === propName.toLowerCase())
|
||||
}
|
||||
|
||||
function resolveRelationshipChipStyle(targetEntry: VaultEntry, typeEntryMap: Record<string, VaultEntry>): CSSProperties | undefined {
|
||||
const typeEntry = resolveTargetTypeEntry(targetEntry, typeEntryMap)
|
||||
const color = getTypeColor(targetEntry.isA, typeEntry?.color)
|
||||
const backgroundColor = getTypeLightColor(targetEntry.isA, typeEntry?.color)
|
||||
if (color === 'var(--muted-foreground)' && backgroundColor === 'var(--muted)') return undefined
|
||||
return { color, backgroundColor }
|
||||
}
|
||||
|
||||
function resolveRelationshipChip(
|
||||
ref: string,
|
||||
allEntries: VaultEntry[],
|
||||
typeEntryMap: Record<string, VaultEntry>,
|
||||
): PropertyChipValue | null {
|
||||
const targetEntry = resolveEntry(allEntries, wikilinkTarget(ref))
|
||||
const displayLabel = wikilinkDisplay(ref)
|
||||
const label = ref.includes('|') ? displayLabel : (targetEntry?.title ?? displayLabel)
|
||||
if (!label) return null
|
||||
if (!targetEntry) {
|
||||
return {
|
||||
label,
|
||||
noteIcon: null,
|
||||
typeIcon: null,
|
||||
tone: 'neutral',
|
||||
}
|
||||
}
|
||||
|
||||
const typeEntry = resolveTargetTypeEntry(targetEntry, typeEntryMap)
|
||||
return {
|
||||
label,
|
||||
noteIcon: targetEntry.icon ?? null,
|
||||
typeIcon: targetEntry.isA ? getTypeIcon(targetEntry.isA, typeEntry?.icon) : null,
|
||||
style: resolveRelationshipChipStyle(targetEntry, typeEntryMap),
|
||||
action: { kind: 'note', entry: targetEntry },
|
||||
tone: 'relationship',
|
||||
}
|
||||
}
|
||||
|
||||
function resolveScalarChip(value: unknown): PropertyChipValue | null {
|
||||
const label = formatChipLabel(value)
|
||||
if (!label) return null
|
||||
|
||||
const openableUrl = typeof value === 'string' ? normalizeOpenableUrl(value) : null
|
||||
if (openableUrl) {
|
||||
return {
|
||||
label,
|
||||
noteIcon: null,
|
||||
typeIcon: null,
|
||||
style: URL_CHIP_STYLE,
|
||||
action: { kind: 'url', url: openableUrl },
|
||||
tone: 'url',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
noteIcon: null,
|
||||
typeIcon: null,
|
||||
tone: 'neutral',
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStatusChip(value: ChipScalarValue): PropertyChipValue | null {
|
||||
const label = formatChipLabel(value)
|
||||
if (!label) return null
|
||||
|
||||
const status = String(value)
|
||||
const style = getMappedStatusStyle(status)
|
||||
return {
|
||||
label: `• ${label}`,
|
||||
noteIcon: null,
|
||||
typeIcon: null,
|
||||
style: style ? { backgroundColor: style.bg, color: style.color } : undefined,
|
||||
tone: 'status',
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePropertyValueChip(propName: string, value: ChipScalarValue | undefined): PropertyChipValue | null {
|
||||
if (value === undefined) return null
|
||||
if (detectPropertyType(propName, value) !== 'status') return resolveScalarChip(value)
|
||||
return resolveStatusChip(value)
|
||||
}
|
||||
|
||||
function resolveRelationshipChipValues(
|
||||
entry: VaultEntry,
|
||||
propName: string,
|
||||
allEntries: VaultEntry[],
|
||||
typeEntryMap: Record<string, VaultEntry>,
|
||||
): PropertyChipValue[] | null {
|
||||
const relationshipKey = findMatchingKey(entry.relationships, propName)
|
||||
if (!relationshipKey) return null
|
||||
return entry.relationships[relationshipKey]
|
||||
.map((ref) => resolveRelationshipChip(ref, allEntries, typeEntryMap))
|
||||
.filter((chip): chip is PropertyChipValue => chip !== null)
|
||||
}
|
||||
|
||||
function resolveScalarChipValues(entry: VaultEntry, propName: string): PropertyChipValue[] {
|
||||
const propertyKey = findMatchingKey(entry.properties, propName)
|
||||
if (!propertyKey) return []
|
||||
|
||||
const rawValue = entry.properties[propertyKey]
|
||||
const values = Array.isArray(rawValue) ? rawValue : [rawValue]
|
||||
return values
|
||||
.map((value) => resolvePropertyValueChip(propertyKey, value))
|
||||
.filter((chip): chip is PropertyChipValue => chip !== null)
|
||||
}
|
||||
|
||||
export function resolvePropertyChipValues(
|
||||
entry: VaultEntry,
|
||||
propName: string,
|
||||
allEntries: VaultEntry[],
|
||||
typeEntryMap: Record<string, VaultEntry>,
|
||||
): PropertyChipValue[] {
|
||||
if (propName.toLowerCase() === 'status') {
|
||||
const statusChip = resolvePropertyValueChip(propName, entry.status)
|
||||
return statusChip ? [statusChip] : []
|
||||
}
|
||||
|
||||
return resolveRelationshipChipValues(entry, propName, allEntries, typeEntryMap) ?? resolveScalarChipValues(entry, propName)
|
||||
}
|
||||
|
||||
export function resolvePropertyChipLabels(
|
||||
entry: VaultEntry,
|
||||
displayProps: string[],
|
||||
allEntries: VaultEntry[],
|
||||
typeEntryMap: Record<string, VaultEntry>,
|
||||
): string[] {
|
||||
const labels: string[] = []
|
||||
for (const propName of displayProps) {
|
||||
const values = resolvePropertyChipValues(entry, propName, allEntries, typeEntryMap)
|
||||
for (const value of values) labels.push(value.label)
|
||||
}
|
||||
return labels
|
||||
}
|
||||
70
src/components/note-list/noteListSearch.ts
Normal file
70
src/components/note-list/noteListSearch.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { VaultEntry } from '../../types'
|
||||
import type { RelationshipGroup } from '../../utils/noteListHelpers'
|
||||
import { resolvePropertyChipLabels } from '../note-item/propertyChipValues'
|
||||
|
||||
interface NoteListSearchContext {
|
||||
allEntries: VaultEntry[]
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
displayPropsOverride?: string[] | null
|
||||
}
|
||||
|
||||
function normalizeQuery(query: string): string {
|
||||
return query.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function resolveDisplayProps(
|
||||
entry: VaultEntry,
|
||||
typeEntryMap: Record<string, VaultEntry>,
|
||||
displayPropsOverride?: string[] | null,
|
||||
): string[] {
|
||||
if (displayPropsOverride && displayPropsOverride.length > 0) return displayPropsOverride
|
||||
return typeEntryMap[entry.isA ?? '']?.listPropertiesDisplay ?? []
|
||||
}
|
||||
|
||||
function resolveSearchableText(entry: VaultEntry, context: NoteListSearchContext): string[] {
|
||||
return [
|
||||
entry.title,
|
||||
entry.snippet ?? '',
|
||||
...resolvePropertyChipLabels(
|
||||
entry,
|
||||
resolveDisplayProps(entry, context.typeEntryMap, context.displayPropsOverride),
|
||||
context.allEntries,
|
||||
context.typeEntryMap,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
export function matchesNoteListQuery(
|
||||
entry: VaultEntry,
|
||||
query: string,
|
||||
context: NoteListSearchContext,
|
||||
): boolean {
|
||||
const normalizedQuery = normalizeQuery(query)
|
||||
if (!normalizedQuery) return true
|
||||
return resolveSearchableText(entry, context).some((value) => value.toLowerCase().includes(normalizedQuery))
|
||||
}
|
||||
|
||||
export function filterEntriesByNoteListQuery(
|
||||
entries: VaultEntry[],
|
||||
query: string,
|
||||
context: NoteListSearchContext,
|
||||
): VaultEntry[] {
|
||||
const normalizedQuery = normalizeQuery(query)
|
||||
if (!normalizedQuery) return entries
|
||||
return entries.filter((entry) => matchesNoteListQuery(entry, normalizedQuery, context))
|
||||
}
|
||||
|
||||
export function filterGroupsByNoteListQuery(
|
||||
groups: RelationshipGroup[],
|
||||
query: string,
|
||||
context: NoteListSearchContext,
|
||||
): RelationshipGroup[] {
|
||||
const normalizedQuery = normalizeQuery(query)
|
||||
if (!normalizedQuery) return groups
|
||||
return groups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
entries: filterEntriesByNoteListQuery(group.entries, normalizedQuery, context),
|
||||
}))
|
||||
.filter((group) => group.entries.length > 0)
|
||||
}
|
||||
@@ -8,10 +8,11 @@ import type {
|
||||
ViewFile,
|
||||
} from '../../types'
|
||||
import type { NoteListFilter } from '../../utils/noteListHelpers'
|
||||
import { countByFilter, countAllByFilter } from '../../utils/noteListHelpers'
|
||||
import { countByFilter, countAllByFilter, countAllNotesByFilter } from '../../utils/noteListHelpers'
|
||||
import { NoteItem } from '../NoteItem'
|
||||
import type { MultiSelectState } from '../../hooks/useMultiSelect'
|
||||
import { resolveHeaderTitle, type DeletedNoteEntry } from './noteListUtils'
|
||||
import { filterEntriesByNoteListQuery, filterGroupsByNoteListQuery } from './noteListSearch'
|
||||
import {
|
||||
useChangeStatusResolver,
|
||||
useListPropertyPicker,
|
||||
@@ -75,7 +76,7 @@ function useFilterCounts(entries: VaultEntry[], selection: SidebarSelection) {
|
||||
return useMemo(() => {
|
||||
if (selection.kind === 'sectionGroup') return countByFilter(entries, selection.type)
|
||||
if (selection.kind === 'folder') return countAllByFilter(entries)
|
||||
if (selection.kind === 'filter' && selection.filter === 'all') return countAllByFilter(entries)
|
||||
if (selection.kind === 'filter' && selection.filter === 'all') return countAllNotesByFilter(entries)
|
||||
return { open: 0, archived: 0 }
|
||||
}, [entries, selection])
|
||||
}
|
||||
@@ -145,10 +146,15 @@ function useNoteListContent({
|
||||
onUpdateInboxNoteListProperties,
|
||||
onUpdateTypeSort,
|
||||
})
|
||||
const { isEntityView, isArchivedView, searched, searchedGroups } = useNoteListData({
|
||||
const {
|
||||
isEntityView,
|
||||
isArchivedView,
|
||||
searched: sortedEntries,
|
||||
searchedGroups: sortedGroups,
|
||||
} = useNoteListData({
|
||||
entries,
|
||||
selection,
|
||||
query,
|
||||
query: '',
|
||||
listSort,
|
||||
listDirection,
|
||||
modifiedPathSet,
|
||||
@@ -158,6 +164,16 @@ function useNoteListContent({
|
||||
inboxPeriod: effectiveInboxPeriod,
|
||||
views,
|
||||
})
|
||||
const searched = useMemo(() => filterEntriesByNoteListQuery(sortedEntries, query, {
|
||||
allEntries: entries,
|
||||
typeEntryMap,
|
||||
displayPropsOverride,
|
||||
}), [displayPropsOverride, entries, query, sortedEntries, typeEntryMap])
|
||||
const searchedGroups = useMemo(() => filterGroupsByNoteListQuery(sortedGroups, query, {
|
||||
allEntries: entries,
|
||||
typeEntryMap,
|
||||
displayPropsOverride,
|
||||
}), [displayPropsOverride, entries, query, sortedGroups, typeEntryMap])
|
||||
useVisibleNotesSync({ visibleNotesRef, isEntityView, searched, searchedGroups })
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
|
||||
import { DynamicRelationshipsPanel } from './InspectorPanels'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
const entry: VaultEntry = {
|
||||
path: '/vault/note.md',
|
||||
@@ -37,15 +39,21 @@ const entry: VaultEntry = {
|
||||
sidebarLabel: null,
|
||||
}
|
||||
|
||||
function renderPropertiesPanel(props: ComponentProps<typeof DynamicPropertiesPanel>) {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<DynamicPropertiesPanel {...props} />
|
||||
</TooltipProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('property panel shared grid layout', () => {
|
||||
it('uses a shared fit-content column grid with subgridded rows', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={entry}
|
||||
frontmatter={{ VeryLongPropertyName: 'Value', Status: 'Active' }}
|
||||
onUpdateProperty={vi.fn()}
|
||||
/>
|
||||
)
|
||||
renderPropertiesPanel({
|
||||
entry,
|
||||
frontmatter: { VeryLongPropertyName: 'Value', Status: 'Active' },
|
||||
onUpdateProperty: vi.fn(),
|
||||
})
|
||||
|
||||
const typeRow = screen.getByTestId('type-selector')
|
||||
const layoutGrid = typeRow.parentElement
|
||||
@@ -61,29 +69,17 @@ describe('property panel shared grid layout', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps suggested property rows on the shared grid', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={entry}
|
||||
frontmatter={{}}
|
||||
onAddProperty={vi.fn()}
|
||||
/>
|
||||
)
|
||||
it('keeps suggested and add-property rows on the shared grid', () => {
|
||||
renderPropertiesPanel({
|
||||
entry,
|
||||
frontmatter: {},
|
||||
onAddProperty: vi.fn(),
|
||||
})
|
||||
|
||||
screen.getAllByTestId('suggested-property').forEach((row) => {
|
||||
expect(row.style.gridTemplateColumns).toBe('subgrid')
|
||||
expect(row.style.gridColumn).toBe('1 / -1')
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the add-property row on the shared grid', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={entry}
|
||||
frontmatter={{}}
|
||||
onAddProperty={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
const row = screen.getByTestId('add-property-row')
|
||||
expect(row.style.gridTemplateColumns).toBe('subgrid')
|
||||
@@ -91,14 +87,12 @@ describe('property panel shared grid layout', () => {
|
||||
})
|
||||
|
||||
it('uses the same fixed icon slot size for type, suggested, and add-property rows', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={entry}
|
||||
frontmatter={{}}
|
||||
onAddProperty={vi.fn()}
|
||||
onUpdateProperty={vi.fn()}
|
||||
/>
|
||||
)
|
||||
renderPropertiesPanel({
|
||||
entry,
|
||||
frontmatter: {},
|
||||
onAddProperty: vi.fn(),
|
||||
onUpdateProperty: vi.fn(),
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('type-row-icon-slot')).toHaveClass('size-5')
|
||||
screen.getAllByTestId('suggested-property-icon-slot').forEach((slot) => {
|
||||
@@ -108,38 +102,32 @@ describe('property panel shared grid layout', () => {
|
||||
})
|
||||
|
||||
it('renders suggested-property labels in lighter placeholder text', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={entry}
|
||||
frontmatter={{}}
|
||||
onAddProperty={vi.fn()}
|
||||
/>
|
||||
)
|
||||
renderPropertiesPanel({
|
||||
entry,
|
||||
frontmatter: {},
|
||||
onAddProperty: vi.fn(),
|
||||
})
|
||||
|
||||
expect(screen.getByText('Status').parentElement).toHaveClass('text-muted-foreground/40')
|
||||
expect(screen.getByText('Date').parentElement).toHaveClass('text-muted-foreground/40')
|
||||
})
|
||||
|
||||
it('renders the add-property row in lighter placeholder text', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={entry}
|
||||
frontmatter={{}}
|
||||
onAddProperty={vi.fn()}
|
||||
/>
|
||||
)
|
||||
renderPropertiesPanel({
|
||||
entry,
|
||||
frontmatter: {},
|
||||
onAddProperty: vi.fn(),
|
||||
})
|
||||
|
||||
expect(screen.getByText('Add property').parentElement).toHaveClass('text-muted-foreground/40')
|
||||
})
|
||||
|
||||
it('renders plain text values flush with the shared value column', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={entry}
|
||||
frontmatter={{ Owner: 'Luca' }}
|
||||
onUpdateProperty={vi.fn()}
|
||||
/>
|
||||
)
|
||||
renderPropertiesPanel({
|
||||
entry,
|
||||
frontmatter: { Owner: 'Luca' },
|
||||
onUpdateProperty: vi.fn(),
|
||||
})
|
||||
|
||||
expect(screen.getByText('Luca').parentElement).toHaveClass('px-0')
|
||||
})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { fireEvent, render as rtlRender, screen, waitFor, within } from '@testing-library/react'
|
||||
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { initDisplayModeOverrides } from '../utils/propertyTypes'
|
||||
import { bindVaultConfigStore, resetVaultConfigStore } from '../utils/vaultConfigStore'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
beforeAll(() => {
|
||||
global.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} }
|
||||
@@ -41,6 +43,10 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
...overrides,
|
||||
})
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(ui, { wrapper: TooltipProvider })
|
||||
}
|
||||
|
||||
describe('property type icon control', () => {
|
||||
beforeEach(() => {
|
||||
resetVaultConfigStore()
|
||||
|
||||
@@ -20,6 +20,7 @@ import { TypeCustomizePopover } from '../TypeCustomizePopover'
|
||||
import { useDragRegion } from '../../hooks/useDragRegion'
|
||||
import { SidebarGroupHeader } from './SidebarGroupHeader'
|
||||
import { SidebarViewItem } from './SidebarViewItem'
|
||||
import { countByFilter } from '../../utils/noteListHelpers'
|
||||
|
||||
export { SidebarTopNav } from './SidebarTopNav'
|
||||
export { FavoritesSection } from './FavoritesSection'
|
||||
@@ -94,9 +95,7 @@ function SortableSection({
|
||||
sectionProps: SidebarSectionProps
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
|
||||
const itemCount = sectionProps.entries.filter((entry) =>
|
||||
!entry.archived && (group.type === 'Note' ? (entry.isA === 'Note' || !entry.isA) : entry.isA === group.type),
|
||||
).length
|
||||
const itemCount = countByFilter(sectionProps.entries, group.type).open
|
||||
const isRenaming = sectionProps.renamingType === group.type
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useMemo, useEffect, useCallback, type RefObject } from 'react
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS, getAppStorageItem } from '../../constants/appStorage'
|
||||
import { buildTypeEntryMap } from '../../utils/typeColors'
|
||||
import { countAllNotesByFilter } from '../../utils/noteListHelpers'
|
||||
import { buildDynamicSections, sortSections } from '../../utils/sidebarSections'
|
||||
|
||||
export type SidebarGroupKey = 'favorites' | 'views' | 'sections' | 'folders'
|
||||
@@ -58,13 +59,8 @@ export function useSidebarCollapsed() {
|
||||
|
||||
export function useEntryCounts(entries: VaultEntry[]) {
|
||||
return useMemo(() => {
|
||||
let active = 0
|
||||
let archived = 0
|
||||
for (const entry of entries) {
|
||||
if (entry.archived) archived++
|
||||
else active++
|
||||
}
|
||||
return { activeCount: active, archivedCount: archived }
|
||||
const counts = countAllNotesByFilter(entries)
|
||||
return { activeCount: counts.open, archivedCount: counts.archived }
|
||||
}, [entries])
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, render as rtlRender, screen } from '@testing-library/react'
|
||||
import type { ReactElement } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { AiAgentsBadge } from './AiAgentsBadge'
|
||||
|
||||
vi.mock('../../utils/url', async () => {
|
||||
@@ -12,6 +14,10 @@ const installedStatuses = {
|
||||
codex: { status: 'installed' as const, version: '0.37.0' },
|
||||
}
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(ui, { wrapper: TooltipProvider })
|
||||
}
|
||||
|
||||
describe('AiAgentsBadge', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { AlertTriangle, ChevronsUpDown, Terminal } from 'lucide-react'
|
||||
import { AlertTriangle, ChevronsUpDown } from 'lucide-react'
|
||||
import { Sparkle } from '@phosphor-icons/react'
|
||||
import { ActionTooltip } from '@/components/ui/action-tooltip'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
AI_AGENT_DEFINITIONS,
|
||||
@@ -71,11 +73,8 @@ function missingAgentDefinitions(statuses: AiAgentsStatus): AiAgentDefinition[]
|
||||
return AI_AGENT_DEFINITIONS.filter((definition) => !isAiAgentInstalled(statuses, definition.id))
|
||||
}
|
||||
|
||||
function triggerLabel(defaultAgent: AiAgentId, selectedAgentReady: boolean): string {
|
||||
const definition = getAiAgentDefinition(defaultAgent)
|
||||
return selectedAgentReady
|
||||
? `AI: ${definition.shortLabel}`
|
||||
: `AI: ${definition.shortLabel} unavailable`
|
||||
function triggerLabel(defaultAgent: AiAgentId): string {
|
||||
return getAiAgentDefinition(defaultAgent).shortLabel
|
||||
}
|
||||
|
||||
function menuHeading(defaultAgent: AiAgentId, selectedAgentReady: boolean): string {
|
||||
@@ -199,23 +198,25 @@ export function AiAgentsBadge({
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild={true}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 px-2 text-[11px] font-medium"
|
||||
title={badgeTooltip(statuses, defaultAgent, guidanceStatus)}
|
||||
data-testid="status-ai-agents"
|
||||
>
|
||||
<span style={{ ...ICON_STYLE, color: showWarning ? 'var(--accent-orange)' : 'var(--muted-foreground)' }}>
|
||||
<Terminal size={13} />
|
||||
{triggerLabel(defaultAgent, selectedAgentReady)}
|
||||
{showWarning && <AlertTriangle size={10} style={{ marginLeft: 2 }} />}
|
||||
{!showWarning && showSwitcherCue && <ChevronsUpDown size={10} style={{ marginLeft: 2 }} />}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<ActionTooltip copy={{ label: badgeTooltip(statuses, defaultAgent, guidanceStatus) }} side="top">
|
||||
<DropdownMenuTrigger asChild={true}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 px-2 text-[11px] font-medium"
|
||||
aria-label="Open AI agent options"
|
||||
data-testid="status-ai-agents"
|
||||
>
|
||||
<span style={{ ...ICON_STYLE, color: showWarning ? 'var(--accent-orange)' : 'var(--muted-foreground)' }}>
|
||||
<Sparkle size={13} weight="fill" />
|
||||
{triggerLabel(defaultAgent)}
|
||||
{showWarning && <AlertTriangle size={10} style={{ marginLeft: 2 }} />}
|
||||
{!showWarning && showSwitcherCue && <ChevronsUpDown size={10} style={{ marginLeft: 2 }} />}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</ActionTooltip>
|
||||
<AgentMenuContent
|
||||
statuses={statuses}
|
||||
guidanceStatus={guidanceStatus}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import type {
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
ReactNode,
|
||||
} from 'react'
|
||||
import { useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
@@ -15,6 +10,9 @@ import {
|
||||
Terminal,
|
||||
} from 'lucide-react'
|
||||
import { GitDiff, Pulse } from '@phosphor-icons/react'
|
||||
import { ActionTooltip, type ActionTooltipCopy } from '@/components/ui/action-tooltip'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ClaudeCodeStatus } from '../../hooks/useClaudeCodeStatus'
|
||||
import type { McpStatus } from '../../hooks/useMcpStatus'
|
||||
import type { GitRemoteStatus, LastCommitInfo, SyncStatus } from '../../types'
|
||||
@@ -48,46 +46,6 @@ const MCP_TOOLTIPS: Partial<Record<McpStatus, string>> = {
|
||||
|
||||
const CLAUDE_INSTALL_URL = 'https://docs.anthropic.com/en/docs/claude-code'
|
||||
|
||||
type HoverHandlers = {
|
||||
onMouseEnter?: (event: ReactMouseEvent<HTMLSpanElement>) => void
|
||||
onMouseLeave?: (event: ReactMouseEvent<HTMLSpanElement>) => void
|
||||
}
|
||||
|
||||
interface ClaudeCodeRenderState extends HoverHandlers {
|
||||
role?: 'button'
|
||||
tabIndex?: number
|
||||
onKeyDown?: (event: ReactKeyboardEvent<HTMLSpanElement>) => void
|
||||
color: string
|
||||
cursor: string
|
||||
warningIcon: ReactNode
|
||||
}
|
||||
|
||||
function createHoverHandlers(interactive: boolean): HoverHandlers {
|
||||
if (!interactive) {
|
||||
return {
|
||||
onMouseEnter: undefined,
|
||||
onMouseLeave: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
onMouseEnter: (event) => {
|
||||
event.currentTarget.style.background = 'var(--hover)'
|
||||
},
|
||||
onMouseLeave: (event) => {
|
||||
event.currentTarget.style.background = 'transparent'
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createEnterKeyHandler(onActivate?: () => void) {
|
||||
return (event: ReactKeyboardEvent<HTMLSpanElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
onActivate?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatElapsedSync(lastSyncTime: number | null): string {
|
||||
if (!lastSyncTime) return 'Not synced'
|
||||
const secs = Math.round((Date.now() - lastSyncTime) / 1000)
|
||||
@@ -102,11 +60,12 @@ function syncIconColor(status: SyncStatus): string {
|
||||
return SYNC_COLORS[status] ?? 'var(--accent-green)'
|
||||
}
|
||||
|
||||
function syncBadgeTitle(status: SyncStatus): string {
|
||||
if (status === 'conflict') return 'Click to resolve conflicts'
|
||||
if (status === 'syncing') return 'Syncing…'
|
||||
if (status === 'pull_required') return 'Click to pull from remote and push'
|
||||
return 'Click to sync now'
|
||||
function syncBadgeTooltipCopy(status: SyncStatus): ActionTooltipCopy {
|
||||
if (status === 'conflict') return { label: 'Resolve merge conflicts' }
|
||||
if (status === 'syncing') return { label: 'Sync in progress' }
|
||||
if (status === 'pull_required') return { label: 'Pull from remote and push' }
|
||||
if (status === 'error') return { label: 'Retry sync' }
|
||||
return { label: 'Sync now' }
|
||||
}
|
||||
|
||||
function syncStatusText(status: SyncStatus): string {
|
||||
@@ -126,10 +85,12 @@ function isRemoteMissing(remoteStatus: GitRemoteStatus | null | undefined): bool
|
||||
return remoteStatus?.hasRemote === false
|
||||
}
|
||||
|
||||
function commitButtonTitle(remoteStatus: GitRemoteStatus | null | undefined): string {
|
||||
return isRemoteMissing(remoteStatus)
|
||||
? 'Commit locally (no remote configured)'
|
||||
: 'Commit & Push'
|
||||
function commitButtonTooltipCopy(remoteStatus: GitRemoteStatus | null | undefined): ActionTooltipCopy {
|
||||
return {
|
||||
label: isRemoteMissing(remoteStatus)
|
||||
? 'Commit changes locally'
|
||||
: 'Commit and push changes',
|
||||
}
|
||||
}
|
||||
|
||||
function getMcpBadgeConfig(status: McpStatus, onInstall?: () => void) {
|
||||
@@ -153,30 +114,57 @@ function getClaudeCodeBadgeConfig(status: ClaudeCodeStatus, version?: string | n
|
||||
}
|
||||
}
|
||||
|
||||
function createClaudeCodeRenderState(
|
||||
config: NonNullable<ReturnType<typeof getClaudeCodeBadgeConfig>>,
|
||||
): ClaudeCodeRenderState {
|
||||
if (!config.missing) {
|
||||
return {
|
||||
role: undefined,
|
||||
tabIndex: undefined,
|
||||
onKeyDown: undefined,
|
||||
color: 'var(--muted-foreground)',
|
||||
cursor: 'default',
|
||||
warningIcon: null,
|
||||
...createHoverHandlers(false),
|
||||
}
|
||||
}
|
||||
function handleStatusBarActionKeyDown(
|
||||
event: ReactKeyboardEvent<HTMLButtonElement>,
|
||||
onClick?: () => void,
|
||||
) {
|
||||
if (!onClick) return
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
onClick()
|
||||
}
|
||||
|
||||
return {
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
onKeyDown: createEnterKeyHandler(config.onActivate),
|
||||
color: 'var(--accent-orange)',
|
||||
cursor: 'pointer',
|
||||
warningIcon: <AlertTriangle size={10} style={{ marginLeft: 2 }} />,
|
||||
...createHoverHandlers(true),
|
||||
}
|
||||
function StatusBarAction({
|
||||
copy,
|
||||
children,
|
||||
onClick,
|
||||
testId,
|
||||
ariaLabel,
|
||||
className,
|
||||
style,
|
||||
disabled = false,
|
||||
}: {
|
||||
copy: ActionTooltipCopy
|
||||
children: ReactNode
|
||||
onClick?: () => void
|
||||
testId?: string
|
||||
ariaLabel?: string
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ActionTooltip copy={copy} side="top">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className={cn(
|
||||
'h-auto gap-1 rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground',
|
||||
disabled && 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
onKeyDown={(event) => handleStatusBarActionKeyDown(event, disabled ? undefined : onClick)}
|
||||
aria-label={ariaLabel ?? copy.label}
|
||||
aria-disabled={disabled || undefined}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</ActionTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function RemoteStatusSummary({ remoteStatus }: { remoteStatus: GitRemoteStatus | null }) {
|
||||
@@ -404,16 +392,12 @@ export function SyncBadge({
|
||||
|
||||
return (
|
||||
<div ref={popupRef} style={{ position: 'relative' }}>
|
||||
<span
|
||||
role="button"
|
||||
onClick={handleClick}
|
||||
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3 }}
|
||||
title={syncBadgeTitle(status)}
|
||||
data-testid="status-sync"
|
||||
>
|
||||
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />
|
||||
{formatSyncLabel(status, lastSyncTime)}
|
||||
</span>
|
||||
<StatusBarAction copy={syncBadgeTooltipCopy(status)} onClick={handleClick} testId="status-sync">
|
||||
<span style={ICON_STYLE}>
|
||||
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />
|
||||
{formatSyncLabel(status, lastSyncTime)}
|
||||
</span>
|
||||
</StatusBarAction>
|
||||
{showPopup && (
|
||||
<GitStatusPopup
|
||||
status={status}
|
||||
@@ -432,25 +416,17 @@ export function ConflictBadge({ count, onClick }: { count: number; onClick?: ()
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role="button"
|
||||
<StatusBarAction
|
||||
copy={{ label: 'Resolve merge conflicts' }}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
...ICON_STYLE,
|
||||
color: 'var(--destructive, #e03e3e)',
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
padding: '2px 4px',
|
||||
borderRadius: 3,
|
||||
background: 'transparent',
|
||||
}}
|
||||
title="Resolve merge conflicts"
|
||||
onMouseEnter={onClick ? (event) => { event.currentTarget.style.background = 'var(--hover)' } : undefined}
|
||||
onMouseLeave={onClick ? (event) => { event.currentTarget.style.background = 'transparent' } : undefined}
|
||||
data-testid="status-conflict-count"
|
||||
testId="status-conflict-count"
|
||||
className="text-[var(--destructive,#e03e3e)]"
|
||||
>
|
||||
<AlertTriangle size={13} />
|
||||
{count} conflict{count > 1 ? 's' : ''}
|
||||
</span>
|
||||
<span style={ICON_STYLE}>
|
||||
<AlertTriangle size={13} />
|
||||
{count} conflict{count > 1 ? 's' : ''}
|
||||
</span>
|
||||
</StatusBarAction>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -461,35 +437,29 @@ export function ChangesBadge({ count, onClick }: { count: number; onClick?: () =
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title="View pending changes"
|
||||
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="status-modified-count"
|
||||
>
|
||||
<GitDiff size={13} style={{ color: 'var(--accent-orange)' }} />
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--accent-orange)',
|
||||
color: '#fff',
|
||||
borderRadius: 9,
|
||||
padding: '0 5px',
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
minWidth: 16,
|
||||
lineHeight: '16px',
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
<StatusBarAction copy={{ label: 'View pending changes' }} onClick={onClick} testId="status-modified-count">
|
||||
<span style={ICON_STYLE}>
|
||||
<GitDiff size={13} style={{ color: 'var(--accent-orange)' }} />
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--accent-orange)',
|
||||
color: '#fff',
|
||||
borderRadius: 9,
|
||||
padding: '0 5px',
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
minWidth: 16,
|
||||
lineHeight: '16px',
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
Changes
|
||||
</span>
|
||||
Changes
|
||||
</span>
|
||||
</StatusBarAction>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -506,18 +476,12 @@ export function CommitButton({
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title={commitButtonTitle(remoteStatus)}
|
||||
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="status-commit-push"
|
||||
>
|
||||
<GitCommitHorizontal size={13} />
|
||||
Commit
|
||||
</span>
|
||||
<StatusBarAction copy={commitButtonTooltipCopy(remoteStatus)} onClick={onClick} testId="status-commit-push">
|
||||
<span style={ICON_STYLE}>
|
||||
<GitCommitHorizontal size={13} />
|
||||
Commit
|
||||
</span>
|
||||
</StatusBarAction>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -526,25 +490,17 @@ export function PulseBadge({ onClick, disabled }: { onClick?: () => void; disabl
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role={disabled ? undefined : 'button'}
|
||||
<StatusBarAction
|
||||
copy={{ label: disabled ? 'History is only available for git-enabled vaults' : 'Open change history' }}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
style={{
|
||||
...ICON_STYLE,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
padding: '2px 4px',
|
||||
borderRadius: 3,
|
||||
background: 'transparent',
|
||||
opacity: disabled ? 0.4 : 1,
|
||||
}}
|
||||
title={disabled ? 'History is only available for git-enabled vaults' : 'View history'}
|
||||
onMouseEnter={disabled ? undefined : (event) => { event.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={disabled ? undefined : (event) => { event.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="status-pulse"
|
||||
testId="status-pulse"
|
||||
disabled={Boolean(disabled)}
|
||||
>
|
||||
<Pulse size={13} />
|
||||
History
|
||||
</span>
|
||||
<span style={ICON_STYLE}>
|
||||
<Pulse size={13} />
|
||||
History
|
||||
</span>
|
||||
</StatusBarAction>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -556,26 +512,18 @@ export function McpBadge({ status, onInstall }: { status: McpStatus; onInstall?:
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role={config.clickable ? 'button' : undefined}
|
||||
<StatusBarAction
|
||||
copy={{ label: config.tooltip }}
|
||||
onClick={config.onClick}
|
||||
style={{
|
||||
...ICON_STYLE,
|
||||
color: 'var(--accent-orange)',
|
||||
cursor: config.clickable ? 'pointer' : 'default',
|
||||
padding: '2px 4px',
|
||||
borderRadius: 3,
|
||||
background: 'transparent',
|
||||
}}
|
||||
title={config.tooltip}
|
||||
data-testid="status-mcp"
|
||||
onMouseEnter={config.clickable ? (event) => { event.currentTarget.style.background = 'var(--hover)' } : undefined}
|
||||
onMouseLeave={config.clickable ? (event) => { event.currentTarget.style.background = 'transparent' } : undefined}
|
||||
testId="status-mcp"
|
||||
className="text-[var(--accent-orange)]"
|
||||
>
|
||||
<Cpu size={13} />
|
||||
MCP
|
||||
<AlertTriangle size={10} style={{ marginLeft: 2 }} />
|
||||
</span>
|
||||
<span style={ICON_STYLE}>
|
||||
<Cpu size={13} />
|
||||
MCP
|
||||
<AlertTriangle size={10} style={{ marginLeft: 2 }} />
|
||||
</span>
|
||||
</StatusBarAction>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -584,33 +532,21 @@ export function ClaudeCodeBadge({ status, version }: { status: ClaudeCodeStatus;
|
||||
const config = getClaudeCodeBadgeConfig(status, version)
|
||||
if (!config) return null
|
||||
|
||||
const renderState = createClaudeCodeRenderState(config)
|
||||
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role={renderState.role}
|
||||
tabIndex={renderState.tabIndex}
|
||||
<StatusBarAction
|
||||
copy={{ label: config.tooltip }}
|
||||
onClick={config.onActivate}
|
||||
onKeyDown={renderState.onKeyDown}
|
||||
style={{
|
||||
...ICON_STYLE,
|
||||
color: renderState.color,
|
||||
cursor: renderState.cursor,
|
||||
padding: '2px 4px',
|
||||
borderRadius: 3,
|
||||
background: 'transparent',
|
||||
}}
|
||||
title={config.tooltip}
|
||||
data-testid="status-claude-code"
|
||||
onMouseEnter={renderState.onMouseEnter}
|
||||
onMouseLeave={renderState.onMouseLeave}
|
||||
testId="status-claude-code"
|
||||
className={config.missing ? 'text-[var(--accent-orange)]' : undefined}
|
||||
>
|
||||
<Terminal size={13} />
|
||||
{config.label}
|
||||
{renderState.warningIcon}
|
||||
</span>
|
||||
<span style={ICON_STYLE}>
|
||||
<Terminal size={13} />
|
||||
{config.label}
|
||||
{config.missing && <AlertTriangle size={10} style={{ marginLeft: 2 }} />}
|
||||
</span>
|
||||
</StatusBarAction>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { VaultAiGuidanceStatus } from '../../lib/vaultAiGuidance'
|
||||
import type { ClaudeCodeStatus } from '../../hooks/useClaudeCodeStatus'
|
||||
import type { McpStatus } from '../../hooks/useMcpStatus'
|
||||
import type { GitRemoteStatus, SyncStatus } from '../../types'
|
||||
import { ActionTooltip } from '@/components/ui/action-tooltip'
|
||||
import { AiAgentsBadge } from './AiAgentsBadge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -22,6 +23,12 @@ import { DISABLED_STYLE, ICON_STYLE, SEP_STYLE } from './styles'
|
||||
import type { VaultOption } from './types'
|
||||
import { VaultMenu } from './VaultMenu'
|
||||
|
||||
const UPDATE_TOOLTIP = { label: 'Check for updates' } as const
|
||||
const ZOOM_RESET_TOOLTIP = { label: 'Reset the zoom level', shortcut: '⌘0' } as const
|
||||
const FEEDBACK_TOOLTIP = { label: 'Share feedback' } as const
|
||||
const NOTIFICATIONS_TOOLTIP = { label: 'Notifications are coming soon' } as const
|
||||
const SETTINGS_TOOLTIP = { label: 'Open settings', shortcut: '⌘,' } as const
|
||||
|
||||
interface StatusBarPrimarySectionProps {
|
||||
modifiedCount: number
|
||||
vaultPath: string
|
||||
@@ -109,18 +116,23 @@ export function StatusBarPrimarySection({
|
||||
onRemoveVault={onRemoveVault}
|
||||
/>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role="button"
|
||||
onClick={onCheckForUpdates}
|
||||
style={{ ...ICON_STYLE, cursor: onCheckForUpdates ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title="Check for updates"
|
||||
data-testid="status-build-number"
|
||||
onMouseEnter={onCheckForUpdates ? (event) => { event.currentTarget.style.background = 'var(--hover)' } : undefined}
|
||||
onMouseLeave={onCheckForUpdates ? (event) => { event.currentTarget.style.background = 'transparent' } : undefined}
|
||||
>
|
||||
<Package size={13} />
|
||||
{buildNumber ?? 'b?'}
|
||||
</span>
|
||||
<ActionTooltip copy={UPDATE_TOOLTIP} side="top">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-auto gap-1 rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground"
|
||||
onClick={onCheckForUpdates}
|
||||
aria-label={UPDATE_TOOLTIP.label}
|
||||
aria-disabled={onCheckForUpdates ? undefined : true}
|
||||
data-testid="status-build-number"
|
||||
>
|
||||
<span style={ICON_STYLE}>
|
||||
<Package size={13} />
|
||||
{buildNumber ?? 'b?'}
|
||||
</span>
|
||||
</Button>
|
||||
</ActionTooltip>
|
||||
<OfflineBadge isOffline={isOffline} />
|
||||
<NoRemoteBadge remoteStatus={remoteStatus} />
|
||||
<ChangesBadge count={modifiedCount} onClick={onClickPending} />
|
||||
@@ -163,45 +175,54 @@ export function StatusBarSecondarySection({
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||
{zoomLevel === 100 ? null : (
|
||||
<span
|
||||
role="button"
|
||||
onClick={onZoomReset}
|
||||
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title="Reset zoom (⌘0)"
|
||||
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="status-zoom"
|
||||
>
|
||||
{zoomLevel}%
|
||||
</span>
|
||||
<ActionTooltip copy={ZOOM_RESET_TOOLTIP} side="top">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-auto rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground"
|
||||
onClick={onZoomReset}
|
||||
aria-label={ZOOM_RESET_TOOLTIP.label}
|
||||
data-testid="status-zoom"
|
||||
>
|
||||
<span style={ICON_STYLE}>{zoomLevel}%</span>
|
||||
</Button>
|
||||
</ActionTooltip>
|
||||
)}
|
||||
{onOpenFeedback && (
|
||||
<ActionTooltip copy={FEEDBACK_TOOLTIP} side="top">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenFeedback}
|
||||
aria-label={FEEDBACK_TOOLTIP.label}
|
||||
data-testid="status-feedback"
|
||||
>
|
||||
<Megaphone size={14} />
|
||||
Feedback
|
||||
</Button>
|
||||
</ActionTooltip>
|
||||
)}
|
||||
<ActionTooltip copy={NOTIFICATIONS_TOOLTIP} side="top">
|
||||
<span style={DISABLED_STYLE} aria-label={NOTIFICATIONS_TOOLTIP.label}>
|
||||
<Bell size={14} />
|
||||
</span>
|
||||
</ActionTooltip>
|
||||
<ActionTooltip copy={SETTINGS_TOOLTIP} side="top" align="end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-6 px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenFeedback}
|
||||
title="Share feedback"
|
||||
data-testid="status-feedback"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground"
|
||||
onClick={onOpenSettings}
|
||||
aria-label={SETTINGS_TOOLTIP.label}
|
||||
data-testid="status-settings"
|
||||
>
|
||||
<Megaphone size={14} />
|
||||
Feedback
|
||||
<Settings size={14} />
|
||||
</Button>
|
||||
)}
|
||||
<span style={DISABLED_STYLE} title="Coming soon">
|
||||
<Bell size={14} />
|
||||
</span>
|
||||
<span
|
||||
role="button"
|
||||
onClick={onOpenSettings}
|
||||
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title="Settings (⌘,)"
|
||||
onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={(event) => { event.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<Settings size={14} />
|
||||
</span>
|
||||
</ActionTooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { AlertTriangle, Check, FolderOpen, GitBranch, Rocket, X } from 'lucide-react'
|
||||
import { ActionTooltip } from '@/components/ui/action-tooltip'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { VaultOption } from './types'
|
||||
import { useDismissibleLayer } from './useDismissibleLayer'
|
||||
|
||||
@@ -204,23 +206,22 @@ export function VaultMenu({
|
||||
|
||||
return (
|
||||
<div ref={menuRef} style={{ position: 'relative' }}>
|
||||
<span
|
||||
role="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
cursor: 'pointer',
|
||||
padding: '2px 4px',
|
||||
borderRadius: 3,
|
||||
background: open ? 'var(--hover)' : 'transparent',
|
||||
}}
|
||||
title="Switch vault"
|
||||
>
|
||||
<FolderOpen size={13} />
|
||||
{activeVault?.label ?? 'Vault'}
|
||||
</span>
|
||||
<ActionTooltip copy={{ label: 'Switch vault' }} side="top">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className={open
|
||||
? 'h-auto gap-1 rounded-sm bg-[var(--hover)] px-1 py-0.5 text-[11px] font-medium text-foreground hover:bg-[var(--hover)]'
|
||||
: 'h-auto gap-1 rounded-sm px-1 py-0.5 text-[11px] font-medium text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground'}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-label="Switch vault"
|
||||
data-testid="status-vault-trigger"
|
||||
>
|
||||
<FolderOpen size={13} />
|
||||
{activeVault?.label ?? 'Vault'}
|
||||
</Button>
|
||||
</ActionTooltip>
|
||||
{open && (
|
||||
<div
|
||||
style={{
|
||||
|
||||
44
src/components/tolariaBlockNoteSideMenu.test.tsx
Normal file
44
src/components/tolariaBlockNoteSideMenu.test.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import type { ComponentType, PropsWithChildren } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { TolariaSideMenu } from './tolariaBlockNoteSideMenu'
|
||||
|
||||
let capturedDragHandleMenu: ComponentType | null = null
|
||||
|
||||
vi.mock('@blocknote/react', () => ({
|
||||
DragHandleMenu: ({ children }: PropsWithChildren) => (
|
||||
<div data-testid="drag-handle-menu">{children}</div>
|
||||
),
|
||||
RemoveBlockItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
|
||||
SideMenu: ({ dragHandleMenu }: { dragHandleMenu?: ComponentType }) => {
|
||||
capturedDragHandleMenu = dragHandleMenu ?? null
|
||||
return <div data-testid="side-menu" />
|
||||
},
|
||||
TableColumnHeaderItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
|
||||
TableRowHeaderItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
|
||||
useDictionary: () => ({
|
||||
drag_handle: {
|
||||
delete_menuitem: 'Delete',
|
||||
header_row_menuitem: 'Header row',
|
||||
header_column_menuitem: 'Header column',
|
||||
colors_menuitem: 'Colors',
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('TolariaSideMenu', () => {
|
||||
it('replaces BlockNote block colors with markdown-safe drag-handle items', () => {
|
||||
render(<TolariaSideMenu />)
|
||||
|
||||
expect(screen.getByTestId('side-menu')).toBeInTheDocument()
|
||||
expect(capturedDragHandleMenu).not.toBeNull()
|
||||
|
||||
const DragHandleMenuComponent = capturedDragHandleMenu!
|
||||
render(<DragHandleMenuComponent />)
|
||||
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument()
|
||||
expect(screen.getByText('Header row')).toBeInTheDocument()
|
||||
expect(screen.getByText('Header column')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Colors')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
25
src/components/tolariaBlockNoteSideMenu.tsx
Normal file
25
src/components/tolariaBlockNoteSideMenu.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
DragHandleMenu,
|
||||
RemoveBlockItem,
|
||||
SideMenu,
|
||||
TableColumnHeaderItem,
|
||||
TableRowHeaderItem,
|
||||
type SideMenuProps,
|
||||
useDictionary,
|
||||
} from '@blocknote/react'
|
||||
|
||||
function TolariaDragHandleMenu() {
|
||||
const dict = useDictionary()
|
||||
|
||||
return (
|
||||
<DragHandleMenu>
|
||||
<RemoveBlockItem>{dict.drag_handle.delete_menuitem}</RemoveBlockItem>
|
||||
<TableRowHeaderItem>{dict.drag_handle.header_row_menuitem}</TableRowHeaderItem>
|
||||
<TableColumnHeaderItem>{dict.drag_handle.header_column_menuitem}</TableColumnHeaderItem>
|
||||
</DragHandleMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function TolariaSideMenu(props: SideMenuProps) {
|
||||
return <SideMenu {...props} dragHandleMenu={TolariaDragHandleMenu} />
|
||||
}
|
||||
@@ -25,7 +25,18 @@ import type {
|
||||
StyleSchema,
|
||||
} from '@blocknote/core'
|
||||
import { FormattingToolbarExtension } from '@blocknote/core/extensions'
|
||||
import { useCallback, useMemo, useState, type FC, type ReactElement } from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type FC,
|
||||
type MutableRefObject,
|
||||
type ReactElement,
|
||||
type SetStateAction,
|
||||
} from 'react'
|
||||
import {
|
||||
Button as MantineButton,
|
||||
CheckIcon as MantineCheckIcon,
|
||||
@@ -43,9 +54,82 @@ import {
|
||||
filterTolariaFormattingToolbarItems,
|
||||
getTolariaBlockTypeSelectItems,
|
||||
} from './tolariaEditorFormattingConfig'
|
||||
import { useBlockNoteFormattingToolbarHoverGuard } from './blockNoteFormattingToolbarHoverGuard'
|
||||
|
||||
type TolariaBasicTextStyle = 'bold' | 'italic' | 'strike' | 'code'
|
||||
|
||||
const FORMATTER_CLOSE_GRACE_MS = 160
|
||||
|
||||
function isFocusStillWithinToolbar(
|
||||
currentTarget: EventTarget & Element,
|
||||
nextTarget: EventTarget | null,
|
||||
) {
|
||||
return nextTarget instanceof Node && currentTarget.contains(nextTarget)
|
||||
}
|
||||
|
||||
function clearToolbarCloseGrace(
|
||||
timeoutRef: MutableRefObject<number | null>,
|
||||
setCloseGraceActive: Dispatch<SetStateAction<boolean>>,
|
||||
) {
|
||||
if (timeoutRef.current !== null) {
|
||||
window.clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
setCloseGraceActive(false)
|
||||
}
|
||||
|
||||
function startToolbarCloseGrace(
|
||||
timeoutRef: MutableRefObject<number | null>,
|
||||
setCloseGraceActive: Dispatch<SetStateAction<boolean>>,
|
||||
) {
|
||||
setCloseGraceActive(true)
|
||||
if (timeoutRef.current !== null) {
|
||||
window.clearTimeout(timeoutRef.current)
|
||||
}
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
timeoutRef.current = null
|
||||
setCloseGraceActive(false)
|
||||
}, FORMATTER_CLOSE_GRACE_MS)
|
||||
}
|
||||
|
||||
function useFormattingToolbarCloseGrace({
|
||||
show,
|
||||
toolbarHasFocus,
|
||||
toolbarHovered,
|
||||
}: {
|
||||
show: boolean
|
||||
toolbarHasFocus: boolean
|
||||
toolbarHovered: boolean
|
||||
}) {
|
||||
const [closeGraceActive, setCloseGraceActive] = useState(false)
|
||||
const closeGraceTimeoutRef = useRef<number | null>(null)
|
||||
const previousShowRef = useRef(show)
|
||||
|
||||
const clearCloseGrace = useCallback(() => {
|
||||
clearToolbarCloseGrace(closeGraceTimeoutRef, setCloseGraceActive)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const toolbarInteractionActive = show || toolbarHasFocus || toolbarHovered
|
||||
|
||||
if (toolbarInteractionActive) {
|
||||
clearCloseGrace()
|
||||
} else if (previousShowRef.current) {
|
||||
startToolbarCloseGrace(closeGraceTimeoutRef, setCloseGraceActive)
|
||||
}
|
||||
|
||||
previousShowRef.current = show
|
||||
}, [clearCloseGrace, show, toolbarHasFocus, toolbarHovered])
|
||||
|
||||
useEffect(() => () => {
|
||||
if (closeGraceTimeoutRef.current !== null) {
|
||||
window.clearTimeout(closeGraceTimeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { closeGraceActive, clearCloseGrace }
|
||||
}
|
||||
|
||||
const TOLARIA_BASIC_TEXT_STYLE_TOOLTIPS = {
|
||||
bold: {
|
||||
label: 'Bold',
|
||||
@@ -83,6 +167,13 @@ type TolariaSelectedBlock = ReturnType<
|
||||
BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>['getTextCursorPosition']
|
||||
>['block']
|
||||
|
||||
const FORMATTING_TOOLBAR_FILE_BLOCK_TYPES = new Set([
|
||||
'audio',
|
||||
'file',
|
||||
'image',
|
||||
'video',
|
||||
])
|
||||
|
||||
type TolariaBlockTypeSelectOption = ReturnType<
|
||||
typeof getTolariaBlockTypeSelectItems
|
||||
>[number] & {
|
||||
@@ -180,6 +271,17 @@ function getTolariaBlockTypeSelectOptions(
|
||||
}))
|
||||
}
|
||||
|
||||
function getFormattingToolbarBridgeBlockId(
|
||||
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
|
||||
) {
|
||||
const selectedBlock =
|
||||
editor.getSelection()?.blocks[0] ?? editor.getTextCursorPosition().block
|
||||
|
||||
return FORMATTING_TOOLBAR_FILE_BLOCK_TYPES.has(selectedBlock.type)
|
||||
? selectedBlock.id
|
||||
: null
|
||||
}
|
||||
|
||||
function updateSelectedBlocksToType(
|
||||
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
|
||||
selectedBlocks: TolariaSelectedBlock[],
|
||||
@@ -360,7 +462,27 @@ export function TolariaFormattingToolbarController(props: {
|
||||
editor,
|
||||
})
|
||||
const [toolbarHasFocus, setToolbarHasFocus] = useState(false)
|
||||
const isOpen = show || toolbarHasFocus
|
||||
const [toolbarHovered, setToolbarHovered] = useState(false)
|
||||
const { closeGraceActive, clearCloseGrace } = useFormattingToolbarCloseGrace({
|
||||
show,
|
||||
toolbarHasFocus,
|
||||
toolbarHovered,
|
||||
})
|
||||
|
||||
const isOpen = show || toolbarHasFocus || toolbarHovered || closeGraceActive
|
||||
const currentBridgeBlockId = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor }) => getFormattingToolbarBridgeBlockId(editor),
|
||||
})
|
||||
|
||||
useBlockNoteFormattingToolbarHoverGuard({
|
||||
container:
|
||||
editor.domElement?.closest('.editor__blocknote-container') ??
|
||||
editor.domElement ??
|
||||
null,
|
||||
selectedFileBlockId: currentBridgeBlockId,
|
||||
isOpen,
|
||||
})
|
||||
|
||||
const position = useEditorState({
|
||||
editor,
|
||||
@@ -398,6 +520,8 @@ export function TolariaFormattingToolbarController(props: {
|
||||
formattingToolbar.store.setState(open)
|
||||
if (!open) {
|
||||
setToolbarHasFocus(false)
|
||||
setToolbarHovered(false)
|
||||
clearCloseGrace()
|
||||
}
|
||||
if (reason === 'escape-key') {
|
||||
editor.focus()
|
||||
@@ -413,7 +537,14 @@ export function TolariaFormattingToolbarController(props: {
|
||||
...props.floatingUIOptions?.elementProps,
|
||||
},
|
||||
}),
|
||||
[editor, formattingToolbar.store, isOpen, placement, props.floatingUIOptions],
|
||||
[
|
||||
clearCloseGrace,
|
||||
editor,
|
||||
formattingToolbar.store,
|
||||
isOpen,
|
||||
placement,
|
||||
props.floatingUIOptions,
|
||||
],
|
||||
)
|
||||
|
||||
const Component = props.formattingToolbar || TolariaFormattingToolbar
|
||||
@@ -422,12 +553,21 @@ export function TolariaFormattingToolbarController(props: {
|
||||
<PositionPopover position={position} {...floatingUIOptions}>
|
||||
{isOpen && (
|
||||
<div
|
||||
onPointerEnter={() => {
|
||||
setToolbarHovered(true)
|
||||
}}
|
||||
onPointerLeave={(event) => {
|
||||
if (isFocusStillWithinToolbar(event.currentTarget, event.relatedTarget)) {
|
||||
return
|
||||
}
|
||||
|
||||
setToolbarHovered(false)
|
||||
}}
|
||||
onFocusCapture={() => {
|
||||
setToolbarHasFocus(true)
|
||||
}}
|
||||
onBlurCapture={(event) => {
|
||||
const nextTarget = event.relatedTarget
|
||||
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
|
||||
if (isFocusStillWithinToolbar(event.currentTarget, event.relatedTarget)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
42
src/components/ui/action-tooltip.tsx
Normal file
42
src/components/ui/action-tooltip.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
export interface ActionTooltipCopy {
|
||||
label: string
|
||||
shortcut?: string
|
||||
}
|
||||
|
||||
interface ActionTooltipProps {
|
||||
copy: ActionTooltipCopy
|
||||
children: ReactNode
|
||||
className?: string
|
||||
side?: ComponentProps<typeof TooltipContent>['side']
|
||||
align?: ComponentProps<typeof TooltipContent>['align']
|
||||
sideOffset?: number
|
||||
}
|
||||
|
||||
export function ActionTooltip({
|
||||
copy,
|
||||
children,
|
||||
className,
|
||||
side = 'top',
|
||||
align = 'center',
|
||||
sideOffset = 6,
|
||||
}: ActionTooltipProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent side={side} align={align} sideOffset={sideOffset} className={cn('px-2.5 py-2', className)}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="min-w-0 flex-1 text-[11px] font-medium leading-tight">{copy.label}</span>
|
||||
{copy.shortcut && (
|
||||
<span className="shrink-0 rounded border border-background/20 bg-background/10 px-1.5 py-0.5 font-mono text-[10px] leading-none text-background/80">
|
||||
{copy.shortcut}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
132
src/components/useFilenameAutolinkGuard.ts
Normal file
132
src/components/useFilenameAutolinkGuard.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import { shouldStripAutoLinkedLocalFileMark } from '../utils/editorLinkAutolink'
|
||||
|
||||
const FILENAME_AUTOLINK_GUARD_META = 'tolaria-filename-autolink-guard'
|
||||
|
||||
type TiptapLinkMark = {
|
||||
attrs?: {
|
||||
href?: string
|
||||
}
|
||||
type: unknown
|
||||
}
|
||||
|
||||
type TiptapTextNode = {
|
||||
isText?: boolean
|
||||
marks?: TiptapLinkMark[]
|
||||
nodeSize: number
|
||||
text?: string | null
|
||||
}
|
||||
|
||||
function hasTextContent(node: TiptapTextNode) {
|
||||
return Boolean(node.isText && node.text && node.marks?.length)
|
||||
}
|
||||
|
||||
function isAccidentalFilenameLinkMark(
|
||||
mark: TiptapLinkMark,
|
||||
linkMarkType: unknown,
|
||||
text: string,
|
||||
) {
|
||||
if (mark.type !== linkMarkType) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof mark.attrs?.href !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
return shouldStripAutoLinkedLocalFileMark({
|
||||
href: { raw: mark.attrs.href },
|
||||
text: { raw: text },
|
||||
})
|
||||
}
|
||||
|
||||
function hasEditorEventApi(
|
||||
tiptap: ReturnType<typeof useCreateBlockNote>['_tiptapEditor'] | undefined,
|
||||
) {
|
||||
return Boolean(tiptap && typeof tiptap.on === 'function' && typeof tiptap.off === 'function')
|
||||
}
|
||||
|
||||
function collectAccidentalFilenameLinkRanges(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
const linkMarkType = editor._tiptapEditor?.schema?.marks?.link
|
||||
if (!linkMarkType) {
|
||||
return []
|
||||
}
|
||||
|
||||
const ranges: Array<{ from: number; to: number }> = []
|
||||
editor._tiptapEditor.state.doc.descendants((node: TiptapTextNode, pos: number) => {
|
||||
if (!hasTextContent(node)) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasAccidentalFilenameLink = node.marks?.some((mark) => (
|
||||
isAccidentalFilenameLinkMark(mark, linkMarkType, node.text ?? '')
|
||||
)) ?? false
|
||||
|
||||
if (hasAccidentalFilenameLink) {
|
||||
ranges.push({ from: pos, to: pos + node.nodeSize })
|
||||
}
|
||||
})
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
function shouldSkipGuardRun(
|
||||
applyingGuard: boolean,
|
||||
transaction: { docChanged?: boolean; getMeta: (key: string) => unknown },
|
||||
) {
|
||||
return (
|
||||
applyingGuard
|
||||
|| !transaction.docChanged
|
||||
|| transaction.getMeta(FILENAME_AUTOLINK_GUARD_META)
|
||||
)
|
||||
}
|
||||
|
||||
function removeLinkRanges(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
ranges: Array<{ from: number; to: number }>,
|
||||
) {
|
||||
const tr = editor._tiptapEditor.state.tr
|
||||
for (const range of ranges) {
|
||||
tr.removeMark(range.from, range.to, editor._tiptapEditor.schema.marks.link)
|
||||
}
|
||||
|
||||
return tr
|
||||
}
|
||||
|
||||
export function useFilenameAutolinkGuard(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
useEffect(() => {
|
||||
const tiptap = editor._tiptapEditor
|
||||
if (!hasEditorEventApi(tiptap)) {
|
||||
return
|
||||
}
|
||||
|
||||
let applyingGuard = false
|
||||
const stripAccidentalFilenameAutolinks = ({ transaction }: { transaction: { docChanged?: boolean; getMeta: (key: string) => unknown } }) => {
|
||||
if (shouldSkipGuardRun(applyingGuard, transaction)) {
|
||||
return
|
||||
}
|
||||
|
||||
const ranges = collectAccidentalFilenameLinkRanges(editor)
|
||||
if (ranges.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const tr = removeLinkRanges(editor, ranges)
|
||||
if (!tr.docChanged) {
|
||||
return
|
||||
}
|
||||
|
||||
tr.setMeta(FILENAME_AUTOLINK_GUARD_META, true)
|
||||
applyingGuard = true
|
||||
tiptap.view.dispatch(tr)
|
||||
applyingGuard = false
|
||||
}
|
||||
|
||||
tiptap.on('update', stripAccidentalFilenameAutolinks)
|
||||
|
||||
return () => {
|
||||
tiptap.off('update', stripAccidentalFilenameAutolinks)
|
||||
}
|
||||
}, [editor])
|
||||
}
|
||||
@@ -76,17 +76,20 @@ function capturePendingRoundTripRawRestore(activeTabPath: string | null): Pendin
|
||||
function useTrackRawBuffer({
|
||||
activeTabContent,
|
||||
activeTabPath,
|
||||
rawInitialContentRef,
|
||||
rawBufferPathRef,
|
||||
rawLatestContentRef,
|
||||
}: {
|
||||
activeTabContent: string | null
|
||||
activeTabPath: string | null
|
||||
rawInitialContentRef: React.MutableRefObject<string | null>
|
||||
rawBufferPathRef: React.MutableRefObject<string | null>
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
}) {
|
||||
useLayoutEffect(() => {
|
||||
if (!activeTabPath) {
|
||||
rawLatestContentRef.current = null
|
||||
rawInitialContentRef.current = null
|
||||
rawBufferPathRef.current = null
|
||||
return
|
||||
}
|
||||
@@ -96,44 +99,52 @@ function useTrackRawBuffer({
|
||||
}
|
||||
|
||||
rawLatestContentRef.current = activeTabContent
|
||||
rawInitialContentRef.current = activeTabContent
|
||||
rawBufferPathRef.current = activeTabContent === null ? null : activeTabPath
|
||||
}, [activeTabContent, activeTabPath, rawBufferPathRef, rawLatestContentRef])
|
||||
}, [activeTabContent, activeTabPath, rawBufferPathRef, rawInitialContentRef, rawLatestContentRef])
|
||||
}
|
||||
|
||||
function resetRawBufferState({
|
||||
rawInitialContentRef,
|
||||
rawBufferPathRef,
|
||||
rawLatestContentRef,
|
||||
}: {
|
||||
rawInitialContentRef: React.MutableRefObject<string | null>
|
||||
rawBufferPathRef: React.MutableRefObject<string | null>
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
}) {
|
||||
rawInitialContentRef.current = null
|
||||
rawBufferPathRef.current = null
|
||||
rawLatestContentRef.current = null
|
||||
}
|
||||
|
||||
export function useRawModeWithFlush(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
activeTabPath: string | null,
|
||||
activeTabContent: string | null,
|
||||
onContentChange?: (path: string, content: string) => void,
|
||||
) {
|
||||
const rawLatestContentRef = useRef<string | null>(null)
|
||||
const rawBufferPathRef = useRef<string | null>(null)
|
||||
const pendingRawRestoreRef = useRef<CodeMirrorRestoreState | null>(null)
|
||||
const pendingRichRestoreRef = useRef<RawEditorPositionSnapshot | null>(null)
|
||||
const pendingRoundTripRawRestoreRef = useRef<PendingRoundTripRawRestore | null>(null)
|
||||
const [pendingRawExitContent, setPendingRawExitContent] = useState<PendingRawExitContent | null>(null)
|
||||
const [rawModeContentOverride, setRawModeContentOverride] = useState<PendingRawExitContent | null>(null)
|
||||
useTrackRawBuffer({ activeTabContent, activeTabPath, rawBufferPathRef, rawLatestContentRef })
|
||||
|
||||
const handleFlushPending = useCallback(async () => {
|
||||
function useHandleFlushPending({
|
||||
editor,
|
||||
activeTabPath,
|
||||
activeTabContent,
|
||||
rawInitialContentRef,
|
||||
rawLatestContentRef,
|
||||
pendingRawRestoreRef,
|
||||
pendingRoundTripRawRestoreRef,
|
||||
setRawModeContentOverride,
|
||||
}: {
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
activeTabPath: string | null
|
||||
activeTabContent: string | null
|
||||
rawInitialContentRef: React.MutableRefObject<string | null>
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
pendingRawRestoreRef: React.MutableRefObject<CodeMirrorRestoreState | null>
|
||||
pendingRoundTripRawRestoreRef: React.MutableRefObject<PendingRoundTripRawRestore | null>
|
||||
setRawModeContentOverride: React.Dispatch<React.SetStateAction<PendingRawExitContent | null>>
|
||||
}) {
|
||||
return useCallback(async () => {
|
||||
const syncedContent = syncActiveTabIntoRawBuffer({
|
||||
editor,
|
||||
activeTabPath,
|
||||
activeTabContent,
|
||||
rawLatestContentRef,
|
||||
onContentChange,
|
||||
})
|
||||
rawInitialContentRef.current = syncedContent ?? activeTabContent
|
||||
pendingRawRestoreRef.current = buildPendingRawRestore({
|
||||
activeTabContent,
|
||||
activeTabPath,
|
||||
@@ -145,20 +156,116 @@ export function useRawModeWithFlush(
|
||||
setRawModeContentOverride(buildPendingRawExitContent(activeTabPath, syncedContent))
|
||||
clearTableResizeState(editor)
|
||||
return true
|
||||
}, [activeTabContent, activeTabPath, editor, onContentChange])
|
||||
}, [
|
||||
activeTabContent,
|
||||
activeTabPath,
|
||||
editor,
|
||||
pendingRawRestoreRef,
|
||||
pendingRoundTripRawRestoreRef,
|
||||
rawInitialContentRef,
|
||||
rawLatestContentRef,
|
||||
setRawModeContentOverride,
|
||||
])
|
||||
}
|
||||
|
||||
const handleBeforeRawEnd = useCallback(() => {
|
||||
function useHandleBeforeRawEnd({
|
||||
activeTabPath,
|
||||
activeTabContent,
|
||||
onContentChange,
|
||||
rawInitialContentRef,
|
||||
rawBufferPathRef,
|
||||
rawLatestContentRef,
|
||||
pendingRawRestoreRef,
|
||||
pendingRichRestoreRef,
|
||||
pendingRoundTripRawRestoreRef,
|
||||
setPendingRawExitContent,
|
||||
setRawModeContentOverride,
|
||||
}: {
|
||||
activeTabPath: string | null
|
||||
activeTabContent: string | null
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
rawInitialContentRef: React.MutableRefObject<string | null>
|
||||
rawBufferPathRef: React.MutableRefObject<string | null>
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
pendingRawRestoreRef: React.MutableRefObject<CodeMirrorRestoreState | null>
|
||||
pendingRichRestoreRef: React.MutableRefObject<RawEditorPositionSnapshot | null>
|
||||
pendingRoundTripRawRestoreRef: React.MutableRefObject<PendingRoundTripRawRestore | null>
|
||||
setPendingRawExitContent: React.Dispatch<React.SetStateAction<PendingRawExitContent | null>>
|
||||
setRawModeContentOverride: React.Dispatch<React.SetStateAction<PendingRawExitContent | null>>
|
||||
}) {
|
||||
return useCallback(() => {
|
||||
pendingRoundTripRawRestoreRef.current = capturePendingRoundTripRawRestore(activeTabPath)
|
||||
pendingRichRestoreRef.current = captureRawEditorPositionSnapshot(document)
|
||||
pendingRawRestoreRef.current = null
|
||||
setPendingRawExitContent(rememberPendingRawExitContent({
|
||||
activeTabPath,
|
||||
activeTabContent,
|
||||
rawInitialContent: rawInitialContentRef.current,
|
||||
rawLatestContentRef,
|
||||
onContentChange,
|
||||
}))
|
||||
setRawModeContentOverride(null)
|
||||
resetRawBufferState({ rawBufferPathRef, rawLatestContentRef })
|
||||
}, [activeTabPath, onContentChange])
|
||||
resetRawBufferState({ rawInitialContentRef, rawBufferPathRef, rawLatestContentRef })
|
||||
}, [
|
||||
activeTabContent,
|
||||
activeTabPath,
|
||||
onContentChange,
|
||||
pendingRawRestoreRef,
|
||||
pendingRichRestoreRef,
|
||||
pendingRoundTripRawRestoreRef,
|
||||
rawInitialContentRef,
|
||||
rawBufferPathRef,
|
||||
rawLatestContentRef,
|
||||
setPendingRawExitContent,
|
||||
setRawModeContentOverride,
|
||||
])
|
||||
}
|
||||
|
||||
export function useRawModeWithFlush(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
activeTabPath: string | null,
|
||||
activeTabContent: string | null,
|
||||
onContentChange?: (path: string, content: string) => void,
|
||||
) {
|
||||
const rawLatestContentRef = useRef<string | null>(null)
|
||||
const rawInitialContentRef = useRef<string | null>(null)
|
||||
const rawBufferPathRef = useRef<string | null>(null)
|
||||
const pendingRawRestoreRef = useRef<CodeMirrorRestoreState | null>(null)
|
||||
const pendingRichRestoreRef = useRef<RawEditorPositionSnapshot | null>(null)
|
||||
const pendingRoundTripRawRestoreRef = useRef<PendingRoundTripRawRestore | null>(null)
|
||||
const [pendingRawExitContent, setPendingRawExitContent] = useState<PendingRawExitContent | null>(null)
|
||||
const [rawModeContentOverride, setRawModeContentOverride] = useState<PendingRawExitContent | null>(null)
|
||||
useTrackRawBuffer({
|
||||
activeTabContent,
|
||||
activeTabPath,
|
||||
rawInitialContentRef,
|
||||
rawBufferPathRef,
|
||||
rawLatestContentRef,
|
||||
})
|
||||
|
||||
const handleFlushPending = useHandleFlushPending({
|
||||
editor,
|
||||
activeTabPath,
|
||||
activeTabContent,
|
||||
rawInitialContentRef,
|
||||
rawLatestContentRef,
|
||||
pendingRawRestoreRef,
|
||||
pendingRoundTripRawRestoreRef,
|
||||
setRawModeContentOverride,
|
||||
})
|
||||
const handleBeforeRawEnd = useHandleBeforeRawEnd({
|
||||
activeTabPath,
|
||||
activeTabContent,
|
||||
onContentChange,
|
||||
rawInitialContentRef,
|
||||
rawBufferPathRef,
|
||||
rawLatestContentRef,
|
||||
pendingRawRestoreRef,
|
||||
pendingRichRestoreRef,
|
||||
pendingRoundTripRawRestoreRef,
|
||||
setPendingRawExitContent,
|
||||
setRawModeContentOverride,
|
||||
})
|
||||
|
||||
const { rawMode, handleToggleRaw } = useRawMode({
|
||||
activeTabPath,
|
||||
|
||||
@@ -32,7 +32,7 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
|
||||
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
|
||||
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', shortcut: '⌘⇧I', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
|
||||
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() },
|
||||
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote && !!onToggleRawEditor, execute: () => onToggleRawEditor?.() },
|
||||
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⇧⌘L', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
|
||||
{ id: 'customize-note-list-columns', label: noteListColumnsLabel, group: 'View', keywords: ['all notes', 'inbox', 'columns', 'chips', 'properties', 'note list'], enabled: !!(canCustomizeNoteListColumns && onCustomizeNoteListColumns), execute: () => onCustomizeNoteListColumns?.() },
|
||||
|
||||
@@ -461,6 +461,57 @@ describe('useAppSave', () => {
|
||||
expect(getTabs()[0].content).toBe('# Fresh Title\n\nBody that keeps changing while rename is pending')
|
||||
})
|
||||
|
||||
it('does not run markdown title-sync renames for non-markdown text files', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
|
||||
const viewPath = '/vault/views/active-projects.yml'
|
||||
const viewContent = 'name: Active Projects\nicon: rocket\ncolor: blue\n'
|
||||
const viewEntry = {
|
||||
...makeEntry(viewPath, 'Active Projects', 'active-projects.yml'),
|
||||
fileKind: 'text' as const,
|
||||
}
|
||||
|
||||
const { result } = renderSave({
|
||||
tabs: [{ entry: viewEntry, content: viewContent }],
|
||||
activeTabPath: viewPath,
|
||||
unsavedPaths: new Set([viewPath]),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(vi.mocked(invoke)).toHaveBeenCalledWith('save_note_content', {
|
||||
path: viewPath,
|
||||
content: viewContent,
|
||||
})
|
||||
expect(deps.handleRenameNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not rename an existing markdown note when Cmd+S saves a desynced H1/title state', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
|
||||
const notePath = '/vault/note-b.md'
|
||||
const noteContent = '# Breadcrumb Sync Target\n\nBody'
|
||||
const entry = makeEntry(notePath, 'Breadcrumb Sync Target', 'note-b.md')
|
||||
|
||||
const { result } = renderSave({
|
||||
tabs: [{ entry, content: noteContent }],
|
||||
activeTabPath: notePath,
|
||||
unsavedPaths: new Set([notePath]),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(vi.mocked(invoke)).toHaveBeenCalledWith('save_note_content', {
|
||||
path: notePath,
|
||||
content: noteContent,
|
||||
})
|
||||
expect(deps.handleRenameNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('remaps a buffered auto-save to the renamed path when untitled rename lands mid-idle window', async () => {
|
||||
const initialContent = '# Fresh Title\n\nInitial body'
|
||||
const bufferedContent = '# Fresh Title\n\nBody typed right before rename'
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { startTransition, useCallback, useEffect, useRef, type MutableRefObject } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { useEditorSaveWithLinks } from './useEditorSaveWithLinks'
|
||||
import { needsRenameOnSave } from './useNoteRename'
|
||||
import { flushEditorContent } from '../utils/autoSave'
|
||||
import { extractH1TitleFromContent } from '../utils/noteTitle'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
@@ -77,20 +76,6 @@ function findUnsavedFallback({
|
||||
return { path: activeTab.entry.path, content: activeTab.content }
|
||||
}
|
||||
|
||||
function activeTabNeedsRename({
|
||||
tabs,
|
||||
activeTabPath,
|
||||
}: {
|
||||
tabs: TabState[]
|
||||
activeTabPath: string | null
|
||||
}): { path: string; title: string } | null {
|
||||
const activeTab = tabs.find(t => t.entry.path === activeTabPath)
|
||||
if (!activeTab) return null
|
||||
return needsRenameOnSave(activeTab.entry.title, activeTab.entry.filename)
|
||||
? { path: activeTab.entry.path, title: activeTab.entry.title }
|
||||
: null
|
||||
}
|
||||
|
||||
function isUntitledRenameCandidate(path: string): boolean {
|
||||
const filename = path.split('/').pop() ?? ''
|
||||
const stem = filename.replace(/\.md$/, '')
|
||||
@@ -546,16 +531,6 @@ function useRenameHandlers({
|
||||
replaceRenamedEntry: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void
|
||||
loadModifiedFiles: AppSaveDeps['loadModifiedFiles']
|
||||
}) {
|
||||
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
|
||||
const currentPath = await preparePathForManualRename({
|
||||
path,
|
||||
resolveCurrentPath,
|
||||
savePendingForPath,
|
||||
cancelPendingUntitledRename,
|
||||
})
|
||||
await handleRenameNote(currentPath, newTitle, resolvedPath, replaceRenamedEntry).then(loadModifiedFiles)
|
||||
}, [resolveCurrentPath, savePendingForPath, cancelPendingUntitledRename, handleRenameNote, resolvedPath, replaceRenamedEntry, loadModifiedFiles])
|
||||
|
||||
const handleFilenameRename = useCallback(async (path: string, newFilenameStem: string) => {
|
||||
const currentPath = await preparePathForManualRename({
|
||||
path,
|
||||
@@ -578,12 +553,11 @@ function useRenameHandlers({
|
||||
.catch((err) => console.error('Title rename failed:', err))
|
||||
}, [resolveCurrentPath, savePendingForPath, cancelPendingUntitledRename, handleRenameNote, resolvedPath, replaceRenamedEntry, loadModifiedFiles])
|
||||
|
||||
return { handleRenameTab, handleFilenameRename, handleTitleSync }
|
||||
return { handleFilenameRename, handleTitleSync }
|
||||
}
|
||||
|
||||
function useHandleSaveAction({
|
||||
handleSaveRaw,
|
||||
handleRenameTab,
|
||||
tabs,
|
||||
activeTabPath,
|
||||
unsavedPaths,
|
||||
@@ -591,7 +565,6 @@ function useHandleSaveAction({
|
||||
resolveCurrentPath,
|
||||
}: {
|
||||
handleSaveRaw: (unsavedFallback?: { path: string; content: string }) => Promise<void>
|
||||
handleRenameTab: (path: string, newTitle: string) => Promise<void>
|
||||
tabs: TabState[]
|
||||
activeTabPath: string | null
|
||||
unsavedPaths: Set<string>
|
||||
@@ -605,10 +578,8 @@ function useHandleSaveAction({
|
||||
activeTabPath: resolvedActiveTabPath,
|
||||
unsavedPaths,
|
||||
}))
|
||||
const flushedUntitledRename = await flushPendingUntitledRename(resolvedActiveTabPath ?? undefined)
|
||||
const rename = activeTabNeedsRename({ tabs, activeTabPath: resolvedActiveTabPath })
|
||||
if (!flushedUntitledRename && rename) await handleRenameTab(rename.path, rename.title)
|
||||
}, [handleSaveRaw, handleRenameTab, tabs, activeTabPath, unsavedPaths, flushPendingUntitledRename, resolveCurrentPath])
|
||||
await flushPendingUntitledRename(resolvedActiveTabPath ?? undefined)
|
||||
}, [handleSaveRaw, tabs, activeTabPath, unsavedPaths, flushPendingUntitledRename, resolveCurrentPath])
|
||||
}
|
||||
|
||||
function useEditorPersistence({
|
||||
@@ -741,7 +712,7 @@ function useAppSaveHandlers({
|
||||
setToastMessage,
|
||||
flushPendingUntitledRename,
|
||||
})
|
||||
const { handleRenameTab, handleFilenameRename, handleTitleSync } = useRenameHandlers({
|
||||
const { handleFilenameRename, handleTitleSync } = useRenameHandlers({
|
||||
resolveCurrentPath,
|
||||
savePendingForPath,
|
||||
cancelPendingUntitledRename,
|
||||
@@ -753,7 +724,6 @@ function useAppSaveHandlers({
|
||||
})
|
||||
const handleSave = useHandleSaveAction({
|
||||
handleSaveRaw,
|
||||
handleRenameTab,
|
||||
tabs,
|
||||
activeTabPath,
|
||||
unsavedPaths,
|
||||
|
||||
113
src/hooks/useAutoGit.test.ts
Normal file
113
src/hooks/useAutoGit.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAutoGit } from './useAutoGit'
|
||||
|
||||
describe('useAutoGit', () => {
|
||||
let hasFocus = true
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(document, 'hasFocus').mockImplementation(() => hasFocus)
|
||||
hasFocus = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('triggers an idle checkpoint after the configured threshold', async () => {
|
||||
const onCheckpoint = vi.fn().mockResolvedValue(true)
|
||||
renderHook(() => useAutoGit({
|
||||
enabled: true,
|
||||
idleThresholdSeconds: 3,
|
||||
inactiveThresholdSeconds: 2,
|
||||
isGitVault: true,
|
||||
hasPendingChanges: true,
|
||||
hasUnsavedChanges: false,
|
||||
onCheckpoint,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2_999)
|
||||
})
|
||||
expect(onCheckpoint).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
})
|
||||
expect(onCheckpoint).toHaveBeenCalledWith('idle')
|
||||
})
|
||||
|
||||
it('waits for the app to become inactive before triggering the inactive checkpoint', async () => {
|
||||
const onCheckpoint = vi.fn().mockResolvedValue(true)
|
||||
renderHook(() => useAutoGit({
|
||||
enabled: true,
|
||||
idleThresholdSeconds: 10,
|
||||
inactiveThresholdSeconds: 2,
|
||||
isGitVault: true,
|
||||
hasPendingChanges: true,
|
||||
hasUnsavedChanges: false,
|
||||
onCheckpoint,
|
||||
}))
|
||||
|
||||
hasFocus = false
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('blur'))
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
})
|
||||
|
||||
expect(onCheckpoint).toHaveBeenCalledWith('inactive')
|
||||
})
|
||||
|
||||
it('does not trigger while the editor still has unsaved changes', async () => {
|
||||
const onCheckpoint = vi.fn().mockResolvedValue(true)
|
||||
renderHook(() => useAutoGit({
|
||||
enabled: true,
|
||||
idleThresholdSeconds: 1,
|
||||
inactiveThresholdSeconds: 1,
|
||||
isGitVault: true,
|
||||
hasPendingChanges: true,
|
||||
hasUnsavedChanges: true,
|
||||
onCheckpoint,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
})
|
||||
|
||||
expect(onCheckpoint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('only triggers once per activity burst until activity is recorded again', async () => {
|
||||
const onCheckpoint = vi.fn().mockResolvedValue(true)
|
||||
const { result } = renderHook(() => useAutoGit({
|
||||
enabled: true,
|
||||
idleThresholdSeconds: 1,
|
||||
inactiveThresholdSeconds: 1,
|
||||
isGitVault: true,
|
||||
hasPendingChanges: true,
|
||||
hasUnsavedChanges: false,
|
||||
onCheckpoint,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
})
|
||||
expect(onCheckpoint).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
})
|
||||
expect(onCheckpoint).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
result.current.recordActivity()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
})
|
||||
expect(onCheckpoint).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
174
src/hooks/useAutoGit.ts
Normal file
174
src/hooks/useAutoGit.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useCallback, useEffect, useEffectEvent, useRef } from 'react'
|
||||
|
||||
export type AutoGitTrigger = 'idle' | 'inactive'
|
||||
|
||||
interface UseAutoGitOptions {
|
||||
enabled: boolean
|
||||
idleThresholdSeconds: number
|
||||
inactiveThresholdSeconds: number
|
||||
isGitVault: boolean
|
||||
hasPendingChanges: boolean
|
||||
hasUnsavedChanges: boolean
|
||||
onCheckpoint: (trigger: AutoGitTrigger) => Promise<boolean>
|
||||
}
|
||||
|
||||
interface TriggerState {
|
||||
idle: number | null
|
||||
inactive: number | null
|
||||
}
|
||||
|
||||
interface AutoGitState {
|
||||
recordActivity: () => void
|
||||
}
|
||||
|
||||
interface CheckpointEligibility {
|
||||
enabled: boolean
|
||||
isGitVault: boolean
|
||||
hasPendingChanges: boolean
|
||||
hasUnsavedChanges: boolean
|
||||
}
|
||||
|
||||
function isDocumentActive(): boolean {
|
||||
return document.visibilityState === 'visible' && document.hasFocus()
|
||||
}
|
||||
|
||||
function resetTriggerState(target: TriggerState): void {
|
||||
target.idle = null
|
||||
target.inactive = null
|
||||
}
|
||||
|
||||
function thresholdMsForTrigger(
|
||||
trigger: AutoGitTrigger,
|
||||
idleThresholdSeconds: number,
|
||||
inactiveThresholdSeconds: number,
|
||||
): number {
|
||||
return (trigger === 'idle' ? idleThresholdSeconds : inactiveThresholdSeconds) * 1000
|
||||
}
|
||||
|
||||
function isCheckpointEligible({
|
||||
enabled,
|
||||
isGitVault,
|
||||
hasPendingChanges,
|
||||
hasUnsavedChanges,
|
||||
}: CheckpointEligibility): boolean {
|
||||
return enabled && isGitVault && hasPendingChanges && !hasUnsavedChanges
|
||||
}
|
||||
|
||||
function markTriggerAsHandled(
|
||||
target: TriggerState,
|
||||
trigger: AutoGitTrigger,
|
||||
activityAt: number,
|
||||
): void {
|
||||
target[trigger] = activityAt
|
||||
}
|
||||
|
||||
function shouldTriggerCheckpoint({
|
||||
eligibility,
|
||||
trigger,
|
||||
lastTriggeredAt,
|
||||
lastActivityAt,
|
||||
idleThresholdSeconds,
|
||||
inactiveThresholdSeconds,
|
||||
}: {
|
||||
eligibility: CheckpointEligibility
|
||||
trigger: AutoGitTrigger
|
||||
lastTriggeredAt: number | null
|
||||
lastActivityAt: number
|
||||
idleThresholdSeconds: number
|
||||
inactiveThresholdSeconds: number
|
||||
}): boolean {
|
||||
if (!isCheckpointEligible(eligibility)) return false
|
||||
if (lastTriggeredAt === lastActivityAt) return false
|
||||
|
||||
const thresholdMs = thresholdMsForTrigger(
|
||||
trigger,
|
||||
idleThresholdSeconds,
|
||||
inactiveThresholdSeconds,
|
||||
)
|
||||
return Date.now() - lastActivityAt >= thresholdMs
|
||||
}
|
||||
|
||||
export function useAutoGit({
|
||||
enabled,
|
||||
idleThresholdSeconds,
|
||||
inactiveThresholdSeconds,
|
||||
isGitVault,
|
||||
hasPendingChanges,
|
||||
hasUnsavedChanges,
|
||||
onCheckpoint,
|
||||
}: UseAutoGitOptions): AutoGitState {
|
||||
const lastActivityAtRef = useRef(0)
|
||||
const lastTriggeredRef = useRef<TriggerState>({ idle: null, inactive: null })
|
||||
const appActiveRef = useRef(true)
|
||||
|
||||
const recordActivity = useCallback(() => {
|
||||
lastActivityAtRef.current = Date.now()
|
||||
resetTriggerState(lastTriggeredRef.current)
|
||||
}, [])
|
||||
|
||||
const maybeTriggerCheckpoint = useEffectEvent((trigger: AutoGitTrigger) => {
|
||||
const lastActivityAt = lastActivityAtRef.current
|
||||
const eligibility = {
|
||||
enabled,
|
||||
isGitVault,
|
||||
hasPendingChanges,
|
||||
hasUnsavedChanges,
|
||||
}
|
||||
if (!shouldTriggerCheckpoint({
|
||||
eligibility,
|
||||
trigger,
|
||||
lastTriggeredAt: lastTriggeredRef.current[trigger],
|
||||
lastActivityAt,
|
||||
idleThresholdSeconds,
|
||||
inactiveThresholdSeconds,
|
||||
})) return
|
||||
|
||||
void onCheckpoint(trigger).then((didRun) => {
|
||||
if (didRun) markTriggerAsHandled(lastTriggeredRef.current, trigger, lastActivityAt)
|
||||
}).catch(() => {})
|
||||
})
|
||||
|
||||
const updateAppActivity = useEffectEvent((active: boolean) => {
|
||||
if (appActiveRef.current === active) return
|
||||
appActiveRef.current = active
|
||||
|
||||
if (active) {
|
||||
lastTriggeredRef.current.inactive = null
|
||||
} else {
|
||||
lastTriggeredRef.current.idle = null
|
||||
}
|
||||
|
||||
maybeTriggerCheckpoint(active ? 'idle' : 'inactive')
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
lastActivityAtRef.current = Date.now()
|
||||
appActiveRef.current = isDocumentActive()
|
||||
|
||||
const handleFocus = () => { updateAppActivity(true) }
|
||||
const handleBlur = () => { updateAppActivity(false) }
|
||||
const handleVisibilityChange = () => { updateAppActivity(isDocumentActive()) }
|
||||
|
||||
window.addEventListener('focus', handleFocus)
|
||||
window.addEventListener('blur', handleBlur)
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleFocus)
|
||||
window.removeEventListener('blur', handleBlur)
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
const id = window.setInterval(() => {
|
||||
maybeTriggerCheckpoint(appActiveRef.current ? 'idle' : 'inactive')
|
||||
}, 1000)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [enabled])
|
||||
|
||||
return { recordActivity }
|
||||
}
|
||||
@@ -214,6 +214,12 @@ describe('useCommandRegistry', () => {
|
||||
expect(findCommand(result.current, 'archive-note')?.shortcut).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables Toggle Raw Editor when the active file cannot switch to rich mode', () => {
|
||||
const config = makeConfig({ onToggleRawEditor: undefined })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
expect(findCommand(result.current, 'toggle-raw-editor')?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('omits Inbox navigation when the explicit workflow is disabled', () => {
|
||||
const config = makeConfig({ showInbox: false })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
|
||||
@@ -36,6 +36,7 @@ describe('useCommitFlow', () => {
|
||||
mockInvokeFn.mockImplementation((command: string) => {
|
||||
if (command === 'git_commit') return Promise.resolve('[main abc1234] test commit')
|
||||
if (command === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed to remote' })
|
||||
if (command === 'get_modified_files') return Promise.resolve([{ path: '/vault/a.md', relativePath: 'a.md', status: 'modified' }])
|
||||
throw new Error(`Unexpected command: ${command}`)
|
||||
})
|
||||
})
|
||||
@@ -83,6 +84,57 @@ describe('useCommitFlow', () => {
|
||||
expect(result.current.showCommitDialog).toBe(false)
|
||||
})
|
||||
|
||||
it('runAutomaticCheckpoint saves pending first and uses the deterministic automatic message', async () => {
|
||||
const { result } = renderCommitFlow()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.runAutomaticCheckpoint({ savePendingBeforeCommit: true })
|
||||
})
|
||||
|
||||
expect(savePending).toHaveBeenCalledTimes(1)
|
||||
expect(mockInvokeFn).toHaveBeenNthCalledWith(1, 'get_modified_files', { vaultPath: '/vault' })
|
||||
expect(mockInvokeFn).toHaveBeenNthCalledWith(2, 'git_commit', { vaultPath: '/vault', message: 'Updated 1 note' })
|
||||
expect(mockInvokeFn).toHaveBeenNthCalledWith(3, 'git_push', { vaultPath: '/vault' })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Committed and pushed')
|
||||
})
|
||||
|
||||
it('runAutomaticCheckpoint retries push-only when local commits are already ahead', async () => {
|
||||
resolveRemoteStatus.mockResolvedValue({ branch: 'main', ahead: 2, behind: 0, hasRemote: true })
|
||||
mockInvokeFn.mockImplementation((command: string) => {
|
||||
if (command === 'get_modified_files') return Promise.resolve([])
|
||||
if (command === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed to remote' })
|
||||
throw new Error(`Unexpected command: ${command}`)
|
||||
})
|
||||
|
||||
const { result } = renderCommitFlow()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.runAutomaticCheckpoint()
|
||||
})
|
||||
|
||||
expect(mockInvokeFn).toHaveBeenCalledTimes(2)
|
||||
expect(mockInvokeFn).toHaveBeenNthCalledWith(1, 'get_modified_files', { vaultPath: '/vault' })
|
||||
expect(mockInvokeFn).toHaveBeenNthCalledWith(2, 'git_push', { vaultPath: '/vault' })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Pushed committed changes')
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runAutomaticCheckpoint reports when there is nothing to commit or push', async () => {
|
||||
mockInvokeFn.mockImplementation((command: string) => {
|
||||
if (command === 'get_modified_files') return Promise.resolve([])
|
||||
throw new Error(`Unexpected command: ${command}`)
|
||||
})
|
||||
|
||||
const { result } = renderCommitFlow()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.runAutomaticCheckpoint()
|
||||
})
|
||||
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Nothing to commit or push')
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleCommitPush commits locally and skips push when no remote is configured', async () => {
|
||||
resolveRemoteStatus.mockResolvedValue({ branch: 'main', ahead: 0, behind: 0, hasRemote: false })
|
||||
mockInvokeFn.mockImplementation((command: string) => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useRef, useState, type MutableRefObject } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type { GitPushResult, GitRemoteStatus } from '../types'
|
||||
import type { GitPushResult, GitRemoteStatus, ModifiedFile } from '../types'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import { generateAutomaticCommitMessage } from '../utils/automaticCommitMessage'
|
||||
|
||||
export type CommitMode = 'push' | 'local'
|
||||
|
||||
@@ -12,6 +13,11 @@ interface LocalCommitResult {
|
||||
}
|
||||
|
||||
type CommitResult = GitPushResult | LocalCommitResult
|
||||
type CheckpointAction = 'commit' | 'push_only'
|
||||
|
||||
interface AutomaticCheckpointOptions {
|
||||
savePendingBeforeCommit?: boolean
|
||||
}
|
||||
|
||||
interface CommitFlowConfig {
|
||||
savePending: () => Promise<void | boolean>
|
||||
@@ -22,11 +28,37 @@ interface CommitFlowConfig {
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
interface VaultPathArgs {
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
interface CommitArgs extends VaultPathArgs {
|
||||
message: string
|
||||
}
|
||||
|
||||
interface CommitExecutionArgs extends CommitArgs {
|
||||
commitMode: CommitMode
|
||||
}
|
||||
|
||||
interface AutomaticCheckpointContext extends VaultPathArgs {
|
||||
remoteStatus: GitRemoteStatus | null
|
||||
}
|
||||
|
||||
interface AutomaticCheckpointCommand extends AutomaticCheckpointContext {
|
||||
action: CheckpointAction
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface ExecutedCheckpoint {
|
||||
action: CheckpointAction
|
||||
result: CommitResult
|
||||
}
|
||||
|
||||
function commitModeFromRemoteStatus(remoteStatus: GitRemoteStatus | null): CommitMode {
|
||||
return remoteStatus?.hasRemote === false ? 'local' : 'push'
|
||||
}
|
||||
|
||||
async function commitLocally(vaultPath: string, message: string): Promise<void> {
|
||||
async function commitLocally({ vaultPath, message }: CommitArgs): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
await mockInvoke<string>('git_commit', { vaultPath, message })
|
||||
return
|
||||
@@ -35,7 +67,7 @@ async function commitLocally(vaultPath: string, message: string): Promise<void>
|
||||
await invoke<string>('git_commit', { vaultPath, message })
|
||||
}
|
||||
|
||||
async function pushCommittedChanges(vaultPath: string): Promise<GitPushResult> {
|
||||
async function pushCommittedChanges({ vaultPath }: VaultPathArgs): Promise<GitPushResult> {
|
||||
if (!isTauri()) {
|
||||
return mockInvoke<GitPushResult>('git_push', { vaultPath })
|
||||
}
|
||||
@@ -43,13 +75,25 @@ async function pushCommittedChanges(vaultPath: string): Promise<GitPushResult> {
|
||||
return invoke<GitPushResult>('git_push', { vaultPath })
|
||||
}
|
||||
|
||||
async function executeCommitAction(vaultPath: string, message: string, commitMode: CommitMode): Promise<CommitResult> {
|
||||
await commitLocally(vaultPath, message)
|
||||
async function readModifiedFiles({ vaultPath }: VaultPathArgs): Promise<ModifiedFile[]> {
|
||||
if (!isTauri()) {
|
||||
return mockInvoke<ModifiedFile[]>('get_modified_files', { vaultPath })
|
||||
}
|
||||
|
||||
return invoke<ModifiedFile[]>('get_modified_files', { vaultPath })
|
||||
}
|
||||
|
||||
async function executeCommitAction({
|
||||
vaultPath,
|
||||
message,
|
||||
commitMode,
|
||||
}: CommitExecutionArgs): Promise<CommitResult> {
|
||||
await commitLocally({ vaultPath, message })
|
||||
if (commitMode === 'local') {
|
||||
return { status: 'local_only', message: 'Committed locally (no remote configured)' }
|
||||
}
|
||||
|
||||
return pushCommittedChanges(vaultPath)
|
||||
return pushCommittedChanges({ vaultPath })
|
||||
}
|
||||
|
||||
function commitToastMessage(result: CommitResult): string {
|
||||
@@ -67,6 +111,182 @@ function formatCommitError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function shouldRetryPush(remoteStatus: GitRemoteStatus | null): boolean {
|
||||
return remoteStatus?.hasRemote === true && remoteStatus.ahead > 0
|
||||
}
|
||||
|
||||
function nothingToCommitToast(remoteStatus: GitRemoteStatus | null): string {
|
||||
return remoteStatus?.hasRemote === false ? 'Nothing to commit' : 'Nothing to commit or push'
|
||||
}
|
||||
|
||||
function checkpointToastMessage(result: CommitResult, action: CheckpointAction): string {
|
||||
if (action === 'push_only') {
|
||||
if (result.status === 'ok') return 'Pushed committed changes'
|
||||
if (result.status === 'rejected') return 'Push rejected — remote has new commits. Pull first.'
|
||||
return result.message
|
||||
}
|
||||
|
||||
return commitToastMessage(result)
|
||||
}
|
||||
|
||||
function createAutomaticCheckpointCommand({
|
||||
remoteStatus,
|
||||
vaultPath,
|
||||
message,
|
||||
}: AutomaticCheckpointContext & { message: string }): AutomaticCheckpointCommand | null {
|
||||
if (message.length > 0) {
|
||||
return { action: 'commit', remoteStatus, vaultPath, message }
|
||||
}
|
||||
|
||||
if (shouldRetryPush(remoteStatus)) {
|
||||
return { action: 'push_only', remoteStatus, vaultPath }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function executeAutomaticCheckpoint(
|
||||
command: AutomaticCheckpointCommand,
|
||||
): Promise<ExecutedCheckpoint> {
|
||||
if (command.action === 'push_only') {
|
||||
return {
|
||||
action: 'push_only',
|
||||
result: await pushCommittedChanges({ vaultPath: command.vaultPath }),
|
||||
}
|
||||
}
|
||||
|
||||
const result = await executeCommitAction({
|
||||
vaultPath: command.vaultPath,
|
||||
message: command.message ?? '',
|
||||
commitMode: commitModeFromRemoteStatus(command.remoteStatus),
|
||||
})
|
||||
trackEvent('commit_made')
|
||||
return { action: 'commit', result }
|
||||
}
|
||||
|
||||
async function runCheckpointRefresh({
|
||||
loadModifiedFiles,
|
||||
resolveRemoteStatus,
|
||||
}: Pick<CommitFlowConfig, 'loadModifiedFiles' | 'resolveRemoteStatus'>): Promise<void> {
|
||||
await loadModifiedFiles()
|
||||
await resolveRemoteStatus()
|
||||
}
|
||||
|
||||
async function finalizeCheckpoint({
|
||||
result,
|
||||
toastMessage,
|
||||
loadModifiedFiles,
|
||||
resolveRemoteStatus,
|
||||
setToastMessage,
|
||||
onPushRejected,
|
||||
}: Pick<CommitFlowConfig, 'loadModifiedFiles' | 'resolveRemoteStatus' | 'setToastMessage' | 'onPushRejected'> & {
|
||||
result: CommitResult
|
||||
toastMessage: string
|
||||
}): Promise<void> {
|
||||
setToastMessage(toastMessage)
|
||||
if (isPushRejected(result)) {
|
||||
onPushRejected?.()
|
||||
}
|
||||
|
||||
await runCheckpointRefresh({ loadModifiedFiles, resolveRemoteStatus })
|
||||
}
|
||||
|
||||
function useAutomaticCheckpointAction({
|
||||
checkpointInFlightRef,
|
||||
savePending,
|
||||
loadModifiedFiles,
|
||||
resolveRemoteStatus,
|
||||
setToastMessage,
|
||||
onPushRejected,
|
||||
vaultPath,
|
||||
}: CommitFlowConfig & {
|
||||
checkpointInFlightRef: MutableRefObject<boolean>
|
||||
}) {
|
||||
return useCallback(async ({
|
||||
savePendingBeforeCommit = false,
|
||||
}: AutomaticCheckpointOptions = {}): Promise<boolean> => {
|
||||
if (checkpointInFlightRef.current) return false
|
||||
checkpointInFlightRef.current = true
|
||||
|
||||
try {
|
||||
if (savePendingBeforeCommit) {
|
||||
await savePending()
|
||||
}
|
||||
|
||||
const remoteStatus = await resolveRemoteStatus()
|
||||
const modifiedFiles = await readModifiedFiles({ vaultPath })
|
||||
const message = generateAutomaticCommitMessage(modifiedFiles)
|
||||
const command = createAutomaticCheckpointCommand({ remoteStatus, vaultPath, message })
|
||||
if (!command) {
|
||||
setToastMessage(nothingToCommitToast(remoteStatus))
|
||||
return false
|
||||
}
|
||||
|
||||
const { action, result } = await executeAutomaticCheckpoint(command)
|
||||
await finalizeCheckpoint({
|
||||
result,
|
||||
toastMessage: checkpointToastMessage(result, action),
|
||||
loadModifiedFiles,
|
||||
resolveRemoteStatus,
|
||||
setToastMessage,
|
||||
onPushRejected,
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Commit failed:', err)
|
||||
setToastMessage(`Commit failed: ${formatCommitError(err)}`)
|
||||
return false
|
||||
} finally {
|
||||
checkpointInFlightRef.current = false
|
||||
}
|
||||
}, [checkpointInFlightRef, loadModifiedFiles, onPushRejected, resolveRemoteStatus, savePending, setToastMessage, vaultPath])
|
||||
}
|
||||
|
||||
function useManualCommitPushAction({
|
||||
checkpointInFlightRef,
|
||||
savePending,
|
||||
loadModifiedFiles,
|
||||
resolveRemoteStatus,
|
||||
setToastMessage,
|
||||
onPushRejected,
|
||||
vaultPath,
|
||||
setShowCommitDialog,
|
||||
}: CommitFlowConfig & {
|
||||
checkpointInFlightRef: MutableRefObject<boolean>
|
||||
setShowCommitDialog: (open: boolean) => void
|
||||
}) {
|
||||
return useCallback(async (message: string) => {
|
||||
setShowCommitDialog(false)
|
||||
if (checkpointInFlightRef.current) return
|
||||
checkpointInFlightRef.current = true
|
||||
|
||||
try {
|
||||
await savePending()
|
||||
const remoteStatus = await resolveRemoteStatus()
|
||||
const result = await executeCommitAction({
|
||||
vaultPath,
|
||||
message,
|
||||
commitMode: commitModeFromRemoteStatus(remoteStatus),
|
||||
})
|
||||
|
||||
trackEvent('commit_made')
|
||||
await finalizeCheckpoint({
|
||||
result,
|
||||
toastMessage: commitToastMessage(result),
|
||||
loadModifiedFiles,
|
||||
resolveRemoteStatus,
|
||||
setToastMessage,
|
||||
onPushRejected,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Commit failed:', err)
|
||||
setToastMessage(`Commit failed: ${formatCommitError(err)}`)
|
||||
} finally {
|
||||
checkpointInFlightRef.current = false
|
||||
}
|
||||
}, [checkpointInFlightRef, loadModifiedFiles, onPushRejected, resolveRemoteStatus, savePending, setShowCommitDialog, setToastMessage, vaultPath])
|
||||
}
|
||||
|
||||
/** Manages the commit dialog state and the save→commit→push/local flow. */
|
||||
export function useCommitFlow({
|
||||
savePending,
|
||||
@@ -78,6 +298,7 @@ export function useCommitFlow({
|
||||
}: CommitFlowConfig) {
|
||||
const [showCommitDialog, setShowCommitDialog] = useState(false)
|
||||
const [commitMode, setCommitMode] = useState<CommitMode>('push')
|
||||
const checkpointInFlightRef = useRef(false)
|
||||
|
||||
const openCommitDialog = useCallback(async () => {
|
||||
await savePending()
|
||||
@@ -87,29 +308,28 @@ export function useCommitFlow({
|
||||
setShowCommitDialog(true)
|
||||
}, [loadModifiedFiles, resolveRemoteStatus, savePending])
|
||||
|
||||
const handleCommitPush = useCallback(async (message: string) => {
|
||||
setShowCommitDialog(false)
|
||||
try {
|
||||
await savePending()
|
||||
const remoteStatus = await resolveRemoteStatus()
|
||||
const nextCommitMode = commitModeFromRemoteStatus(remoteStatus)
|
||||
const result = await executeCommitAction(vaultPath, message, nextCommitMode)
|
||||
const runAutomaticCheckpoint = useAutomaticCheckpointAction({
|
||||
checkpointInFlightRef,
|
||||
savePending,
|
||||
loadModifiedFiles,
|
||||
resolveRemoteStatus,
|
||||
setToastMessage,
|
||||
onPushRejected,
|
||||
vaultPath,
|
||||
})
|
||||
|
||||
trackEvent('commit_made')
|
||||
setToastMessage(commitToastMessage(result))
|
||||
if (isPushRejected(result)) {
|
||||
onPushRejected?.()
|
||||
}
|
||||
|
||||
await loadModifiedFiles()
|
||||
await resolveRemoteStatus()
|
||||
} catch (err) {
|
||||
console.error('Commit failed:', err)
|
||||
setToastMessage(`Commit failed: ${formatCommitError(err)}`)
|
||||
}
|
||||
}, [loadModifiedFiles, onPushRejected, resolveRemoteStatus, savePending, setToastMessage, vaultPath])
|
||||
const handleCommitPush = useManualCommitPushAction({
|
||||
checkpointInFlightRef,
|
||||
savePending,
|
||||
loadModifiedFiles,
|
||||
resolveRemoteStatus,
|
||||
setToastMessage,
|
||||
onPushRejected,
|
||||
vaultPath,
|
||||
setShowCommitDialog,
|
||||
})
|
||||
|
||||
const closeCommitDialog = useCallback(() => setShowCommitDialog(false), [])
|
||||
|
||||
return { showCommitDialog, commitMode, openCommitDialog, handleCommitPush, closeCommitDialog }
|
||||
return { showCommitDialog, commitMode, openCommitDialog, handleCommitPush, closeCommitDialog, runAutomaticCheckpoint }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,18 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useEditorSaveWithLinks } from './useEditorSaveWithLinks'
|
||||
|
||||
const { startTransitionMock } = vi.hoisted(() => ({
|
||||
startTransitionMock: vi.fn((callback: () => void) => callback()),
|
||||
}))
|
||||
|
||||
vi.mock('react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react')>()
|
||||
return {
|
||||
...actual,
|
||||
startTransition: startTransitionMock,
|
||||
}
|
||||
})
|
||||
|
||||
const mockHandleContentChange = vi.fn()
|
||||
const mockHandleSave = vi.fn()
|
||||
const mockSavePendingForPath = vi.fn()
|
||||
@@ -25,6 +37,7 @@ describe('useEditorSaveWithLinks', () => {
|
||||
setTabs = vi.fn()
|
||||
setToastMessage = vi.fn()
|
||||
onAfterSave = vi.fn()
|
||||
startTransitionMock.mockClear()
|
||||
mockHandleContentChange.mockClear()
|
||||
mockHandleSave.mockClear()
|
||||
mockSavePendingForPath.mockClear()
|
||||
@@ -147,6 +160,8 @@ describe('useEditorSaveWithLinks', () => {
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
isA: 'Project',
|
||||
status: 'Active',
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
title: 'Note',
|
||||
hasH1: false,
|
||||
})
|
||||
@@ -172,6 +187,8 @@ describe('useEditorSaveWithLinks', () => {
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
isA: 'Essay',
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
title: 'Note',
|
||||
hasH1: false,
|
||||
})
|
||||
@@ -181,6 +198,8 @@ describe('useEditorSaveWithLinks', () => {
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
isA: 'Note',
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
title: 'Note',
|
||||
hasH1: false,
|
||||
})
|
||||
@@ -199,6 +218,20 @@ describe('useEditorSaveWithLinks', () => {
|
||||
expect(updateEntry).toHaveBeenCalledWith(path, expected)
|
||||
})
|
||||
|
||||
it('defers H1 title sync updates in a transition so typing stays responsive', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/old-title.md', '# Renamed Note\n\nBody')
|
||||
})
|
||||
|
||||
expect(startTransitionMock).toHaveBeenCalledTimes(1)
|
||||
expect(updateEntry).toHaveBeenCalledWith('/old-title.md', {
|
||||
title: 'Renamed Note',
|
||||
hasH1: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('spreads all properties from useEditorSave onto the return value', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { startTransition, useCallback, useRef } from 'react'
|
||||
import { useEditorSave } from './useEditorSave'
|
||||
import { extractOutgoingLinks, extractSnippet, countWords } from '../utils/wikilinks'
|
||||
import { contentToEntryPatch } from './frontmatterOps'
|
||||
@@ -35,6 +35,7 @@ export function useEditorSaveWithLinks(config: {
|
||||
const { handleContentChange: rawOnChange } = editor
|
||||
const prevLinksKeyRef = useRef('')
|
||||
const prevFmKeyRef = useRef('')
|
||||
const prevTitleKeyRef = useRef('')
|
||||
const handleContentChange = useCallback((path: string, content: string) => {
|
||||
rawOnChange(path, content)
|
||||
const links = extractOutgoingLinks(content)
|
||||
@@ -45,19 +46,25 @@ export function useEditorSaveWithLinks(config: {
|
||||
}
|
||||
const frontmatterPatch = contentToEntryPatch(content)
|
||||
const filename = path.split('/').pop() ?? path
|
||||
const fmPatch = {
|
||||
...frontmatterPatch,
|
||||
...deriveDisplayTitleState({
|
||||
content,
|
||||
filename,
|
||||
frontmatterTitle: typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
|
||||
}),
|
||||
}
|
||||
const titlePatch = deriveDisplayTitleState({
|
||||
content,
|
||||
filename,
|
||||
frontmatterTitle: typeof frontmatterPatch.title === 'string' ? frontmatterPatch.title : null,
|
||||
})
|
||||
const fmPatch = { ...frontmatterPatch }
|
||||
delete fmPatch.title
|
||||
const fmKey = JSON.stringify(fmPatch)
|
||||
if (fmKey !== prevFmKeyRef.current) {
|
||||
prevFmKeyRef.current = fmKey
|
||||
if (Object.keys(fmPatch).length > 0) updateEntry(path, fmPatch)
|
||||
}
|
||||
const titleKey = JSON.stringify(titlePatch)
|
||||
if (titleKey !== prevTitleKeyRef.current) {
|
||||
prevTitleKeyRef.current = titleKey
|
||||
startTransition(() => {
|
||||
updateEntry(path, titlePatch)
|
||||
})
|
||||
}
|
||||
}, [rawOnChange, updateEntry])
|
||||
return { ...editor, handleContentChange }
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ describe('useEditorTabSwap untitled rename continuity', () => {
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
expect(editor.replaceBlocks).not.toHaveBeenCalled()
|
||||
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
result.current.handleEditorChange()
|
||||
@@ -104,4 +105,46 @@ describe('useEditorTabSwap untitled rename continuity', () => {
|
||||
|
||||
expect(editor.tryParseMarkdownToBlocks).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the live editor session when the renamed tab arrives one render after the path switch', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
const editor = makeMockEditor('# Fresh Title\n\nBody typed live')
|
||||
const onContentChange = vi.fn()
|
||||
const untitledTab = makeTab('untitled-note-123.md', 'Untitled Note 123', 'Body')
|
||||
const renamedTab = makeTab('fresh-title.md', 'Fresh Title', 'Body')
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ tabs, activeTabPath }) => useEditorTabSwap({
|
||||
tabs,
|
||||
activeTabPath,
|
||||
editor: editor as never,
|
||||
onContentChange,
|
||||
}),
|
||||
{ initialProps: { tabs: [untitledTab], activeTabPath: untitledTab.entry.path } },
|
||||
)
|
||||
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
editor.replaceBlocks.mockClear()
|
||||
editor.tryParseMarkdownToBlocks.mockClear()
|
||||
|
||||
rerender({ tabs: [untitledTab], activeTabPath: renamedTab.entry.path })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
rerender({ tabs: [renamedTab], activeTabPath: renamedTab.entry.path })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
expect(editor.replaceBlocks).not.toHaveBeenCalled()
|
||||
expect(editor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
result.current.handleEditorChange()
|
||||
})
|
||||
|
||||
expect(onContentChange).toHaveBeenCalledWith(
|
||||
'fresh-title.md',
|
||||
expect.stringContaining('Body typed live'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,23 +35,36 @@ export function extractEditorBody(rawFileContent: string): string {
|
||||
return rawBody.trimStart()
|
||||
}
|
||||
|
||||
/** Extract H1 text from the editor's first block, or null if not an H1. */
|
||||
export function getH1TextFromBlocks(blocks: unknown[]): string | null {
|
||||
type HeadingTextInline = { type?: string; text?: string }
|
||||
|
||||
function extractH1Content(blocks: unknown[]): HeadingTextInline[] | null {
|
||||
const first = blocks?.[0] as {
|
||||
type?: string
|
||||
props?: { level?: number }
|
||||
content?: Array<{ type?: string; text?: string }>
|
||||
content?: HeadingTextInline[]
|
||||
} | undefined
|
||||
const content = first?.type === 'heading' && first.props?.level === 1 && Array.isArray(first.content)
|
||||
? first.content
|
||||
: null
|
||||
|
||||
if (!first) return null
|
||||
if (first.type !== 'heading') return null
|
||||
if (first.props?.level !== 1) return null
|
||||
if (!Array.isArray(first.content)) return null
|
||||
return first.content
|
||||
}
|
||||
|
||||
/** Extract H1 text from the editor's first block, or null if not an H1. */
|
||||
export function getH1TextFromBlocks(blocks: unknown[]): string | null {
|
||||
const content = extractH1Content(blocks)
|
||||
if (!content) return null
|
||||
|
||||
const text = content
|
||||
.filter(item => item.type === 'text')
|
||||
.map(item => item.text || '')
|
||||
.join('')
|
||||
return text.trim() || null
|
||||
let text = ''
|
||||
for (const item of content) {
|
||||
if (item.type === 'text') {
|
||||
text += item.text || ''
|
||||
}
|
||||
}
|
||||
|
||||
const trimmed = text.trim()
|
||||
return trimmed || null
|
||||
}
|
||||
|
||||
/** Replace the title: line in YAML frontmatter with a new title value. */
|
||||
@@ -274,9 +287,11 @@ function normalizeTabBody(content: string): string {
|
||||
}
|
||||
|
||||
function renameBodiesOverlap(currentBody: string, nextBody: string): boolean {
|
||||
return currentBody === nextBody
|
||||
|| currentBody.startsWith(nextBody)
|
||||
|| nextBody.startsWith(currentBody)
|
||||
const current = currentBody.trimEnd()
|
||||
const next = nextBody.trimEnd()
|
||||
return current === next
|
||||
|| current.startsWith(next)
|
||||
|| next.startsWith(current)
|
||||
}
|
||||
|
||||
function isUntitledRenameTransition(
|
||||
@@ -377,21 +392,52 @@ function cachePreviousTabOnPathChange(options: {
|
||||
cacheEditorState(cache, prevPath, editor.document)
|
||||
}
|
||||
|
||||
function rememberPendingTabArrival(
|
||||
function shouldWaitForActiveTab(
|
||||
pathChanged: boolean,
|
||||
activeTabPath: string | null,
|
||||
activeTab: Tab | undefined,
|
||||
pendingTabArrivalPathRef: MutableRefObject<string | null>,
|
||||
) {
|
||||
if (!activeTabPath) {
|
||||
pendingTabArrivalPathRef.current = null
|
||||
return pathChanged && !!activeTabPath && !activeTab
|
||||
}
|
||||
|
||||
function syncActivePathTransition(options: {
|
||||
prevPath: string | null
|
||||
pathChanged: boolean
|
||||
activeTabPath: string | null
|
||||
activeTab: Tab | undefined
|
||||
cache: Map<string, CachedTabState>
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
prevActivePathRef: MutableRefObject<string | null>
|
||||
}) {
|
||||
const {
|
||||
prevPath,
|
||||
pathChanged,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
cache,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
prevActivePathRef,
|
||||
} = options
|
||||
|
||||
cachePreviousTabOnPathChange({ prevPath, pathChanged, editorMountedRef, cache, editor })
|
||||
if (shouldWaitForActiveTab(pathChanged, activeTabPath, activeTab)) return true
|
||||
|
||||
if (!preserveUntitledRenameState({
|
||||
prevPath,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
cache,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
})) {
|
||||
prevActivePathRef.current = activeTabPath
|
||||
return false
|
||||
}
|
||||
if (activeTab) {
|
||||
pendingTabArrivalPathRef.current = null
|
||||
return true
|
||||
}
|
||||
pendingTabArrivalPathRef.current = activeTabPath
|
||||
return false
|
||||
|
||||
prevActivePathRef.current = activeTabPath
|
||||
return true
|
||||
}
|
||||
|
||||
function handleStableActivePath(options: {
|
||||
@@ -399,7 +445,6 @@ function handleStableActivePath(options: {
|
||||
rawModeJustEnded: boolean
|
||||
activeTabPath: string | null
|
||||
activeTab: Tab | undefined
|
||||
pendingTabArrival: boolean
|
||||
cache: Map<string, CachedTabState>
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
@@ -410,7 +455,6 @@ function handleStableActivePath(options: {
|
||||
rawModeJustEnded,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
pendingTabArrival,
|
||||
cache,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
@@ -423,7 +467,6 @@ function handleStableActivePath(options: {
|
||||
rawSwapPendingRef.current = true
|
||||
return false
|
||||
}
|
||||
if (pendingTabArrival) return false
|
||||
if (rawSwapPendingRef.current) return true
|
||||
|
||||
cacheStableActivePath({
|
||||
@@ -638,7 +681,81 @@ function scheduleTabSwap(options: {
|
||||
pendingSwapRef.current = doSwap
|
||||
}
|
||||
|
||||
function useTabSwapEffect(options: {
|
||||
function runTabSwapEffect(options: {
|
||||
tabs: Tab[]
|
||||
activeTabPath: string | null
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
rawMode?: boolean
|
||||
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>
|
||||
prevActivePathRef: MutableRefObject<string | null>
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
pendingSwapRef: MutableRefObject<(() => void) | null>
|
||||
prevRawModeRef: MutableRefObject<boolean>
|
||||
rawSwapPendingRef: MutableRefObject<boolean>
|
||||
suppressChangeRef: MutableRefObject<boolean>
|
||||
}) {
|
||||
const {
|
||||
tabs,
|
||||
activeTabPath,
|
||||
editor,
|
||||
rawMode,
|
||||
tabCacheRef,
|
||||
prevActivePathRef,
|
||||
editorMountedRef,
|
||||
pendingSwapRef,
|
||||
prevRawModeRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
} = options
|
||||
|
||||
const cache = tabCacheRef.current
|
||||
const prevPath = prevActivePathRef.current
|
||||
const pathChanged = prevPath !== activeTabPath
|
||||
const activeTab = findActiveTab(tabs, activeTabPath)
|
||||
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
|
||||
|
||||
if (rawMode) return
|
||||
if (syncActivePathTransition({
|
||||
prevPath,
|
||||
pathChanged,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
cache,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
prevActivePathRef,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
if (handleStableActivePath({
|
||||
pathChanged,
|
||||
rawModeJustEnded,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
cache,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
rawSwapPendingRef,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!activeTabPath || !activeTab) return
|
||||
|
||||
scheduleTabSwap({
|
||||
editor,
|
||||
cache,
|
||||
targetPath: activeTabPath,
|
||||
activeTab,
|
||||
pendingSwapRef,
|
||||
prevActivePathRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
})
|
||||
}
|
||||
|
||||
function useTabSwapEffect(options: {
|
||||
tabs: Tab[]
|
||||
activeTabPath: string | null
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
@@ -647,7 +764,6 @@ function useTabSwapEffect(options: {
|
||||
prevActivePathRef: MutableRefObject<string | null>
|
||||
editorMountedRef: MutableRefObject<boolean>
|
||||
pendingSwapRef: MutableRefObject<(() => void) | null>
|
||||
pendingTabArrivalPathRef: MutableRefObject<string | null>
|
||||
prevRawModeRef: MutableRefObject<boolean>
|
||||
rawSwapPendingRef: MutableRefObject<boolean>
|
||||
suppressChangeRef: MutableRefObject<boolean>
|
||||
@@ -661,64 +777,22 @@ function useTabSwapEffect(options: {
|
||||
prevActivePathRef,
|
||||
editorMountedRef,
|
||||
pendingSwapRef,
|
||||
pendingTabArrivalPathRef,
|
||||
prevRawModeRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
} = options
|
||||
|
||||
useEffect(() => {
|
||||
const cache = tabCacheRef.current
|
||||
const prevPath = prevActivePathRef.current
|
||||
const pathChanged = prevPath !== activeTabPath
|
||||
const activeTab = findActiveTab(tabs, activeTabPath)
|
||||
const pendingTabArrival = activeTabPath !== null
|
||||
&& pendingTabArrivalPathRef.current === activeTabPath
|
||||
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
|
||||
|
||||
if (rawMode) return
|
||||
cachePreviousTabOnPathChange({ prevPath, pathChanged, editorMountedRef, cache, editor })
|
||||
prevActivePathRef.current = activeTabPath
|
||||
|
||||
if (preserveUntitledRenameState({
|
||||
prevPath,
|
||||
runTabSwapEffect({
|
||||
tabs,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
cache,
|
||||
editor,
|
||||
rawMode,
|
||||
tabCacheRef,
|
||||
editorMountedRef,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
if (handleStableActivePath({
|
||||
pathChanged,
|
||||
rawModeJustEnded,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
pendingTabArrival,
|
||||
cache,
|
||||
editor,
|
||||
editorMountedRef,
|
||||
rawSwapPendingRef,
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!rememberPendingTabArrival(activeTabPath, activeTab, pendingTabArrivalPathRef)) {
|
||||
return
|
||||
}
|
||||
const targetPath = activeTabPath
|
||||
const readyActiveTab = activeTab
|
||||
if (!targetPath || !readyActiveTab) return
|
||||
|
||||
scheduleTabSwap({
|
||||
editor,
|
||||
cache,
|
||||
targetPath,
|
||||
activeTab: readyActiveTab,
|
||||
pendingSwapRef,
|
||||
prevActivePathRef,
|
||||
pendingSwapRef,
|
||||
prevRawModeRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
})
|
||||
@@ -727,7 +801,6 @@ function useTabSwapEffect(options: {
|
||||
editor,
|
||||
editorMountedRef,
|
||||
pendingSwapRef,
|
||||
pendingTabArrivalPathRef,
|
||||
prevActivePathRef,
|
||||
prevRawModeRef,
|
||||
rawMode,
|
||||
@@ -771,7 +844,6 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
|
||||
const prevActivePathRef = useRef<string | null>(null)
|
||||
const editorMountedRef = useRef(false)
|
||||
const pendingSwapRef = useRef<(() => void) | null>(null)
|
||||
const pendingTabArrivalPathRef = useRef<string | null>(null)
|
||||
const prevRawModeRef = useRef(!!rawMode)
|
||||
const rawSwapPendingRef = useRef(false)
|
||||
const suppressChangeRef = useRef(false)
|
||||
@@ -795,7 +867,6 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
|
||||
prevActivePathRef,
|
||||
editorMountedRef,
|
||||
pendingSwapRef,
|
||||
pendingTabArrivalPathRef,
|
||||
prevRawModeRef,
|
||||
rawSwapPendingRef,
|
||||
suppressChangeRef,
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface NoteActionsConfig {
|
||||
addEntry: (entry: VaultEntry) => void
|
||||
removeEntry: (path: string) => void
|
||||
entries: VaultEntry[]
|
||||
flushBeforeNoteSwitch?: (path: string) => Promise<void>
|
||||
flushBeforePathRename?: (path: string) => Promise<void>
|
||||
reloadVault?: () => Promise<unknown>
|
||||
setToastMessage: (msg: string | null) => void
|
||||
@@ -176,9 +177,89 @@ async function updateFrontmatterAndMaybeRename({
|
||||
config.onFrontmatterPersisted?.()
|
||||
}
|
||||
|
||||
function buildTabManagementOptions(
|
||||
flushBeforeNoteSwitch?: NoteActionsConfig['flushBeforeNoteSwitch'],
|
||||
) {
|
||||
return flushBeforeNoteSwitch
|
||||
? { beforeNavigate: (fromPath: string) => flushBeforeNoteSwitch(fromPath) }
|
||||
: undefined
|
||||
}
|
||||
|
||||
function useFrontmatterActionHandlers({
|
||||
config,
|
||||
renameTabsRef,
|
||||
setTabs,
|
||||
activeTabPathRef,
|
||||
handleSwitchTab,
|
||||
setToastMessage,
|
||||
updateTabContent,
|
||||
runFrontmatterOp,
|
||||
}: {
|
||||
config: NoteActionsConfig
|
||||
renameTabsRef: TitleRenameDeps['tabsRef']
|
||||
setTabs: React.Dispatch<React.SetStateAction<{ entry: VaultEntry; content: string }[]>>
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
handleSwitchTab: (path: string) => void
|
||||
setToastMessage: (msg: string | null) => void
|
||||
updateTabContent: (path: string, newContent: string) => void
|
||||
runFrontmatterOp: (
|
||||
op: 'update' | 'delete',
|
||||
path: string,
|
||||
key: string,
|
||||
value?: FrontmatterValue,
|
||||
options?: FrontmatterOpOptions,
|
||||
) => Promise<string | undefined>
|
||||
}) {
|
||||
const handleUpdateFrontmatter = useCallback(async (
|
||||
path: string,
|
||||
key: string,
|
||||
value: FrontmatterValue,
|
||||
options?: FrontmatterOpOptions,
|
||||
) => {
|
||||
await updateFrontmatterAndMaybeRename({
|
||||
config,
|
||||
deps: {
|
||||
vaultPath: config.vaultPath,
|
||||
tabsRef: renameTabsRef,
|
||||
reloadVault: config.reloadVault,
|
||||
replaceEntry: config.replaceEntry,
|
||||
onPathRenamed: config.onPathRenamed,
|
||||
setTabs,
|
||||
activeTabPathRef,
|
||||
handleSwitchTab,
|
||||
setToastMessage,
|
||||
updateTabContent,
|
||||
},
|
||||
path,
|
||||
key,
|
||||
value,
|
||||
options,
|
||||
runFrontmatterOp,
|
||||
})
|
||||
}, [activeTabPathRef, config, handleSwitchTab, renameTabsRef, runFrontmatterOp, setTabs, setToastMessage, updateTabContent])
|
||||
|
||||
const handleDeleteProperty = useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
|
||||
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
|
||||
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
|
||||
config.onFrontmatterPersisted?.()
|
||||
}, [config, runFrontmatterOp])
|
||||
|
||||
const handleAddProperty = useCallback(async (path: string, key: string, value: FrontmatterValue) => {
|
||||
const newContent = await runFrontmatterOp('update', path, key, value)
|
||||
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
|
||||
config.onFrontmatterPersisted?.()
|
||||
}, [config, runFrontmatterOp])
|
||||
|
||||
return {
|
||||
handleUpdateFrontmatter,
|
||||
handleDeleteProperty,
|
||||
handleAddProperty,
|
||||
}
|
||||
}
|
||||
|
||||
export function useNoteActions(config: NoteActionsConfig) {
|
||||
const { entries, setToastMessage, updateEntry } = config
|
||||
const tabMgmt = useTabManagement()
|
||||
const tabMgmt = useTabManagement(buildTabManagementOptions(config.flushBeforeNoteSwitch))
|
||||
const { setTabs, handleSelectNote, openTabWithContent, activeTabPathRef, handleSwitchTab } = tabMgmt
|
||||
|
||||
const updateTabContent = useCallback((path: string, newContent: string) => {
|
||||
@@ -201,6 +282,16 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
runFrontmatterAndApply(op, path, key, value, { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => entries.find((e) => e.path === p) }, options),
|
||||
[updateTabContent, updateEntry, setToastMessage, entries],
|
||||
)
|
||||
const frontmatterActions = useFrontmatterActionHandlers({
|
||||
config,
|
||||
renameTabsRef: rename.tabsRef,
|
||||
setTabs,
|
||||
activeTabPathRef,
|
||||
handleSwitchTab,
|
||||
setToastMessage,
|
||||
updateTabContent,
|
||||
runFrontmatterOp,
|
||||
})
|
||||
|
||||
return {
|
||||
...tabMgmt,
|
||||
@@ -210,38 +301,9 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
handleCreateNoteForRelationship: creation.handleCreateNoteForRelationship,
|
||||
handleCreateType: creation.handleCreateType,
|
||||
createTypeEntrySilent: creation.createTypeEntrySilent,
|
||||
handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue, options?: FrontmatterOpOptions) => {
|
||||
await updateFrontmatterAndMaybeRename({
|
||||
config,
|
||||
deps: {
|
||||
vaultPath: config.vaultPath,
|
||||
tabsRef: rename.tabsRef,
|
||||
reloadVault: config.reloadVault,
|
||||
replaceEntry: config.replaceEntry,
|
||||
onPathRenamed: config.onPathRenamed,
|
||||
setTabs,
|
||||
activeTabPathRef,
|
||||
handleSwitchTab,
|
||||
setToastMessage,
|
||||
updateTabContent,
|
||||
},
|
||||
path,
|
||||
key,
|
||||
value,
|
||||
options,
|
||||
runFrontmatterOp,
|
||||
})
|
||||
}, [runFrontmatterOp, config, rename.tabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]),
|
||||
handleDeleteProperty: useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
|
||||
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
|
||||
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
|
||||
config.onFrontmatterPersisted?.()
|
||||
}, [runFrontmatterOp, config]),
|
||||
handleAddProperty: useCallback(async (path: string, key: string, value: FrontmatterValue) => {
|
||||
const newContent = await runFrontmatterOp('update', path, key, value)
|
||||
if (!applyFrontmatterCallbacks({ config, path, newContent })) return
|
||||
config.onFrontmatterPersisted?.()
|
||||
}, [runFrontmatterOp, config]),
|
||||
handleUpdateFrontmatter: frontmatterActions.handleUpdateFrontmatter,
|
||||
handleDeleteProperty: frontmatterActions.handleDeleteProperty,
|
||||
handleAddProperty: frontmatterActions.handleAddProperty,
|
||||
handleRenameNote: rename.handleRenameNote,
|
||||
handleRenameFilename: rename.handleRenameFilename,
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ interface ReloadTabsAfterRenameRequest {
|
||||
|
||||
/** Check if a note's filename doesn't match the slug of its current title. */
|
||||
export function needsRenameOnSave(title: string, filename: string): boolean {
|
||||
if (!filename.toLowerCase().endsWith('.md')) return false
|
||||
return `${slugify(title)}.md` !== filename
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,22 @@ async function expectStatus(
|
||||
})
|
||||
}
|
||||
|
||||
async function expectCancelledPickerLeavesWelcome(
|
||||
action: (onboarding: ReturnType<typeof useOnboarding>) => Promise<void>,
|
||||
) {
|
||||
mockCommands()
|
||||
vi.mocked(pickFolder).mockResolvedValue(null)
|
||||
|
||||
const { result } = await renderOnboarding()
|
||||
|
||||
await expectStatus(result, 'welcome')
|
||||
await act(async () => {
|
||||
await action(result.current)
|
||||
})
|
||||
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
}
|
||||
|
||||
describe('useOnboarding', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -94,9 +110,15 @@ describe('useOnboarding', () => {
|
||||
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: DEFAULT_GETTING_STARTED_PATH })
|
||||
})
|
||||
|
||||
it('shows vault-missing when the welcome screen was previously dismissed', async () => {
|
||||
it('shows vault-missing when a previously configured active vault is missing', async () => {
|
||||
localStorage.setItem(APP_STORAGE_KEYS.welcomeDismissed, '1')
|
||||
mockCommands()
|
||||
mockCommands({
|
||||
load_vault_list: {
|
||||
vaults: [{ label: 'Old Vault', path: '/vault/deleted' }],
|
||||
active_vault: '/vault/deleted',
|
||||
hidden_defaults: [],
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = await renderOnboarding('/vault/deleted')
|
||||
|
||||
@@ -153,17 +175,9 @@ describe('useOnboarding', () => {
|
||||
})
|
||||
|
||||
it('does nothing when the template folder picker is cancelled', async () => {
|
||||
mockCommands()
|
||||
vi.mocked(pickFolder).mockResolvedValue(null)
|
||||
|
||||
const { result } = await renderOnboarding()
|
||||
|
||||
await expectStatus(result, 'welcome')
|
||||
await act(async () => {
|
||||
await result.current.handleCreateVault()
|
||||
await expectCancelledPickerLeavesWelcome(async (onboarding) => {
|
||||
await onboarding.handleCreateVault()
|
||||
})
|
||||
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
expect(mockInvokeFn).not.toHaveBeenCalledWith('create_getting_started_vault', expect.anything())
|
||||
})
|
||||
|
||||
@@ -233,17 +247,9 @@ describe('useOnboarding', () => {
|
||||
})
|
||||
|
||||
it('does nothing when the empty-vault picker is cancelled', async () => {
|
||||
mockCommands()
|
||||
vi.mocked(pickFolder).mockResolvedValue(null)
|
||||
|
||||
const { result } = await renderOnboarding()
|
||||
|
||||
await expectStatus(result, 'welcome')
|
||||
await act(async () => {
|
||||
await result.current.handleCreateNewVault()
|
||||
await expectCancelledPickerLeavesWelcome(async (onboarding) => {
|
||||
await onboarding.handleCreateNewVault()
|
||||
})
|
||||
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
|
||||
it('opens an existing folder and transitions to ready', async () => {
|
||||
@@ -262,17 +268,9 @@ describe('useOnboarding', () => {
|
||||
})
|
||||
|
||||
it('does nothing when the open-folder picker is cancelled', async () => {
|
||||
mockCommands()
|
||||
vi.mocked(pickFolder).mockResolvedValue(null)
|
||||
|
||||
const { result } = await renderOnboarding()
|
||||
|
||||
await expectStatus(result, 'welcome')
|
||||
await act(async () => {
|
||||
await result.current.handleOpenFolder()
|
||||
await expectCancelledPickerLeavesWelcome(async (onboarding) => {
|
||||
await onboarding.handleOpenFolder()
|
||||
})
|
||||
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
|
||||
it('marks the welcome screen dismissed and keeps the initial vault path', async () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
removeDisplayModeOverride,
|
||||
} from '../utils/propertyTypes'
|
||||
import { containsWikilinks } from '../components/DynamicPropertiesPanel'
|
||||
import { isSystemMetadataKey } from '../utils/systemMetadata'
|
||||
import { canonicalSystemMetadataKey, isSystemMetadataKey } from '../utils/systemMetadata'
|
||||
|
||||
// Keys to skip showing in Properties (handled by dedicated UI or internal)
|
||||
// Compared case-insensitively via isVisibleProperty()
|
||||
@@ -79,6 +79,22 @@ function isVisibleProperty([key, value]: [string, FrontmatterValue]): boolean {
|
||||
return !isHiddenPropertyKey(key) && !containsWikilinks(value)
|
||||
}
|
||||
|
||||
function buildVisiblePropertyEntries(frontmatter: ParsedFrontmatter): [string, FrontmatterValue][] {
|
||||
const result: [string, FrontmatterValue][] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const [key, value] of Object.entries(frontmatter)) {
|
||||
if (!isVisibleProperty([key, value])) continue
|
||||
|
||||
const canonicalKey = canonicalSystemMetadataKey(key)
|
||||
if (seen.has(canonicalKey)) continue
|
||||
seen.add(canonicalKey)
|
||||
result.push([key, value])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function addTagValues(tagsByKey: Map<string, Set<string>>, key: string, value: unknown) {
|
||||
if (!Array.isArray(value)) return
|
||||
|
||||
@@ -102,6 +118,8 @@ function toSortedTagRecord(tagsByKey: Map<string, Set<string>>): Record<string,
|
||||
}
|
||||
|
||||
function isHiddenPropertyKey(key: string): boolean {
|
||||
const canonicalKey = canonicalSystemMetadataKey(key)
|
||||
if (canonicalKey === '_icon') return false
|
||||
return SKIP_KEYS.has(key.toLowerCase()) || isSystemMetadataKey(key)
|
||||
}
|
||||
|
||||
@@ -137,7 +155,7 @@ export function usePropertyPanelState(deps: PropertyPanelDeps) {
|
||||
const { availableTypes, customColorKey, typeColorKeys, typeIconKeys } = useMemo(() => deriveTypeInfo(entries, entryIsA), [entries, entryIsA])
|
||||
const vaultStatuses = useMemo(() => collectVaultStatuses(entries), [entries])
|
||||
const vaultTagsByKey = useMemo(() => collectAllVaultTags(entries), [entries])
|
||||
const propertyEntries = useMemo(() => Object.entries(frontmatter).filter(isVisibleProperty), [frontmatter])
|
||||
const propertyEntries = useMemo(() => buildVisiblePropertyEntries(frontmatter), [frontmatter])
|
||||
|
||||
const handleSaveValue = useCallback((key: string, newValue: string) => {
|
||||
setEditingKey(null)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke, updateMockContent } from '../mock-tauri'
|
||||
import { cacheNoteContent } from './useTabManagement'
|
||||
|
||||
export async function persistContent(path: string, content: string): Promise<void> {
|
||||
if (isTauri()) {
|
||||
@@ -19,6 +20,7 @@ export async function persistContent(path: string, content: string): Promise<voi
|
||||
export function useSaveNote(updateContent: (path: string, content: string) => void) {
|
||||
const saveNote = useCallback(async (path: string, content: string) => {
|
||||
await persistContent(path, content)
|
||||
cacheNoteContent(path, content)
|
||||
if (!isTauri()) {
|
||||
updateMockContent(path, content)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import { useSettings } from './useSettings'
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
auto_pull_interval_minutes: null,
|
||||
autogit_enabled: null,
|
||||
autogit_idle_threshold_seconds: null,
|
||||
autogit_inactive_threshold_seconds: null,
|
||||
telemetry_consent: null,
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
@@ -15,6 +18,9 @@ const defaultSettings: Settings = {
|
||||
|
||||
const savedSettings: Settings = {
|
||||
auto_pull_interval_minutes: 15,
|
||||
autogit_enabled: true,
|
||||
autogit_idle_threshold_seconds: 90,
|
||||
autogit_inactive_threshold_seconds: 30,
|
||||
telemetry_consent: null,
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
@@ -94,6 +100,9 @@ describe('useSettings', () => {
|
||||
|
||||
const newSettings: Settings = {
|
||||
auto_pull_interval_minutes: null,
|
||||
autogit_enabled: false,
|
||||
autogit_idle_threshold_seconds: 120,
|
||||
autogit_inactive_threshold_seconds: 45,
|
||||
telemetry_consent: null,
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
|
||||
@@ -11,6 +11,9 @@ function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockA
|
||||
|
||||
const EMPTY_SETTINGS: Settings = {
|
||||
auto_pull_interval_minutes: null,
|
||||
autogit_enabled: null,
|
||||
autogit_idle_threshold_seconds: null,
|
||||
autogit_inactive_threshold_seconds: null,
|
||||
telemetry_consent: null,
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { useTabManagement, prefetchNoteContent, clearPrefetchCache } from './useTabManagement'
|
||||
import { useTabManagement, prefetchNoteContent, cacheNoteContent, clearPrefetchCache } from './useTabManagement'
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
@@ -47,15 +47,32 @@ async function replaceActiveNote(result: HookState, overrides: Partial<VaultEntr
|
||||
})
|
||||
}
|
||||
|
||||
async function prefetchResolvedContent(path: string, content: string) {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke).mockResolvedValue(content)
|
||||
prefetchNoteContent(path)
|
||||
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
|
||||
return mockInvoke
|
||||
}
|
||||
|
||||
function expectSingleActiveTab(result: HookState, path: string) {
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
expect(result.current.tabs[0].entry.path).toBe(path)
|
||||
expect(result.current.activeTabPath).toBe(path)
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('useTabManagement (single-note model)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
clearPrefetchCache()
|
||||
})
|
||||
|
||||
it('starts with no note and null active path', () => {
|
||||
@@ -204,11 +221,7 @@ describe('useTabManagement (single-note model)', () => {
|
||||
|
||||
describe('content prefetch cache', () => {
|
||||
it('prefetch serves content to loadNoteContent (no extra IPC)', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke).mockResolvedValue('# Prefetched content')
|
||||
|
||||
prefetchNoteContent('/vault/note/pre.md')
|
||||
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
|
||||
const mockInvoke = await prefetchResolvedContent('/vault/note/pre.md', '# Prefetched content')
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
await selectNote(result, { path: '/vault/note/pre.md', title: 'Pre' })
|
||||
@@ -218,11 +231,7 @@ describe('useTabManagement (single-note model)', () => {
|
||||
})
|
||||
|
||||
it('clearPrefetchCache prevents stale content from being served', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke).mockResolvedValue('# Stale')
|
||||
|
||||
prefetchNoteContent('/vault/note/stale.md')
|
||||
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
|
||||
const mockInvoke = await prefetchResolvedContent('/vault/note/stale.md', '# Stale')
|
||||
|
||||
clearPrefetchCache()
|
||||
vi.mocked(mockInvoke).mockResolvedValue('# Fresh')
|
||||
@@ -244,6 +253,18 @@ describe('useTabManagement (single-note model)', () => {
|
||||
|
||||
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('serves refreshed cached content after a save replaces stale prefetched data', async () => {
|
||||
const mockInvoke = await prefetchResolvedContent('/vault/note/saved.md', '# Stale prefetched content')
|
||||
|
||||
cacheNoteContent('/vault/note/saved.md', '# Persisted content')
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
await selectNote(result, { path: '/vault/note/saved.md', title: 'Saved' })
|
||||
|
||||
expect(result.current.tabs[0].content).toBe('# Persisted content')
|
||||
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rapid switching safety', () => {
|
||||
@@ -277,5 +298,91 @@ describe('useTabManagement (single-note model)', () => {
|
||||
|
||||
expect(result.current.activeTabPath).toBe('/vault/b.md')
|
||||
})
|
||||
|
||||
it('waits for beforeNavigate before switching away from the current note', async () => {
|
||||
const beforeNavigate = vi.fn(() => createDeferred<void>().promise)
|
||||
const deferred = createDeferred<void>()
|
||||
beforeNavigate.mockReturnValueOnce(deferred.promise)
|
||||
|
||||
const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
|
||||
await selectNote(result, { path: '/vault/a.md', title: 'A' })
|
||||
|
||||
let replaceDone = false
|
||||
await act(async () => {
|
||||
result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
.then(() => { replaceDone = true })
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(beforeNavigate).toHaveBeenCalledWith('/vault/a.md', '/vault/b.md')
|
||||
expect(result.current.activeTabPath).toBe('/vault/a.md')
|
||||
expect(replaceDone).toBe(false)
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve(undefined)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(replaceDone).toBe(true))
|
||||
expectSingleActiveTab(result, '/vault/b.md')
|
||||
})
|
||||
|
||||
it('keeps only the latest target when note switches overlap during beforeNavigate', async () => {
|
||||
const first = createDeferred<void>()
|
||||
const second = createDeferred<void>()
|
||||
const beforeNavigate = vi.fn()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise)
|
||||
|
||||
const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
|
||||
await selectNote(result, { path: '/vault/a.md', title: 'A' })
|
||||
|
||||
let switchToBDone = false
|
||||
await act(async () => {
|
||||
result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
.then(() => { switchToBDone = true })
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
let switchToCDone = false
|
||||
await act(async () => {
|
||||
result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/c.md', title: 'C' }))
|
||||
.then(() => { switchToCDone = true })
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
first.resolve(undefined)
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(result.current.activeTabPath).toBe('/vault/a.md')
|
||||
|
||||
await act(async () => {
|
||||
second.resolve(undefined)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(switchToBDone && switchToCDone).toBe(true))
|
||||
expect(result.current.activeTabPath).toBe('/vault/c.md')
|
||||
})
|
||||
|
||||
it('keeps the current note active when beforeNavigate fails', async () => {
|
||||
const beforeNavigate = vi.fn().mockRejectedValueOnce(new Error('save failed'))
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const { result } = renderHook(() => useTabManagement({ beforeNavigate }))
|
||||
await selectNote(result, { path: '/vault/a.md', title: 'A' })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleReplaceActiveTab(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
})
|
||||
|
||||
expect(result.current.activeTabPath).toBe('/vault/a.md')
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Failed to persist note before navigation:',
|
||||
expect.any(Error),
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,6 +30,10 @@ export function prefetchNoteContent(path: string): void {
|
||||
prefetchCache.set(path, promise)
|
||||
}
|
||||
|
||||
export function cacheNoteContent(path: string, content: string): void {
|
||||
prefetchCache.set(path, Promise.resolve(content))
|
||||
}
|
||||
|
||||
/** Clear the prefetch cache. Call on vault reload to prevent stale content. */
|
||||
export function clearPrefetchCache(): void {
|
||||
prefetchCache.clear()
|
||||
@@ -49,6 +53,10 @@ async function loadNoteContent(path: string): Promise<string> {
|
||||
|
||||
export type { Tab }
|
||||
|
||||
interface TabManagementOptions {
|
||||
beforeNavigate?: (fromPath: string, toPath: string) => Promise<void>
|
||||
}
|
||||
|
||||
function syncActiveTabPath(
|
||||
activeTabPathRef: React.MutableRefObject<string | null>,
|
||||
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>,
|
||||
@@ -112,7 +120,7 @@ async function navigateToEntry(options: {
|
||||
}
|
||||
}
|
||||
|
||||
export function useTabManagement() {
|
||||
export function useTabManagement(options: TabManagementOptions = {}) {
|
||||
// Single-note model: tabs has 0 or 1 elements.
|
||||
const [tabs, setTabs] = useState<Tab[]>([])
|
||||
const [activeTabPath, setActiveTabPath] = useState<string | null>(null)
|
||||
@@ -123,18 +131,38 @@ export function useTabManagement() {
|
||||
|
||||
// Sequence counter for rapid-switch safety: only the latest navigation wins.
|
||||
const navSeqRef = useRef(0)
|
||||
const beforeNavigateSeqRef = useRef(0)
|
||||
const beforeNavigate = options.beforeNavigate
|
||||
|
||||
const executeNavigationWithBoundary = useCallback(async (
|
||||
targetPath: string,
|
||||
navigate: () => void | Promise<void>,
|
||||
) => {
|
||||
const seq = ++beforeNavigateSeqRef.current
|
||||
const currentPath = activeTabPathRef.current
|
||||
if (beforeNavigate && currentPath && currentPath !== targetPath) {
|
||||
try {
|
||||
await beforeNavigate(currentPath, targetPath)
|
||||
} catch (err) {
|
||||
console.warn('Failed to persist note before navigation:', err)
|
||||
return
|
||||
}
|
||||
if (beforeNavigateSeqRef.current !== seq) return
|
||||
}
|
||||
await navigate()
|
||||
}, [beforeNavigate])
|
||||
|
||||
/** Open a note — replaces the current note (single-note model). */
|
||||
const handleSelectNote = useCallback(async (entry: VaultEntry) => {
|
||||
await navigateToEntry({
|
||||
await executeNavigationWithBoundary(entry.path, () => navigateToEntry({
|
||||
entry,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
activeTabPathRef,
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
})
|
||||
}, [])
|
||||
}))
|
||||
}, [executeNavigationWithBoundary])
|
||||
|
||||
const handleSwitchTab = useCallback((path: string) => {
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, path)
|
||||
@@ -142,20 +170,22 @@ export function useTabManagement() {
|
||||
|
||||
/** Open a tab with known content — no IPC round-trip. Used for newly created notes. */
|
||||
const openTabWithContent = useCallback((entry: VaultEntry, content: string) => {
|
||||
setSingleTab(tabsRef, setTabs, { entry, content })
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
|
||||
}, [])
|
||||
void executeNavigationWithBoundary(entry.path, () => {
|
||||
setSingleTab(tabsRef, setTabs, { entry, content })
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
|
||||
})
|
||||
}, [executeNavigationWithBoundary])
|
||||
|
||||
const handleReplaceActiveTab = useCallback(async (entry: VaultEntry) => {
|
||||
await navigateToEntry({
|
||||
await executeNavigationWithBoundary(entry.path, () => navigateToEntry({
|
||||
entry,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
activeTabPathRef,
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
})
|
||||
}, [])
|
||||
}))
|
||||
}, [executeNavigationWithBoundary])
|
||||
|
||||
const closeAllTabs = useCallback(() => {
|
||||
tabsRef.current = []
|
||||
|
||||
@@ -148,6 +148,16 @@ describe('useVaultSwitcher', () => {
|
||||
expect(onSwitch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not persist the implicit default vault as an active selection', async () => {
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
expect(result.current.vaultPath).toBe(expectedDefaultVaultPath)
|
||||
expect(result.current.selectedVaultPath).toBeNull()
|
||||
expect(mockVaultListStore.active_vault).toBeNull()
|
||||
})
|
||||
|
||||
it('handles load error gracefully', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
@@ -413,6 +423,20 @@ describe('useVaultSwitcher', () => {
|
||||
|
||||
expect(result.current.vaultPath).toBe(persistedPath)
|
||||
})
|
||||
|
||||
it('treats a remembered default vault as unselected when it is the only vault', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [],
|
||||
active_vault: expectedDefaultVaultPath,
|
||||
hidden_defaults: [],
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
expect(result.current.vaultPath).toBe(expectedDefaultVaultPath)
|
||||
expect(result.current.selectedVaultPath).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isGettingStartedHidden', () => {
|
||||
|
||||
@@ -31,8 +31,10 @@ interface PersistedVaultState {
|
||||
extraVaults: VaultOption[]
|
||||
hiddenDefaults: string[]
|
||||
loaded: boolean
|
||||
selectedVaultPath: string | null
|
||||
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
|
||||
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
|
||||
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
|
||||
setVaultPath: Dispatch<SetStateAction<string>>
|
||||
vaultPath: string
|
||||
}
|
||||
@@ -48,10 +50,12 @@ interface PersistedVaultStore {
|
||||
extraVaults: VaultOption[]
|
||||
hiddenDefaults: string[]
|
||||
loaded: boolean
|
||||
selectedVaultPath: string | null
|
||||
setDefaultPath: Dispatch<SetStateAction<string>>
|
||||
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
|
||||
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
|
||||
setLoaded: Dispatch<SetStateAction<boolean>>
|
||||
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
|
||||
setVaultPath: Dispatch<SetStateAction<string>>
|
||||
vaultPath: string
|
||||
}
|
||||
@@ -77,10 +81,13 @@ interface RemainingVaultOptions {
|
||||
}
|
||||
|
||||
interface RemoveVaultStateOptions extends RemainingVaultOptions {
|
||||
selectedVaultPath: string | null
|
||||
onSwitchRef: MutableRefObject<() => void>
|
||||
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
|
||||
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
|
||||
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
|
||||
setVaultPath: Dispatch<SetStateAction<string>>
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
interface RemoveVaultActionOptions {
|
||||
@@ -91,10 +98,17 @@ interface RemoveVaultActionOptions {
|
||||
onToastRef: MutableRefObject<(msg: string) => void>
|
||||
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
|
||||
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
|
||||
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
|
||||
setVaultPath: Dispatch<SetStateAction<string>>
|
||||
selectedVaultPath: string | null
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
function labelFromPath(path: string): string {
|
||||
interface VaultPathInput {
|
||||
path: string
|
||||
}
|
||||
|
||||
function labelFromPath({ path }: VaultPathInput): string {
|
||||
return path.split('/').pop() || 'Local Vault'
|
||||
}
|
||||
|
||||
@@ -127,22 +141,37 @@ async function loadInitialVaultState() {
|
||||
return { activeVault, hiddenDefaults, resolvedDefaultPath, vaults }
|
||||
}
|
||||
|
||||
function buildDefaultVaults(defaultPath: string): VaultOption[] {
|
||||
function buildDefaultVaults({ defaultPath }: { defaultPath: string }): VaultOption[] {
|
||||
return [{ label: GETTING_STARTED_LABEL, path: defaultPath }]
|
||||
}
|
||||
|
||||
function buildVisibleDefaultVaults(defaultVaults: VaultOption[], hiddenDefaults: string[]): VaultOption[] {
|
||||
function buildVisibleDefaultVaults({
|
||||
defaultVaults,
|
||||
hiddenDefaults,
|
||||
}: {
|
||||
defaultVaults: VaultOption[]
|
||||
hiddenDefaults: string[]
|
||||
}): VaultOption[] {
|
||||
return defaultVaults.filter(vault => !hiddenDefaults.includes(vault.path))
|
||||
}
|
||||
|
||||
function buildAllVaults(visibleDefaults: VaultOption[], extraVaults: VaultOption[]): VaultOption[] {
|
||||
function buildAllVaults({
|
||||
visibleDefaults,
|
||||
extraVaults,
|
||||
}: {
|
||||
visibleDefaults: VaultOption[]
|
||||
extraVaults: VaultOption[]
|
||||
}): VaultOption[] {
|
||||
return [...visibleDefaults, ...extraVaults]
|
||||
}
|
||||
|
||||
function applyResolvedDefaultPath(
|
||||
resolvedDefaultPath: string,
|
||||
setDefaultPath: Dispatch<SetStateAction<string>>,
|
||||
) {
|
||||
function applyResolvedDefaultPath({
|
||||
resolvedDefaultPath,
|
||||
setDefaultPath,
|
||||
}: {
|
||||
resolvedDefaultPath: string
|
||||
setDefaultPath: Dispatch<SetStateAction<string>>
|
||||
}) {
|
||||
if (!resolvedDefaultPath) {
|
||||
return
|
||||
}
|
||||
@@ -151,14 +180,35 @@ function applyResolvedDefaultPath(
|
||||
syncDefaultVaultExport(resolvedDefaultPath)
|
||||
}
|
||||
|
||||
function applyInitialVaultTarget(
|
||||
function normalizeInitialSelectedVaultPath(
|
||||
activeVault: string | null,
|
||||
resolvedDefaultPath: string,
|
||||
setVaultPath: Dispatch<SetStateAction<string>>,
|
||||
onSwitchRef: MutableRefObject<() => void>,
|
||||
) {
|
||||
vaults: VaultOption[],
|
||||
): string | null {
|
||||
if (!activeVault) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isRememberedDefaultOnlySelection = activeVault === resolvedDefaultPath && vaults.length === 0
|
||||
return isRememberedDefaultOnlySelection ? null : activeVault
|
||||
}
|
||||
|
||||
function applyInitialVaultTarget({
|
||||
activeVault,
|
||||
resolvedDefaultPath,
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
onSwitchRef,
|
||||
}: {
|
||||
activeVault: string | null
|
||||
resolvedDefaultPath: string
|
||||
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
|
||||
setVaultPath: Dispatch<SetStateAction<string>>
|
||||
onSwitchRef: MutableRefObject<() => void>
|
||||
}) {
|
||||
if (activeVault) {
|
||||
setVaultPath(activeVault)
|
||||
setSelectedVaultPath(activeVault)
|
||||
onSwitchRef.current()
|
||||
return
|
||||
}
|
||||
@@ -174,15 +224,15 @@ function useVaultCollections(
|
||||
extraVaults: VaultOption[],
|
||||
): VaultCollections {
|
||||
const defaultVaults = useMemo(
|
||||
() => buildDefaultVaults(defaultPath),
|
||||
() => buildDefaultVaults({ defaultPath }),
|
||||
[defaultPath],
|
||||
)
|
||||
const visibleDefaults = useMemo(
|
||||
() => buildVisibleDefaultVaults(defaultVaults, hiddenDefaults),
|
||||
() => buildVisibleDefaultVaults({ defaultVaults, hiddenDefaults }),
|
||||
[defaultVaults, hiddenDefaults],
|
||||
)
|
||||
const allVaults = useMemo(
|
||||
() => buildAllVaults(visibleDefaults, extraVaults),
|
||||
() => buildAllVaults({ visibleDefaults, extraVaults }),
|
||||
[extraVaults, visibleDefaults],
|
||||
)
|
||||
const isGettingStartedHidden = useMemo(
|
||||
@@ -202,6 +252,7 @@ function useLoadPersistedVaultState(
|
||||
setExtraVaults,
|
||||
setHiddenDefaults,
|
||||
setLoaded,
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
} = store
|
||||
|
||||
@@ -214,8 +265,14 @@ function useLoadPersistedVaultState(
|
||||
|
||||
setExtraVaults(vaults)
|
||||
setHiddenDefaults(hidden)
|
||||
applyResolvedDefaultPath(resolvedDefaultPath, setDefaultPath)
|
||||
applyInitialVaultTarget(activeVault, resolvedDefaultPath, setVaultPath, onSwitchRef)
|
||||
applyResolvedDefaultPath({ resolvedDefaultPath, setDefaultPath })
|
||||
applyInitialVaultTarget({
|
||||
activeVault: normalizeInitialSelectedVaultPath(activeVault, resolvedDefaultPath, vaults),
|
||||
resolvedDefaultPath,
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
onSwitchRef,
|
||||
})
|
||||
})
|
||||
.catch(err => console.warn('Failed to load vault list:', err))
|
||||
.finally(() => {
|
||||
@@ -225,23 +282,24 @@ function useLoadPersistedVaultState(
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [onSwitchRef, setDefaultPath, setExtraVaults, setHiddenDefaults, setLoaded, setVaultPath])
|
||||
}, [onSwitchRef, setDefaultPath, setExtraVaults, setHiddenDefaults, setLoaded, setSelectedVaultPath, setVaultPath])
|
||||
}
|
||||
|
||||
function usePersistedVaultStorage(store: PersistedVaultStore) {
|
||||
const { extraVaults, hiddenDefaults, loaded, vaultPath } = store
|
||||
const { extraVaults, hiddenDefaults, loaded, selectedVaultPath } = store
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded) return
|
||||
|
||||
saveVaultList(extraVaults, vaultPath, hiddenDefaults).catch(err =>
|
||||
saveVaultList(extraVaults, selectedVaultPath, hiddenDefaults).catch(err =>
|
||||
console.warn('Failed to persist vault list:', err),
|
||||
)
|
||||
}, [extraVaults, hiddenDefaults, loaded, vaultPath])
|
||||
}, [extraVaults, hiddenDefaults, loaded, selectedVaultPath])
|
||||
}
|
||||
|
||||
function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): PersistedVaultState {
|
||||
const [vaultPath, setVaultPath] = useState(STATIC_DEFAULT_PATH)
|
||||
const [selectedVaultPath, setSelectedVaultPath] = useState<string | null>(null)
|
||||
const [extraVaults, setExtraVaults] = useState<VaultOption[]>([])
|
||||
const [hiddenDefaults, setHiddenDefaults] = useState<string[]>([])
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
@@ -252,10 +310,12 @@ function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): Pers
|
||||
extraVaults,
|
||||
hiddenDefaults,
|
||||
loaded,
|
||||
selectedVaultPath,
|
||||
setDefaultPath,
|
||||
setExtraVaults,
|
||||
setHiddenDefaults,
|
||||
setLoaded,
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
vaultPath,
|
||||
}
|
||||
@@ -268,8 +328,10 @@ function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): Pers
|
||||
extraVaults,
|
||||
hiddenDefaults,
|
||||
loaded,
|
||||
selectedVaultPath,
|
||||
setExtraVaults,
|
||||
setHiddenDefaults,
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
vaultPath,
|
||||
}
|
||||
@@ -304,23 +366,34 @@ async function ensureGettingStartedVaultReady(path: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function addVaultToList(
|
||||
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>,
|
||||
path: string,
|
||||
label: string,
|
||||
) {
|
||||
function addVaultToList({
|
||||
setExtraVaults,
|
||||
path,
|
||||
label,
|
||||
}: {
|
||||
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
|
||||
path: string
|
||||
label: string
|
||||
}) {
|
||||
setExtraVaults(previousVaults => {
|
||||
const exists = previousVaults.some(vault => vault.path === path)
|
||||
return exists ? previousVaults : [...previousVaults, { label, path, available: true }]
|
||||
})
|
||||
}
|
||||
|
||||
function switchVaultPath(
|
||||
setVaultPath: Dispatch<SetStateAction<string>>,
|
||||
onSwitchRef: MutableRefObject<() => void>,
|
||||
path: string,
|
||||
) {
|
||||
function switchVaultPath({
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
onSwitchRef,
|
||||
path,
|
||||
}: {
|
||||
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
|
||||
setVaultPath: Dispatch<SetStateAction<string>>
|
||||
onSwitchRef: MutableRefObject<() => void>
|
||||
path: string
|
||||
}) {
|
||||
trackEvent('vault_switched')
|
||||
setSelectedVaultPath(path)
|
||||
setVaultPath(path)
|
||||
onSwitchRef.current()
|
||||
}
|
||||
@@ -349,7 +422,10 @@ function removeVaultFromState({
|
||||
removedPath,
|
||||
setExtraVaults,
|
||||
setHiddenDefaults,
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
selectedVaultPath,
|
||||
vaultPath,
|
||||
}: RemoveVaultStateOptions) {
|
||||
if (isDefault) {
|
||||
setHiddenDefaults(previousHidden => previousHidden.includes(removedPath) ? previousHidden : [...previousHidden, removedPath])
|
||||
@@ -357,43 +433,52 @@ function removeVaultFromState({
|
||||
setExtraVaults(previousVaults => previousVaults.filter(vault => vault.path !== removedPath))
|
||||
}
|
||||
|
||||
setVaultPath(currentPath => {
|
||||
if (currentPath !== removedPath) {
|
||||
return currentPath
|
||||
if (vaultPath !== removedPath) {
|
||||
if (selectedVaultPath === removedPath) {
|
||||
setSelectedVaultPath(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const remainingVaults = listRemainingVaults({
|
||||
defaultVaults,
|
||||
extraVaults,
|
||||
hiddenDefaults,
|
||||
isDefault,
|
||||
removedPath,
|
||||
})
|
||||
if (remainingVaults.length === 0) {
|
||||
return currentPath
|
||||
}
|
||||
|
||||
onSwitchRef.current()
|
||||
return remainingVaults[0].path
|
||||
const remainingVaults = listRemainingVaults({
|
||||
defaultVaults,
|
||||
extraVaults,
|
||||
hiddenDefaults,
|
||||
isDefault,
|
||||
removedPath,
|
||||
})
|
||||
if (remainingVaults.length === 0) {
|
||||
setSelectedVaultPath(null)
|
||||
return
|
||||
}
|
||||
|
||||
const nextPath = remainingVaults[0].path
|
||||
setSelectedVaultPath(nextPath)
|
||||
setVaultPath(nextPath)
|
||||
onSwitchRef.current()
|
||||
}
|
||||
|
||||
function getRemovedVaultLabel(
|
||||
path: string,
|
||||
defaultVaults: VaultOption[],
|
||||
extraVaults: VaultOption[],
|
||||
): string {
|
||||
function getRemovedVaultLabel({
|
||||
path,
|
||||
defaultVaults,
|
||||
extraVaults,
|
||||
}: {
|
||||
path: string
|
||||
defaultVaults: VaultOption[]
|
||||
extraVaults: VaultOption[]
|
||||
}): string {
|
||||
const removedVault = [...defaultVaults, ...extraVaults].find(vault => vault.path === path)
|
||||
return removedVault?.label ?? labelFromPath(path)
|
||||
return removedVault?.label ?? labelFromPath({ path })
|
||||
}
|
||||
|
||||
function useSwitchVaultAction(
|
||||
onSwitchRef: MutableRefObject<() => void>,
|
||||
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>,
|
||||
setVaultPath: Dispatch<SetStateAction<string>>,
|
||||
) {
|
||||
return useCallback((path: string) => {
|
||||
switchVaultPath(setVaultPath, onSwitchRef, path)
|
||||
}, [onSwitchRef, setVaultPath])
|
||||
switchVaultPath({ setSelectedVaultPath, setVaultPath, onSwitchRef, path })
|
||||
}, [onSwitchRef, setSelectedVaultPath, setVaultPath])
|
||||
}
|
||||
|
||||
function useVaultClonedAction(
|
||||
@@ -414,7 +499,7 @@ function useOpenLocalFolderAction(
|
||||
const path = await pickFolder('Open vault folder')
|
||||
if (!path) return
|
||||
|
||||
const label = labelFromPath(path)
|
||||
const label = labelFromPath({ path })
|
||||
addAndSwitch(path, label)
|
||||
onToastRef.current(`Vault "${label}" opened`)
|
||||
}, [addAndSwitch, onToastRef])
|
||||
@@ -428,7 +513,10 @@ function useRemoveVaultAction({
|
||||
onToastRef,
|
||||
setExtraVaults,
|
||||
setHiddenDefaults,
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
selectedVaultPath,
|
||||
vaultPath,
|
||||
}: RemoveVaultActionOptions) {
|
||||
return useCallback((path: string) => {
|
||||
const isDefault = defaultVaults.some(vault => vault.path === path)
|
||||
@@ -442,9 +530,12 @@ function useRemoveVaultAction({
|
||||
removedPath: path,
|
||||
setExtraVaults,
|
||||
setHiddenDefaults,
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
selectedVaultPath,
|
||||
vaultPath,
|
||||
})
|
||||
onToastRef.current(`Vault "${getRemovedVaultLabel(path, defaultVaults, extraVaults)}" removed from list`)
|
||||
onToastRef.current(`Vault "${getRemovedVaultLabel({ path, defaultVaults, extraVaults })}" removed from list`)
|
||||
}, [
|
||||
defaultVaults,
|
||||
extraVaults,
|
||||
@@ -453,7 +544,10 @@ function useRemoveVaultAction({
|
||||
onToastRef,
|
||||
setExtraVaults,
|
||||
setHiddenDefaults,
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
selectedVaultPath,
|
||||
vaultPath,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -479,13 +573,16 @@ function useVaultActions({
|
||||
onToastRef,
|
||||
setExtraVaults,
|
||||
setHiddenDefaults,
|
||||
selectedVaultPath,
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
vaultPath,
|
||||
}: VaultActionOptions) {
|
||||
const addVault = useCallback((path: string, label: string) => {
|
||||
addVaultToList(setExtraVaults, path, label)
|
||||
addVaultToList({ setExtraVaults, path, label })
|
||||
}, [setExtraVaults])
|
||||
|
||||
const switchVault = useSwitchVaultAction(onSwitchRef, setVaultPath)
|
||||
const switchVault = useSwitchVaultAction(onSwitchRef, setSelectedVaultPath, setVaultPath)
|
||||
const addAndSwitch = useCallback((path: string, label: string) => {
|
||||
addVault(path, label)
|
||||
switchVault(path)
|
||||
@@ -502,7 +599,10 @@ function useVaultActions({
|
||||
onToastRef,
|
||||
setExtraVaults,
|
||||
setHiddenDefaults,
|
||||
setSelectedVaultPath,
|
||||
setVaultPath,
|
||||
selectedVaultPath,
|
||||
vaultPath,
|
||||
}),
|
||||
restoreGettingStarted: useRestoreGettingStartedAction({
|
||||
defaultPath,
|
||||
@@ -543,7 +643,14 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
useEffect(() => { onSwitchRef.current = onSwitch; onToastRef.current = onToast })
|
||||
|
||||
const persistedState = usePersistedVaultState(onSwitchRef)
|
||||
const { defaultPath, extraVaults, hiddenDefaults, loaded, vaultPath } = persistedState
|
||||
const {
|
||||
defaultPath,
|
||||
extraVaults,
|
||||
hiddenDefaults,
|
||||
loaded,
|
||||
selectedVaultPath,
|
||||
vaultPath,
|
||||
} = persistedState
|
||||
const { allVaults, defaultVaults, isGettingStartedHidden } = useVaultCollections(
|
||||
defaultPath,
|
||||
hiddenDefaults,
|
||||
@@ -559,7 +666,16 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
})
|
||||
|
||||
return {
|
||||
vaultPath, allVaults, switchVault, handleVaultCloned, handleOpenLocalFolder, loaded,
|
||||
removeVault, restoreGettingStarted, isGettingStartedHidden,
|
||||
allVaults,
|
||||
defaultPath,
|
||||
handleOpenLocalFolder,
|
||||
handleVaultCloned,
|
||||
isGettingStartedHidden,
|
||||
loaded,
|
||||
removeVault,
|
||||
restoreGettingStarted,
|
||||
selectedVaultPath,
|
||||
switchVault,
|
||||
vaultPath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,18 @@ if (typeof window !== 'undefined') {
|
||||
window.__mockHandlers = mockHandlers
|
||||
}
|
||||
|
||||
function resolveMockHandler(command: string) {
|
||||
if (typeof window !== 'undefined' && window.__mockHandlers?.[command]) {
|
||||
return window.__mockHandlers[command]
|
||||
}
|
||||
return mockHandlers[command]
|
||||
}
|
||||
|
||||
export async function mockInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
const vaultResult = await tryVaultApi<T>(cmd, args)
|
||||
if (vaultResult !== undefined) return vaultResult
|
||||
|
||||
const handler = mockHandlers[cmd]
|
||||
const handler = resolveMockHandler(cmd)
|
||||
if (handler) {
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
return handler(args) as T
|
||||
|
||||
@@ -92,6 +92,9 @@ const mockSavedSinceCommit = new Set<string>()
|
||||
|
||||
let mockSettings: Settings = {
|
||||
auto_pull_interval_minutes: 5,
|
||||
autogit_enabled: false,
|
||||
autogit_idle_threshold_seconds: 90,
|
||||
autogit_inactive_threshold_seconds: 30,
|
||||
telemetry_consent: false,
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
@@ -100,11 +103,17 @@ let mockSettings: Settings = {
|
||||
default_ai_agent: 'claude_code',
|
||||
}
|
||||
|
||||
let mockLastVaultPath: string | null = null
|
||||
const DEFAULT_MOCK_VAULT_PATH = '/Users/mock/demo-vault-v2'
|
||||
const DEFAULT_MOCK_VAULT = {
|
||||
label: 'demo-vault-v2',
|
||||
path: DEFAULT_MOCK_VAULT_PATH,
|
||||
}
|
||||
|
||||
let mockLastVaultPath: string | null = DEFAULT_MOCK_VAULT_PATH
|
||||
|
||||
let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vault: string | null } = {
|
||||
vaults: [],
|
||||
active_vault: null,
|
||||
vaults: [DEFAULT_MOCK_VAULT],
|
||||
active_vault: DEFAULT_MOCK_VAULT_PATH,
|
||||
}
|
||||
|
||||
let mockVaultAiGuidanceStatus = {
|
||||
@@ -314,6 +323,9 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
const s = args.settings
|
||||
mockSettings = {
|
||||
auto_pull_interval_minutes: s.auto_pull_interval_minutes ?? 5,
|
||||
autogit_enabled: s.autogit_enabled ?? false,
|
||||
autogit_idle_threshold_seconds: s.autogit_idle_threshold_seconds ?? 90,
|
||||
autogit_inactive_threshold_seconds: s.autogit_inactive_threshold_seconds ?? 30,
|
||||
telemetry_consent: s.telemetry_consent,
|
||||
crash_reporting_enabled: s.crash_reporting_enabled,
|
||||
analytics_enabled: s.analytics_enabled,
|
||||
|
||||
61
src/mock-tauri/vault-api.test.ts
Normal file
61
src/mock-tauri/vault-api.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('tryVaultApi', () => {
|
||||
afterEach(() => {
|
||||
vi.resetModules()
|
||||
vi.restoreAllMocks()
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
it('retries vault API discovery after an unavailable response', async () => {
|
||||
let vaultApiOnline = false
|
||||
const fetchMock = vi.fn(async (input: string | URL) => {
|
||||
const url = String(input)
|
||||
if (url === '/api/vault/ping') {
|
||||
return jsonResponse({ ok: vaultApiOnline }, vaultApiOnline ? 200 : 503)
|
||||
}
|
||||
if (url === '/api/vault/list?path=%2Ffixture') {
|
||||
return jsonResponse([{ title: 'Alpha Project' }])
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`)
|
||||
})
|
||||
globalThis.fetch = fetchMock as typeof fetch
|
||||
|
||||
const { tryVaultApi } = await import('./vault-api')
|
||||
|
||||
await expect(tryVaultApi('list_vault', { path: '/fixture' })).resolves.toBeUndefined()
|
||||
|
||||
vaultApiOnline = true
|
||||
|
||||
await expect(tryVaultApi('list_vault', { path: '/fixture' })).resolves.toEqual([{ title: 'Alpha Project' }])
|
||||
expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/api/vault/ping')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('unwraps note content responses from the vault API', async () => {
|
||||
const fetchMock = vi.fn(async (input: string | URL) => {
|
||||
const url = String(input)
|
||||
if (url === '/api/vault/ping') {
|
||||
return jsonResponse({ ok: true })
|
||||
}
|
||||
if (url === '/api/vault/content?path=%2Ffixture%2Falpha.md') {
|
||||
return jsonResponse({ content: '# Alpha Project' })
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`)
|
||||
})
|
||||
globalThis.fetch = fetchMock as typeof fetch
|
||||
|
||||
const { tryVaultApi } = await import('./vault-api')
|
||||
|
||||
await expect(tryVaultApi('get_note_content', { path: '/fixture/alpha.md' })).resolves.toBe('# Alpha Project')
|
||||
expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/api/vault/ping')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -6,16 +6,22 @@
|
||||
|
||||
let vaultApiAvailable: boolean | null = null
|
||||
|
||||
async function checkVaultApi(): Promise<boolean> {
|
||||
if (vaultApiAvailable !== null) return vaultApiAvailable
|
||||
async function detectVaultApiAvailability(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch('/api/vault/ping', { signal: AbortSignal.timeout(500) })
|
||||
vaultApiAvailable = res.ok
|
||||
return res.ok
|
||||
} catch {
|
||||
vaultApiAvailable = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkVaultApi(): Promise<boolean> {
|
||||
if (vaultApiAvailable === true) return true
|
||||
|
||||
const available = await detectVaultApiAvailability()
|
||||
vaultApiAvailable = available
|
||||
console.info(`[mock-tauri] Vault API available: ${vaultApiAvailable}`)
|
||||
return vaultApiAvailable
|
||||
return available
|
||||
}
|
||||
|
||||
interface VaultApiRequest {
|
||||
@@ -65,25 +71,38 @@ const VAULT_API_COMMANDS: Record<string, (args: Record<string, unknown>) => Vaul
|
||||
},
|
||||
}
|
||||
|
||||
export async function tryVaultApi<T>(cmd: string, args?: Record<string, unknown>): Promise<T | undefined> {
|
||||
const available = await checkVaultApi()
|
||||
if (!available) return undefined
|
||||
|
||||
function buildVaultApiRequest(cmd: string, args?: Record<string, unknown>) {
|
||||
if (!args) return null
|
||||
const requestBuilder = VAULT_API_COMMANDS[cmd]
|
||||
if (!requestBuilder || !args) return undefined
|
||||
return requestBuilder?.(args) ?? null
|
||||
}
|
||||
|
||||
const request = requestBuilder(args)
|
||||
function buildFetchOptions(request: VaultApiRequest): RequestInit {
|
||||
if (!request.body) {
|
||||
return { method: request.method || 'GET' }
|
||||
}
|
||||
|
||||
return {
|
||||
method: request.method || 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request.body),
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVaultApiResponse(request: VaultApiRequest) {
|
||||
const res = await fetch(request.url, buildFetchOptions(request))
|
||||
if (!res.ok) return undefined
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function tryVaultApi<T>(cmd: string, args?: Record<string, unknown>): Promise<T | undefined> {
|
||||
const request = buildVaultApiRequest(cmd, args)
|
||||
if (!request) return undefined
|
||||
if (!await checkVaultApi()) return undefined
|
||||
|
||||
try {
|
||||
const fetchOpts: RequestInit = { method: request.method || 'GET' }
|
||||
if (request.body) {
|
||||
fetchOpts.headers = { 'Content-Type': 'application/json' }
|
||||
fetchOpts.body = JSON.stringify(request.body)
|
||||
}
|
||||
const res = await fetch(request.url, fetchOpts)
|
||||
if (!res.ok) return undefined
|
||||
const data = await res.json()
|
||||
const data = await fetchVaultApiResponse(request)
|
||||
if (data === undefined) return undefined
|
||||
return (cmd === 'get_note_content' ? data.content : data) as T
|
||||
} catch (err) {
|
||||
console.warn(`[mock-tauri] Vault API call failed for ${cmd}, falling back to mock:`, err)
|
||||
|
||||
@@ -80,6 +80,9 @@ export interface ModifiedFile {
|
||||
|
||||
export interface Settings {
|
||||
auto_pull_interval_minutes: number | null
|
||||
autogit_enabled?: boolean | null
|
||||
autogit_idle_threshold_seconds?: number | null
|
||||
autogit_inactive_threshold_seconds?: number | null
|
||||
telemetry_consent: boolean | null
|
||||
crash_reporting_enabled: boolean | null
|
||||
analytics_enabled: boolean | null
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface LaputaTestBridge {
|
||||
triggerMenuCommand?: (id: string) => Promise<unknown>
|
||||
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
|
||||
seedBlockNoteTable?: (columnWidths?: Array<number | null>) => Promise<void> | void
|
||||
seedAutoGitSavedChange?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
26
src/utils/automaticCommitMessage.test.ts
Normal file
26
src/utils/automaticCommitMessage.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { generateAutomaticCommitMessage } from './automaticCommitMessage'
|
||||
import type { ModifiedFile } from '../types'
|
||||
|
||||
function file(relativePath: string): ModifiedFile {
|
||||
return {
|
||||
path: `/vault/${relativePath}`,
|
||||
relativePath,
|
||||
status: 'modified',
|
||||
}
|
||||
}
|
||||
|
||||
describe('generateAutomaticCommitMessage', () => {
|
||||
it('returns an empty string when there is nothing to commit', () => {
|
||||
expect(generateAutomaticCommitMessage([])).toBe('')
|
||||
})
|
||||
|
||||
it('counts markdown files as notes', () => {
|
||||
expect(generateAutomaticCommitMessage([file('note-a.md')])).toBe('Updated 1 note')
|
||||
expect(generateAutomaticCommitMessage([file('note-a.md'), file('note-b.md')])).toBe('Updated 2 notes')
|
||||
})
|
||||
|
||||
it('falls back to files when any modified path is not markdown', () => {
|
||||
expect(generateAutomaticCommitMessage([file('note-a.md'), file('attachments/image.png')])).toBe('Updated 2 files')
|
||||
})
|
||||
})
|
||||
17
src/utils/automaticCommitMessage.ts
Normal file
17
src/utils/automaticCommitMessage.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ModifiedFile } from '../types'
|
||||
|
||||
function pluralize(count: number, singular: string, plural: string): string {
|
||||
return count === 1 ? singular : plural
|
||||
}
|
||||
|
||||
function allFilesAreNotes(files: ModifiedFile[]): boolean {
|
||||
return files.every((file) => file.relativePath.toLowerCase().endsWith('.md'))
|
||||
}
|
||||
|
||||
export function generateAutomaticCommitMessage(files: ModifiedFile[]): string {
|
||||
if (files.length === 0) return ''
|
||||
const noun = allFilesAreNotes(files)
|
||||
? pluralize(files.length, 'note', 'notes')
|
||||
: pluralize(files.length, 'file', 'files')
|
||||
return `Updated ${files.length} ${noun}`
|
||||
}
|
||||
32
src/utils/commitEntryAction.test.ts
Normal file
32
src/utils/commitEntryAction.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { triggerCommitEntryAction } from './commitEntryAction'
|
||||
|
||||
describe('triggerCommitEntryAction', () => {
|
||||
it('runs the automatic checkpoint when AutoGit is enabled', () => {
|
||||
const openCommitDialog = vi.fn().mockResolvedValue(undefined)
|
||||
const runAutomaticCheckpoint = vi.fn().mockResolvedValue(true)
|
||||
|
||||
triggerCommitEntryAction({
|
||||
autoGitEnabled: true,
|
||||
openCommitDialog,
|
||||
runAutomaticCheckpoint,
|
||||
})
|
||||
|
||||
expect(runAutomaticCheckpoint).toHaveBeenCalledWith({ savePendingBeforeCommit: true })
|
||||
expect(openCommitDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the manual commit dialog when AutoGit is disabled', () => {
|
||||
const openCommitDialog = vi.fn().mockResolvedValue(undefined)
|
||||
const runAutomaticCheckpoint = vi.fn().mockResolvedValue(true)
|
||||
|
||||
triggerCommitEntryAction({
|
||||
autoGitEnabled: false,
|
||||
openCommitDialog,
|
||||
runAutomaticCheckpoint,
|
||||
})
|
||||
|
||||
expect(openCommitDialog).toHaveBeenCalledTimes(1)
|
||||
expect(runAutomaticCheckpoint).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
21
src/utils/commitEntryAction.ts
Normal file
21
src/utils/commitEntryAction.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
type RunAutomaticCheckpoint = (options?: { savePendingBeforeCommit?: boolean }) => Promise<boolean>
|
||||
type OpenCommitDialog = () => Promise<void>
|
||||
|
||||
interface CommitEntryActionConfig {
|
||||
autoGitEnabled: boolean
|
||||
openCommitDialog: OpenCommitDialog
|
||||
runAutomaticCheckpoint: RunAutomaticCheckpoint
|
||||
}
|
||||
|
||||
export function triggerCommitEntryAction({
|
||||
autoGitEnabled,
|
||||
openCommitDialog,
|
||||
runAutomaticCheckpoint,
|
||||
}: CommitEntryActionConfig): void {
|
||||
if (autoGitEnabled) {
|
||||
void runAutomaticCheckpoint({ savePendingBeforeCommit: true })
|
||||
return
|
||||
}
|
||||
|
||||
void openCommitDialog()
|
||||
}
|
||||
@@ -122,7 +122,17 @@ describe('compactMarkdown', () => {
|
||||
|
||||
it('decodes   HTML entities from BlockNote bold+code output', () => {
|
||||
const input = '**Remove **`NoteWindow`** and render the full **`App`** component.**\n'
|
||||
expect(compactMarkdown(input)).toBe('**Remove **`NoteWindow`** and render the full **`App`** component.**\n')
|
||||
expect(compactMarkdown(input)).toBe('**Remove** `NoteWindow` **and render the full** `App` **component.**\n')
|
||||
})
|
||||
|
||||
it('moves trailing whitespace outside bold markers', () => {
|
||||
const input = '**Luca **\n'
|
||||
expect(compactMarkdown(input)).toBe('**Luca** \n')
|
||||
})
|
||||
|
||||
it('moves leading whitespace outside bold markers', () => {
|
||||
const input = '** Luca**\n'
|
||||
expect(compactMarkdown(input)).toBe(' **Luca**\n')
|
||||
})
|
||||
|
||||
it('decodes multiple HTML entity types', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user