Compare commits

..
100 Commits
Author SHA1 Message Date
Gregory SchierandClaude Fable 5.2 6a34d0a46d Yaak Proxy: the rejected card claims decryption only when the list decrypts
Once an address is no longer passed through, the foot said the next
connection will be decrypted even when the list excludes it or the
certificate is untrusted. It now says so only when the list still
decrypts the connection, and keeps the trust line while untrusted.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 08:47:57 -07:00
Gregory SchierandClaude Fable 5.2 35058cc3be Yaak Proxy: prose pass over the UI copy
Apply the approved wording table: labels stay short, explanations are one
full sentence about the user's traffic, and detail moves behind an info
icon on the first-run card, the body banners, the Settings Helper row and
the rejected card. One word per thing: traffic and flows, recorded,
truncated, domains, searches, the CLI, Retry.

Backend messages that reach the window change with them, with the tests
and harness fixtures that assert on the old text. The two working files
for the pass are removed from the branch.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 08:41:49 -07:00
Gregory Schier 1f0541f379 Proxy prose pass 2026-11-11 08:13:49 -07:00
Gregory Schier f0e767d77e Proxy prose pass 2026-11-11 08:13:49 -07:00
Gregory Schier 075fcaf2c4 Proxy prose pass 2026-11-11 08:13:49 -07:00
Gregory SchierandClaude Opus 5.5 6e39955f17 Yaak Proxy: release build, dry run and updater (#836)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 23:45:25 -07:00
Gregory Schier 9cc9f7a5b8 Fix font size 2026-11-10 22:32:37 -07:00
Gregory SchierandClaude Opus 5.5 c0f75bcfc2 Yaak Proxy: square app icons beside long names, and the final uncovered why line
App icons keep their square however a long app name squeezes them, in the
card's buttons, the strip's Choose menu and the sidebar, and a long entry
hint truncates instead of the button's label. A connection nothing on the
list covers reads "Encrypted Content: Enable decryption to see requests
to <host>", with the host as code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:30:40 -07:00
Gregory SchierandClaude Opus 5.5 e3ff29ee6d Yaak Proxy: mute encrypted and refused rows by colour only
No opacity on the app icon or the port; the host, number, size and time
take the subtle text colour, the faintest that keeps 4.5:1 on the row in
both default themes, and the full text colour while selected. The lock
takes the subtle colour too, and refused rows follow the same rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:30:40 -07:00
Gregory SchierandClaude Opus 5.5 f2555bdcd5 Yaak Proxy: code-styled names and an info button in the encrypted card's why line
Hosts, apps and entries named in the why and opened-before lines render
as InlineCode, an info button beside the why line explains decryption in
a tooltip, and the "New connections only" line is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:30:40 -07:00
Gregory SchierandClaude Opus 5.5 ae666adfb7 Yaak Proxy: name the host when decryption isn't configured for it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:30:40 -07:00
Gregory SchierandClaude Opus 5.5 f5ff9cb63e Yaak Proxy: fix review findings in the decryption UI
Count Reconnect's connections within its own scope, refresh the
pass-through set from Try Again's answer, report a failed trust, keep
the pill's switch where the app can't trust the certificate, and keep a
failed tunnel's error across Type and Size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:30:40 -07:00
Gregory SchierandClaude Opus 5.5 b0425069f4 Yaak Proxy: HTTPS decryption UI, and Info and Body in the card's tabs
The strip, quiet encrypted rows, warning rows for refused handshakes, the
encrypted card with named actions, trust at the first Decrypt, the toast
with Undo and Reconnect, the pill's Decrypt everything switch, Settings'
Decryption section, the Encrypted sidebar row, and two-step setup. Both
body tabs read Body with a type badge; Info leads the request half with
the timeline folded in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:30:40 -07:00
Gregory SchierandClaude Opus 5.5 5148b0f0ba Yaak Proxy: open Settings as a child of the main window
Settings now opens through the shared create_child_window, as Yaak's
does, so it stays in front of the main window, takes focus back when the
main window is clicked, and closes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:34:22 -07:00
Gregory SchierandClaude Opus 5.5 7057a902c7 Yaak Proxy: scale resized numeric column widths with the interface size too
Every numeric width is kept in pixels at the default interface size, so
the least width matches what renders at any size and a resize follows
the pointer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:13:16 -07:00
Gregory SchierandClaude Opus 5.5 82ac6e5c15 Yaak Proxy: keep numeric column values from truncating
Size, Request Size, Time and Latency are never narrower than their
widest value (999.9 KB, 999m 59s) at any interface size, and their
default widths grow with it. Stored widths under the new least width
are raised when read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:13:16 -07:00
Gregory SchierandClaude Opus 5.5 17b1623e83 Settings: one shared page layout with a fixed gap under the header
Every settings tab in Yaak and Yaak Proxy now renders its heading, description,
optional banner and content through SettingsPage, which puts the same 1.5rem gap
between each. The banner slot hides itself when nothing inside it has content,
so Yaak's General tab no longer gets an accidental gap from the empty
commercial-use banner, and the proxy's General tab no longer sits tight under
its description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:06:36 -07:00
Gregory SchierandClaude Sonnet 5.5 dc09688bc9 Remove moved-actions banner from response history dropdown
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 15:14:18 -07:00
Gregory SchierandClaude Opus 5.5 f02dce21e7 Yaak Proxy: record why HTTPS is encrypted, refused handshakes, and reconnect on request (#835)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:49:12 -07:00
Gregory SchierandClaude Opus 5.5 69b7c9921c Keep Workspace Settings and Delete usable when a workspace's meta is missing (#834)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:15:52 -07:00
Gregory SchierandClaude Opus 5.5 eba8dd5864 CLI: handle gRPC and WebSocket requests, keep IDs on Yaak import (#816)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:47:25 -07:00
alexcastgonz c0bfe7a97a fix(http): resolve redirect locations against the current URL (#832) 2026-11-10 13:46:23 -07:00
Raphael Fakhri 1a9b3f09a5 fix(copy-as): skip disabled API key auth in Copy as Curl (#811) 2026-11-10 13:44:20 -07:00
Gregory SchierandClaude Opus 5.5 f7402f5f4f Open Yaak Proxy's Settings in its own window
Settings opens in a window labelled `settings`, created through the shared
window code, as Yaak's does. The page renders the settings alone or the app
by its window label. Opening it again focuses it and moves it to the tab
asked for; Escape closes it, and closing the main window closes it too.
Changes made there reach the main window at once: theme and sizes by an
event carrying the values, and engine settings by an event after each RPC
mutation that makes other windows read the proxy state and settings
queries again. The browser harness, with no second window, keeps the
dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:34:34 -07:00
Gregory SchierandClaude Opus 5.5 ca1494b54a Fill out Yaak Proxy's sidebar menus, and move System's toggle into its menu
Rows for apps, folders, sites, hosts and paths get Filter by This Value,
Exclude This Value and Copy Value, as the list's cells have, covering
every selected row. Opening a row's menu no longer applies the row, so
the menu adds to the filter as it is. System's eye becomes Show in All
Traffic in its menu, and Domains counts its hosts as a plain number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:21:31 -07:00
Gregory SchierandClaude Opus 5.5 a14fc18b4b Merge origin/main into stetta/tk_mwaz7asqhsi6jowm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:19:21 -07:00
Gregory SchierandClaude Opus 5.5 62a15af395 Confirm clearing Yaak Proxy's traffic in a dialog, with Don't ask again
The trash button and its hotkey open a confirm dialog instead of a
dropdown. Its Don't ask again checkbox saves a preference that Settings
turns back on under Traffic, as Confirm before clearing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:08:40 -07:00
Gregory SchierandClaude Opus 5.5 d12beda8a9 Keep Clear in the header with Cmd+Shift+K in its tooltip, and leave the port to the menu's address line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:06:03 -07:00
Gregory SchierandClaude Opus 5.5 a4061d5dee Move Clear into the status menu on Cmd+Shift+K, and the port into the menu as a copyable address
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:06:03 -07:00
Gregory SchierandClaude Opus 5.5 bba92c5a23 Tint the proxy's header by its status, sharing Yaak's environment tint
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:06:03 -07:00
Gregory Schier a4355b956a Merge remote-tracking branch 'origin/main' into stetta/tk_mwaxu5pizxqxbmzj
# Conflicts:
#	apps/yaak-proxy/components/FlowCard.tsx
2026-11-10 11:05:49 -07:00
Gregory SchierandClaude Opus 5.5 e0b435c416 Keep a multi-row sidebar selection through new traffic, and restore rows on Back
The shared Tree reset its selection to the last focused row after every
render, so several selected rows collapsed to one whenever the tree rendered
again, as new traffic changing the counts makes it. It now leaves the
selection alone while that row is still in view and selected.

The proxy's sidebar sync also makes All traffic exclusive, groups a saved
search's own OR in parentheses beside other rows, applies System's heading
query, and on Back or Forward selects the rows whose queries make up the
restored filter, compared by shape so AND and OR stay apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 11:01:54 -07:00
Gregory SchierandClaude Opus 5.5 29a173a07e Give the actions bar one inset in both of Yaak Proxy's panes
The request half's tab content ran to the card's bottom edge while the
response half's stopped above it, so the Pretty/Raw bar sat 7px from
the bottom on one side and 19px on the other. The request half is now
padded below as the response is. The bar's inset is one constant that
viewers can no longer override, and the form view draws the switch in
one bar with its bulk-view toggle instead of shifting the bar past it,
so every viewer's bar sits 11px from the pane's right and 19px from its
bottom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:52:36 -07:00
Gregory SchierandClaude Opus 5.5 700003e9a2 Compact CSV tables, and stop dimming empty tabs in Yaak Proxy
The shared Table gains a density prop; the CSV viewer uses compact,
bringing its rows from about 38px to about 25px with the header to
match. Every other table keeps its spacing.

The proxy no longer reads empty tabs in the subtlest colour: every
inactive tab label is one colour and the count badge alone says a tab
has content. dimEmptyTabs leaves both shared panes and the empty option
leaves Tabs, since nothing else used them. The harness gains a TSV
fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:52:36 -07:00
Gregory SchierandClaude Opus 5.5 529be76378 Line up the actions bar's items, and drop debug classes from CSV parts
Every action in an ActionsBar is now held to the height and radius of an
sm icon button and centred on the row; the Pretty/Raw switch is sized to
match instead of sitting shorter than the filter toggle. A CSV part in
the multipart viewer loses leftover debug classes that drew it as a
small primary-coloured block, and takes the pb-2 the other part viewers
use. The harness's multipart fixture gains a CSV part.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:52:36 -07:00
Gregory SchierandClaude Opus 5.5 6baa230f92 Keep the form's bulk toggle clear of the view switch, and the cut note off the request
The Form/Raw switch sat over the URL-encoded form's own bulk-view toggle
in the same corner; it now sits beside it. A request body that is both
cut short and undecodable no longer repeats the tab's cut note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:52:36 -07:00
Gregory SchierandClaude Opus 5.5 7ece5fa5df Add a shared actions bar and a Pretty/Raw switch for Yaak Proxy's bodies
The actions row moves out of the code editor into ActionsBar, a plain
component a wrapper places over its content. FormatAction reformats an
editor through its ref, and BodyViewModeSwitch names its first state for
the body's format (Pretty, Table, Parts, Preview, Form) next to Raw.

Yaak Proxy's request and response body tabs now share one viewer
choice, with the switch in each tab's bar beside the text viewer's
filter. The choice is remembered for both panes; bodies over the large
size open raw. "Response (Raw)" leaves the proxy's tab menu, while
Yaak's response pane keeps it until the remaining sites move.

SegmentedControl moves from the client into packages/ui.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:52:36 -07:00
Gregory SchierandClaude Opus 5.5 cc83c7cd1e Keep filters out of the web view's storage, and let a newer selection win over a late read
Filters can hold captured values, so neither the reload's places nor the
launch's last filter go into web view storage: the engine holds the places in
memory while the app runs, and proxy.db's last_filter row keeps the last
filter. A read of a selected flow that answers after the window shows a newer
copy no longer replaces it. The pane and stream harnesses select through
goToPlace, so switching a tab no longer empties their card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:48:34 -07:00
Gregory SchierandClaude Opus 5.5 c2d4c39c62 Apply Yaak Proxy's filter on Enter, with places, Back and Forward, and filter history
The search box now composes a filter and Enter applies it. The main window's
view (filter, selected flow, card tabs) is a place: a session stack of them
backs Back and Forward (⌘[ ⌘], mouse buttons, a Go menu), the current one
survives a reload, and the last filter comes back at launch. Filters applied
with Enter go into a new filter_history table, listed from the magnifier and
recalled with Up in the empty box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:33:18 -07:00
Gregory SchierandClaude Opus 5.5 fd89201ccb Measure a new column drag after the last one's columns are home
A drag started while the last one's columns slid home read their edges
mid-slide, and dropped against them. The list now ends that slide first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:20:51 -07:00
Gregory SchierandClaude Opus 5.5 5e37fca58a Put a column drag back when the columns change, and keep a merged reason to Type
A drag keeps the columns' edges from when it began, so columns shown or
hidden meanwhile, as App is when the first flow with an app arrives, would
drop it against columns that are gone; it is put back instead. A failed
flow's reason spans Type and Size, so while they part it keeps to Type's
width rather than sliding over the column past Size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:15:26 -07:00
Gregory SchierandClaude Opus 5.5 80b587f507 Slide columns aside for a dragged one instead of drawing a drop line
While a column is dragged it hides where it is and the columns between it and
where it would land slide by its width, header and cells together, so the gap
shows the drop. One style element carries a rule a column and changes only when
the target does; the rows don't render. On drop the rules go as the order
changes, in the same task, so nothing animates; on Escape the columns slide
home and the column shows again. The drop line and its code are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:09:45 -07:00
Gregory SchierandClaude Opus 5.5 185a4e30df Drag a whole column's ghost, and keep default places until reordered
The ghost of a dragged column is now its header and the cells of the rows in
view, copied once when the drag starts and moved sideways by a transform,
kept within the list; the column dims in place, its cells by one style rule
so no row renders. A built-in column turned on before any reordering takes
its default place again; once the person has an order, a newly shown column
goes at the end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 09:22:17 -07:00
Gregory SchierandClaude Opus 5.5 9ee37e4015 Show a dragged column's drop line at every boundary
The line was hidden while a drop would leave the column where it is, so a drag
showed nothing until it crossed a neighbour's middle. It now shows from the
first movement, on the nearer edge of the column's own slot when the drop
would change nothing, and at the first boundary over the mark column. The
choice of boundary moves to a tested function in the columns module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 09:03:19 -07:00
Gregory SchierandClaude Opus 5.5 78351f620e Drop a column dragged and released in one move
dnd-kit starts a drag on the move that crosses its threshold and reports the
release only once it has rendered the drag, so a quick drag could miss its
last position or not drop at all. The header now follows the pointer from the
press and drops the column on its release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 01:28:16 -07:00
Gregory SchierandClaude Opus 5.5 ba403307d8 Drop a dragged column where the pointer is when released
A release right after a move used the target from the last frame, or none,
since dnd-kit reports a move only once it renders. The drag now tracks the
pointer itself and works out the target again on release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 01:18:44 -07:00
Gregory SchierandClaude Opus 5.5 4e63ba2ce7 Drag Yaak Proxy's flow list columns to reorder them
A column's label drags it: a lifted ghost follows the pointer, a line marks the
boundary it would drop at, down the header and the rows in view, and the list
scrolls sideways near its edges. A press that doesn't move still sorts, the
resize handles still resize, and Escape cancels. The order is kept with the
other column preferences as a list of ids, and the header menu gains Move Left
and Move Right, the columns menu Reset Column Order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 01:10:35 -07:00
Gregory SchierandClaude Opus 5.5 e870604607 Sort Yaak Proxy's custom columns, and complete Add Column's field alone
A sort key can now name a header or query parameter column (`field:header.x-cache`). The
database sorts it by the value the column shows, read from the stored headers or URL with the
name bound as a parameter, and the app's in-memory sort places live flows the same way. Custom
column headers sort on click and Option-click and their menus' sort items work; the sort is
kept with the columns and removing a column drops it from the sort.

The Add Column field completes `@` and the paths a column accepts, with no colon and no values,
opens its menu on focus, normalises a pasted term, and says why a path cannot be added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:46:27 -07:00
Gregory SchierandClaude Opus 5.5 b5951b36ae Flow list columns, multi-column sorting and header and cell menus (#833)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 00:24:03 -07:00
Gregory SchierandClaude Opus 5.5 a43c3f7c2c Take over a system proxy that comes back to the engine
When routing returns to the running engine without the app setting it,
as Proxyman leaves it in putting back what it found, the watch now
records it as the app's: the helper is asked to point it there, which
makes the app its owner, and the password and sudo paths save a
snapshot for the port. Stopping or quitting then turns it off as it
does when the app set it. Tests cover the exact sequence on both paths,
with and without the takeover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:26:38 -07:00
Gregory SchierandClaude Opus 5.5 3f3fd15959 Release the clear's lock fully and clear conversion leftovers
The exclusive traffic.lock a launch takes to clear is let go before it
is taken shared, since Windows would otherwise hold both. Clearing the
blob store also deletes what an interrupted encryption left beside it,
so opening doesn't recover the old bodies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:26:38 -07:00
Gregory SchierandClaude Opus 5.5 52d9c9596b Guard the launch clear and system proxy watch against races
A launch now clears captured traffic only when it can take traffic.lock
alone, and every process with the databases open holds it shared, so an
overlapping launch can't delete a database another has open. Changes the
app decides to the system proxy are made under a lock the watch also
takes to apply what it read, so a reading never overwrites them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:26:38 -07:00
Gregory SchierandClaude Opus 5.5 4fc95d264c Clear Yaak Proxy traffic at launch and follow the real system proxy
Captured traffic is now cleared as the app launches by default, with a
"Keep between launches" choice that brings the size limit with it. An
install that had a size limit set keeps its traffic. The Proxy settings
tab is regrouped into Proxy, Decryption, Traffic and Terminal, and the
per-body limit row is gone.

The app now watches macOS proxy settings and follows them: when another
app or the user points the system proxy elsewhere, the override shows as
off and the settings saved for restoring are dropped, so quitting puts
back nothing the app didn't set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:26:38 -07:00
Gregory SchierandClaude Opus 5.5 13ef9c60f1 Fix review findings in the flow list polish
Search actions keep the editor focused so a wrapped box does not collapse
under the click. Header scrolls from focus move the rows too. Timeline
details always show their tooltip. A sidebar row shared by same-named
folders or programs in different places offers no path rather than an
arbitrary one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:54:09 -07:00
Gregory SchierandClaude Opus 5.5 c78732ecaf Polish Yaak Proxy's flow list, sidebar and header
Columns keep fixed widths, Path included, and the list scrolls sideways
with the header in step instead of dropping columns or capping Host.
Resize handles sit on every column boundary, a double-click resets one,
and a third click on a sorted header goes back to start time, with empty
values sorting lowest. The search box takes up to 44rem and wraps over
the list while focused, its completions as wide as it. The list/detail
handle fills the gutter. Native title tooltips become Yaak's tooltip,
shared buttons gain an opt-in for it, and folder and program rows in the
sidebar get Reveal in Finder and Copy path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:45:46 -07:00
Gregory SchierandClaude Fable 5.1 77226dbedb Put Yaak Proxy's search on the shared query engine (#826)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 09:07:04 -07:00
Gregory SchierandClaude Opus 5.5 7453869ac6 Move Yaak's sidebar filter onto a shared Rust query engine (#825)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:30:56 -07:00
Gregory Schier 2ca2bc5f00 Detail card, settings window and small fixes for Yaak Proxy (#824) 2026-11-07 13:45:39 -07:00
Gregory SchierandClaude Opus 5.5 4538123d88 Make HTTPS decryption opt-in with a decrypt list (#822)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 21:24:39 -07:00
Gregory Schier 91696c2d1f Encrypt Yaak Proxy's databases at rest with SQLCipher (#823) 2026-11-06 21:07:38 -07:00
Gregory SchierandClaude Opus 5.5 cc382b9226 Messages tab for streams, with messages as the single record (#819)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:45:52 -07:00
Gregory SchierandClaude Opus 5.5 f63586f9f0 Make the multipart viewer tolerant of preambles, batch responses and cut-off bodies (#821)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:19:19 -07:00
Gregory SchierandClaude Opus 5.5 c629d4c66c Proxy shell from the design: list, sidebar, header, empty states and instant Clear (#820)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:45:44 -07:00
Gregory Schier c09ee5cc53 Skip Private Relay for the proxy engine's plain-HTTP upstream on macOS (#818) 2026-11-06 12:52:30 -07:00
Gregory SchierandClaude Opus 5.5 603a4ce364 Share Yaak's settings dialog (#817)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:37:42 -07:00
Gregory SchierandClaude Opus 5.5 1129f7aa93 Capture WebSocket, SSE and gRPC (#814)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:53:56 -07:00
Gregory SchierandClaude Opus 5.5 08a98d5f82 Share Yaak's request and response panes (#815)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 09:23:38 -07:00
Gregory SchierandClaude Opus 5.5 4ad0533901 Body viewer and decode fixes (#812)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:21:05 -07:00
Gregory Schier 87a30200f9 Debug nested template arguments (#788) 2026-11-01 21:46:14 -07:00
Gregory SchierandClaude Opus 5.5 c9ad753672 Bump zip from 7 to 9.0.5 (#802)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 20:07:04 -07:00
4e7c0095d8 chore(deps-dev): bump vite-plus from 0.3.3 to 1.4.0 (#768)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:30:19 -08:00
dependabot[bot]andGregory Schier 07ed9d75fa chore(deps): bump the npm-production group across 1 directory with 23 updates (#804)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-11-01 18:18:37 -08:00
dependabot[bot]andGregory Schier 8c0c76e812 chore(deps): bump seroval from 1.6.7 to 1.7.6 (#794)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-11-01 18:03:49 -08:00
60ff38cbdc chore(deps): bump nanoid from 0.4.0 to 0.5.0 (#771)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:57:17 -08:00
dependabot[bot] 405b8b2567 chore(deps): bump md5 from 0.7.0 to 0.8.0 (#770)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 17:44:11 -08:00
dependabot[bot] 489955a913 chore(deps-dev): bump follow-redirects from 1.16.0 to 1.16.2 (#801)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 17:29:14 -08:00
11cbb96746 chore(deps): bump fancy-regex from 0.17.0 to 0.19.2 (#773)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:16:13 -08:00
dependabot[bot] f1ae62b2dc chore(deps): bump fast-uri from 3.1.8 to 3.1.9 (#780)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 17:09:26 -08:00
dependabot[bot] 4bfb1a106a chore(deps): bump the cargo group across 1 directory with 15 updates (#805)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 16:57:32 -08:00
dependabot[bot] bf47095cc0 chore(deps-dev): bump the npm-development group across 1 directory with 7 updates (#803)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 16:43:30 -08:00
0489b67f6c chore(deps): bump rand from 0.8.7 to 0.10.2 (#772)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:30:49 -08:00
dependabot[bot] 143acdbd7e chore(deps): bump rustls from 0.23.45 to 0.23.46 (#790)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 16:30:28 -08:00
dependabot[bot] 27698b15bb chore(deps-dev): bump @vitest/mocker from 4.1.11 to 4.1.12 (#778)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 16:20:33 -08:00
dependabot[bot] 48a9c7e81f chore(deps): bump ip-address from 10.7.3 to 10.7.5 (#782)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 16:18:30 -08:00
dependabot[bot] 3c5429897e chore(deps): bump body-parser from 2.3.0 to 2.4.0 (#781)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-01 16:18:06 -08:00
David Leung 53e766b2a9 Pin the wasm target in the build scripts (#799) 2026-11-01 16:46:55 -07:00
Gregory SchierandClaude Opus 5.5 cd431186b6 Patch the Brotli decoder CVE in bundled Node and yaak-http
CVE-2026-111497 is an out-of-bounds write in the Brotli decoder. It
reaches Yaak through the bundled Node (`yaaknode`, used by plugins) and
through the pure-Rust `brotli-decompressor` that yaak-http uses to decode
`br` responses.

- Bump the plugin runtime Node from 24.11.1 to 24.22.2 and update the
  vendoring script's SHA256 pins from the signed SHASUMS256.txt.
- Move yaak-http from `brotli = "8"` to `brotli = "9"`, locking brotli
  9.1.1 and brotli-decompressor 6.2.0.

Tauri's pinned git rev still pulls brotli 8.0.1 and brotli-decompressor
5.0.0 for its embedded-asset compression; that needs a Tauri bump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:01:25 -07:00
Gregory SchierandClaude Opus 5.5 f7aea62c07 Privileged helper for system proxy (#796)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:41:00 -07:00
Gregory SchierandClaude Opus 5.5 4d38a482f6 HTTP/2 on the client side (#797)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:31:49 -07:00
Gregory SchierandClaude Opus 5.5 e82ffc53ad Proxy GUI pass (#793)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:27:18 -07:00
Gregory SchierandClaude Opus 5.5 96ab0a8a60 Give empty sidebar nodes a collapsed flag too
The shared tree treats every node with a children list as a folder and
collapses an empty one before the left arrow moves to its parent. Left
out of the collapsed map, a leaf path never read as collapsed, so the
left arrow kept trying to collapse it and never moved up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:44:22 -07:00
Gregory SchierandClaude Opus 5.5 974b9de673 Start the proxy sidebar's hosts and paths collapsed
Every domain and path node starts collapsed, so paths stay hidden until
a host is expanded. The nodes the user expands are kept by id in local
storage, so they stay open across launches; hosts seen later start
collapsed. The Domains folder, selection and counts are as before. The
shared tree is unchanged: the proxy hands it its own collapsed atom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:40:30 -07:00
Gregory SchierandClaude Opus 5.5 e33aeee5ff Close the clear and count races the second review found
- Send a flow's write event inside its transaction, so a clear's event,
  sent after its own commit, always comes after it.
- Record a saved request only on a flow that still exists, so a save
  that finishes after a clear does not bring the flow back.
- Count the total again from the database a second after new flows,
  so a flow both in a page and in the events around it settles to the
  right count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:34:14 -07:00
Gregory SchierandClaude Opus 5.5 39a7397ebb Close three races the review found in the flow window
- Replay live changes over a page of older flows, so a slow flow that
  ends while the page loads is not lost from the window.
- Read list_flows' page and total from one snapshot, so a flow written
  between them is not counted twice.
- Commit a clear's flow deletion only after its bodies' deletion, so a
  failure leaves the flows to clear again rather than orphaned bodies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:04:25 -07:00
Gregory SchierandClaude Opus 5.5 1a994f7532 Keep the proxy app fast with many flows
The app held every flow in memory and redid all of its work for each new
one: the sidebar mounted every domain and path node (its tree scrolled
inside a container that never bounded it), every tree row looked up its
atoms by scanning every atom the family had made, and the layout, detail
pane and list rows re-rendered per flow. At 3,000 flows each new one cost
about 90 ms of main thread, more than the traffic left time for.

The app now holds a window of the newest 2,000 flows, applies new ones in
batches, and loads older ones in pages from the database on scrolling to
the top, through a new list_flows RPC that pages in SQL. The database
keeps every flow.

Also adds clear_flows: an RPC, POST /flows/clear on the control API, and
a Clear button in the header that Cmd+K opens, confirming first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:52:00 -07:00
Gregory SchierandClaude Opus 5.5 4abc882e13 Send the client's Host as :authority to HTTP/2 upstreams
Dropping Host lost a virtual host that differs from the CONNECT target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:38:54 -07:00
Gregory SchierandClaude Opus 5.5 11aca4539d Fix three proxy engine bugs found while dogfooding
- Rebuild a bare-path plain-HTTP request's URL from Host, answer 400
  "no host in request" when there is none, and refuse with 508 a rebuilt
  request that is routed back into the proxy, which would otherwise loop
  without end.
- Drop Host when forwarding to an HTTP/2 upstream. Google's servers reset
  every stream that carries Host alongside :authority.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:35:17 -07:00
Gregory SchierandClaude Opus 5.5 9bbd28ce4a PR 2: proxy services (#787)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:19:50 -07:00
Gregory SchierandClaude Opus 5.5 686294939b Rescue the proxy GUI slice (#785)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 06:38:34 -07:00
Gregory Schier 6132171791 Agents pane: CLI-only MCP, yaak mcp --http, and plugin deprecation (#761) 2026-10-25 23:14:45 -07:00
566 changed files with 83696 additions and 11535 deletions

No files matched your search

+1
View File
@@ -5,3 +5,4 @@ crates/yaak-templates/pkg/* linguist-generated=true
# Ensure consistent line endings for test files that check exact content
crates/yaak-http/tests/test.txt text eol=lf
crates-proxy/yaak-proxy-lib/tests/fixtures/export.har text eol=lf
+190 -1
View File
@@ -2,10 +2,24 @@ name: Release App Artifacts
on:
push:
tags: [v*]
# The Yaak Proxy dry run only: builds, signs and notarizes the proxy for the chosen ref and
# keeps the results as workflow artifacts. It never builds Yaak, releases, tags or publishes.
workflow_dispatch:
inputs:
proxy_dry_run:
description: "Dry-run the Yaak Proxy build (artifacts only; nothing is released)"
type: boolean
required: true
default: true
proxy_version:
description: "Version to stamp into the dry-run build"
type: string
required: true
default: "0.0.1-dryrun"
jobs:
build-artifacts:
if: github.repository == 'mountain-loop/yaak'
if: github.repository == 'mountain-loop/yaak' && github.event_name == 'push'
permissions:
contents: write
@@ -290,3 +304,178 @@ jobs:
path: ${{ runner.temp }}/yaak-release-artifacts/
retention-days: 7
if-no-files-found: warn
# Yaak Proxy, macOS only, on the same tag and version as Yaak. Its own job, sharing nothing
# with Yaak's, so a proxy failure never stops Yaak's artifacts; it fails the run, visibly.
#
# On a v* tag it runs only once the repository variable RELEASE_PROXY is "true": the website's
# download links pick Yaak's assets by suffix and architecture, and its release audit expects
# Yaak's exact asset set and this run's success, so the proxy must not join Yaak's release
# until the website expects it there. The dry run (workflow_dispatch) runs regardless, and
# only keeps workflow artifacts.
build-proxy:
if: >-
github.repository == 'mountain-loop/yaak' && (
(github.event_name == 'push' && vars.RELEASE_PROXY == 'true') ||
(github.event_name == 'workflow_dispatch' && inputs.proxy_dry_run)
)
permissions:
contents: write
name: Build Yaak Proxy
strategy:
fail-fast: false
matrix:
include:
- target: "aarch64-apple-darwin" # for Arm-based Macs (M1 and above).
yaak_arch: "arm64"
lipo_arch: "arm64"
asset_arch: "aarch64"
- target: "x86_64-apple-darwin" # for Intel-based Macs.
yaak_arch: "x64"
lipo_arch: "x86_64"
asset_arch: "x64"
runs-on: macos-latest
timeout-minutes: 240
env:
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' }}
# The tag, as Yaak's version is taken, or the dry run's own
PROXY_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.proxy_version || github.ref_name }}
steps:
- name: Checkout yaakapp/app
uses: actions/checkout@v4
- name: Setup Vite+
uses: voidzero-dev/setup-vp@v1
with:
node-version: "24"
cache: true
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
# A cache of its own, so the proxy's builds never change what Yaak's jobs restore
- uses: Swatinem/rust-cache@v2
with:
shared-key: release-proxy
key: ${{ matrix.target }}
cache-on-failure: true
- name: Install Protoc for plugin-runtime
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- run: vp install
- name: Set version
run: npm run replace-version -- proxy
env:
YAAK_VERSION: ${{ env.PROXY_VERSION }}
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
set -euo pipefail
KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/certificate.p12"
security import "$RUNNER_TEMP/certificate.p12" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
rm "$RUNNER_TEMP/certificate.p12"
# Lets codesign use the key unattended; without it the first signature can wait on a
# prompt nobody answers
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" > /dev/null
security list-keychain -d user -s "$KEYCHAIN_PATH"
# Builds the helper for the target and signs it and protoc before bundling (the bundle
# config's before-build step), then signs, notarizes and staples the app and makes the
# updater archive and its signature.
- name: Build, sign and notarize
run: >-
npm run proxy:bundle --
--target ${{ matrix.target }}
--bundles dmg
--config crates-tauri/yaak-app-proxy/tauri.release.conf.json
env:
CI: true
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# The helper only answers an app signed by this team
YAAK_PROXY_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: Verify the bundle
run: >-
node scripts/verify-proxy-bundle.mjs
"target/${{ matrix.target }}/release/bundle/macos/Yaak Proxy.app"
--signed
--version "$PROXY_VERSION"
--arch ${{ matrix.lipo_arch }}
--team "$YAAK_PROXY_TEAM_ID"
env:
YAAK_PROXY_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Named Yaak.Proxy_*, so neither people nor tools take them for Yaak's Yaak_* assets.
# The app refuses an update not named so (ARTIFACT_PREFIX in yaak-app-proxy's updates.rs).
- name: Collect the release assets
run: |
set -euo pipefail
bundle="target/${{ matrix.target }}/release/bundle"
version="${PROXY_VERSION#v}"
out="$RUNNER_TEMP/yaak-proxy-release"
mkdir -p "$out"
dmgs=("$bundle"/dmg/*.dmg)
if [ "${#dmgs[@]}" -ne 1 ] || [ ! -f "${dmgs[0]}" ]; then
echo "Expected one .dmg in $bundle/dmg"; exit 1
fi
cp "${dmgs[0]}" "$out/Yaak.Proxy_${version}_${{ matrix.asset_arch }}.dmg"
cp "$bundle/macos/Yaak Proxy.app.tar.gz" "$out/Yaak.Proxy_${{ matrix.asset_arch }}.app.tar.gz"
cp "$bundle/macos/Yaak Proxy.app.tar.gz.sig" "$out/Yaak.Proxy_${{ matrix.asset_arch }}.app.tar.gz.sig"
(cd "$out" && shasum -a 256 Yaak.Proxy_* > checksums.txt)
ls -l "$out"
- name: Keep the bundles as workflow artifacts
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: proxy-${{ env.PROXY_VERSION }}-${{ matrix.asset_arch }}-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: ${{ runner.temp }}/yaak-proxy-release/
retention-days: ${{ env.DRY_RUN == 'true' && 14 || 7 }}
if-no-files-found: error
# Yaak's jobs create the draft release; this waits for it rather than making its own,
# which could race theirs into two drafts.
- name: Upload to the release
if: env.DRY_RUN != 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
for attempt in $(seq 1 120); do
if gh release view "$GITHUB_REF_NAME" > /dev/null 2>&1; then break; fi
if [ "$attempt" -eq 120 ]; then
echo "No release for $GITHUB_REF_NAME after two hours; Yaak's jobs make it"; exit 1
fi
sleep 60
done
for asset in "$RUNNER_TEMP"/yaak-proxy-release/Yaak.Proxy_*; do
for attempt in 1 2 3 4; do
if gh release upload "$GITHUB_REF_NAME" "$asset" --clobber; then
break
fi
if [ "$attempt" -eq 4 ]; then exit 1; fi
sleep 10
done
done
+3
View File
@@ -59,3 +59,6 @@ flatpak/node-sources.json
# Claude Code local settings
.claude/settings.local.json
.claude/worktrees/
# Headless screenshots taken while working
.shots/
Generated
+444 -235
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -19,6 +19,7 @@ members = [
"crates/yaak-mcp",
"crates/yaak-models",
"crates/yaak-plugins",
"crates/yaak-query",
"crates/yaak-sse",
"crates/yaak-sync",
"crates/yaak-templates",
@@ -40,8 +41,10 @@ members = [
"crates-tauri/yaak-fonts",
"crates-tauri/yaak-license",
"crates-tauri/yaak-mac-window",
"crates-tauri/yaak-proxy-helper",
"crates-tauri/yaak-system-appearance",
"crates-tauri/yaak-tauri-utils",
"crates-tauri/yaak-updater",
"crates-tauri/yaak-window",
]
@@ -95,6 +98,7 @@ yaak-lifecycle = { path = "crates/yaak-lifecycle" }
yaak-mcp = { path = "crates/yaak-mcp" }
yaak-models = { path = "crates/yaak-models" }
yaak-plugins = { path = "crates/yaak-plugins" }
yaak-query = { path = "crates/yaak-query" }
yaak-sse = { path = "crates/yaak-sse" }
yaak-sync = { path = "crates/yaak-sync" }
yaak-templates = { path = "crates/yaak-templates" }
@@ -110,8 +114,10 @@ yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
yaak-license = { path = "crates-tauri/yaak-license" }
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
yaak-proxy-helper = { path = "crates-tauri/yaak-proxy-helper" }
yaak-system-appearance = { path = "crates-tauri/yaak-system-appearance" }
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
yaak-updater = { path = "crates-tauri/yaak-updater" }
yaak-window = { path = "crates-tauri/yaak-window" }
[profile.release]
@@ -0,0 +1,99 @@
import { platform } from "@yaakapp-internal/platform";
import {
Banner,
type EditorHost,
EditorHostProvider,
EmptyStateText,
type HostBodyViewerArgs,
type HttpBodyLoader,
LoadingIcon,
type StoredValue,
type StoredValueNamespace,
} from "@yaakapp-internal/ui";
import { lazy, type ReactNode, Suspense } from "react";
import { useKeyValue } from "../hooks/useKeyValue";
import { copyToClipboard } from "../lib/copy";
import { showDialog } from "../lib/dialog";
import { showPrompt } from "../lib/prompt";
import { useDetailsBannerStoredValue } from "./core/DetailsBanner";
import { Editor } from "./core/Editor/LazyEditor";
import { Input } from "./core/Input";
import { ErrorBoundary } from "./ErrorBoundary";
import { MultipartViewer } from "./responseViewers/MultipartViewer";
import { TextViewer } from "./responseViewers/TextViewer";
import { SelectFile } from "./SelectFile";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
);
function useClientStoredValue<T extends StoredValue>(
key: string | string[] | null,
fallback: T,
namespace: StoredValueNamespace = "global",
) {
// A null key is never written by the shared components, so any stable key will do
const { value, set } = useKeyValue<T>({ namespace, key: key ?? ["tabs", "default"], fallback });
return [value ?? fallback, set] as const;
}
function renderBodyViewer({ source, mimeType, body, complete }: HostBodyViewerArgs) {
if (!mimeType?.match(/pdf/i)) return null;
return source === "response" ? (
<PdfResponseBody body={body} complete={complete} />
) : (
<PdfRequestBody body={body} />
);
}
/** Waits for the whole response, then loads the PDF from its file */
function PdfResponseBody({ body, complete }: { body: HttpBodyLoader; complete: boolean }) {
const bodyUrl = body.useUrl(complete);
if (!complete || bodyUrl.isLoading) {
return (
<EmptyStateText>
<LoadingIcon />
</EmptyStateText>
);
}
if (bodyUrl.error) {
return <Banner color="danger">{String(bodyUrl.error)}</Banner>;
}
if (bodyUrl.data == null) {
return <div>Empty response body</div>;
}
return <PdfViewer bodyUrl={bodyUrl.data} />;
}
function PdfRequestBody({ body }: { body: HttpBodyLoader }) {
const bytes = body.useBytes();
if (bytes.data == null) return null;
return (
<Suspense fallback={<LoadingIcon />}>
<PdfViewer data={bytes.data} />
</Suspense>
);
}
const clientEditorHost: EditorHost = {
Editor,
Input,
SelectFile,
showDialog,
showPrompt,
basename: (path) => platform.files.basename(path),
useStoredValue: useClientStoredValue,
useDetailsBannerStoredValue,
ErrorBoundary,
TextViewer,
MultipartViewer,
renderBodyViewer,
openUrl: (url) => platform.openUrl(url),
copy: (text) => copyToClipboard(text, { disableToast: true }),
};
/** Gives the shared editing components the client's templated editor, dialogs and storage */
export function ClientEditorHost({ children }: { children: ReactNode }) {
return <EditorHostProvider value={clientEditorHost}>{children}</EditorHostProvider>;
}
@@ -1,60 +0,0 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode, SizeTag } from "@yaakapp-internal/ui";
import { type ReactNode, useMemo } from "react";
import { useSaveResponse } from "../hooks/useSaveResponse";
import { useToggle } from "../hooks/useToggle";
import { isProbablyTextContentType } from "../lib/contentType";
import { getContentTypeFromHeaders } from "../lib/model_util";
import { getResponseBodyText } from "../lib/responseBody";
import { CopyButton } from "./CopyButton";
import { Button } from "./core/Button";
interface Props {
children: ReactNode;
response: HttpResponse;
}
const LARGE_BYTES = 2 * 1000 * 1000;
export function ConfirmLargeResponse({ children, response }: Props) {
const { mutate: saveResponse } = useSaveResponse(response);
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
const isProbablyText = useMemo(() => {
const contentType = getContentTypeFromHeaders(response.headers);
return isProbablyTextContentType(contentType);
}, [response.headers]);
const contentLength = response.contentLength ?? 0;
const isLarge = contentLength > LARGE_BYTES;
if (!showLargeResponse && isLarge) {
return (
<Banner color="primary" className="flex flex-col gap-3">
<p>
Showing responses over{" "}
<InlineCode>
<SizeTag contentLength={LARGE_BYTES} />
</InlineCode>{" "}
may impact performance
</p>
<HStack wrap space={2}>
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
Reveal Response
</Button>
<Button color="secondary" variant="border" size="xs" onClick={() => saveResponse()}>
Save to File
</Button>
{isProbablyText && (
<CopyButton
color="secondary"
variant="border"
size="xs"
text={() => getResponseBodyText({ response, filter: null })}
/>
)}
</HStack>
</Banner>
);
}
return <>{children}</>;
}
@@ -1,55 +0,0 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode, SizeTag } from "@yaakapp-internal/ui";
import { type ReactNode, useMemo } from "react";
import { getRequestBodyText as getHttpResponseRequestBodyText } from "../hooks/useHttpRequestBody";
import { useToggle } from "../hooks/useToggle";
import { isProbablyTextContentType } from "../lib/contentType";
import { getContentTypeFromHeaders } from "../lib/model_util";
import { CopyButton } from "./CopyButton";
import { Button } from "./core/Button";
interface Props {
children: ReactNode;
response: HttpResponse;
}
const LARGE_BYTES = 2 * 1000 * 1000;
export function ConfirmLargeResponseRequest({ children, response }: Props) {
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
const isProbablyText = useMemo(() => {
const contentType = getContentTypeFromHeaders(response.headers);
return isProbablyTextContentType(contentType);
}, [response.headers]);
const contentLength = response.requestContentLength ?? 0;
const isLarge = contentLength > LARGE_BYTES;
if (!showLargeResponse && isLarge) {
return (
<Banner color="primary" className="flex flex-col gap-3">
<p>
Showing content over{" "}
<InlineCode>
<SizeTag contentLength={LARGE_BYTES} />
</InlineCode>{" "}
may impact performance
</p>
<HStack wrap space={2}>
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
Reveal Request Body
</Button>
{isProbablyText && (
<CopyButton
color="secondary"
variant="border"
size="xs"
text={() => getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? "")}
/>
)}
</HStack>
</Banner>
);
}
return <>{children}</>;
}
+1 -24
View File
@@ -1,24 +1 @@
import classNames from "classnames";
import type { ReactNode } from "react";
interface Props {
children: ReactNode;
className?: string;
wrapperClassName?: string;
}
export function EmptyStateText({ children, className, wrapperClassName }: Props) {
return (
<div className={classNames("w-full h-full pb-2", wrapperClassName)}>
<div
className={classNames(
className,
"rounded-lg border border-dashed border-border-subtle",
"h-full py-2 text-text-subtlest flex items-center justify-center italic",
)}
>
{children}
</div>
</div>
);
}
export { EmptyStateText } from "@yaakapp-internal/ui";
+11 -52
View File
@@ -1,60 +1,19 @@
import { Banner, Button, InlineCode } from "@yaakapp-internal/ui";
import type { ErrorInfo, ReactNode } from "react";
import { Component, useEffect } from "react";
import { ErrorBoundary as BaseErrorBoundary } from "@yaakapp-internal/ui";
import type { ReactNode } from "react";
import { useEffect } from "react";
import { showDialog } from "../lib/dialog";
import RouteError from "./RouteError";
interface ErrorBoundaryProps {
name: string;
children: ReactNode;
export function ErrorBoundary({ name, children }: { name: string; children: ReactNode }) {
return (
<BaseErrorBoundary name={name} onShowError={showErrorDialog}>
{children}
</BaseErrorBoundary>
);
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.warn("Error caught by ErrorBoundary:", error, info);
}
render() {
if (this.state.hasError) {
return (
<Banner color="danger" className="flex items-center gap-2 overflow-auto">
<div>
Error rendering <InlineCode>{this.props.name}</InlineCode> component
</div>
<Button
className="inline-flex"
variant="border"
color="danger"
size="2xs"
onClick={() => {
showDialog({
id: "error-boundary",
render: () => <RouteError error={this.state.error} />,
});
}}
>
Show
</Button>
</Banner>
);
}
return this.props.children;
}
export function showErrorDialog(error: unknown) {
showDialog({ id: "error-boundary", render: () => <RouteError error={error} /> });
}
export function ErrorBoundaryTestThrow() {
@@ -1,46 +0,0 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { useCallback, useMemo } from "react";
import type { Pair, PairEditorProps } from "./core/PairEditor";
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
type Props = {
forceUpdateKey: string;
request: HttpRequest;
onChange: (headers: HttpRequest["body"]) => void;
};
export function FormUrlencodedEditor({ request, forceUpdateKey, onChange }: Props) {
const pairs = useMemo<Pair[]>(
() =>
(Array.isArray(request.body.form) ? request.body.form : []).map((p) => ({
enabled: !!p.enabled,
name: p.name || "",
value: p.value || "",
id: p.id,
})),
[request.body.form],
);
const handleChange = useCallback<PairEditorProps["onChange"]>(
(pairs) =>
onChange({ form: pairs.map((p) => ({ enabled: p.enabled, name: p.name, value: p.value })) }),
[onChange],
);
return (
<PairOrBulkEditor
allowMultilineValues
preferenceName="form_urlencoded"
valueAutocompleteFunctions
valueAutocompleteVariables
nameAutocompleteFunctions
nameAutocompleteVariables
namePlaceholder="entry_name"
valuePlaceholder="Value"
pairs={pairs}
onChange={handleChange}
forceUpdateKey={forceUpdateKey}
stateKey={`urlencoded.${request.id}`}
/>
);
}
+1 -180
View File
@@ -1,180 +1 @@
import type { HttpRequestHeader } from "@yaakapp-internal/models";
import { HStack, CountBadge } from "@yaakapp-internal/ui";
import { acceptLanguages } from "../lib/data/acceptLanguages";
import { cacheControlDirectives } from "../lib/data/cacheControl";
import { charsets } from "../lib/data/charsets";
import { connections } from "../lib/data/connections";
import { encodings } from "../lib/data/encodings";
import type { HeaderValuePreset } from "../lib/data/headerValuePresets";
import { headerNames } from "../lib/data/headerNames";
import { mimeTypes } from "../lib/data/mimetypes";
import { userAgents } from "../lib/data/userAgents";
import { validateHttpHeader } from "../lib/validateHttpHeader";
import { DetailsBanner } from "./core/DetailsBanner";
import type { GenericCompletion, GenericCompletionConfig } from "./core/Editor/genericCompletion";
import type { InputProps } from "./core/Input";
import type { Pair, PairEditorProps } from "./core/PairEditor";
import { PairEditorRow } from "./core/PairEditor";
import { ensurePairId } from "./core/PairEditor.util";
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
type Props = {
forceUpdateKey: string;
headers: HttpRequestHeader[];
inheritedHeaders?: HttpRequestHeader[];
inheritedHeadersLabel?: string;
stateKey: string;
onChange: (headers: HttpRequestHeader[]) => void;
label?: string;
};
export function HeadersEditor({
stateKey,
headers,
inheritedHeaders,
inheritedHeadersLabel = "Inherited",
onChange,
forceUpdateKey,
}: Props) {
// Get header names defined at current level (case-insensitive)
const currentHeaderNames = new Set(
headers.filter((h) => h.name).map((h) => h.name.toLowerCase()),
);
// Filter inherited headers: must be enabled, have content, and not be overridden by current level
const validInheritedHeaders =
inheritedHeaders?.filter(
(pair) =>
pair.enabled &&
(pair.name || pair.value) &&
!currentHeaderNames.has(pair.name.toLowerCase()),
) ?? [];
const hasInheritedHeaders = validInheritedHeaders.length > 0;
return (
<div
className={
hasInheritedHeaders
? "@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5"
: "@container w-full h-full"
}
>
{hasInheritedHeaders && (
<DetailsBanner
color="secondary"
className="text-sm"
summary={
<HStack>
{inheritedHeadersLabel} <CountBadge count={validInheritedHeaders.length} />
</HStack>
}
>
<div className="pb-2">
{validInheritedHeaders?.map((pair, i) => (
<PairEditorRow
key={`${pair.id}.${i}`}
index={i}
disabled
disableDrag
className="py-1"
pair={ensurePairId(pair)}
stateKey={null}
nameAutocompleteFunctions
nameAutocompleteVariables
valueAutocompleteFunctions
valueAutocompleteVariables
/>
))}
</div>
</DetailsBanner>
)}
<PairOrBulkEditor
forceUpdateKey={forceUpdateKey}
nameAutocomplete={nameAutocomplete}
nameAutocompleteFunctions
nameAutocompleteVariables
namePlaceholder="Header-Name"
nameValidate={validateHttpHeader}
onChange={onChange}
pairs={headers}
preferenceName="headers"
stateKey={stateKey}
valueType={valueType}
valueAutocomplete={valueAutocomplete}
valueAutocompleteFunctions
valueAutocompleteVariables
/>
</div>
);
}
const MIN_MATCH = 3;
const headerOptionsMap: Record<string, HeaderValuePreset[]> = {
"content-type": mimeTypes,
accept: ["*/*", ...mimeTypes],
"accept-encoding": encodings,
"content-encoding": encodings,
connection: connections,
"accept-charset": charsets,
"accept-language": acceptLanguages,
"cache-control": cacheControlDirectives,
"user-agent": userAgents,
pragma: ["no-cache"],
te: ["trailers", "compress", "deflate", "gzip"],
dnt: ["1", "0"],
"upgrade-insecure-requests": ["1"],
"x-requested-with": ["XMLHttpRequest"],
};
const valueType = (pair: Pair): InputProps["type"] => {
const name = pair.name.toLowerCase().trim();
if (
name.includes("authorization") ||
name.includes("api-key") ||
name.includes("access-token") ||
name.includes("auth") ||
name.includes("secret") ||
name.includes("token") ||
name === "cookie" ||
name === "set-cookie"
) {
return "password";
}
return "text";
};
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
const name = headerName.toLowerCase().trim();
const options: GenericCompletion[] =
headerOptionsMap[name]?.map((o) =>
typeof o === "string"
? {
label: o,
type: "constant",
boost: 1, // Put above other completions
}
: {
// Show the short label but insert the full value (e.g. User-Agent)
label: o.label,
apply: o.value,
type: "constant",
boost: 1,
},
) ?? [];
return { minMatch: MIN_MATCH, options };
};
const nameAutocomplete: PairEditorProps["nameAutocomplete"] = {
minMatch: MIN_MATCH,
options: headerNames.map((t) =>
typeof t === "string"
? {
label: t,
type: "constant",
boost: 1, // Put above other completions
}
: {
...t,
boost: 1, // Put above other completions
},
),
};
export { HeadersEditor } from "@yaakapp-internal/ui";
@@ -6,7 +6,7 @@ import type {
Workspace,
} from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
import { HStack, Icon, InlineCode, SegmentedControl } from "@yaakapp-internal/ui";
import { useCallback } from "react";
import { openFolderSettings } from "../commands/openFolderSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
@@ -21,7 +21,6 @@ import { IconButton } from "./core/IconButton";
import { Input, type InputProps } from "./core/Input";
import { Link } from "./core/Link";
import { RadioDropdown } from "./core/RadioDropdown";
import { SegmentedControl } from "./core/SegmentedControl";
import { DynamicForm } from "./DynamicForm";
import { EmptyStateText } from "./EmptyStateText";
+165 -194
View File
@@ -1,9 +1,16 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { getModel, patchModel, patchModelDebounced } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import {
CountBadge,
type EditablePair,
type ExtraPaneTab,
HttpRequestPaneView,
InlineCode,
languageFromContentType,
} from "@yaakapp-internal/ui";
import { atom, useAtomValue } from "jotai";
import type { CSSProperties } from "react";
import type { CSSProperties, ReactNode } from "react";
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
import { importCurl, looksLikeCurl } from "../commands/importCurl";
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
@@ -15,8 +22,8 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
import { convertRequestBody } from "../lib/requestBodyConversion";
import { fireAndForget } from "../lib/fireAndForget";
import { httpRequestToView } from "../lib/httpViews";
import {
BODY_TYPE_BINARY,
BODY_TYPE_FORM_MULTIPART,
@@ -28,28 +35,22 @@ import {
BODY_TYPE_XML,
getContentTypeFromHeaders,
} from "../lib/model_util";
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
import { convertRequestBody } from "../lib/requestBodyConversion";
import { resolvedModelName } from "../lib/resolvedModelName";
import { showToast } from "../lib/toast";
import { BinaryFileEditor } from "./BinaryFileEditor";
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
import { Editor } from "./core/Editor/LazyEditor";
import { InlineCode, languageFromContentType, CountBadge } from "@yaakapp-internal/ui";
import { PlainInput } from "./core/PlainInput";
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
import { EmptyStateText } from "./EmptyStateText";
import { FormMultipartEditor } from "./FormMultipartEditor";
import { FormUrlencodedEditor } from "./FormUrlencodedEditor";
import { HeadersEditor } from "./HeadersEditor";
import { setActiveTab } from "./core/Tabs/Tabs";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { JsonBodyEditor } from "./JsonBodyEditor";
import { MarkdownEditor } from "./MarkdownEditor";
import { RequestMethodDropdown } from "./RequestMethodDropdown";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar";
import { RequestMethodDropdown } from "./RequestMethodDropdown";
import { UrlParametersEditor } from "./UrlParameterEditor";
import { generateId } from "@yaakapp-internal/lib/generateId";
@@ -165,9 +166,9 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const hasDescription = activeRequest.description.trim().length > 0;
const tabs = useMemo<TabItem[]>(
() => [
{
const bodyTab = useMemo<TabItem>(
() =>
({
value: TAB_BODY,
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
options: {
@@ -244,36 +245,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
}
},
},
},
{
value: TAB_PARAMS,
rightSlot: <CountBadge count={urlParameterPairs.length} />,
label: "Params",
},
...headersTab,
...authTab,
{
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
{
value: TAB_DESCRIPTION,
label: "Info",
rightSlot: hasDescription && <CountBadge count={true} />,
},
],
[
activeRequest,
authTab,
contentType,
handleContentTypeChange,
hasDescription,
headersTab,
numParams,
numSettingsOverrides,
urlParameterPairs.length,
],
}) satisfies TabItem,
[activeRequest, contentType, handleContentTypeChange, numParams],
);
const { mutate: sendRequest } = useSendAnyHttpRequest();
@@ -335,154 +308,152 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
[activeRequest],
);
return (
<div
style={style}
className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1")}
>
{activeRequest && (
<>
<UrlBar
stateKey={`url.${activeRequest.id}`}
key={forceUpdateKey + urlKey}
url={activeRequest.url}
placeholder="https://example.com"
onPasteOverwrite={handlePaste}
autocomplete={autocomplete}
onSend={handleSend}
onCancel={cancelResponse}
onUrlChange={handleUrlChange}
leftSlot={
<div className="py-0.5">
<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />
</div>
}
forceUpdateKey={updateKey}
isLoading={activeResponse != null && activeResponse.state !== "closed"}
const handleJsonOrBodyTextChange = useCallback(
(text: string, bodyType: string | null) => {
// JSON writes at once, as its editor always has; other text debounces
if (bodyType === BODY_TYPE_JSON) {
return patchModel(activeRequest, { body: { ...activeRequest.body, text } });
}
return handleBodyTextChange(text);
},
[activeRequest, handleBodyTextChange],
);
const handleSendJsonCommentsChange = useCallback(
(sendJsonComments: boolean) => {
const newBody = { ...activeRequest.body };
if (sendJsonComments) {
newBody.sendJsonComments = true;
} else {
delete newBody.sendJsonComments;
}
fireAndForget(patchModel(activeRequest, { body: newBody }));
},
[activeRequest],
);
const requestView = useMemo(() => httpRequestToView(activeRequest), [activeRequest]);
const extraTabs = useMemo<ExtraPaneTab[]>(
() => [
{
tab: {
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
content: <ModelSettingsEditor model={activeRequest} />,
},
{
tab: {
value: TAB_DESCRIPTION,
label: "Info",
rightSlot: hasDescription && <CountBadge count={true} />,
},
content: (
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<PlainInput
label="Request Name"
hideLabel
forceUpdateKey={updateKey}
defaultValue={activeRequest.name}
className="font-sans text-xl! px-0!"
containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })}
/>
<MarkdownEditor
name="request-description"
placeholder="Request description"
defaultValue={activeRequest.description}
stateKey={`description.${activeRequest.id}`}
forceUpdateKey={updateKey}
onChange={(description) => patchModel(activeRequest, { description })}
/>
</div>
),
},
],
[activeRequest, hasDescription, numSettingsOverrides, updateKey],
);
const renderBody = useCallback(
(sharedEditor: ReactNode) => (
<ConfirmLargeRequestBody request={activeRequest}>
{activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
<Suspense>
<GraphQLEditor
forceUpdateKey={forceUpdateKey}
baseRequest={activeRequest}
request={activeRequest}
onChange={handleBodyChange}
/>
</Suspense>
) : activeRequest.bodyType === BODY_TYPE_BINARY ? (
<BinaryFileEditor
requestId={activeRequest.id}
contentType={contentType}
body={activeRequest.body}
onChange={(body) => patchModelDebounced(activeRequest, { body })}
onChangeContentType={handleContentTypeChange}
/>
<Tabs
ref={tabsRef}
label="Request"
tabs={tabs}
tabListClassName="mt-1 -mb-1.5"
storageKey={TABS_STORAGE_KEY}
activeTabKey={activeRequestId}
>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_HEADERS}>
<HeadersEditor
inheritedHeaders={inheritedHeaders}
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
headers={activeRequest.headers}
stateKey={`headers.${activeRequest.id}`}
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
/>
</TabContent>
<TabContent value={TAB_PARAMS}>
<UrlParametersEditor
stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs}
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
/>
</TabContent>
<TabContent value={TAB_SETTINGS}>
<ModelSettingsEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_BODY}>
<ConfirmLargeRequestBody request={activeRequest}>
{activeRequest.bodyType === BODY_TYPE_JSON ? (
<JsonBodyEditor
forceUpdateKey={forceUpdateKey}
heightMode={fullHeight ? "full" : "auto"}
request={activeRequest}
/>
) : activeRequest.bodyType === BODY_TYPE_XML ? (
<Editor
forceUpdateKey={forceUpdateKey}
autocompleteFunctions
autocompleteVariables
placeholder="..."
heightMode={fullHeight ? "full" : "auto"}
defaultValue={`${activeRequest.body?.text ?? ""}`}
language="xml"
onChange={handleBodyTextChange}
stateKey={`xml.${activeRequest.id}`}
/>
) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
<Suspense>
<GraphQLEditor
forceUpdateKey={forceUpdateKey}
baseRequest={activeRequest}
request={activeRequest}
onChange={handleBodyChange}
/>
</Suspense>
) : activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ? (
<FormUrlencodedEditor
forceUpdateKey={forceUpdateKey}
request={activeRequest}
onChange={handleBodyChange}
/>
) : activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART ? (
<FormMultipartEditor
forceUpdateKey={forceUpdateKey}
request={activeRequest}
onChange={handleBodyChange}
/>
) : activeRequest.bodyType === BODY_TYPE_BINARY ? (
<BinaryFileEditor
requestId={activeRequest.id}
contentType={contentType}
body={activeRequest.body}
onChange={(body) => patchModelDebounced(activeRequest, { body })}
onChangeContentType={handleContentTypeChange}
/>
) : typeof activeRequest.bodyType === "string" ? (
<Editor
forceUpdateKey={forceUpdateKey}
autocompleteFunctions
autocompleteVariables
language={languageFromContentType(contentType)}
placeholder="..."
heightMode={fullHeight ? "full" : "auto"}
defaultValue={`${activeRequest.body?.text ?? ""}`}
onChange={handleBodyTextChange}
stateKey={`other.${activeRequest.id}`}
/>
) : (
<EmptyStateText>No Body</EmptyStateText>
)}
</ConfirmLargeRequestBody>
</TabContent>
<TabContent value={TAB_DESCRIPTION}>
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<PlainInput
label="Request Name"
hideLabel
forceUpdateKey={updateKey}
defaultValue={activeRequest.name}
className="font-sans text-xl! px-0!"
containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })}
/>
<MarkdownEditor
name="request-description"
placeholder="Request description"
defaultValue={activeRequest.description}
stateKey={`description.${activeRequest.id}`}
forceUpdateKey={updateKey}
onChange={(description) => patchModel(activeRequest, { description })}
/>
</div>
</TabContent>
</Tabs>
</>
)}
</div>
) : (
sharedEditor
)}
</ConfirmLargeRequestBody>
),
[activeRequest, contentType, forceUpdateKey, handleBodyChange, handleContentTypeChange],
);
const renderUrlParameters = useCallback(
(pairs: EditablePair[]) => (
<UrlParametersEditor
stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={pairs}
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
/>
),
[activeRequest, forceUpdateKey, urlParametersKey],
);
return (
<HttpRequestPaneView
editable
style={style}
className={className}
fullHeight={fullHeight}
request={requestView}
tabsRef={tabsRef}
tabsStorageKey={TABS_STORAGE_KEY}
forceUpdateKey={forceUpdateKey}
urlBarKey={forceUpdateKey + urlKey}
urlForceUpdateKey={updateKey}
urlAutocomplete={autocomplete}
onUrlChange={handleUrlChange}
onUrlPasteOverwrite={handlePaste}
onSend={handleSend}
onCancel={cancelResponse}
isSending={activeResponse != null && activeResponse.state !== "closed"}
sendHotkeyAction="request.send"
focusHotkeyAction="url_bar.focus"
methodSlot={<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />}
bodyTab={bodyTab}
renderBody={renderBody}
onBodyChange={handleBodyChange}
onBodyTextChange={handleJsonOrBodyTextChange}
onSendJsonCommentsChange={handleSendJsonCommentsChange}
jsonNoticeStorageKey={activeRequest.workspaceId}
urlParameterPairs={urlParameterPairs}
renderUrlParameters={renderUrlParameters}
headersTabs={headersTab}
inheritedHeaders={inheritedHeaders}
headersForceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
onHeadersChange={(headers) => patchModelDebounced(activeRequest, { headers })}
authTabs={authTab}
authContent={<HttpAuthenticationEditor model={activeRequest} />}
extraTabs={extraTabs}
/>
);
}
+179 -358
View File
@@ -1,56 +1,36 @@
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
import {
Banner,
HStack,
Icon,
LoadingIcon,
VStack,
CountBadge,
HttpResponseDurationTag,
HttpStatusTag,
SizeTag,
type ExtraPaneTab,
type HostBodyViewerArgs,
HttpResponsePaneView,
Icon,
type ResponseTextViewerExtras,
type TextFilterApi,
Tooltip,
AudioViewer,
CsvViewer,
ImageViewer,
SvgViewer,
VideoViewer,
VStack,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ComponentType, CSSProperties } from "react";
import { lazy, Suspense, useMemo } from "react";
import type { CSSProperties } from "react";
import { useCallback, useMemo } from "react";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
import { useRequestBodyLoader, useResponseBodyLoader } from "../hooks/useHttpBodyLoaders";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
import { useResponseBodyUrl } from "../hooks/useResponseBodyUrl";
import { useRecentFilters } from "../hooks/useRecentFilters";
import { useResponseViewMode } from "../hooks/useResponseViewMode";
import { useSaveResponse } from "../hooks/useSaveResponse";
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
import { getMimeTypeFromContentType } from "../lib/contentType";
import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util";
import { ConfirmLargeResponse } from "./ConfirmLargeResponse";
import { ConfirmLargeResponseRequest } from "./ConfirmLargeResponseRequest";
import { Button } from "./core/Button";
import { httpResponseToView } from "../lib/httpViews";
import { getCookieCounts } from "../lib/model_util";
import { CopyButton } from "./CopyButton";
import { HotkeyList } from "./core/HotkeyList";
import { IconButton } from "./core/IconButton";
import { PillButton } from "./core/PillButton";
import type { TabItem } from "./core/Tabs/Tabs";
import { TabContent, Tabs } from "./core/Tabs/Tabs";
import { EmptyStateText } from "./EmptyStateText";
import { ErrorBoundary } from "./ErrorBoundary";
import { HttpResponseTimeline } from "./HttpResponseTimeline";
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
import { RequestBodyViewer } from "./RequestBodyViewer";
import { ResponseCookies } from "./ResponseCookies";
import { ResponseHeaders } from "./ResponseHeaders";
import { EventStreamViewer } from "./responseViewers/EventStreamViewer";
import { HTMLOrTextViewer } from "./responseViewers/HTMLOrTextViewer";
import { MultipartViewer } from "./responseViewers/MultipartViewer";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
);
import { RecentFiltersDropdown } from "./responseViewers/RecentFiltersDropdown";
interface Props {
style?: CSSProperties;
@@ -58,10 +38,6 @@ interface Props {
activeRequestId: string;
}
const TAB_BODY = "body";
const TAB_REQUEST = "request";
const TAB_HEADERS = "headers";
const TAB_COOKIES = "cookies";
const TAB_TIMELINE = "timeline";
export type TimelineViewMode = "timeline" | "text";
@@ -71,12 +47,11 @@ interface RedirectDropWarning {
droppedHeaders: string[];
}
/** The shared response pane, bound to the pinned response of the active request */
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
const responseEvents = useHttpResponseEvents(activeResponse);
const redirectDropWarning = useMemo(
@@ -89,280 +64,181 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
const saveResponse = useSaveResponse(activeResponse ?? null);
const copyResponse = useCopyHttpResponse(activeResponse ?? null);
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
const body = useResponseBodyLoader(activeResponse ?? null);
const requestBody = useRequestBodyLoader(activeResponse ?? null);
const textViewer = useResponseTextViewerExtras(activeResponse ?? null);
const tabs = useMemo<TabItem[]>(
() => [
{
value: TAB_BODY,
label: "Response",
options: {
value: viewMode,
onChange: setViewMode,
items: [
{ label: "Response", value: "pretty" },
...(mimeType?.startsWith("image")
? []
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
],
itemsAfter: [
{
label: "Save to File",
onSelect: saveResponse.mutate,
leftSlot: <Icon icon="save" />,
hidden: activeResponse == null || !!activeResponse.error,
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
},
{
label: "Copy Body",
onSelect: copyResponse.mutate,
leftSlot: <Icon icon="copy" />,
hidden: activeResponse == null || !!activeResponse.error,
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
},
],
},
},
{
value: TAB_REQUEST,
label: "Request",
rightSlot:
(activeResponse?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null,
},
{
value: TAB_HEADERS,
label: "Headers",
rightSlot: (
<CountBadge
count={activeResponse?.requestHeaders.length ?? 0}
count2={activeResponse?.headers.length ?? 0}
showZero
/>
),
},
{
value: TAB_COOKIES,
label: "Cookies",
rightSlot:
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
) : null,
},
{
value: TAB_TIMELINE,
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
options: {
value: timelineViewMode,
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"),
items: [
{ label: "Timeline", value: "timeline" },
{ label: "Timeline (Text)", shortLabel: "Timeline", value: "text" },
],
},
},
],
[
activeResponse?.headers,
activeResponse,
activeResponse?.error,
activeResponse?.requestContentLength,
activeResponse?.requestHeaders.length,
activeResponse?.state,
activeResponse?.status,
cookieCounts.sent,
cookieCounts.received,
copyResponse.mutate,
mimeType,
responseEvents.data?.length,
saveResponse.mutate,
setViewMode,
viewMode,
timelineViewMode,
setTimelineViewMode,
],
const responseView = useMemo(
() => (activeResponse == null ? null : httpResponseToView(activeResponse, responseEvents.data)),
[activeResponse, responseEvents.data],
);
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
const extraTabs = useMemo<ExtraPaneTab[]>(
() =>
activeResponse == null
? []
: [
{
tab: {
value: TAB_TIMELINE,
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
options: {
value: timelineViewMode,
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"),
items: [
{ label: "Timeline", value: "timeline" },
{ label: "Timeline (Text)", shortLabel: "Timeline", value: "text" },
],
},
},
content: (
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
),
},
],
[activeResponse, responseEvents.data?.length, setTimelineViewMode, timelineViewMode],
);
const renderBodyViewer = useCallback(
({ mimeType, viewMode }: HostBodyViewerArgs) =>
activeResponse != null && mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? (
<EventStreamViewer response={activeResponse} />
) : null,
[activeResponse],
);
return (
<div
<HttpResponsePaneView
style={style}
className={classNames(
className,
"x-theme-responsePane",
"max-h-full h-full",
"bg-surface rounded-md border border-border-subtle overflow-hidden",
"relative",
)}
>
{activeResponse == null ? (
className={className}
response={responseView}
body={body}
requestBody={requestBody}
emptyState={
<HotkeyList hotkeys={["request.send", "model.create", "sidebar.focus", "url_bar.focus"]} />
) : (
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
<HStack
className={classNames(
"text-text-subtle w-full shrink-0",
// Remove a bit of space because the tabs have lots too
"-mb-1.5",
)}
>
{activeResponse && (
<div
className={classNames(
"grid grid-cols-[auto_minmax(4rem,1fr)_auto]",
"cursor-default select-none",
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
}
viewMode={viewMode === "raw" ? "raw" : "pretty"}
onViewModeChange={setViewMode}
activeTabKey={activeRequestId}
onSaveBody={saveResponse.mutate}
onCopyBody={copyResponse.mutate}
onCancel={cancel.mutate}
cookieCounts={cookieCounts}
extraTabs={extraTabs}
textViewer={textViewer}
renderBodyViewer={renderBodyViewer}
renderCopyButton={renderCopyButton}
statusLineCenter={
shouldShowRedirectDropWarning ? (
<Tooltip
tabIndex={0}
className="my-auto pl-3 shrink-0 max-w-full justify-self-end overflow-hidden"
content={
<VStack alignItems="start" space={1} className="text-xs">
<span className="font-medium text-warning">Redirect changed this request</span>
{redirectDropWarning.droppedBodyCount > 0 && (
<span>
Body dropped on {redirectDropWarning.droppedBodyCount}{" "}
{redirectDropWarning.droppedBodyCount === 1 ? "redirect hop" : "redirect hops"}
</span>
)}
>
<HStack space={2} className="w-full shrink-0">
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
<HttpStatusTag showReason response={activeResponse} />
<span>&bull;</span>
<HttpResponseDurationTag response={activeResponse} />
<span>&bull;</span>
<SizeTag
contentLength={activeResponse.contentLength ?? 0}
contentLengthCompressed={activeResponse.contentLengthCompressed}
/>
</HStack>
{shouldShowRedirectDropWarning ? (
<Tooltip
tabIndex={0}
className="my-auto pl-3 shrink-0 max-w-full justify-self-end overflow-hidden"
content={
<VStack alignItems="start" space={1} className="text-xs">
<span className="font-medium text-warning">
Redirect changed this request
</span>
{redirectDropWarning.droppedBodyCount > 0 && (
<span>
Body dropped on {redirectDropWarning.droppedBodyCount}{" "}
{redirectDropWarning.droppedBodyCount === 1
? "redirect hop"
: "redirect hops"}
</span>
)}
{redirectDropWarning.droppedHeaders.length > 0 && (
<span>
Headers dropped:{" "}
<span className="font-mono">
{redirectDropWarning.droppedHeaders.join(", ")}
</span>
</span>
)}
<span className="text-text-subtle">See Timeline for details.</span>
</VStack>
}
>
<span className="inline-flex min-w-0">
<PillButton
color="warning"
className="font-sans text-sm shrink! max-w-full"
innerClassName="flex items-center"
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
>
<span className="truncate">
{getRedirectWarningLabel(redirectDropWarning)}
</span>
</PillButton>
{redirectDropWarning.droppedHeaders.length > 0 && (
<span>
Headers dropped:{" "}
<span className="font-mono">
{redirectDropWarning.droppedHeaders.join(", ")}
</span>
</Tooltip>
) : (
<span />
</span>
)}
<div className="justify-self-end shrink-0">
<RecentHttpResponsesDropdown
responses={responses}
activeResponse={activeResponse}
onPinnedResponseId={setPinnedResponseId}
/>
</div>
</div>
)}
</HStack>
<div className="overflow-hidden flex flex-col min-h-0">
{activeResponse?.error && (
<Banner color="danger" className="mx-3 mt-1 shrink-0">
{activeResponse.error}
</Banner>
)}
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
<Tabs
tabs={tabs}
label="Response"
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
tabListClassName="mt-0.5 -mb-1.5"
storageKey="http_response_tabs"
activeTabKey={activeRequestId}
>
<TabContent value={TAB_BODY}>
<ErrorBoundary name="Http Response Viewer">
<Suspense>
<ConfirmLargeResponse response={activeResponse}>
{activeResponse.state === "initialized" ? (
<EmptyStateText>
<VStack space={3}>
<HStack space={3}>
<LoadingIcon className="text-text-subtlest" />
Sending Request
</HStack>
<Button size="sm" variant="border" onClick={() => cancel.mutate()}>
Cancel
</Button>
</VStack>
</EmptyStateText>
) : activeResponse.state === "closed" &&
(activeResponse.contentLength ?? 0) === 0 ? (
<EmptyStateText>Empty</EmptyStateText>
) : mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? (
<EventStreamViewer response={activeResponse} />
) : mimeType?.match(/^image\/svg/) ? (
<HttpSvgViewer response={activeResponse} />
) : mimeType?.match(/^image/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={ImageViewer} />
) : mimeType?.match(/^audio/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={AudioViewer} />
) : mimeType?.match(/^video/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={VideoViewer} />
) : mimeType?.match(/^multipart/i) && viewMode === "pretty" ? (
<HttpMultipartViewer response={activeResponse} />
) : mimeType?.match(/pdf/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={PdfViewer} />
) : mimeType?.match(/csv|tab-separated/i) && viewMode === "pretty" ? (
<HttpCsvViewer className="pb-2" response={activeResponse} />
) : (
<HTMLOrTextViewer
textViewerClassName="-mr-2 bg-surface" // Pull to the right
response={activeResponse}
pretty={viewMode === "pretty"}
/>
)}
</ConfirmLargeResponse>
</Suspense>
</ErrorBoundary>
</TabContent>
<TabContent value={TAB_REQUEST}>
<ConfirmLargeResponseRequest response={activeResponse}>
<RequestBodyViewer response={activeResponse} />
</ConfirmLargeResponseRequest>
</TabContent>
<TabContent value={TAB_HEADERS}>
<ResponseHeaders response={activeResponse} />
</TabContent>
<TabContent value={TAB_COOKIES}>
<ResponseCookies response={activeResponse} />
</TabContent>
<TabContent value={TAB_TIMELINE}>
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
</TabContent>
</Tabs>
</div>
</div>
)}
</div>
<span className="text-text-subtle">See Timeline for details.</span>
</VStack>
}
>
<span className="inline-flex min-w-0">
<PillButton
color="warning"
className="font-sans text-sm shrink! max-w-full"
innerClassName="flex items-center"
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
>
<span className="truncate">{getRedirectWarningLabel(redirectDropWarning)}</span>
</PillButton>
</span>
</Tooltip>
) : null
}
statusLineRight={
activeResponse != null && (
<RecentHttpResponsesDropdown
responses={responses}
activeResponse={activeResponse}
onPinnedResponseId={setPinnedResponseId}
/>
)
}
/>
);
}
/** The client's additions to the response text viewer: filter history, save and copy */
function useResponseTextViewerExtras(
response: HttpResponse | null,
): ResponseTextViewerExtras | undefined {
const filterStateKey = `response.body.${response?.requestId ?? ""}`;
const { recentFilters, addFilter, removeFilter, togglePin, clearFilters } =
useRecentFilters(filterStateKey);
const saveResponse = useSaveResponse(response);
const copyResponse = useCopyHttpResponse(response);
const filterLeftSlot = useCallback(
(filter: TextFilterApi) => (
<div className="py-0.5 flex">
<RecentFiltersDropdown
recentFilters={recentFilters}
activeFilter={filter.appliedFilter}
onSelect={filter.replaceFilter}
onRemove={removeFilter}
onTogglePin={togglePin}
onClear={clearFilters}
/>
</div>
),
[recentFilters, removeFilter, togglePin, clearFilters],
);
if (response == null) return undefined;
const actionsDisabled = response.state !== "closed" && response.status >= 100;
return {
filterStateKey,
onFilterApplied: addFilter,
filterLeftSlot,
footerActions: [
<IconButton
key="save"
size="sm"
icon="save"
title="Save response to file"
disabled={actionsDisabled}
onClick={() => saveResponse.mutate()}
className="border !border-border-subtle"
/>,
<IconButton
key="copy"
size="sm"
icon="copy"
title="Copy response body"
disabled={actionsDisabled}
onClick={() => copyResponse.mutate()}
className="border !border-border-subtle"
/>,
],
};
}
function renderCopyButton(getText: () => Promise<string | null>) {
return <CopyButton color="secondary" variant="border" size="xs" text={getText} />;
}
function getRedirectDropWarning(
events: HttpResponseEvent[] | undefined,
): RedirectDropWarning | null {
@@ -410,58 +286,3 @@ function getRedirectWarningLabel(warning: RedirectDropWarning): string {
}
return "Dropped headers";
}
function EnsureCompleteResponse({
response,
Component,
}: {
response: HttpResponse;
Component: ComponentType<{ bodyUrl: string }>;
}) {
// Wait until the response has been fully-downloaded before asking for it
const complete = response.state === "closed";
const bodyUrl = useResponseBodyUrl(complete ? response : null);
if (!complete || bodyUrl.isPending) {
return (
<EmptyStateText>
<LoadingIcon />
</EmptyStateText>
);
}
if (bodyUrl.error) {
return <Banner color="danger">{String(bodyUrl.error)}</Banner>;
}
if (bodyUrl.data == null) {
return <div>Empty response body</div>;
}
return <Component bodyUrl={bodyUrl.data} />;
}
function HttpSvgViewer({ response }: { response: HttpResponse }) {
const body = useResponseBodyText({ response, filter: null });
if (!body.data) return null;
return <SvgViewer text={body.data} />;
}
function HttpCsvViewer({ response, className }: { response: HttpResponse; className?: string }) {
const body = useResponseBodyText({ response, filter: null });
return <CsvViewer text={body.data ?? null} className={className} />;
}
function HttpMultipartViewer({ response }: { response: HttpResponse }) {
const body = useResponseBodyBytes({ response });
if (body.data == null) return null;
const contentTypeHeader = getContentTypeFromHeaders(response.headers);
const boundary = contentTypeHeader?.split("boundary=")[1] ?? "unknown";
return <MultipartViewer data={body.data} boundary={boundary} idPrefix={response.id} />;
}
@@ -5,7 +5,15 @@ import {
type ImportSource,
type Workspace,
} from "@yaakapp-internal/models";
import { Banner, FormattedError, HStack, Icon, VStack, IconTooltip } from "@yaakapp-internal/ui";
import {
Banner,
FormattedError,
HStack,
Icon,
VStack,
IconTooltip,
SegmentedControl,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { errorMessage } from "../lib/errorMessage";
@@ -22,7 +30,6 @@ import { CheckboxTree } from "./core/CheckboxTree";
import { Chip } from "./core/Chip";
import { DialogFooter } from "./core/Dialog";
import { Select } from "./core/Select";
import { SegmentedControl } from "./core/SegmentedControl";
import { pluralize } from "@yaakapp-internal/lib/pluralize";
interface Props {
@@ -1,121 +0,0 @@
import { linter } from "@codemirror/lint";
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { Banner, Icon, IconTooltip } from "@yaakapp-internal/ui";
import { useCallback, useMemo } from "react";
import { useKeyValue } from "../hooks/useKeyValue";
import { fireAndForget } from "../lib/fireAndForget";
import { textLikelyContainsJsonComments } from "../lib/jsonComments";
import type { DropdownItem } from "./core/Dropdown";
import { Dropdown } from "./core/Dropdown";
import type { EditorProps } from "./core/Editor/Editor";
import { jsonParseLinter } from "./core/Editor/json-lint";
import { Editor } from "./core/Editor/LazyEditor";
import { IconButton } from "./core/IconButton";
interface Props {
forceUpdateKey: string;
heightMode: EditorProps["heightMode"];
request: HttpRequest;
}
export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
const handleChange = useCallback(
(text: string) => patchModel(request, { body: { ...request.body, text } }),
[request],
);
const autoFix = request.body?.sendJsonComments !== true;
const lintExtension = useMemo(
() =>
linter(
jsonParseLinter(
autoFix
? { allowComments: true, allowTrailingCommas: true }
: { allowComments: false, allowTrailingCommas: false },
),
),
[autoFix],
);
const hasComments = useMemo(
() => textLikelyContainsJsonComments(request.body?.text ?? ""),
[request.body?.text],
);
const { value: bannerDismissed, set: setBannerDismissed } = useKeyValue<boolean>({
namespace: "no_sync",
key: ["json-fix-3", request.workspaceId],
fallback: false,
});
const handleToggleAutoFix = useCallback(() => {
const newBody = { ...request.body };
if (autoFix) {
newBody.sendJsonComments = true;
} else {
delete newBody.sendJsonComments;
}
fireAndForget(patchModel(request, { body: newBody }));
}, [request, autoFix]);
const handleDropdownOpen = useCallback(() => {
if (!bannerDismissed) {
fireAndForget(setBannerDismissed(true));
}
}, [bannerDismissed, setBannerDismissed]);
const showBanner = hasComments && autoFix && !bannerDismissed;
const stripMessage = "Automatically strip comments and trailing commas before sending";
const actions = useMemo<EditorProps["actions"]>(
() => [
showBanner && (
<Banner color="notice" className="opacity-100! h-sm py-0! px-2! flex items-center text-xs">
<p className="inline-flex items-center gap-1 min-w-0">
<span className="truncate">Auto-fix enabled</span>
<Icon icon="arrow_right" size="sm" className="opacity-disabled" />
</p>
</Banner>
),
<div key="settings" className="opacity-100! shadow!">
<Dropdown
onOpen={handleDropdownOpen}
items={
[
{
label: "Automatically Fix JSON",
keepOpenOnSelect: true,
onSelect: handleToggleAutoFix,
rightSlot: <IconTooltip content={stripMessage} />,
leftSlot: (
<Icon icon={autoFix ? "check_square_checked" : "check_square_unchecked"} />
),
},
] satisfies DropdownItem[]
}
>
<IconButton size="sm" variant="border" icon="settings" title="JSON Settings" />
</Dropdown>
</div>,
],
[handleDropdownOpen, handleToggleAutoFix, autoFix, showBanner],
);
return (
<Editor
forceUpdateKey={forceUpdateKey}
autocompleteFunctions
autocompleteVariables
placeholder="..."
heightMode={heightMode}
defaultValue={`${request.body?.text ?? ""}`}
language="json"
onChange={handleChange}
stateKey={`json.${request.id}`}
actions={actions}
lintExtension={lintExtension}
/>
);
}
@@ -1,8 +1,8 @@
import { SegmentedControl } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useRef, useState } from "react";
import type { EditorProps } from "./core/Editor/Editor";
import { Editor } from "./core/Editor/LazyEditor";
import { SegmentedControl } from "./core/SegmentedControl";
import { Markdown } from "./Markdown";
type ViewMode = "edit" | "preview";
@@ -9,8 +9,6 @@ import {
isYesterday,
} from "date-fns";
import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
import { useKeyValue } from "../hooks/useKeyValue";
import { DismissibleBanner } from "./core/DismissibleBanner";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { IconButton } from "./core/IconButton";
@@ -27,12 +25,6 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
onPinnedResponseId,
}: Props) {
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
const movedActionsBannerId = "response-actions-moved-to-response-menu-2026-07-02-v2";
const { value: dismissedMovedActions } = useKeyValue<boolean>({
namespace: "global",
key: ["dismiss-banner", movedActionsBannerId],
fallback: false,
});
const latestResponseId = responses[0]?.id ?? "n/a";
const responseHistoryItems: DropdownItem[] = [];
let lastHistoryGroup: string | null = null;
@@ -123,20 +115,6 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
hidden: latestResponseId === activeResponse.id,
disabled: responses.length === 0,
},
{
type: "content",
hidden: dismissedMovedActions === true,
label: (
<DismissibleBanner
id={movedActionsBannerId}
color="info"
size="xs"
className="max-w-72"
>
<p>Copy and save actions moved to the Response tab menu.</p>
</DismissibleBanner>
),
},
{
type: "separator",
label: "Recent",
@@ -1,105 +0,0 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { lazy, Suspense } from "react";
import { useHttpRequestBody } from "../hooks/useHttpRequestBody";
import { getMimeTypeFromContentType } from "../lib/contentType";
import {
LoadingIcon,
languageFromContentType,
AudioViewer,
CsvViewer,
ImageViewer,
SvgViewer,
VideoViewer,
WebPageViewer,
} from "@yaakapp-internal/ui";
import { EmptyStateText } from "./EmptyStateText";
import { MultipartViewer } from "./responseViewers/MultipartViewer";
import { TextViewer } from "./responseViewers/TextViewer";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
);
interface Props {
response: HttpResponse;
}
export function RequestBodyViewer({ response }: Props) {
return <RequestBodyViewerInner key={response.id} response={response} />;
}
function RequestBodyViewerInner({ response }: Props) {
const { data, isLoading, error } = useHttpRequestBody(response);
if (isLoading) {
return (
<EmptyStateText>
<LoadingIcon />
</EmptyStateText>
);
}
if (error) {
return <EmptyStateText>Error loading request body: {error.message}</EmptyStateText>;
}
if (data?.bodyText == null || data.bodyText.length === 0) {
return <EmptyStateText>No request body</EmptyStateText>;
}
const { bodyText, body } = data;
// Try to detect language from content-type header that was sent
const contentTypeHeader = response.requestHeaders.find(
(h) => h.name.toLowerCase() === "content-type",
);
const contentType = contentTypeHeader?.value ?? null;
const mimeType = contentType ? getMimeTypeFromContentType(contentType).essence : null;
const language = languageFromContentType(contentType, bodyText);
// Route to appropriate viewer based on content type
if (mimeType?.match(/^multipart/i)) {
const boundary = contentType?.split("boundary=")[1] ?? "unknown";
// Create a copy because parseMultipart may detach the buffer
const bodyCopy = new Uint8Array(body);
return (
<MultipartViewer data={bodyCopy} boundary={boundary} idPrefix={`request.${response.id}`} />
);
}
if (mimeType?.match(/^image\/svg/i)) {
return <SvgViewer text={bodyText} />;
}
if (mimeType?.match(/^image/i)) {
return <ImageViewer data={body.buffer} />;
}
if (mimeType?.match(/^audio/i)) {
return <AudioViewer data={body} />;
}
if (mimeType?.match(/^video/i)) {
return <VideoViewer data={body} />;
}
if (mimeType?.match(/csv|tab-separated/i)) {
return <CsvViewer text={bodyText} />;
}
if (mimeType?.match(/^text\/html/i)) {
return <WebPageViewer html={bodyText} />;
}
if (mimeType?.match(/pdf/i)) {
return (
<Suspense fallback={<LoadingIcon />}>
<PdfViewer data={body} />
</Suspense>
);
}
return (
<TextViewer text={bodyText} language={language} stateKey={`request.body.${response.id}`} />
);
}
@@ -1,38 +1,20 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import classNames from "classnames";
import { memo, useCallback, useMemo } from "react";
import { showPrompt } from "../lib/prompt";
import { Button } from "./core/Button";
import type { DropdownItem } from "./core/Dropdown";
import { HttpMethodTag, HttpMethodTagRaw } from "./core/HttpMethodTag";
import { Icon } from "@yaakapp-internal/ui";
import type { RadioDropdownItem } from "./core/RadioDropdown";
import { RadioDropdown } from "./core/RadioDropdown";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { RequestMethodDropdown as BaseRequestMethodDropdown } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { memo, useCallback } from "react";
type Props = {
request: HttpRequest;
className?: string;
};
const radioItems: RadioDropdownItem<string>[] = [
"GET",
"PUT",
"POST",
"PATCH",
"DELETE",
"OPTIONS",
"QUERY",
"HEAD",
].map((m) => ({
value: m,
label: <HttpMethodTagRaw method={m} />,
}));
/** The shared method menu, writing to the request and coloured by the user's setting */
export const RequestMethodDropdown = memo(function RequestMethodDropdown({
request,
className,
}: Props) {
const settings = useAtomValue(settingsAtom);
const handleChange = useCallback(
async (method: string) => {
await patchModel(request, { method });
@@ -40,39 +22,12 @@ export const RequestMethodDropdown = memo(function RequestMethodDropdown({
[request],
);
const itemsAfter = useMemo<DropdownItem[]>(
() => [
{
key: "custom",
label: "CUSTOM",
leftSlot: <Icon icon="sparkles" />,
onSelect: async () => {
const newMethod = await showPrompt({
id: "custom-method",
label: "Http Method",
title: "Custom Method",
confirmText: "Save",
description: "Enter a custom method name",
placeholder: "CUSTOM",
});
if (newMethod == null) return;
await handleChange(newMethod);
},
},
],
[handleChange],
);
return (
<RadioDropdown
value={request.method}
items={radioItems}
itemsAfter={itemsAfter}
<BaseRequestMethodDropdown
method={request.method}
colored={settings.coloredMethods}
onChange={handleChange}
>
<Button size="xs" className={classNames(className, "text-text-subtle hover:text")}>
<HttpMethodTag request={request} noAlias />
</Button>
</RadioDropdown>
className={className}
/>
);
});
@@ -1,63 +0,0 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
import { HeadersTable, KeyValueRows } from "@yaakapp-internal/ui";
import { format, formatDistanceToNowStrict } from "date-fns";
import { DetailsBanner, useDetailsBannerStoredValue } from "./core/DetailsBanner";
import { IconButton } from "./core/IconButton";
import { KeyValueRow } from "./core/KeyValueRow";
interface Props {
response: HttpResponse;
}
export function ResponseHeaders({ response }: Props) {
return (
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
<DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}>
<KeyValueRows>
<KeyValueRow labelColor="secondary" label="Sent">
<time
dateTime={new Date(`${response.createdAt}Z`).toISOString()}
title={formatDistanceToNowStrict(`${response.createdAt}Z`, { addSuffix: true })}
>
{format(`${response.createdAt}Z`, "MMM d, yyyy, h:mm:ss a O")}
</time>
</KeyValueRow>
<KeyValueRow labelColor="secondary" label="Request URL">
<div className="flex items-center gap-1">
<span className="select-text cursor-text">{response.url}</span>
<IconButton
iconSize="sm"
className="inline-block w-auto h-auto! opacity-50 hover:opacity-100"
icon="external_link"
onClick={() => platform.openUrl(response.url)}
title="Open in browser"
/>
</div>
</KeyValueRow>
<KeyValueRow labelColor="secondary" label="Remote Address">
{response.remoteAddr ?? <span className="text-text-subtlest">--</span>}
</KeyValueRow>
<KeyValueRow labelColor="secondary" label="Version">
{response.version ?? <span className="text-text-subtlest">--</span>}
</KeyValueRow>
</KeyValueRows>
</DetailsBanner>
<HeadersTable
title="Request Headers"
headers={response.requestHeaders}
labelColor="primary"
storageKey={`${response.requestId}.request_headers`}
useStoredValue={useDetailsBannerStoredValue}
/>
<HeadersTable
defaultOpen
title="Response Headers"
headers={response.headers}
labelColor="info"
storageKey={`${response.requestId}.response_headers`}
useStoredValue={useDetailsBannerStoredValue}
/>
</div>
);
}
+64 -108
View File
@@ -1,13 +1,19 @@
import { platform } from "@yaakapp-internal/platform";
import { useLicense } from "@yaakapp-internal/license";
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
import { HeaderSize, HStack, Icon, CountBadge } from "@yaakapp-internal/ui";
import classNames from "classnames";
import {
CountBadge,
SETTINGS_TAB_GENERAL,
SETTINGS_TAB_INTERFACE,
SETTINGS_TAB_LICENSE,
SETTINGS_TAB_THEME,
SettingsDialog,
type SettingsTabDef,
} from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useKeyPressEvent } from "react-use";
import { appInfo } from "../../lib/appInfo";
import { capitalize } from "../../lib/capitalize";
import { TabContent, type TabItem, Tabs } from "../core/Tabs/Tabs";
import { SettingsAgents } from "./SettingsAgents";
import { SettingsCertificates } from "./SettingsCertificates";
import { SettingsGeneral } from "./SettingsGeneral";
@@ -24,15 +30,15 @@ interface Props {
hide?: () => void;
}
const TAB_GENERAL = "general";
const TAB_INTERFACE = "interface";
const TAB_THEME = "theme";
const TAB_GENERAL = SETTINGS_TAB_GENERAL;
const TAB_INTERFACE = SETTINGS_TAB_INTERFACE;
const TAB_THEME = SETTINGS_TAB_THEME;
const TAB_SHORTCUTS = "shortcuts";
const TAB_PROXY = "proxy";
const TAB_CERTIFICATES = "certificates";
const TAB_PLUGINS = "plugins";
const TAB_AGENTS = "agents";
const TAB_LICENSE = "license";
const TAB_LICENSE = SETTINGS_TAB_LICENSE;
const tabs = [
TAB_GENERAL,
TAB_THEME,
@@ -60,107 +66,57 @@ export default function Settings({ tab, hide }: Props) {
if (hide == null) await platform.window.close();
});
const tabDefs: Record<SettingsTab, Omit<SettingsTabDef, "value" | "label">> = {
[TAB_GENERAL]: { icon: "settings", content: <SettingsGeneral /> },
[TAB_THEME]: { icon: "palette", content: <SettingsTheme /> },
[TAB_INTERFACE]: { icon: "columns_2", content: <SettingsInterface /> },
[TAB_SHORTCUTS]: { icon: "keyboard", content: <SettingsHotkeys /> },
[TAB_PLUGINS]: {
icon: "puzzle",
rightSlot: <CountBadge count={plugins.filter((p) => p.source !== "bundled").length} />,
content: <SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />,
contentClassName: "h-full grid grid-rows-1",
},
[TAB_AGENTS]: {
icon: "sparkles",
hidden: !platform.capabilities.agents,
content: <SettingsAgents />,
},
[TAB_CERTIFICATES]: {
icon: "shield_check",
rightSlot: <CountBadge count={settings.clientCertificates.length} />,
content: <SettingsCertificates />,
},
[TAB_PROXY]: {
icon: "wifi",
rightSlot: settings.proxy?.type === "enabled" ? <CountBadge count /> : null,
content: <SettingsProxy />,
},
[TAB_LICENSE]: {
icon: "key_round",
hidden: !appInfo.featureLicense,
rightSlot:
licenseCheck.check.data?.status === "personal_use" ? (
<CountBadge count color="notice" />
) : null,
content: <SettingsLicense />,
},
};
return (
<div className={classNames("grid grid-rows-[auto_minmax(0,1fr)] h-full")}>
{hide ? (
<span />
) : (
<HeaderSize
data-tauri-drag-region
ignoreControlsSpacing
onlyXWindowControl
size="md"
className="x-theme-appHeader bg-surface text-text-subtle flex items-center justify-center border-b border-border-subtle text-sm font-semibold"
osType={platform.osType()}
hideWindowControls={settings.hideWindowControls}
useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale}
>
<HStack
space={2}
justifyContent="center"
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
>
<div className={classNames(platform.osType() === "macos" ? "text-center" : "pl-2")}>Settings</div>
</HStack>
</HeaderSize>
)}
<Tabs
layout="horizontal"
defaultValue={mainTab}
addBorders
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
label="Settings"
tabs={tabs.map(
(value): TabItem => ({
value,
label: capitalize(value),
hidden:
(!appInfo.featureLicense && value === TAB_LICENSE) ||
(!platform.capabilities.agents && value === TAB_AGENTS),
leftSlot:
value === TAB_GENERAL ? (
<Icon icon="settings" className="text-secondary" />
) : value === TAB_THEME ? (
<Icon icon="palette" className="text-secondary" />
) : value === TAB_INTERFACE ? (
<Icon icon="columns_2" className="text-secondary" />
) : value === TAB_SHORTCUTS ? (
<Icon icon="keyboard" className="text-secondary" />
) : value === TAB_CERTIFICATES ? (
<Icon icon="shield_check" className="text-secondary" />
) : value === TAB_PROXY ? (
<Icon icon="wifi" className="text-secondary" />
) : value === TAB_PLUGINS ? (
<Icon icon="puzzle" className="text-secondary" />
) : value === TAB_AGENTS ? (
<Icon icon="sparkles" className="text-secondary" />
) : value === TAB_LICENSE ? (
<Icon icon="key_round" className="text-secondary" />
) : null,
rightSlot:
value === TAB_CERTIFICATES ? (
<CountBadge count={settings.clientCertificates.length} />
) : value === TAB_PLUGINS ? (
<CountBadge count={plugins.filter((p) => p.source !== "bundled").length} />
) : value === TAB_AGENTS && settings.mcpServerEnabled ? (
<CountBadge count />
) : value === TAB_PROXY && settings.proxy?.type === "enabled" ? (
<CountBadge count />
) : value === TAB_LICENSE && licenseCheck.check.data?.status === "personal_use" ? (
<CountBadge count color="notice" />
) : null,
}),
)}
>
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-6 py-4!">
<SettingsGeneral />
</TabContent>
<TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-6 py-4!">
<SettingsInterface />
</TabContent>
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 py-4!">
<SettingsTheme />
</TabContent>
<TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 py-4!">
<SettingsHotkeys />
</TabContent>
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1">
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
</TabContent>
<TabContent value={TAB_AGENTS} className="overflow-y-auto h-full px-6 py-4!">
<SettingsAgents />
</TabContent>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 py-4!">
<SettingsProxy />
</TabContent>
<TabContent value={TAB_CERTIFICATES} className="overflow-y-auto h-full px-6 py-4!">
<SettingsCertificates />
</TabContent>
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 py-4!">
<SettingsLicense />
</TabContent>
</Tabs>
</div>
<SettingsDialog
defaultTab={mainTab}
titlebar={
hide
? null
: {
osType: platform.osType(),
hideWindowControls: settings.hideWindowControls,
useNativeTitlebar: settings.useNativeTitlebar,
interfaceScale: settings.interfaceScale,
}
}
tabs={tabs.map((value) => ({ value, label: capitalize(value), ...tabDefs[value] }))}
/>
);
}
@@ -1,145 +1,63 @@
import { useQuery } from "@tanstack/react-query";
import { settingsAtom } from "@yaakapp-internal/models";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { platform } from "@yaakapp-internal/platform";
import { Heading, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import type { AppMetaData } from "@yaakapp-internal/rpc-schema";
import { useAtomValue } from "jotai";
import type { AgentSetupStatus, AppMetaData } from "@yaakapp-internal/rpc-schema";
import { Banner, Icon, InlineCode, SegmentedControl, SettingsPage } from "@yaakapp-internal/ui";
import { useEffect } from "react";
import { useKeyValue } from "../../hooks/useKeyValue";
import { appInfo } from "../../lib/appInfo";
import { usePluginInfos } from "../../hooks/usePluginInfo";
import {
AGENT_CLIENTS,
type AgentClient,
type AgentSetup,
appSetup,
cliSetup,
skillInstallCommand,
} from "../../lib/agentSetup";
import { appInfo } from "../../lib/appInfo";
import { rpc } from "../../lib/rpc";
import { CopyIconButton } from "../CopyIconButton";
import { Button } from "../core/Button";
import { Editor } from "../core/Editor/LazyEditor";
import { IconButton } from "../core/IconButton";
import { SegmentedControl } from "../core/SegmentedControl";
import {
ModelSettingRowBoolean,
SettingRow,
SettingValue,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
import { Link } from "../core/Link";
import { SettingCommand, SettingSnippet, SettingsList, SettingsSection } from "../core/SettingRow";
import { DeprecatedPluginBanner } from "./SettingsPlugins";
const MCP_URL = "http://127.0.0.1:64343/mcp";
const CLI_INSTALL_COMMAND = "npm install -g @yaakapp/cli";
const SKILL_INSTALL_COMMAND = "yaak agent install";
const MCP_SERVER_PLUGIN = "@yaak/mcp-server";
export function SettingsAgents() {
const settings = useAtomValue(settingsAtom);
const { value: token } = useKeyValue<string | null>({
namespace: "no_sync",
key: "mcp_server_token",
fallback: null,
});
const queryClient = useQueryClient();
const { value: storedClient, set: setClient } = useKeyValue<AgentClient>({
key: "agents_setup_client",
fallback: "claude_code",
});
const client = AGENT_CLIENTS.find((c) => c.value === storedClient)?.value ?? "claude_code";
const clientLabel = AGENT_CLIENTS.find((c) => c.value === client)?.label ?? "your agent";
const cliVersion = useQuery({
queryKey: ["cli_version"],
queryFn: async () => (await rpc<AppMetaData>("cmd_metadata")).cliVersion,
initialData: appInfo.cliVersion,
});
const setupStatus = useQuery({
queryKey: ["agent_setup_status", client],
queryFn: () => rpc<AgentSetupStatus>("cmd_agent_setup_status", { agent: client }),
});
const mcpServerPlugin = usePluginInfos().find(({ info }) => info.name === MCP_SERVER_PLUGIN);
useEffect(() => {
return platform.window.onFocusChanged((focused) => {
if (!focused) return;
void queryClient.invalidateQueries({ queryKey: ["cli_version"] });
void queryClient.invalidateQueries({ queryKey: ["agent_setup_status"] });
});
}, [queryClient]);
const cliInstalled = cliVersion.data != null;
const cli = cliSetup(client);
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Agents</Heading>
<p className="text-text-subtle">
Connect AI coding agents like Claude Code and Cursor to Yaak, so they can read, edit, and
send your requests.
</p>
</div>
<SettingsList className="space-y-8">
<SettingsSection
title="Yaak CLI"
description="Lets agents use Yaak from the command line, even with the app closed."
>
<SettingRow
title="Status"
description={
cliVersion.data == null
? "The Yaak CLI is not installed."
: `Yaak CLI ${cliVersion.data} is installed.`
}
controlClassName="gap-2"
>
<IconButton
size="sm"
icon="refresh"
title="Check again"
spin={cliVersion.isFetching}
onClick={() => cliVersion.refetch()}
/>
{cliVersion.data == null && (
<Button
variant="border"
color="secondary"
size="sm"
onClick={() => platform.openUrl("https://yaak.app/docs/cli")}
rightSlot={<Icon icon="external_link" />}
>
Install CLI
</Button>
)}
</SettingRow>
{cliVersion.data == null ? (
<SettingRow title="Install" description="Install it with npm.">
<SettingValue value={CLI_INSTALL_COMMAND} />
</SettingRow>
) : (
<SettingRow
title="Agent skill"
description={
<>
Teach Claude Code, Cursor, Codex, and opencode to drive the{" "}
<InlineCode>yaak</InlineCode> command, wherever they're installed.
</>
}
>
<SettingValue value={SKILL_INSTALL_COMMAND} />
</SettingRow>
)}
</SettingsSection>
<SettingsSection
title="MCP server in the app"
description="For agents that connect over HTTP, or to act on the workspaces open in Yaak."
>
<ModelSettingRowBoolean
model={settings}
modelKey="mcpServerEnabled"
title="Enable MCP server"
description={
<>
Serve Yaak's tools at <InlineCode>{MCP_URL}</InlineCode> while Yaak is open.
</>
}
/>
{settings.mcpServerEnabled && token != null && (
<SettingRow
title="Token"
description="Agents send this as a bearer token."
controlClassName="min-w-0 max-w-[min(24rem,40vw)] gap-2"
>
<SettingValue value={token} />
</SettingRow>
)}
</SettingsSection>
<SettingsSection
title="Connect an agent"
description="Pick your agent or editor to see how to add Yaak's MCP tools to it."
>
<div className="py-4 border-b border-border-subtle">
<SettingsPage
title="Agents"
description="Connect AI coding agents like Claude Code and Cursor to Yaak."
>
<SettingsList>
<SettingsSection title={null}>
<div className="pb-4 border-b border-border-subtle">
<SegmentedControl
name="agents_setup_client"
label="Agent"
@@ -149,59 +67,65 @@ export function SettingsAgents() {
options={AGENT_CLIENTS}
/>
</div>
<SetupRow
title="Through the Yaak CLI"
description={
cliVersion.data == null
? "Recommended. Install the Yaak CLI above first."
: `Recommended. ${cliSetup(client).where}`
}
setup={cliSetup(client)}
/>
{settings.mcpServerEnabled && token != null ? (
<SetupRow
title="Through the app"
description={appSetup(client, MCP_URL, token).where}
setup={appSetup(client, MCP_URL, token)}
/>
{cliInstalled ? (
<div className="flex items-center gap-2 py-3 text-sm text-text-subtle border-b border-border-subtle">
<Icon icon="check" size="sm" color="success" />
<span>
Yaak CLI <span className="text-text">{cliVersion.data}</span> is installed.
</span>
</div>
) : (
<SettingRow
title="Through the app"
description="Turn on the MCP server above to connect this way."
>
<span />
</SettingRow>
<div className="py-4 border-b border-border-subtle">
<Banner color="info">
<div className="flex items-center gap-2 text text-text">
<Icon icon="square_terminal" color="info" />
Install the Yaak CLI first
</div>
<p className="mt-1 text-sm text-text-subtle">
Agents launch it to reach Yaak, even with the app closed.
</p>
<SettingSnippet className="mt-3" language="shell" snippet={CLI_INSTALL_COMMAND} />
<div className="mt-3 mb-1 flex items-center gap-4 text-sm">
<Link href="https://yaak.app/docs/cli">Docs</Link>
<span className="text-text-subtlest">
Checks again when you come back to Yaak.
</span>
</div>
</Banner>
</div>
)}
{mcpServerPlugin != null && (
<div className="py-4 border-b border-border-subtle">
<DeprecatedPluginBanner
plugin={mcpServerPlugin.plugin}
name={mcpServerPlugin.info.displayName}
>
The two steps below replace it. Uninstall it once your agent is connected.
</DeprecatedPluginBanner>
</div>
)}
<SettingCommand
title="Add the MCP server"
description={cli.where}
snippet={cli.snippet}
language={cli.language}
disabled={!cliInstalled}
done={setupStatus.data?.mcpServer ? "Added" : null}
/>
<SettingCommand
title="Install the skill"
description={
<>
Lets {clientLabel} run the <InlineCode>yaak</InlineCode> command directly.
</>
}
snippet={skillInstallCommand(client)}
language="shell"
disabled={!cliInstalled}
done={setupStatus.data?.skill ? "Installed" : null}
/>
</SettingsSection>
</SettingsList>
</VStack>
);
}
function SetupRow({
title,
description,
setup,
}: {
title: string;
description: string;
setup: AgentSetup;
}) {
return (
<div className="py-4 border-b border-border-subtle">
<div className="text text-text">{title}</div>
<div className="mt-1 text-sm text-text-subtle">{description}</div>
<Editor
readOnly
hideGutter
wrapLines
heightMode="auto"
stateKey={null}
language={setup.language}
defaultValue={setup.snippet}
className="mt-3 border border-border-subtle rounded-md px-2"
actions={<CopyIconButton size="sm" variant="border" text={setup.snippet} title="Copy setup" />}
/>
</div>
</SettingsPage>
);
}
@@ -1,6 +1,6 @@
import type { ClientCertificate } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
import { HStack, InlineCode, SettingsPage, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useRef } from "react";
import { showConfirmDelete } from "../../lib/confirm";
@@ -218,24 +218,19 @@ export function SettingsCertificates() {
};
return (
<VStack space={3}>
<div className="mb-3">
<HStack justifyContent="between" alignItems="start">
<div>
<Heading>Client Certificates</Heading>
<p className="text-text-subtle">
Add and manage TLS certificates on a per domain basis
</p>
</div>
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
Add Certificate
</Button>
</HStack>
</div>
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
{certificates.length > 0 && (
<SettingsPage
title="Client Certificates"
description="Add and manage TLS certificates on a per domain basis"
actions={
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
Add Certificate
</Button>
}
banner={
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
}
>
{certificates.length > 0 ? (
<VStack space={3}>
{certificates.map((cert, index) => (
<CertificateEditor
@@ -248,7 +243,7 @@ export function SettingsCertificates() {
/>
))}
</VStack>
)}
</VStack>
) : null}
</SettingsPage>
);
}
@@ -1,32 +1,17 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, VStack } from "@yaakapp-internal/ui";
import { settingsAtom } from "@yaakapp-internal/models";
import { SettingsGeneral as SharedSettingsGeneral } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
import { appInfo } from "../../lib/appInfo";
import { revealInFinderText } from "../../lib/reveal";
import { CargoFeature } from "../CargoFeature";
import { useSettingsView } from "../../hooks/useSettingsView";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { DismissibleBanner } from "../core/DismissibleBanner";
import { IconButton } from "../core/IconButton";
import { platform } from "@yaakapp-internal/platform";
import {
ModelSettingRowBoolean,
ModelSettingSelectControl,
SettingValue,
SettingRow,
SettingRowBoolean,
SettingRowSelect,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
export function SettingsGeneral() {
const settings = useAtomValue(settingsAtom);
const checkForUpdates = useCheckForUpdates();
const bound = useSettingsView();
if (settings == null) {
if (settings == null || bound == null) {
return null;
}
@@ -34,147 +19,24 @@ export function SettingsGeneral() {
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
return (
<VStack space={1.5} className="mb-4">
<div>
<Heading>General</Heading>
<p className="text-text-subtle">
Configure general settings for update behavior and more.
</p>
</div>
<div className="mt-3 mb-5">
<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />
</div>
<SettingsList className="space-y-8">
<CargoFeature feature="updater">
<SettingsSection title="Updates">
<SettingRow
title="Update Channel"
description="Choose whether Yaak should use stable releases or beta releases."
>
<div className="grid grid-cols-[12rem_auto] gap-1">
<ModelSettingSelectControl
model={settings}
modelKey="updateChannel"
label="Update Channel"
selectClassName="w-full!"
options={[
{ label: "Stable", value: "stable" },
{ label: "Beta", value: "beta" },
]}
/>
<IconButton
variant="border"
size="sm"
title="Check for updates"
icon="refresh"
spin={checkForUpdates.isPending}
onClick={() => checkForUpdates.mutateAsync()}
/>
</div>
</SettingRow>
<SettingRowSelect
title="Update Behavior"
description="Choose whether updates are installed automatically or manually."
name="autoupdate"
value={settings.autoupdate ? "auto" : "manual"}
onChange={(v) =>
patchModel(settings, { autoupdate: v === "auto" })
}
options={[
{ label: "Automatic", value: "auto" },
{ label: "Manual", value: "manual" },
]}
/>
<ModelSettingRowBoolean
model={settings}
modelKey="autoDownloadUpdates"
title="Automatically download updates"
description="Download Yaak updates in the background so they are ready to install."
disabled={!settings.autoupdate}
/>
<ModelSettingRowBoolean
model={settings}
modelKey="checkNotifications"
title="Check for notifications"
description="Periodically ping Yaak servers to check for relevant notifications."
/>
<SettingRowBoolean
title="Send anonymous usage statistics"
description="Yaak is local-first and does not collect analytics or usage data."
disabled
checked={false}
onChange={() => {}}
/>
</SettingsSection>
</CargoFeature>
<CargoFeature feature="license">
<SettingsSection title="Feedback">
<SettingRowBoolean
title="Prompt for feedback"
description="Show rare one-time prompts asking how new features are working."
checked={settings.promptFeedback}
onChange={(promptFeedback) => patchModel(settings, { promptFeedback })}
/>
</SettingsSection>
</CargoFeature>
{showWorkspaceSettingsMovedBanner && (
<SharedSettingsGeneral
view={bound.view}
callbacks={bound.callbacks}
banner={<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />}
extraSections={
showWorkspaceSettingsMovedBanner && (
<DismissibleBanner
id="workspace-settings-moved-2026-06-30"
color="info"
className="w-full p-4 max-w-xl mr-auto"
>
<p>
Workspace specific settings have moved to{" "}
<b>Workspace Settings</b>, accessible from the workspace switcher
menu.
Workspace specific settings have moved to <b>Workspace Settings</b>, accessible from
the workspace switcher menu.
</p>
</DismissibleBanner>
)}
<SettingsSection title="App Info">
<SettingRow title="Version" description="Current Yaak version.">
<SettingValue value={appInfo.version} />
</SettingRow>
<SettingRow
title="Data Directory"
description="Where Yaak stores application data."
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
>
<SettingValue
value={appInfo.appDataDir}
actions={[
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => platform.revealItemInDir(appInfo.appDataDir),
},
]}
/>
</SettingRow>
<SettingRow
title="Logs Directory"
description="Where Yaak writes application logs."
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
>
<SettingValue
value={appInfo.appLogDir}
actions={[
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => platform.revealItemInDir(appInfo.appLogDir),
},
]}
/>
</SettingRow>
</SettingsSection>
</SettingsList>
</VStack>
)
}
/>
);
}
@@ -1,8 +1,8 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import {
Heading,
HStack,
Icon,
SettingsPage,
Table,
TableBody,
TableCell,
@@ -94,59 +94,58 @@ export function SettingsHotkeys() {
}
return (
<VStack space={3} className="mb-4">
<div className="mb-3">
<Heading>Keyboard Shortcuts</Heading>
<p className="text-text-subtle">
Click the menu button to add, remove, or reset keyboard shortcuts.
</p>
</div>
<PlainInput
label="Filter"
placeholder="Filter shortcuts..."
defaultValue={filter}
onChange={setFilter}
hideLabel
containerClassName="max-w-xs"
/>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Scope</TableHeaderCell>
<TableHeaderCell>Action</TableHeaderCell>
<TableHeaderCell>Shortcut</TableHeaderCell>
<TableHeaderCell></TableHeaderCell>
</TableRow>
</TableHead>
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
<TableBody key={filter}>
{filteredActions.map((action) => (
<HotkeyRow
key={action}
action={action}
currentKeys={hotkeys[action] ?? []}
defaultKeys={defaultHotkeys[action]}
onSave={async (keys) => {
const newHotkeys = { ...settings.hotkeys };
if (arraysEqual(keys, defaultHotkeys[action])) {
// Remove from settings if it matches default (use default)
<SettingsPage
title="Keyboard Shortcuts"
description="Click the menu button to add, remove, or reset keyboard shortcuts."
>
<VStack space={3}>
<PlainInput
label="Filter"
placeholder="Filter shortcuts..."
defaultValue={filter}
onChange={setFilter}
hideLabel
containerClassName="max-w-xs"
/>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Scope</TableHeaderCell>
<TableHeaderCell>Action</TableHeaderCell>
<TableHeaderCell>Shortcut</TableHeaderCell>
<TableHeaderCell></TableHeaderCell>
</TableRow>
</TableHead>
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
<TableBody key={filter}>
{filteredActions.map((action) => (
<HotkeyRow
key={action}
action={action}
currentKeys={hotkeys[action] ?? []}
defaultKeys={defaultHotkeys[action]}
onSave={async (keys) => {
const newHotkeys = { ...settings.hotkeys };
if (arraysEqual(keys, defaultHotkeys[action])) {
// Remove from settings if it matches default (use default)
delete newHotkeys[action];
} else {
// Store the keys (including empty array to disable)
newHotkeys[action] = keys;
}
await patchModel(settings, { hotkeys: newHotkeys });
}}
onReset={async () => {
const newHotkeys = { ...settings.hotkeys };
delete newHotkeys[action];
} else {
// Store the keys (including empty array to disable)
newHotkeys[action] = keys;
}
await patchModel(settings, { hotkeys: newHotkeys });
}}
onReset={async () => {
const newHotkeys = { ...settings.hotkeys };
delete newHotkeys[action];
await patchModel(settings, { hotkeys: newHotkeys });
}}
/>
))}
</TableBody>
</Table>
</VStack>
await patchModel(settings, { hotkeys: newHotkeys });
}}
/>
))}
</TableBody>
</Table>
</VStack>
</SettingsPage>
);
}
@@ -1,59 +1,30 @@
import { platform } from "@yaakapp-internal/platform";
import { useFonts } from "@yaakapp-internal/fonts";
import { useLicense } from "@yaakapp-internal/license";
import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
import type { Settings } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { clamp, Heading, VStack } from "@yaakapp-internal/ui";
import { SettingsInterface as SharedSettingsInterface, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { useSettingsView } from "../../hooks/useSettingsView";
import { showConfirm } from "../../lib/confirm";
import { pricingUrl } from "../../lib/pricingUrl";
import { rpc } from "../../lib/rpc";
import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox";
import { Link } from "../core/Link";
import {
ModelSettingRowBoolean,
ModelSettingRowSelect,
SettingRow,
SettingRowBoolean,
SettingRowSelect,
SettingSelectControl,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const NULL_FONT_VALUE = "__NULL_FONT__";
const fontSizeOptions = [
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
].map((n) => ({ label: `${n}`, value: `${n}` }));
const keymaps: { value: EditorKeymap; label: string }[] = [
{ value: "default", label: "Default" },
{ value: "vim", label: "Vim" },
{ value: "vscode", label: "VSCode" },
{ value: "emacs", label: "Emacs" },
];
import { SettingRowBoolean, SettingRowSelect, SettingsSection } from "../core/SettingRow";
export function SettingsInterface() {
const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom);
const fonts = useFonts();
const bound = useSettingsView();
if (settings == null || workspace == null) {
if (settings == null || workspace == null || bound == null) {
return null;
}
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Interface</Heading>
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
</div>
<SettingsList className="space-y-8">
<SharedSettingsInterface
view={bound.view}
callbacks={bound.callbacks}
leadingSections={
<SettingsSection title="Workspaces">
<SettingRowSelect
title="Open workspace behavior"
@@ -78,151 +49,13 @@ export function SettingsInterface() {
]}
/>
</SettingsSection>
<SettingsSection title="Fonts">
<SettingRow
title="Interface font"
description="Font used for Yaak interface controls."
controlClassName="gap-1"
>
{fonts.data && (
<SettingSelectControl
name="uiFont"
label="Interface font"
selectClassName="w-72!"
value={settings.interfaceFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE}
options={[
{ label: "System default", value: NULL_FONT_VALUE },
...fonts.data.uiFonts.map((f) => ({ label: f, value: f })),
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
]}
onChange={async (v) => {
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
await patchModel(settings, { interfaceFont });
}}
/>
)}
<SettingSelectControl
name="interfaceFontSize"
label="Interface Font Size"
selectClassName="w-20!"
value={`${settings.interfaceFontSize}`}
defaultValue="14"
options={fontSizeOptions}
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
/>
</SettingRow>
<SettingRow
title="Editor font"
description="Font used in request and response editors."
controlClassName="gap-1"
>
{fonts.data && (
<SettingSelectControl
name="editorFont"
label="Editor font"
selectClassName="w-72!"
value={settings.editorFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE}
options={[
{ label: "System default", value: NULL_FONT_VALUE },
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
]}
onChange={async (v) => {
const editorFont = v === NULL_FONT_VALUE ? null : v;
await patchModel(settings, { editorFont });
}}
/>
)}
<SettingSelectControl
name="editorFontSize"
label="Editor Font Size"
selectClassName="w-20!"
value={`${settings.editorFontSize}`}
defaultValue="12"
options={fontSizeOptions}
onChange={(v) =>
patchModel(settings, {
editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30),
})
}
/>
</SettingRow>
</SettingsSection>
<SettingsSection title="Editor">
<ModelSettingRowSelect
model={settings}
modelKey="editorKeymap"
title="Editor keymap"
description="Keyboard shortcut preset used by text editors."
options={keymaps}
/>
<ModelSettingRowBoolean
model={settings}
modelKey="editorSoftWrap"
title="Wrap editor lines"
description="Wrap long lines in request and response editors."
/>
<ModelSettingRowBoolean
model={settings}
modelKey="coloredMethods"
title="Colorize request methods"
description="Use method-specific colors for HTTP request methods."
/>
</SettingsSection>
<SettingsSection title="Window">
<NativeTitlebarSetting settings={settings} />
{platform.osType() !== "macos" && (
<ModelSettingRowBoolean
model={settings}
modelKey="hideWindowControls"
title="Hide window controls"
description="Hide the close, maximize, and minimize controls on Windows or Linux."
/>
)}
</SettingsSection>
}
trailingSections={
<CargoFeature feature="license">
<LicenseSettings settings={settings} />
</CargoFeature>
</SettingsList>
</VStack>
);
}
function NativeTitlebarSetting({ settings }: { settings: Settings }) {
const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar);
return (
<SettingRow
title="Native title bar"
description="Use the operating system's standard title bar and window controls."
controlClassName="gap-2"
>
<Checkbox
hideLabel
size="md"
checked={nativeTitlebar}
title="Native title bar"
onChange={setNativeTitlebar}
/>
{settings.useNativeTitlebar !== nativeTitlebar && (
<Button
color="primary"
size="xs"
onClick={async () => {
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
await rpc("cmd_restart");
}}
>
Apply and Restart
</Button>
)}
</SettingRow>
}
/>
);
}
@@ -1,17 +1,6 @@
import { useLicense } from "@yaakapp-internal/license";
import { Banner, HStack, Icon, VStack } from "@yaakapp-internal/ui";
import { differenceInDays } from "date-fns";
import { formatDate } from "date-fns/format";
import { useState } from "react";
import { useToggle } from "../../hooks/useToggle";
import { pricingUrl } from "../../lib/pricingUrl";
import { SettingsLicense as SharedSettingsLicense } from "@yaakapp-internal/ui";
import { useSettingsView } from "../../hooks/useSettingsView";
import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button";
import { Link } from "../core/Link";
import { PlainInput } from "../core/PlainInput";
import { Separator } from "../core/Separator";
import { platform } from "@yaakapp-internal/platform";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
export function SettingsLicense() {
return (
@@ -22,166 +11,7 @@ export function SettingsLicense() {
}
function SettingsLicenseCmp() {
const { check, activate, deactivate } = useLicense();
const [key, setKey] = useState<string>("");
const [activateFormVisible, toggleActivateFormVisible] = useToggle(false);
if (check.isPending) {
return null;
}
const renderBanner = () => {
if (!check.data) return null;
switch (check.data.status) {
case "active":
return <Banner color="success">Your license is active 🥳</Banner>;
case "trialing":
return (
<Banner color="info" className="max-w-lg">
<p className="w-full">
<strong>
{pluralizeCount("day", differenceInDays(check.data.data.end, new Date()))}
</strong>{" "}
left to evaluate Yaak for commercial use.
<br />
<span className="opacity-50">Personal use is always free, forever.</span>
<Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
Learn More
</Link>
</div>
</p>
</Banner>
);
case "personal_use":
return (
<Banner color="notice" className="max-w-lg">
<p className="w-full">
Your commercial-use trial has ended.
<br />
<span className="opacity-50">
You may continue using Yaak for personal use only.
<br />A license is required for commercial use.
</span>
<Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
Learn More
</Link>
</div>
</p>
</Banner>
);
case "inactive":
return (
<Banner color="danger">
Your license is invalid. Please <Link href="https://yaak.app/dashboard">Sign In</Link>{" "}
for more details
</Banner>
);
case "expired":
return (
<Banner color="notice">
Your license expired{" "}
<strong>{formatDate(check.data.data.periodEnd, "MMMM dd, yyyy")}</strong>. Please{" "}
<Link href="https://yaak.app/dashboard">Resubscribe</Link> to continue receiving
updates.
{check.data.data.changesUrl && (
<>
<br />
<Link href={check.data.data.changesUrl}>What's new in latest builds</Link>
</>
)}
</Banner>
);
case "past_due":
return (
<Banner color="danger">
<strong>Your payment method needs attention.</strong>
<br />
To re-activate your license, please{" "}
<Link href={check.data.data.billingUrl}>update your billing info</Link>.
</Banner>
);
case "error":
return (
<Banner color="danger">
License check failed: {check.data.data.message} (Code: {check.data.data.code})
</Banner>
);
}
};
return (
<div className="flex flex-col gap-6 max-w-xl">
{renderBanner()}
{check.error && <Banner color="danger">{check.error}</Banner>}
{activate.error && <Banner color="danger">{activate.error}</Banner>}
{check.data?.status === "active" ? (
<HStack space={2}>
<Button variant="border" color="secondary" size="sm" onClick={() => deactivate.mutate()}>
Deactivate License
</Button>
<Button
color="secondary"
size="sm"
onClick={() => platform.openUrl("https://yaak.app/dashboard?intent=app.license.support")}
rightSlot={<Icon icon="external_link" />}
>
Direct Support
</Button>
</HStack>
) : (
<HStack space={2}>
<Button variant="border" color="secondary" size="sm" onClick={toggleActivateFormVisible}>
Activate License
</Button>
<Button
size="sm"
color="primary"
rightSlot={<Icon icon="external_link" />}
onClick={() =>
platform.openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
}
>
Purchase License
</Button>
</HStack>
)}
{activateFormVisible && (
<VStack
as="form"
space={3}
className="max-w-sm"
onSubmit={async (e) => {
e.preventDefault();
await activate.mutateAsync({ licenseKey: key });
toggleActivateFormVisible();
}}
>
<PlainInput
autoFocus
label="License Key"
name="key"
onChange={setKey}
placeholder="YK1-XXXXX-XXXXX-XXXXX-XXXXX"
/>
<Button type="submit" color="primary" size="sm" isLoading={activate.isPending}>
Submit
</Button>
</VStack>
)}
</div>
);
const bound = useSettingsView();
if (bound == null) return null;
return <SharedSettingsLicense view={bound.view} callbacks={bound.callbacks} />;
}
@@ -2,13 +2,9 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import type { Plugin } from "@yaakapp-internal/models";
import { patchModel, pluginsAtom } from "@yaakapp-internal/models";
import type { PluginVersion } from "@yaakapp-internal/plugins";
import { checkPluginUpdates, installPlugin, searchPlugins } from "@yaakapp-internal/plugins";
import {
checkPluginUpdates,
installPlugin,
searchPlugins,
uninstallPlugin,
} from "@yaakapp-internal/plugins";
import {
Banner,
HStack,
Icon,
InlineCode,
@@ -24,12 +20,11 @@ import {
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { type ReactNode, useState } from "react";
import { useInstallPlugin } from "../../hooks/useInstallPlugin";
import { usePluginInfo } from "../../hooks/usePluginInfo";
import { usePluginInfo, usePluginInfos } from "../../hooks/usePluginInfo";
import { usePluginsKey, useRefreshPlugins } from "../../hooks/usePlugins";
import { showConfirmDelete } from "../../lib/confirm";
import { minPromiseMillis } from "../../lib/minPromiseMillis";
import { usePromptUninstallPlugin } from "../../hooks/useUninstallPlugin";
import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox";
import { IconButton } from "../core/IconButton";
@@ -205,7 +200,7 @@ function PluginTableRow({
mutationKey: ["install_plugin", name],
mutationFn: (name: string) => installPlugin(name, null),
});
const uninstall = usePromptUninstall(plugin?.id ?? null, displayName);
const uninstall = usePromptUninstallPlugin(plugin?.id ?? null, displayName);
const refreshPlugins = useRefreshPlugins();
return (
@@ -296,7 +291,10 @@ function PluginSearch() {
const debouncedQuery = useDebouncedValue(query);
const results = useQuery({
queryKey: ["plugins", debouncedQuery],
queryFn: () => searchPlugins(query),
queryFn: async () => {
const { plugins } = await searchPlugins(query);
return { plugins: plugins.filter((p) => p.deprecated == null) };
},
});
return (
@@ -349,22 +347,77 @@ function InstalledPlugins({ plugins, className }: { plugins: Plugin[]; className
</EmptyStateText>
</div>
) : (
<Table scrollable className={className}>
<TableHead>
<TableRow>
<TableHeaderCell className="w-0" />
<TableHeaderCell>Display Name</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell />
</TableRow>
</TableHead>
<tbody className="divide-y divide-surface-highlight">
{plugins.map((p) => (
<PluginTableRowForInstalledPlugin key={p.id} plugin={p} />
))}
</tbody>
</Table>
<div className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)]")}>
<DeprecatedPluginBanners />
<Table scrollable>
<TableHead>
<TableRow>
<TableHeaderCell className="w-0" />
<TableHeaderCell>Display Name</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell />
</TableRow>
</TableHead>
<tbody className="divide-y divide-surface-highlight">
{plugins.map((p) => (
<PluginTableRowForInstalledPlugin key={p.id} plugin={p} />
))}
</tbody>
</Table>
</div>
);
}
function DeprecatedPluginBanners() {
const deprecated = usePluginInfos().filter(
({ plugin, info }) => plugin.source !== "bundled" && info.deprecated != null,
);
return (
<div className={classNames("flex flex-col gap-2", deprecated.length > 0 && "pb-3")}>
{deprecated.map(({ plugin, info }) => (
<DeprecatedPluginBanner key={plugin.id} plugin={plugin} name={info.displayName}>
{withInlineCode(info.deprecated ?? "")}
</DeprecatedPluginBanner>
))}
</div>
);
}
/** Backtick spans in a package.json string, set as code. */
function withInlineCode(text: string): ReactNode {
return text
.split("`")
.map((part, i) => (i % 2 === 1 ? <InlineCode key={i}>{part}</InlineCode> : part));
}
export function DeprecatedPluginBanner({
children,
name,
plugin,
}: {
children: ReactNode;
name: string;
plugin: Plugin;
}) {
const uninstall = usePromptUninstallPlugin(plugin.id, name);
return (
<Banner color="info" className="flex items-center gap-4">
<div className="min-w-0 flex-1">
<div className="text text-text">The {name} plugin is deprecated.</div>
<p className="mt-1 text-sm text-text-subtle">{children}</p>
</div>
<Button
size="xs"
variant="border"
title="Uninstall plugin"
className="shrink-0"
isLoading={uninstall?.isPending}
onClick={() => uninstall?.mutate()}
>
Uninstall
</Button>
</Banner>
);
}
@@ -393,31 +446,6 @@ function BundledPlugins({ plugins }: { plugins: Plugin[] }) {
);
}
function usePromptUninstall(pluginId: string | null, name: string) {
const mut = useMutation({
mutationKey: ["uninstall_plugin", pluginId],
mutationFn: async () => {
if (pluginId == null) return;
const confirmed = await showConfirmDelete({
id: `uninstall-plugin-${pluginId}`,
title: "Uninstall Plugin",
confirmText: "Uninstall",
description: (
<>
Permanently uninstall <InlineCode>{name}</InlineCode>?
</>
),
});
if (confirmed) {
await minPromiseMillis(uninstallPlugin(pluginId), 700);
}
},
});
return pluginId == null ? null : mut;
}
function usePluginUpdates() {
return useQuery({
queryKey: ["plugin_updates", usePluginsKey()],
@@ -1,6 +1,6 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import type { ProxySetting } from "@yaakapp-internal/models";
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
import { InlineCode, SettingsPage } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { CommercialUseBanner } from "../CommercialUseBanner";
import {
@@ -26,15 +26,11 @@ export function SettingsProxy() {
};
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Proxy</Heading>
<p className="text-text-subtle">
Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging
traffic, or routing through specific infrastructure.
</p>
</div>
<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />
<SettingsPage
title="Proxy"
description="Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging traffic, or routing through specific infrastructure."
banner={<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />}
>
<SettingsList className="space-y-8">
<SettingsSection title="Proxy">
<SettingRowSelect
@@ -160,7 +156,7 @@ export function SettingsProxy() {
</>
)}
</SettingsList>
</VStack>
</SettingsPage>
);
}
@@ -1,173 +1,15 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, HStack, Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
import { SettingsTheme as SharedSettingsTheme } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { lazy, Suspense } from "react";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { useResolvedAppearance } from "../../hooks/useResolvedAppearance";
import { useResolvedTheme } from "../../hooks/useResolvedTheme";
import type { ButtonProps } from "../core/Button";
import { IconButton } from "../core/IconButton";
import { Link } from "../core/Link";
import type { SelectProps } from "../core/Select";
import {
ModelSettingRowSelect,
SettingRowSelect,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const Editor = lazy(() => import("../core/Editor/Editor").then((m) => ({ default: m.Editor })));
const buttonColors: ButtonProps["color"][] = [
"primary",
"info",
"success",
"notice",
"warning",
"danger",
"secondary",
"default",
];
const icons: IconProps["icon"][] = [
"info",
"box",
"update",
"alert_triangle",
"arrow_big_right_dash",
"download",
"copy",
"magic_wand",
"settings",
"trash",
"sparkles",
"pencil",
"paste",
"search",
"send_horizontal",
];
import { useSettingsView } from "../../hooks/useSettingsView";
export function SettingsTheme() {
const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom);
const appearance = useResolvedAppearance();
const activeTheme = useResolvedTheme();
const bound = useSettingsView();
if (settings == null || workspace == null || activeTheme.data == null) {
if (workspace == null || bound == null) {
return null;
}
const lightThemes: SelectProps<string>["options"] = activeTheme.data.themes
.filter((theme) => !theme.dark)
.map((theme) => ({
label: theme.label,
value: theme.id,
}));
const darkThemes: SelectProps<string>["options"] = activeTheme.data.themes
.filter((theme) => theme.dark)
.map((theme) => ({
label: theme.label,
value: theme.id,
}));
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Theme</Heading>
<p className="text-text-subtle">
Make Yaak your own by selecting a theme, or{" "}
<Link href="https://yaak.app/docs/plugin-development/plugins-quick-start">
Create Your Own
</Link>
</p>
</div>
<SettingsList className="space-y-8">
<SettingsSection title="Theme">
<ModelSettingRowSelect
model={settings}
modelKey="appearance"
title="Appearance"
description="Choose whether Yaak follows your system appearance or uses a fixed mode."
options={[
{ label: "Automatic", value: "system" },
{ label: "Light", value: "light" },
{ label: "Dark", value: "dark" },
]}
/>
{(settings.appearance === "system" || settings.appearance === "light") && (
<SettingRowSelect
name="lightTheme"
title="Light theme"
description="Theme used when Yaak is in light mode."
value={activeTheme.data.light.id}
options={lightThemes}
onChange={(themeLight) => patchModel(settings, { themeLight })}
/>
)}
{(settings.appearance === "system" || settings.appearance === "dark") && (
<SettingRowSelect
name="darkTheme"
title="Dark theme"
description="Theme used when Yaak is in dark mode."
value={activeTheme.data.dark.id}
options={darkThemes}
onChange={(themeDark) => patchModel(settings, { themeDark })}
/>
)}
</SettingsSection>
<SettingsSection title="Preview">
<VStack
space={3}
className="mt-4 w-full bg-surface p-3 border border-dashed border-border-subtle rounded-sm overflow-x-auto"
>
<HStack className="text" space={1.5}>
<Icon icon={appearance === "dark" ? "moon" : "sun"} />
<strong>{activeTheme.data.active.label}</strong>
<em>(preview)</em>
</HStack>
<HStack space={1.5} className="w-full">
{buttonColors.map((c, i) => (
<IconButton
key={c}
color={c}
size="2xs"
iconSize="xs"
icon={icons[i % icons.length] ?? "info"}
iconClassName="text"
title={`${c}`}
/>
))}
{buttonColors.map((c, i) => (
<IconButton
key={c}
color={c}
variant="border"
size="2xs"
iconSize="xs"
icon={icons[i % icons.length] ?? "info"}
iconClassName="text"
title={`${c}`}
/>
))}
</HStack>
<Suspense>
<Editor
defaultValue={[
"let foo = { // Demo code editor",
' foo: ("bar" || "baz" ?? \'qux\'),',
" baz: [1, 10.2, null, false, true],",
"};",
].join("\n")}
heightMode="auto"
language="javascript"
stateKey={null}
/>
</Suspense>
</VStack>
</SettingsSection>
</SettingsList>
</VStack>
);
return <SharedSettingsTheme view={bound.view} callbacks={bound.callbacks} />;
}
+75 -39
View File
@@ -63,11 +63,9 @@ import { isSidebarFocused } from "../lib/scopes";
import { navigateToRequestOrFolderOrWorkspace } from "../lib/setWorkspaceSearchParams";
import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
import { ContextMenu, Dropdown } from "./core/Dropdown";
import type { FieldDef } from "./core/Editor/filter/extension";
import { filter } from "./core/Editor/filter/extension";
import type { Ast } from "./core/Editor/filter/query";
import { evaluate, parseQuery } from "./core/Editor/filter/query";
import { formatFieldFilter } from "./core/Editor/filter/format";
import type { Node as QueryNode, Schema } from "@yaakapp-internal/query";
import { formatField, parseQuery, QueryMatcher } from "@yaakapp-internal/query";
import { queryFilter } from "@yaakapp-internal/ui/editor";
import { HttpMethodTag } from "./core/HttpMethodTag";
import {
Icon,
@@ -112,7 +110,7 @@ function Sidebar({ className }: { className?: string }) {
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
const filterText = useAtomValue(sidebarFilterAtom);
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
const [tree, fieldValues, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
const wrapperRef = useRef<HTMLElement>(null);
const treeRef = useRef<TreeHandle>(null);
@@ -553,18 +551,18 @@ function Sidebar({ className }: { className?: string }) {
const filterCompartmentMountExtRef = useRef<Extension | null>(null);
if (filterCompartmentMountExtRef.current == null) {
filterCompartmentMountExtRef.current = filterLanguageCompartmentRef.current.of(
filter({ fields: allFields ?? [] }),
queryFilter({ schema: sidebarFilterSchema, values: fieldValues }),
);
}
useEffect(() => {
const view = filterRef.current;
if (!view) return;
const ext = filter({ fields: allFields ?? [] });
const ext = queryFilter({ schema: sidebarFilterSchema, values: fieldValues });
view.dispatch({
effects: filterLanguageCompartmentRef.current.reconfigure(ext),
});
}, [allFields]);
}, [fieldValues]);
if (tree == null || hidden) {
return null;
@@ -852,16 +850,44 @@ function setSidebarFilterText(text: string) {
jotaiStore.set(sidebarFilterAtom, { text, key: `${Math.random()}` });
}
function getSidebarSuggestionValue(ast: Ast | null) {
/**
* The fields `getItemFields` fills in, each with what it is and a term using it, as completion
* shows them. Bare words match `getItemText` by word prefix
*/
const sidebarFilterFields: [name: string, info: string, example: string][] = [
["method", "GET, POST, …", "@method:post"],
["url", "Request URL", "@url:*/users*"],
["name", "Request name", '@name:"Get user"'],
["type", "http, grpc or ws", "@type:grpc"],
["folder", "Parent folder", "@folder:auth"],
["grpc_service", "gRPC service", "@grpc_service:*Greeter"],
["grpc_method", "gRPC method", "@grpc_method:SayHello"],
];
const sidebarFilterColumns = sidebarFilterFields.map(([name]) => name);
const sidebarFilterSchema: Schema = {
fields: sidebarFilterFields.map(([name, info, example]) => ({
name,
kind: { type: "text" },
info,
example,
})),
freeText: "fuzzy",
};
function getSidebarSuggestionValue(ast: QueryNode | null, query: string) {
if (ast == null) return null;
if (ast.type === "Term" || ast.type === "Phrase") {
const value = ast.value.trim();
const value = ast.text.trim();
return value.length > 0 ? value : null;
}
if (ast.type === "Field") {
const value = ast.value.trim();
const raw =
ast.value.type === "text"
? ast.value.text
: query.slice(ast.valueSpan.start, ast.valueSpan.end);
const value = raw.trim();
return value.length > 0 ? value : null;
}
@@ -883,7 +909,7 @@ const sidebarSuggestionFieldOrder = [
];
const sidebarTreeAtom = atom<
[TreeNode<SidebarModel>, FieldDef[], SidebarFilterSuggestion[]] | null
[TreeNode<SidebarModel>, Record<string, string[]>, SidebarFilterSuggestion[]] | null
>((get) => {
const allModels = get(memoAllPotentialChildrenAtom);
const activeWorkspace = get(activeWorkspaceAtom);
@@ -904,15 +930,18 @@ const sidebarTreeAtom = atom<
return null;
}
const queryAst = parseQuery(filter.text);
const suggestionValue = getSidebarSuggestionValue(queryAst);
const suggestionValue = getSidebarSuggestionValue(parseQuery(filter.text), filter.text);
const matcher = new QueryMatcher(filter.text, sidebarFilterSchema);
// Every request goes into one flat table (its text, then a cell per column), matched in a
// single call after the tree is built
const leaves: TreeNode<SidebarModel>[] = [];
const cells: (string | null)[] = [];
// returns true if this node OR any child matches the filter
const allFields: Record<string, Set<string>> = {};
const suggestionFields = new Set<string>();
const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
const build = (node: TreeNode<SidebarModel>, depth: number) => {
const childItems = childrenMap[node.item.id] ?? [];
let matchesSelf = true;
const fields = getItemFields(node);
const model = node.item.model;
const isLeafNode = !(model === "folder" || model === "workspace");
@@ -930,12 +959,12 @@ const sidebarTreeAtom = atom<
}
}
if (queryAst != null) {
matchesSelf = isLeafNode && evaluate(queryAst, { text: getItemText(node.item), fields });
if (isLeafNode && !matcher.isEmpty) {
leaves.push(node);
cells.push(getItemText(node.item));
for (const column of sidebarFilterColumns) cells.push(fields[column] ?? null);
}
let matchesChild = false;
// Recurse to children
node.children = !isLeafNode ? [] : undefined;
@@ -953,19 +982,21 @@ const sidebarTreeAtom = atom<
for (const item of childItems) {
const childNode = { item, parent: node, depth };
const childMatches = build(childNode, depth + 1);
if (childMatches) {
matchesChild = true;
}
build(childNode, depth + 1);
node.children.push(childNode);
}
}
};
// hide node IFF nothing in its subtree matches
const anyMatch = matchesSelf || matchesChild;
node.hidden = !anyMatch;
return anyMatch;
// Hide a node IFF nothing in its subtree matches; with no query, nothing is hidden
let matched: Set<TreeNode<SidebarModel>> | null = null;
const markHidden = (node: TreeNode<SidebarModel>): boolean => {
let visible = matched == null || matched.has(node);
for (const child of node.children ?? []) {
if (markHidden(child)) visible = true;
}
node.hidden = !visible;
return visible;
};
const root: TreeNode<SidebarModel> = {
@@ -975,15 +1006,20 @@ const sidebarTreeAtom = atom<
depth: 0,
};
// Build tree and mark visibility in one pass
build(root, 1);
if (!matcher.isEmpty) {
const rows = matcher.matchingRows(sidebarFilterColumns, cells);
matched = new Set(Array.from(rows, (i) => leaves[i]!));
}
matcher.free();
markHidden(root);
const fields: FieldDef[] = [];
// Values offered after `@field:`. URLs and names are left out: nearly every one is unique,
// so a list of them is noise rather than a shortcut.
const fieldValues: Record<string, string[]> = {};
for (const [name, values] of Object.entries(allFields)) {
fields.push({
name,
values: Array.from(values).filter((v) => v.length < 20),
});
if (name === "url" || name === "name") continue;
fieldValues[name] = Array.from(values);
}
const suggestions = Array.from(suggestionFields)
.sort((a, b) => {
@@ -994,9 +1030,9 @@ const sidebarTreeAtom = atom<
})
.map((field) => ({
field,
filterText: formatFieldFilter(field, suggestionValue ?? ""),
filterText: formatField(field, suggestionValue ?? ""),
}));
return [root, fields, suggestions] as const;
return [root, fieldValues, suggestions] as const;
});
const sidebarGitStatusByModelIdAtom = atom<Record<string, GitStatus>>((get) => {
@@ -0,0 +1,152 @@
// @vitest-environment happy-dom
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import type { Workspace } from "@yaakapp-internal/models";
import type { TemplateFunction } from "@yaakapp-internal/plugins";
import type { Tokens } from "@yaakapp-internal/templates";
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeAll, beforeEach, expect, test, vi } from "vite-plus/test";
import { TemplateFunctionDialog } from "./TemplateFunctionDialog";
interface FormProps {
data: Record<string, string | boolean>;
onChange: (values: Record<string, string | boolean>) => void;
}
const mocks = vi.hoisted(() => ({
parse: vi.fn<(text: string) => Tokens>(),
print: vi.fn<(tokens: Tokens) => Promise<string>>(),
serialize: vi.fn<(tokens: Tokens) => { data: string }>(),
preview: vi.fn<({ template }: { template: string }) => { data: string }>(),
form: null as FormProps | null,
config: {
name: "timestamp.format",
args: [
{ type: "text", name: "date" },
{ type: "text", name: "format" },
],
} as TemplateFunction,
}));
vi.mock("@yaakapp-internal/templates", () => ({ parseTemplate: mocks.parse }));
vi.mock("@yaakapp-internal/ui", () => ({
HStack: ({ children }: { children: ReactNode }) => <div>{children}</div>,
InlineCode: "code",
LoadingIcon: () => null,
useDebouncedValue: (value: string) => value,
}));
vi.mock("../hooks/useActiveWorkspace", () => ({ activeWorkspaceAtom: null }));
vi.mock("../hooks/useRenderTemplate", () => ({ useRenderTemplate: mocks.preview }));
vi.mock("../hooks/useTemplateFunctionConfig", () => ({
useTemplateFunctionConfig: () => ({ data: mocks.config }),
}));
vi.mock("../hooks/useTemplateTokensToString", () => ({
templateTokensToString: mocks.print,
useTemplateTokensToString: mocks.serialize,
}));
vi.mock("../lib/dialog", () => ({ showDialog: vi.fn() }));
vi.mock("../lib/encryption", () => ({ convertTemplateToInsecure: vi.fn() }));
vi.mock("../lib/jotai", () => ({ jotaiStore: { get: vi.fn() } }));
vi.mock("../lib/setupOrConfigureEncryption", () => ({ setupOrConfigureEncryption: vi.fn() }));
vi.mock("./core/Dialog", () => ({ DialogFooter: () => <button type="submit">Save</button> }));
vi.mock("./core/IconButton", () => ({ IconButton: () => <button type="button" /> }));
vi.mock("./core/PlainInput", () => ({ PlainInput: () => null }));
vi.mock("./DynamicForm", () => ({
DYNAMIC_FORM_NULL_ARG: "__NULL__",
DynamicForm: (props: FormProps) => {
mocks.form = props;
return <input aria-label="Timestamp" readOnly value={String(props.data.date ?? "")} />;
},
}));
beforeAll(async () => {
const base = pathToFileURL(
resolve(import.meta.dirname, "../../../crates/yaak-templates/pkg") + "/",
);
const bindings = await import(new URL("yaak_templates_bg.js", base).href);
const { instance } = await WebAssembly.instantiate(
await readFile(new URL("yaak_templates_bg.wasm", base)),
{ "./yaak_templates_bg.js": bindings },
);
bindings.__wbg_set_wasm(instance.exports);
(instance.exports.__wbindgen_start as () => void)();
mocks.parse.mockImplementation(bindings.parse_template);
});
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
mocks.form = null;
// The transport returns an opaque string. Carry the AST as JSON so the assertions can
// inspect what Preview and Save receive, without duplicating the backend's printer.
mocks.serialize.mockImplementation((tokens) => ({ data: JSON.stringify(tokens) }));
mocks.preview.mockImplementation(({ template }) => ({ data: template }));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
vi.unstubAllGlobals();
});
function functionValue(tokens: Tokens) {
const first = tokens.tokens[0];
if (first?.type !== "tag" || first.val.type !== "fn") throw new Error("Expected function");
return first.val;
}
test.each([
["timestamp.offset(expression='-1h')", "${[ timestamp.offset(expression='-1h') ]}"],
["date_var", "${[ date_var ]}"],
["dates.previous", "${[ dates.previous ]}"],
["'${[ date_var ]}'", "${[ date_var ]}"],
])("Preview and Save preserve %s until that input changes", async (expression, display) => {
const initialTokens = mocks.parse(`\${[ timestamp.format(date=${expression}, format='HH:mm') ]}`);
const originalDate = functionValue(initialTokens).args[0];
const saved = vi.fn<(value: string) => void>();
mocks.print.mockResolvedValue(display);
await act(async () =>
root.render(
<TemplateFunctionDialog
templateFunction={mocks.config}
initialTokens={initialTokens}
model={{ id: "wk_test" } as Workspace}
onChange={saved}
hide={() => {}}
/>,
),
);
expect(host.querySelector<HTMLInputElement>("input")?.value).toBe(display);
async function submit() {
const form = host.querySelector("form");
if (form == null) throw new Error("Dialog did not initialize");
await act(async () => {
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
const value = saved.mock.calls.at(-1)?.[0];
expect(value).toBe(mocks.preview.mock.calls.at(-1)?.[0].template);
return functionValue(JSON.parse(value ?? "null") as Tokens).args;
}
expect((await submit())[0]).toEqual(originalDate);
await act(async () => mocks.form?.onChange({ ...mocks.form.data, format: "yyyy" }));
expect(await submit()).toEqual([
originalDate,
{ name: "format", value: { type: "str", text: "yyyy" } },
]);
const edited = "${[ timestamp.offset(expression='-2h') ]}";
await act(async () => mocks.form?.onChange({ ...mocks.form.data, date: edited }));
expect((await submit())[0]).toEqual({ name: "date", value: { type: "str", text: edited } });
await act(async () => mocks.form?.onChange({ ...mocks.form.data, date: "" }));
expect((await submit())[0]).toEqual({ name: "date", value: { type: "str", text: "" } });
});
@@ -24,8 +24,11 @@ import { showDialog } from "../lib/dialog";
import { convertTemplateToInsecure } from "../lib/encryption";
import { jotaiStore } from "../lib/jotai";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import {
collectEditableArgumentValues,
preserveUnchangedArgumentExpressions,
} from "../lib/templateFunctionArguments";
import { DialogFooter } from "./core/Dialog";
import { collectArgumentValues } from "./core/Editor/twig/util";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from "./DynamicForm";
@@ -48,7 +51,11 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
}
(async () => {
const initial = collectArgumentValues(initialTokens, templateFunction);
const initial = await collectEditableArgumentValues(
initialTokens,
templateFunction,
templateTokensToString,
);
// HACK: Replace the secure() function's encrypted `value` arg with the decrypted version so
// we can display it in the editor input.
@@ -74,6 +81,7 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
<InitializedTemplateFunctionDialog
{...props}
templateFunction={templateFunction}
initialTokens={initialTokens}
initialArgValues={initialArgValues}
/>
);
@@ -82,10 +90,11 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
function InitializedTemplateFunctionDialog({
templateFunction: { name, previewType: ogPreviewType },
initialArgValues,
initialTokens,
hide,
onChange,
model,
}: Omit<Props, "initialTokens"> & {
}: Props & {
initialArgValues: Record<string, string | boolean>;
}) {
const previewType = ogPreviewType == null ? "live" : ogPreviewType;
@@ -111,12 +120,17 @@ function InitializedTemplateFunctionDialog({
val: {
type: "fn",
name,
args: argTokens,
args: preserveUnchangedArgumentExpressions(
argTokens,
initialTokens,
initialArgValues,
argValues,
),
},
},
],
};
}, [argValues, name]);
}, [argValues, initialArgValues, initialTokens, name]);
const tagText = useTemplateTokensToString(tokens);
const templateFunction = useTemplateFunctionConfig(name, argValues, model);
+4 -41
View File
@@ -1,43 +1,6 @@
import { useAtomValue } from "jotai";
import { AnimatePresence } from "motion/react";
import type { ReactNode } from "react";
import { hideToast, toastsAtom } from "../lib/toast";
import { Toast, type ToastProps } from "./core/Toast";
import { ErrorBoundary } from "./ErrorBoundary";
import { Portal } from "@yaakapp-internal/ui";
import { Toasts as BaseToasts } from "@yaakapp-internal/ui";
import { showErrorDialog } from "./ErrorBoundary";
export type ToastInstance = {
id: string;
uniqueKey: string;
message: ReactNode;
timeout: 3000 | 5000 | 8000 | (number & {}) | null;
onClose?: ToastProps["onClose"];
} & Omit<ToastProps, "onClose" | "open" | "children" | "timeout">;
export type { ToastInstance } from "@yaakapp-internal/ui";
export const Toasts = () => {
const toasts = useAtomValue(toastsAtom);
return (
<Portal name="toasts">
<div className="absolute right-0 bottom-0 z-50">
<AnimatePresence>
{toasts.map((toast: ToastInstance) => {
const { message, uniqueKey, ...props } = toast;
return (
<ErrorBoundary key={uniqueKey} name={`Toast ${uniqueKey}`}>
<Toast
open
{...props}
// We call onClose inside actions.hide instead of passing to toast so that
// it gets called from external close calls as well
onClose={() => hideToast(toast)}
>
{message}
</Toast>
</ErrorBoundary>
);
})}
</AnimatePresence>
</div>
</Portal>
);
};
export const Toasts = () => <BaseToasts onShowError={showErrorDialog} />;
+6 -105
View File
@@ -1,113 +1,14 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import type { IconProps } from "@yaakapp-internal/ui";
import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { FormEvent, ReactNode } from "react";
import { memo, useCallback, useRef, useState } from "react";
import { useHotKey } from "../hooks/useHotKey";
import { IconButton } from "./core/IconButton";
import type { InputHandle, InputProps } from "./core/Input";
import { Input } from "./core/Input";
import { UrlBar as BaseUrlBar, type UrlBarProps } from "@yaakapp-internal/ui";
import { memo } from "react";
type Props = Pick<HttpRequest, "url"> & {
className?: string;
placeholder: string;
type Props = Omit<UrlBarProps, "sendHotkeyAction" | "focusHotkeyAction" | "readOnly"> & {
onSend: () => void;
onUrlChange: (url: string) => void;
onPaste?: (v: string) => void;
onPasteOverwrite?: InputProps["onPasteOverwrite"];
onCancel: () => void;
submitIcon?: IconProps["icon"] | null;
isLoading: boolean;
forceUpdateKey: string;
rightSlot?: ReactNode;
leftSlot?: ReactNode;
autocomplete?: InputProps["autocomplete"];
stateKey: InputProps["stateKey"];
};
export const UrlBar = memo(function UrlBar({
forceUpdateKey,
onUrlChange,
url,
placeholder,
className,
onSend,
onCancel,
onPaste,
onPasteOverwrite,
submitIcon = "send_horizontal",
autocomplete,
leftSlot,
rightSlot,
isLoading,
stateKey,
}: Props) {
const inputRef = useRef<InputHandle>(null);
const [isFocused, setIsFocused] = useState<boolean>(false);
const handleInitInputRef = useCallback((h: InputHandle | null) => {
inputRef.current = h;
}, []);
useHotKey("url_bar.focus", () => {
inputRef.current?.selectAll();
});
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isLoading) onCancel();
else onSend();
};
return (
<form onSubmit={handleSubmit} className={classNames("x-theme-urlBar", className)}>
<Input
setRef={handleInitInputRef}
autocompleteFunctions
autocompleteVariables
stateKey={stateKey}
size="sm"
wrapLines={isFocused}
hideLabel
language="url"
className="px-1.5 py-0.5"
label="Enter URL"
name="url"
autocomplete={autocomplete}
forceUpdateKey={forceUpdateKey}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onPaste={onPaste}
onPasteOverwrite={onPasteOverwrite}
onChange={onUrlChange}
defaultValue={url}
placeholder={placeholder}
leftSlot={leftSlot}
rightSlot={
<HStack space={0.5}>
{rightSlot && <div className="py-0.5 h-full">{rightSlot}</div>}
{submitIcon !== null && (
<div className="py-0.5 h-full">
<IconButton
size="xs"
iconSize="md"
title="Send Request"
type="submit"
className="w-8 mr-0.5 h-full!"
iconColor="secondary"
icon={isLoading ? "x" : submitIcon}
hotkeyAction="request.send"
onMouseDown={(e) => {
// Prevent the button from taking focus
e.preventDefault();
}}
/>
</div>
)}
</HStack>
}
/>
</form>
);
/** The shared URL bar, sending and focusing on the client's hotkeys */
export const UrlBar = memo(function UrlBar(props: Props) {
return <BaseUrlBar {...props} sendHotkeyAction="request.send" focusHotkeyAction="url_bar.focus" />;
});
@@ -1,8 +1,11 @@
import { VStack } from "@yaakapp-internal/ui";
import {
type EditablePair,
type PairEditorHandle,
type PairEditorProps,
UrlParametersEditor as BaseUrlParametersEditor,
} from "@yaakapp-internal/ui";
import { useCallback, useRef } from "react";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import type { EditablePair, PairEditorHandle, PairEditorProps } from "./core/PairEditor";
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
type Props = {
forceUpdateKey: string;
@@ -11,6 +14,7 @@ type Props = {
onChange: PairEditorProps["onChange"];
};
/** The shared parameters editor, wired to the request editor's focus events */
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
const pairEditorRef = useRef<PairEditorHandle>(null);
const handleInitPairEditorRef = useCallback((ref: PairEditorHandle) => {
@@ -33,22 +37,12 @@ export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey
);
return (
<VStack className="h-full">
<PairOrBulkEditor
setRef={handleInitPairEditorRef}
allowMultilineValues
forceUpdateKey={forceUpdateKey + urlParametersKey}
nameAutocompleteFunctions
nameAutocompleteVariables
namePlaceholder="param_name"
onChange={onChange}
pairs={pairs}
preferenceName="url_parameters"
stateKey={stateKey}
valueAutocompleteFunctions
valueAutocompleteVariables
valuePlaceholder="Value"
/>
</VStack>
<BaseUrlParametersEditor
setRef={handleInitPairEditorRef}
forceUpdateKey={forceUpdateKey + urlParametersKey}
onChange={onChange}
pairs={pairs}
stateKey={stateKey}
/>
);
}
@@ -1,13 +1,14 @@
import type { WebsocketEvent, WebsocketRequest } from "@yaakapp-internal/models";
import {
HStack,
hexdump,
HexdumpViewer,
Icon,
LoadingIcon,
VStack,
useStateWithDeps,
languageFromContentType,
} from "@yaakapp-internal/ui";
import { hexy } from "hexy";
import { useAtomValue } from "jotai";
import { useMemo, useState } from "react";
import { useFormatText } from "../hooks/useFormatText";
@@ -19,6 +20,7 @@ import {
} from "../hooks/usePinnedWebsocketConnection";
import { Button } from "./core/Button";
import { Editor } from "./core/Editor/LazyEditor";
import { renderEditor } from "./core/Editor/renderEditor";
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
import { EventViewerRow } from "./core/EventViewerRow";
import { HotkeyList } from "./core/HotkeyList";
@@ -122,10 +124,10 @@ function WebsocketEventRow({
messageType === "error"
? "alert_triangle"
: messageType === "close" || messageType === "open"
? "info"
: isServer
? "arrow_big_down_dash"
: "arrow_big_up_dash";
? "info"
: isServer
? "arrow_big_down_dash"
: "arrow_big_up_dash";
const content =
messageType === "close" ? (
@@ -172,7 +174,7 @@ function WebsocketEventDetail({
}) {
const message = useMemo(() => {
if (hexDump) {
return event.message ? hexy(event.message) : "";
return event.message ? hexdump(event.message) : "";
}
return event.message ? new TextDecoder("utf-8").decode(Uint8Array.from(event.message)) : "";
}, [event.message, hexDump]);
@@ -232,6 +234,8 @@ function WebsocketEventDetail({
</VStack>
) : event.message.length === 0 ? (
<EmptyStateText>No Content</EmptyStateText>
) : hexDump ? (
<HexdumpViewer data={event.message} renderEditor={renderEditor} />
) : (
<Editor
language={language}
+3 -15
View File
@@ -1,10 +1,10 @@
import { platform } from "@yaakapp-internal/platform";
import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
import { Banner, HeaderSize, HStack, SidebarLayout } from "@yaakapp-internal/ui";
import { Banner, HeaderSize, HeaderTint, HStack, SidebarLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import * as m from "motion/react-m";
import { useMemo, useState } from "react";
import { useState } from "react";
import {
getActiveCookieJar,
useEnsureActiveCookieJar,
@@ -61,12 +61,6 @@ export function Workspace() {
const activeEnvironment = useAtomValue(activeEnvironmentAtom);
const [floating, setFloating] = useState(false);
const environmentBgStyle = useMemo(() => {
if (activeEnvironment?.color == null) return undefined;
const background = `linear-gradient(to right, ${activeEnvironment.color} 15%, transparent 40%)`;
return { background };
}, [activeEnvironment?.color]);
if (workspaces.length === 0) {
return <RedirectToLatestWorkspace />;
}
@@ -81,13 +75,7 @@ export function Workspace() {
useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale}
>
<div className="absolute inset-0 pointer-events-none">
<div style={environmentBgStyle} className="absolute inset-0 opacity-[0.07]" />
<div
style={environmentBgStyle}
className="absolute left-0 right-0 -bottom-px h-px opacity-20"
/>
</div>
<HeaderTint color={activeEnvironment?.color ?? null} />
<WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} />
</HeaderSize>
);
@@ -0,0 +1,177 @@
// @vitest-environment happy-dom
import type { Workspace, WorkspaceMeta } from "@yaakapp-internal/models";
import { workspaceMetasAtom } from "@yaakapp-internal/models";
import { getDefaultStore, type PrimitiveAtom } from "jotai";
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test";
import { WorkspaceSettingsDialog } from "./WorkspaceSettingsDialog";
const mocks = vi.hoisted(() => ({
rpc: vi.fn<(cmd: string, payload: unknown) => Promise<unknown>>(),
mergeModelsInStore: vi.fn(),
deleteModelWithConfirm: vi.fn<(model: unknown) => Promise<boolean>>(),
workspace: {
id: "wk_test",
model: "workspace",
name: "Broken",
settingDnsOverrides: [],
} as unknown as Workspace,
workspaceMeta: {
id: "wm_test",
model: "workspace_meta",
workspaceId: "wk_test",
settingSyncDir: "/sync/broken",
encryptionKey: null,
} as WorkspaceMeta,
}));
const { workspace, workspaceMeta } = mocks;
vi.mock("@yaakapp-internal/models", async () => {
const { atom } = await import("jotai");
return {
workspacesAtom: atom([mocks.workspace]),
// Starts empty in each test: the workspace failed to load, so the store has no meta for it
workspaceMetasAtom: atom<WorkspaceMeta[]>([]),
mergeModelsInStore: mocks.mergeModelsInStore,
patchModel: vi.fn(),
};
});
vi.mock("@yaakapp-internal/ui", () => ({
Banner: ({ children }: { children: ReactNode }) => <div role="alert">{children}</div>,
CountBadge: () => null,
HStack: ({ children }: { children: ReactNode }) => <div>{children}</div>,
InlineCode: ({ children }: { children: ReactNode }) => <code>{children}</code>,
}));
vi.mock("../hooks/useAuthTab", () => ({ useAuthTab: () => [] }));
vi.mock("../hooks/useHeadersTab", () => ({ useHeadersTab: () => [] }));
vi.mock("../hooks/useInheritedHeaders", () => ({ useInheritedHeaders: () => [] }));
vi.mock("../lib/deleteModelWithConfirm", () => ({
deleteModelWithConfirm: mocks.deleteModelWithConfirm,
}));
vi.mock("../lib/dialog", () => ({ showDialog: vi.fn() }));
vi.mock("../lib/router", () => ({ router: { navigate: vi.fn() } }));
vi.mock("../lib/rpc", () => ({ rpc: mocks.rpc }));
vi.mock("./CopyIconButton", () => ({ CopyIconButton: () => null }));
vi.mock("./core/Button", () => ({
Button: ({ children, onClick }: { children: ReactNode; onClick: () => void }) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
}));
vi.mock("./core/PlainInput", () => ({
PlainInput: ({ label, defaultValue }: { label: string; defaultValue: string }) => (
<input aria-label={label} defaultValue={defaultValue} />
),
}));
vi.mock("./core/SettingRow", () => ({
SettingsList: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SettingsSection: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
// Render every tab at once so the test can see all of them
vi.mock("./core/Tabs/Tabs", () => ({
Tabs: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabContent: ({ children, value }: { children: ReactNode; value: string }) => (
<div data-tab={value}>{children}</div>
),
}));
vi.mock("./DnsOverridesEditor", () => ({ DnsOverridesEditor: () => null }));
vi.mock("./HeadersEditor", () => ({ HeadersEditor: () => null }));
vi.mock("./HttpAuthenticationEditor", () => ({ HttpAuthenticationEditor: () => null }));
vi.mock("./MarkdownEditor", () => ({ MarkdownEditor: () => null }));
vi.mock("./ModelSettingsEditor", () => ({ ModelSettingsEditor: () => null }));
vi.mock("./SyncToFilesystemSetting", () => ({
SyncToFilesystemSetting: ({ value }: { value: { filePath: string | null } }) => (
<div data-testid="sync-setting">{value.filePath}</div>
),
}));
vi.mock("./WorkspaceEncryptionSetting", () => ({
WorkspaceEncryptionSetting: () => <div data-testid="encryption-setting" />,
}));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
vi.clearAllMocks();
// Behave like the real store: merged models land in the atom the dialog reads
const store = getDefaultStore();
const metasAtom = workspaceMetasAtom as unknown as PrimitiveAtom<WorkspaceMeta[]>;
store.set(metasAtom, []);
mocks.mergeModelsInStore.mockImplementation((_model: string, models: WorkspaceMeta[]) =>
store.set(metasAtom, (prev) => [...prev, ...models]),
);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
vi.unstubAllGlobals();
});
async function render() {
await act(async () =>
root.render(<WorkspaceSettingsDialog workspaceId="wk_test" hide={() => {}} />),
);
}
function tab(value: string) {
const el = host.querySelector(`[data-tab="${value}"]`);
if (el == null) throw new Error(`No ${value} tab`);
return el;
}
function deleteButton() {
return Array.from(host.querySelectorAll("button")).find(
(b) => b.textContent === "Delete Workspace",
);
}
test("a workspace with no meta in the store still offers Delete", async () => {
mocks.rpc.mockReturnValue(new Promise(() => {}));
mocks.deleteModelWithConfirm.mockResolvedValue(false);
await render();
expect(host.textContent).not.toContain("not found");
const button = deleteButton();
expect(button).toBeDefined();
await act(async () => button?.click());
expect(mocks.deleteModelWithConfirm).toHaveBeenCalledWith(workspace, { confirmName: "Broken" });
});
test("a missing meta is fetched, put in the store, and its settings rendered", async () => {
mocks.rpc.mockResolvedValue(workspaceMeta);
await render();
expect(mocks.rpc).toHaveBeenCalledWith("cmd_get_workspace_meta", { workspaceId: "wk_test" });
expect(mocks.mergeModelsInStore).toHaveBeenCalledWith("workspace_meta", [workspaceMeta]);
const settings = tab("settings");
expect(settings.querySelector('[data-testid="sync-setting"]')?.textContent).toBe("/sync/broken");
expect(settings.querySelector('[data-testid="encryption-setting"]')).not.toBeNull();
expect(host.querySelector('[role="alert"]')).toBeNull();
});
test("a meta that cannot be loaded shows the error only where sync and encryption go", async () => {
mocks.rpc.mockRejectedValue("database is locked");
await render();
// The error is the one banner, inside the Settings tab, in place of sync and encryption
const banners = host.querySelectorAll('[role="alert"]');
expect(banners).toHaveLength(1);
expect(tab("settings").contains(banners[0] ?? null)).toBe(true);
expect(banners[0]?.textContent).toContain("database is locked");
expect(host.querySelector('[data-testid="sync-setting"]')).toBeNull();
expect(host.querySelector('[data-testid="encryption-setting"]')).toBeNull();
expect(mocks.mergeModelsInStore).not.toHaveBeenCalled();
// The workspace's own settings and Delete still render
const general = tab("general");
expect(general.querySelector<HTMLInputElement>('input[aria-label="Name"]')?.value).toBe("Broken");
expect(general.contains(deleteButton() ?? null)).toBe(true);
});
@@ -1,12 +1,20 @@
import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models";
import type { WorkspaceMeta } from "@yaakapp-internal/models";
import {
mergeModelsInStore,
patchModel,
workspaceMetasAtom,
workspacesAtom,
} from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode, CountBadge } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useEffect, useState } from "react";
import { useAuthTab } from "../hooks/useAuthTab";
import { useHeadersTab } from "../hooks/useHeadersTab";
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { showDialog } from "../lib/dialog";
import { router } from "../lib/router";
import { rpc } from "../lib/rpc";
import { CopyIconButton } from "./CopyIconButton";
import { Button } from "./core/Button";
import { PlainInput } from "./core/PlainInput";
@@ -47,6 +55,10 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
const authTab = useAuthTab(TAB_AUTH, workspace ?? null);
const headersTab = useHeadersTab(TAB_HEADERS, workspace ?? null);
const inheritedHeaders = useInheritedHeaders(workspace ?? null);
const workspaceMetaError = useEnsureWorkspaceMeta(
workspaceId,
workspace != null && workspaceMeta == null,
);
if (workspace == null) {
return (
@@ -56,13 +68,6 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
);
}
if (workspaceMeta == null)
return (
<Banner color="danger">
<InlineCode>WorkspaceMeta</InlineCode> not found for workspace
</Banner>
);
return (
<Tabs
defaultValue={tab ?? DEFAULT_TAB}
@@ -105,15 +110,26 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
<TabContent value={TAB_SETTINGS} className="overflow-y-auto h-full px-4">
<SettingsList className="space-y-8 pb-3">
<SettingsSection title={null}>
<SyncToFilesystemSetting
layout="settings"
value={{ filePath: workspaceMeta.settingSyncDir }}
onCreateNewWorkspace={hide}
onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })}
/>
<div className="mt-4">
<WorkspaceEncryptionSetting layout="settings" size="xs" />
</div>
{workspaceMeta != null ? (
<>
<SyncToFilesystemSetting
layout="settings"
value={{ filePath: workspaceMeta.settingSyncDir }}
onCreateNewWorkspace={hide}
onChange={({ filePath }) =>
patchModel(workspaceMeta, { settingSyncDir: filePath })
}
/>
<div className="mt-4">
<WorkspaceEncryptionSetting layout="settings" size="xs" />
</div>
</>
) : workspaceMetaError != null ? (
<Banner color="danger">
Sync and encryption settings are unavailable because{" "}
<InlineCode>WorkspaceMeta</InlineCode> could not be loaded: {workspaceMetaError}
</Banner>
) : null}
</SettingsSection>
<ModelSettingsEditor model={workspace} showSectionTitles />
</SettingsList>
@@ -177,6 +193,34 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
);
}
/**
* The meta row normally reaches the store when the workspace loads, which also creates it if it
* is missing. If that load failed (for example on a corrupt row elsewhere in the workspace), the
* store never gets it, so ask for it directly. Returns the error if that fails too.
*/
function useEnsureWorkspaceMeta(workspaceId: string, missing: boolean): string | null {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!missing) return;
let cancelled = false;
setError(null);
rpc<WorkspaceMeta>("cmd_get_workspace_meta", { workspaceId }).then(
(meta) => {
if (!cancelled) mergeModelsInStore("workspace_meta", [meta]);
},
(err) => {
if (!cancelled) setError(String(err));
},
);
return () => {
cancelled = true;
};
}, [workspaceId, missing]);
return error;
}
WorkspaceSettingsDialog.show = (workspaceId: string, tab?: WorkspaceSettingsTab) => {
showDialog({
id: "workspace-settings",
+2 -72
View File
@@ -1,72 +1,2 @@
import { HStack, Icon, IconTooltip } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ReactNode } from "react";
export interface CheckboxProps {
checked: boolean | "indeterminate";
title: ReactNode;
onChange: (checked: boolean) => void;
className?: string;
disabled?: boolean;
inputWrapperClassName?: string;
hideLabel?: boolean;
fullWidth?: boolean;
help?: ReactNode;
size?: "sm" | "md";
}
export function Checkbox({
checked,
onChange,
className,
inputWrapperClassName,
disabled,
title,
hideLabel,
fullWidth,
help,
size = "sm",
}: CheckboxProps) {
return (
<HStack
as="label"
alignItems="center"
space={2}
className={classNames(className, "text-text mr-auto")}
>
<div className={classNames(inputWrapperClassName, "x-theme-input", "relative flex mr-0.5")}>
<input
aria-hidden
className={classNames(
"appearance-none shrink-0 border border-border",
size === "sm" && "w-4 h-4",
size === "md" && "w-5 h-5",
"rounded-sm outline-hidden ring-0",
!disabled && "hocus:border-border-focus hocus:bg-focus/[5%]",
disabled && "border-dotted",
)}
type="checkbox"
disabled={disabled}
onChange={() => {
onChange(checked === "indeterminate" ? true : !checked);
}}
/>
<div className="absolute inset-0 flex items-center justify-center">
<Icon
size={size}
className={classNames(disabled && "opacity-disabled")}
icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"}
/>
</div>
</div>
{!hideLabel && (
<div
className={classNames("text-sm", fullWidth && "w-full", disabled && "opacity-disabled")}
>
{title}
</div>
)}
{help && <IconTooltip content={help} />}
</HStack>
);
}
export type { CheckboxProps } from "@yaakapp-internal/ui";
export { Checkbox } from "@yaakapp-internal/ui";
+2 -200
View File
@@ -1,200 +1,2 @@
import type { DialogSize } from "@yaakapp-internal/plugins";
import { Heading, HStack, Overlay } from "@yaakapp-internal/ui";
import classNames from "classnames";
import * as m from "motion/react-m";
import type { ReactNode } from "react";
import { createContext, useContext, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { Button, type ButtonProps } from "./Button";
import { IconButton } from "./IconButton";
export interface DialogProps {
children: ReactNode;
open: boolean;
onClose?: () => void;
/** Block dismissal from the backdrop, Escape key, and built-in close button. */
disableClose?: boolean;
title?: ReactNode;
description?: ReactNode;
className?: string;
size?: DialogSize;
/** Hide the built-in close button without changing backdrop or Escape behavior. */
hideX?: boolean;
noPadding?: boolean;
noScroll?: boolean;
vAlign?: "top" | "center";
}
export function Dialog({
children,
className,
size = "full",
open,
onClose,
disableClose,
title,
description,
hideX,
noPadding,
noScroll,
vAlign = "center",
}: DialogProps) {
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
const descriptionId = useMemo(
() => (description ? Math.random().toString(36).slice(2) : undefined),
[description],
);
return (
<Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
<div
role="dialog"
className={classNames(
"py-4 x-theme-dialog absolute inset-0 pointer-events-none",
"h-full flex flex-col items-center justify-center",
vAlign === "top" && "justify-start",
vAlign === "center" && "justify-center",
)}
aria-labelledby={titleId}
aria-describedby={descriptionId}
tabIndex={-1}
onKeyDown={(e) => {
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
// dialogs and can be intercepted by children if needed.
if (e.key === "Escape") {
if (!disableClose) onClose?.();
e.stopPropagation();
e.preventDefault();
}
}}
>
<m.div
initial={{ top: 5, scale: 0.97 }}
animate={{ top: 0, scale: 1 }}
className={classNames(
className,
"grid grid-rows-[auto_auto_minmax(0,1fr)_auto]",
"grid-cols-1", // must be here for inline code blocks to correctly break words
"relative bg-surface pointer-events-auto",
"rounded-lg",
"border border-border-subtle shadow-lg shadow-[rgba(0,0,0,0.1)]",
"min-h-40",
"max-w-[calc(100vw-5rem)] max-h-[calc(100vh-5rem)]",
size === "sm" && "w-120",
size === "md" && "w-200",
size === "lg" && "w-280",
size === "full" && "w-screen h-screen",
size === "dynamic" && "min-w-80 max-w-[100vw]",
)}
>
{title ? (
<Heading className="px-6 mt-4 mb-2" level={1} id={titleId}>
{title}
</Heading>
) : (
<span />
)}
{description ? (
<div className="min-h-0 px-6 text-text-subtle mb-3" id={descriptionId}>
{description}
</div>
) : (
<span />
)}
<div
className={classNames(
"h-full w-full grid grid-cols-[minmax(0,1fr)] grid-rows-1",
!noPadding && "px-6 py-2",
!noScroll && "overflow-y-auto overflow-x-hidden",
)}
>
<DialogFooterSlot.Provider value={footerEl}>{children}</DialogFooterSlot.Provider>
</div>
<div ref={setFooterEl} className="contents" />
{/*Put close at the end so that it's the last thing to be tabbed to*/}
{!disableClose && !hideX && (
<div className="ml-auto absolute right-1 top-1">
<IconButton
className="opacity-70 hover:opacity-100"
onClick={onClose}
title="Close dialog (Esc)"
aria-label="Close"
size="sm"
icon="x"
/>
</div>
)}
</m.div>
</div>
</Overlay>
);
}
const DialogFooterSlot = createContext<HTMLDivElement | null>(null);
export interface DialogFooterAction {
label: string;
onClick?: () => void;
/** Submit the form with this id, which can be anywhere in the dialog body */
form?: string;
color?: ButtonProps["color"];
variant?: ButtonProps["variant"];
disabled?: boolean;
isLoading?: boolean;
autoFocus?: boolean;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
}
/**
* The action row at the bottom of a dialog. Render it anywhere in the dialog's content and it is
* placed below the scrolling body, outside its padding. `inline` drops the divider for small
* dialogs whose body never scrolls.
*/
export function DialogFooter({
actions,
leftSlot,
inline,
}: {
actions: DialogFooterAction[];
leftSlot?: ReactNode;
inline?: boolean;
}) {
const slot = useContext(DialogFooterSlot);
const footer = (
<footer
className={classNames(
"px-6 flex items-center gap-3",
inline ? "pt-1 pb-4" : "py-3 border-t border-border-subtle",
)}
>
{leftSlot != null && <div className="mr-auto min-w-0">{leftSlot}</div>}
<HStack space={2} justifyContent="end" className="ml-auto shrink-0">
{actions.map((action, i) => (
<Button
// Labels change with state ("Cloning...", "Apply 3 Changes"), so they can't be keys
key={i}
type={action.form != null ? "submit" : "button"}
form={action.form}
color={action.color ?? "secondary"}
variant={action.variant ?? (action.color == null ? "border" : "solid")}
disabled={action.disabled}
isLoading={action.isLoading}
autoFocus={action.autoFocus}
leftSlot={action.leftSlot}
rightSlot={action.rightSlot}
onClick={action.onClick}
>
{action.label}
</Button>
))}
</HStack>
</footer>
);
return slot == null ? footer : createPortal(footer, slot);
}
export type { DialogFooterAction, DialogProps } from "@yaakapp-internal/ui";
export { Dialog, DialogFooter } from "@yaakapp-internal/ui";
File diff suppressed because it is too large. Load diff
@@ -1,78 +0,0 @@
@top Query { Expr }
@skip { space+ }
@tokens {
space { $[ \t\r\n]+ }
LParen { "(" }
RParen { ")" }
At { "@" }
Colon { ":" }
Not { "-" | "NOT" }
// Keywords (case-insensitive)
And { "AND" }
Or { "OR" }
// "quoted phrase" with simple escapes: \" and \\
Phrase { '"' (!["\\] | "\\" _)* '"' }
// Bare words run until filter syntax or whitespace. Leading '-' remains unary
// negation, but '-' may appear after the first character.
Word { ![ \t\r\n():"@-] ![ \t\r\n():"@]* }
FieldValueWord { ![ \t\r\n"] ![ \t\r\n]* }
@precedence { Not, And, Or, Word }
}
@detectDelim
// Precedence: NOT (highest) > AND > OR (lowest)
// We also allow implicit AND in your parser/evaluator, but for highlighting,
// this grammar parses explicit AND/OR/NOT + adjacency as a sequence (Seq).
Expr {
OrExpr
}
OrExpr {
AndExpr (Or AndExpr)*
}
AndExpr {
Unary (And Unary | Unary)* // allow implicit AND by adjacency: Unary Unary
}
Unary {
Not Unary
| Primary
}
Primary {
Group
| Field
| Phrase
| Term
}
Group {
LParen Expr RParen
}
Field {
FieldName Colon FieldValue
}
FieldName {
At? Word
}
FieldValue {
Phrase
| FieldValueWord
}
Term {
Word
}
@external propSource highlight from "./highlight"
@@ -1,42 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { parser } from "./filter";
function getNodeNames(input: string): string[] {
const tree = parser.parse(input);
const nodes: string[] = [];
const cursor = tree.cursor();
do {
if (cursor.name !== "Query") {
nodes.push(cursor.name);
}
} while (cursor.next());
return nodes;
}
describe("filter grammar", () => {
test("parses URL-like field values as one value", () => {
const nodes = getNodeNames("@url:yaak.app/foo-bar");
expect(nodes).not.toContain("⚠");
expect(nodes).toContain("FieldValue");
expect(nodes).toContain("FieldValueWord");
});
test("parses punctuation-heavy field values as one value", () => {
const nodes = getNodeNames("@url:yaa$&#*@tsrna(*)");
expect(nodes).not.toContain("⚠");
expect(nodes).toContain("FieldValue");
expect(nodes).toContain("FieldValueWord");
});
test("parses operator-looking field values as one value", () => {
const negativeValueNodes = getNodeNames("@url:-foo");
const operatorWordNodes = getNodeNames("@url:AND");
expect(negativeValueNodes).not.toContain("⚠");
expect(negativeValueNodes).toContain("FieldValueWord");
expect(operatorWordNodes).not.toContain("⚠");
expect(operatorWordNodes).toContain("FieldValueWord");
});
});
@@ -1,22 +0,0 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {highlight} from "./highlight"
export const parser = LRParser.deserialize({
version: 14,
states: "%WOVQPOOPhOPOOOVQPO'#CfOmQPO'#ChO!_QPO'#ChO!dQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!iQPO'#C`O!yQPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cr'#CrP#UOPO)C>lO#]QPO,59QOOQO,59S,59SO#bQQO,59ROOQO,58{,58{OVQPO'#CsOOQO'#Cs'#CsO#jQPO,58zOVQPO'#CtO#zQPO,58yPOOO-E6p-E6pOOQO1G.l1G.lOOQO'#Cl'#ClOOQO1G.m1G.mOOQO,59_,59_OOQO-E6q-E6qOOQO,59`,59`OOQO-E6r-E6r",
stateData: "$]~OkPQ~OUVOXQO]SO^ROaUO~Ok]O~OUcXXcX]cX^cX_[XacXdcXecXicXWcX~O^`O~O_aO~OdcOeSXiSXWSX~PVOefOiRXWRX~Ok]O~Qj]WiO~OajObjO~OdcOeSaiSaWSa~PVOefOiRaWRa~OUde^e~",
goto: "#^iPPjpt{P![PP!e!e!nPPP!wPP!ePP!z#Q#WQ[OR_QTZOQSYOQRnfUXOQfQbVSdXeRlc_WOQVXcef_UOQVXcef_TOQVXcefRkaQ^PRh^QeXRmeQgYRog",
nodeNames: "⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName At Word Colon FieldValue Phrase FieldValueWord Term And Or",
maxTerm: 27,
nodeProps: [
["openedBy", 8,"LParen"],
["closedBy", 9,"RParen"]
],
propSources: [highlight],
skippedNodes: [0,22],
repeatNodeCount: 3,
tokenData: "2h~RiOX!pXY$hYZ$hZ]!p]^$h^p!ppq$hqr!prs$ysx!pxy&gyz'Qz}!p}!O'k!O![!p![!](U!]!b!p!b!c(o!c!d)Y!d!p!p!p!q,q!q!r0Y!r;'S!p;'S;=`$b<%lO!pR!w^bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pQ#xUbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sQ$_P;=`<%l#sR$eP;=`<%l!p~$mSk~XY$hYZ$h]^$hpq$h~$|VOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a<%lO$y~%hOa~~%kRO;'S$y;'S;=`%t;=`O$y~%wWOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a;=`<%l$y<%lO$y~&dP;=`<%l$yR&nUbQXPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'XUbQWPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'rUbQUPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(]U_PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(vU]PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR)a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!p!p!p!q*c!q;'S!p;'S;=`$b<%lO!pR*j`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!f!p!f!g+l!g;'S!p;'S;=`$b<%lO!pR+u^bQdP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR,x`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!q!p!q!r-z!r;'S!p;'S;=`$b<%lO!pR.R`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!v!p!v!w/T!w;'S!p;'S;=`$b<%lO!pR/^^bQUP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR0a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!t!p!t!u1c!u;'S!p;'S;=`$b<%lO!pR1l^bQeP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!p",
tokenizers: [0, 1],
topRules: {"Query":[0,1]},
tokenPrec: 145
})
@@ -1,43 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { formatFieldFilter } from "./format";
import { evaluate, parseQuery } from "./query";
function matchesFormattedUrl(value: string) {
return evaluate(parseQuery(formatFieldFilter("url", value)), {
fields: { url: value },
});
}
describe("formatFieldFilter", () => {
test("keeps URL-like values bare", () => {
expect(formatFieldFilter("url", "yaak.app/foo-bar")).toBe("@url:yaak.app/foo-bar");
expect(matchesFormattedUrl("yaak.app/foo-bar")).toBe(true);
});
test("keeps non-syntax punctuation bare", () => {
expect(formatFieldFilter("url", "yaa$&#*@tsrna(*)")).toBe("@url:yaa$&#*@tsrna(*)");
expect(matchesFormattedUrl("yaa$&#*@tsrna(*)")).toBe(true);
});
test("keeps values that start with an operator token bare", () => {
expect(formatFieldFilter("url", "-foo")).toBe("@url:-foo");
expect(matchesFormattedUrl("-foo")).toBe(true);
});
test("keeps boolean operator words bare", () => {
expect(formatFieldFilter("url", "AND")).toBe("@url:AND");
expect(formatFieldFilter("url", "or")).toBe("@url:or");
expect(formatFieldFilter("url", "Not")).toBe("@url:Not");
expect(matchesFormattedUrl("AND")).toBe(true);
});
test("escapes quoted values", () => {
expect(formatFieldFilter("url", 'say "hi"')).toBe('@url:"say \\"hi\\""');
expect(matchesFormattedUrl('say "hi"')).toBe(true);
});
test("quotes values that start with a quote", () => {
expect(formatFieldFilter("url", '"hi"')).toBe('@url:"\\"hi\\""');
expect(matchesFormattedUrl('"hi"')).toBe(true);
});
});
@@ -1,7 +0,0 @@
const bareFieldValue = /^[^\s"]\S*$/;
export function formatFieldFilter(field: string, value: string) {
const escapedValue = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const filterValue = bareFieldValue.test(value) ? value : `"${escapedValue}"`;
return `@${field}:${filterValue}`;
}
@@ -1,22 +0,0 @@
import { styleTags, tags as t } from "@lezer/highlight";
export const highlight = styleTags({
// Boolean operators
And: t.operatorKeyword,
Or: t.operatorKeyword,
Not: t.operatorKeyword,
// Structural punctuation
LParen: t.paren,
RParen: t.paren,
Colon: t.punctuation,
Minus: t.operator,
// Literals
Phrase: t.string, // "quoted string"
// Fields
"FieldName/At": t.attributeName,
"FieldName/Word": t.attributeName,
"FieldValue/FieldValueWord": t.attributeValue,
});
@@ -1,308 +0,0 @@
// query.ts
// A tiny query language parser with NOT/AND/OR, parentheses, phrases, negation, and field:value.
import { fuzzyMatch } from "fuzzbunny";
/////////////////////////
// AST
/////////////////////////
export type Ast =
| { type: "Term"; value: string } // foo
| { type: "Phrase"; value: string } // "hi there"
| { type: "Field"; field: string; value: string } // method:POST or title:"exact phrase"
| { type: "Not"; node: Ast } // -foo or NOT foo
| { type: "And"; left: Ast; right: Ast } // a AND b
| { type: "Or"; left: Ast; right: Ast }; // a OR b
/////////////////////////
// Tokenizer
/////////////////////////
type Tok =
| { kind: "LPAREN" }
| { kind: "RPAREN" }
| { kind: "AND" }
| { kind: "OR" }
| { kind: "NOT" } // explicit NOT
| { kind: "MINUS" } // unary minus before term/phrase/paren group
| { kind: "COLON" }
| { kind: "WORD"; text: string } // bareword (unquoted)
| { kind: "PHRASE"; text: string } // "quoted phrase"
| { kind: "EOF" };
const isSpace = (c: string) => /\s/.test(c);
const isWordStart = (c: string) => c !== "" && !isSpace(c) && !/[():"@-]/.test(c);
const isWordChar = (c: string) => c !== "" && !isSpace(c) && !/[():"@]/.test(c);
export function tokenize(input: string): Tok[] {
const toks: Tok[] = [];
let i = 0;
const n = input.length;
const peek = () => input[i] ?? "";
const advance = () => input[i++];
const readWord = () => {
let s = "";
while (i < n && isWordChar(peek())) s += advance();
return s;
};
const readFieldValue = () => {
let s = "";
while (i < n && !isSpace(peek())) s += advance();
return s;
};
const readPhrase = () => {
// assumes current char is opening quote
advance(); // consume opening "
let s = "";
while (i < n) {
const c = advance();
if (c === `"`) break;
if (c === "\\" && i < n) {
// escape \" and \\ (simple)
const next = advance();
s += next;
} else {
s += c;
}
}
return s;
};
while (i < n) {
const c = peek();
if (isSpace(c)) {
i++;
continue;
}
if (c === "(") {
toks.push({ kind: "LPAREN" });
i++;
continue;
}
if (c === ")") {
toks.push({ kind: "RPAREN" });
i++;
continue;
}
if (c === ":") {
toks.push({ kind: "COLON" });
i++;
if (peek() && !isSpace(peek()) && peek() !== `"`) {
toks.push({ kind: "WORD", text: readFieldValue() });
}
continue;
}
if (c === `"`) {
const text = readPhrase();
toks.push({ kind: "PHRASE", text });
continue;
}
if (c === "-") {
toks.push({ kind: "MINUS" });
i++;
continue;
}
// WORD / AND / OR / NOT
if (isWordStart(c)) {
const w = readWord();
const upper = w.toUpperCase();
if (upper === "AND") toks.push({ kind: "AND" });
else if (upper === "OR") toks.push({ kind: "OR" });
else if (upper === "NOT") toks.push({ kind: "NOT" });
else toks.push({ kind: "WORD", text: w });
continue;
}
// Unknown char—skip to be forgiving
i++;
}
toks.push({ kind: "EOF" });
return toks;
}
class Parser {
private i = 0;
constructor(private toks: Tok[]) {}
private peek(): Tok {
return this.toks[this.i] ?? { kind: "EOF" };
}
private advance(): Tok {
return this.toks[this.i++] ?? { kind: "EOF" };
}
private at(kind: Tok["kind"]) {
return this.peek().kind === kind;
}
// Top-level: parse OR-precedence chain, allowing implicit AND.
parse(): Ast | null {
if (this.at("EOF")) return null;
const expr = this.parseOr();
if (!this.at("EOF")) {
// Optionally, consume remaining tokens or throw
}
return expr;
}
// Precedence: NOT (highest), AND, OR (lowest)
private parseOr(): Ast {
let node = this.parseAnd();
while (this.at("OR")) {
this.advance();
const rhs = this.parseAnd();
node = { type: "Or", left: node, right: rhs };
}
return node;
}
private parseAnd(): Ast {
let node = this.parseUnary();
// Implicit AND: if next token starts a primary, treat as AND.
while (this.at("AND") || this.startsPrimary()) {
if (this.at("AND")) this.advance();
const rhs = this.parseUnary();
node = { type: "And", left: node, right: rhs };
}
return node;
}
private parseUnary(): Ast {
if (this.at("NOT") || this.at("MINUS")) {
this.advance();
const node = this.parseUnary();
return { type: "Not", node };
}
return this.parsePrimaryOrField();
}
private startsPrimary(): boolean {
const k = this.peek().kind;
return k === "WORD" || k === "PHRASE" || k === "LPAREN" || k === "MINUS" || k === "NOT";
}
private parsePrimaryOrField(): Ast {
// Parenthesized group
if (this.at("LPAREN")) {
this.advance();
const inside = this.parseOr();
// if (!this.at('RPAREN')) throw new Error("Missing closing ')'");
this.advance();
return inside;
}
// Phrase
if (this.at("PHRASE")) {
const t = this.advance() as Extract<Tok, { kind: "PHRASE" }>;
return { type: "Phrase", value: t.text };
}
// Field or bare word
if (this.at("WORD")) {
const wordTok = this.advance() as Extract<Tok, { kind: "WORD" }>;
if (this.at("COLON")) {
// field:value or field:"phrase"
this.advance(); // :
let value: string;
if (this.at("PHRASE")) {
const p = this.advance() as Extract<Tok, { kind: "PHRASE" }>;
value = p.text;
} else if (this.at("WORD")) {
const w = this.advance() as Extract<Tok, { kind: "WORD" }>;
value = w.text;
} else {
// Anything else after colon is treated literally as a single Term token.
const t = this.advance();
value = tokText(t);
}
return { type: "Field", field: wordTok.text, value };
}
// plain term
return { type: "Term", value: wordTok.text };
}
const w = this.advance() as Extract<Tok, { kind: "WORD" }>;
return { type: "Phrase", value: "text" in w ? w.text : "" };
}
}
function tokText(t: Tok): string {
if ("text" in t) return t.text;
switch (t.kind) {
case "COLON":
return ":";
case "LPAREN":
return "(";
case "RPAREN":
return ")";
default:
return "";
}
}
export function parseQuery(q: string): Ast | null {
if (q.trim() === "") return null;
const toks = tokenize(q);
const parser = new Parser(toks);
return parser.parse();
}
export type Doc = {
text?: string;
fields?: Record<string, unknown>;
};
type Technique = "substring" | "fuzzy" | "strict";
function includes(hay: string | undefined, needle: string, technique: Technique): boolean {
if (!hay || !needle) return false;
if (technique === "strict") return hay === needle;
if (technique === "fuzzy") return !!fuzzyMatch(hay, needle);
return hay.indexOf(needle) !== -1;
}
export function evaluate(ast: Ast | null, doc: Doc): boolean {
if (!ast) return true; // Match everything if no query is provided
const text = (doc.text ?? "").toLowerCase();
const fieldsNorm: Record<string, string[]> = {};
for (const [k, v] of Object.entries(doc.fields ?? {})) {
if (!(typeof v === "string" || Array.isArray(v))) continue;
fieldsNorm[k.toLowerCase()] = Array.isArray(v)
? v.filter((v) => typeof v === "string").map((s) => s.toLowerCase())
: [String(v ?? "").toLowerCase()];
}
const evalNode = (node: Ast): boolean => {
switch (node.type) {
case "Term":
return includes(text, node.value.toLowerCase(), "fuzzy");
case "Phrase":
// Quoted phrases match exactly
return includes(text, node.value.toLowerCase(), "substring");
case "Field": {
const vals = fieldsNorm[node.field.toLowerCase()] ?? [];
if (vals.length === 0) return false;
return vals.some((v) => includes(v, node.value.toLowerCase(), "substring"));
}
case "Not":
return !evalNode(node.node);
case "And":
return evalNode(node.left) && evalNode(node.right);
case "Or":
return evalNode(node.left) || evalNode(node.right);
}
};
return evalNode(ast);
}
@@ -1,15 +1,8 @@
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import type { CompletionContext } from "@codemirror/autocomplete";
import type { GenericCompletionConfig } from "@yaakapp-internal/ui";
import { defaultBoost } from "./twig/completion";
export type GenericCompletion = GenericCompletionOption & {
apply?: Completion["apply"];
};
export interface GenericCompletionConfig {
minMatch?: number;
options: GenericCompletion[];
}
export type { GenericCompletion, GenericCompletionConfig } from "@yaakapp-internal/ui";
/**
* Complete options, always matching until the start of the line
@@ -1,312 +1,2 @@
import type { Virtualizer } from "@tanstack/react-virtual";
import { Banner, HStack, SplitLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { format } from "date-fns";
import type { ReactNode } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useEventViewerKeyboard } from "../../hooks/useEventViewerKeyboard";
import { CopyIconButton } from "../CopyIconButton";
import { AutoScroller } from "./AutoScroller";
import { Button } from "./Button";
import { IconButton } from "./IconButton";
import type { SelectProps } from "./Select";
import { Select } from "./Select";
import { Separator } from "./Separator";
interface EventViewerProps<T> {
/** Array of events to display */
events: T[];
/** Get unique key for each event */
getEventKey: (event: T, index: number) => string;
/** Render the event row - receives event, index, isActive, and onClick */
renderRow: (props: {
event: T;
index: number;
isActive: boolean;
onClick: () => void;
}) => ReactNode;
/** Render the detail pane for the selected event */
renderDetail?: (props: { event: T; index: number; onClose: () => void }) => ReactNode;
/** Optional header above the event list (e.g., connection status) */
header?: ReactNode;
/** Error message to display as a banner */
error?: string | null;
/** Key for SplitLayout state persistence */
splitLayoutStorageKey: string;
/** Default ratio for the split (0.0 - 1.0) */
defaultRatio?: number;
/** Enable keyboard navigation (arrow keys) */
enableKeyboardNav?: boolean;
/** Loading state */
isLoading?: boolean;
/** Message to show while loading */
loadingMessage?: string;
/** Message to show when no events */
emptyMessage?: string;
/** Callback when active index changes (for controlled state in parent) */
onActiveIndexChange?: (index: number | null) => void;
}
export function EventViewer<T>({
events,
getEventKey,
renderRow,
renderDetail,
header,
error,
splitLayoutStorageKey,
defaultRatio = 0.4,
enableKeyboardNav = true,
isLoading = false,
loadingMessage = "Loading events...",
emptyMessage = "No events recorded",
onActiveIndexChange,
}: EventViewerProps<T>) {
const [activeIndex, setActiveIndexInternal] = useState<number | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(false);
// Wrap setActiveIndex to notify parent
const setActiveIndex = useCallback(
(indexOrUpdater: number | null | ((prev: number | null) => number | null)) => {
setActiveIndexInternal((prev) => {
const newIndex =
typeof indexOrUpdater === "function" ? indexOrUpdater(prev) : indexOrUpdater;
onActiveIndexChange?.(newIndex);
return newIndex;
});
},
[onActiveIndexChange],
);
const containerRef = useRef<HTMLDivElement>(null);
const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element> | null>(null);
const activeEvent = useMemo(
() => (activeIndex != null ? events[activeIndex] : null),
[activeIndex, events],
);
// Check if the event list container is focused
const isContainerFocused = useCallback(() => {
return containerRef.current?.contains(document.activeElement) ?? false;
}, []);
// Keyboard navigation
useEventViewerKeyboard({
totalCount: events.length,
activeIndex,
setActiveIndex,
virtualizer: virtualizerRef.current,
isContainerFocused,
enabled: enableKeyboardNav,
closePanel: () => setIsPanelOpen(false),
openPanel: () => setIsPanelOpen(true),
});
// Handle virtualizer ready callback
const handleVirtualizerReady = useCallback(
(virtualizer: Virtualizer<HTMLDivElement, Element>) => {
virtualizerRef.current = virtualizer;
},
[],
);
// Handle row click - select and open panel, scroll into view
const handleRowClick = useCallback(
(index: number) => {
setActiveIndex(index);
setIsPanelOpen(true);
// Scroll to ensure selected item is visible after panel opens
requestAnimationFrame(() => {
virtualizerRef.current?.scrollToIndex(index, { align: "auto" });
});
},
[setActiveIndex],
);
const handleClose = useCallback(() => {
setIsPanelOpen(false);
}, []);
if (isLoading) {
return <div className="p-3 text-text-subtlest italic">{loadingMessage}</div>;
}
if (events.length === 0 && !error) {
return <div className="p-3 text-text-subtlest italic">{emptyMessage}</div>;
}
return (
<div ref={containerRef} className="h-full">
<SplitLayout
layout="vertical"
storageKey={splitLayoutStorageKey}
defaultRatio={defaultRatio}
minHeightPx={72}
firstSlot={({ style }) => (
<div style={style} className="w-full h-full grid grid-rows-[auto_minmax(0,1fr)]">
{header ?? <span aria-hidden />}
<AutoScroller
data={events}
focusable={enableKeyboardNav}
onVirtualizerReady={handleVirtualizerReady}
header={
error && (
<Banner color="danger" className="m-3">
{error}
</Banner>
)
}
render={(event, index) => (
<div key={getEventKey(event, index)}>
{renderRow({
event,
index,
isActive: index === activeIndex,
onClick: () => handleRowClick(index),
})}
</div>
)}
/>
</div>
)}
secondSlot={
activeEvent != null && renderDetail && isPanelOpen
? ({ style }) => (
<div style={style} className="grid grid-rows-[auto_minmax(0,1fr)] bg-surface">
<div className="pb-3 px-2">
<Separator />
</div>
<div className="mx-2 overflow-y-auto">
{renderDetail({
event: activeEvent,
index: activeIndex ?? 0,
onClose: handleClose,
})}
</div>
</div>
)
: null
}
/>
</div>
);
}
export type EventDetailAction =
| {
type?: "button";
/** Unique key for React */
key: string;
/** Button label */
label: string;
/** Optional icon */
icon?: ReactNode;
/** Click handler */
onClick: () => void;
}
| {
type: "select";
/** Unique key for React */
key: string;
/** Select label */
label: string;
/** Selected value */
value: string;
/** Select options */
options: SelectProps<string>["options"];
/** Change handler */
onChange: (value: string) => void;
};
interface EventDetailHeaderProps {
title: string;
prefix?: ReactNode;
timestamp?: string;
actions?: EventDetailAction[];
copyText?: string | (() => Promise<string | null>);
onClose?: () => void;
}
export function EventDetailHeader({
title,
prefix,
timestamp,
actions,
copyText,
onClose,
}: EventDetailHeaderProps) {
const formattedTime = timestamp ? format(new Date(`${timestamp}Z`), "HH:mm:ss.SSS") : null;
return (
<div className="flex items-center justify-between gap-2 mb-2 h-xs">
<HStack space={2} className="items-center min-w-0">
{prefix}
<h3 className="font-semibold select-auto cursor-auto truncate">{title}</h3>
</HStack>
<HStack space={2} className="items-center">
{actions?.map((action) =>
action.type === "select" ? (
<div key={action.key} className="w-32">
<Select
name={action.key}
label={action.label}
hideLabel
size="xs"
value={action.value}
options={action.options}
onChange={action.onChange}
/>
</div>
) : (
<Button
key={action.key}
type="button"
variant="border"
size="xs"
onClick={action.onClick}
>
{action.icon}
{action.label}
</Button>
),
)}
{copyText != null && (
<CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" />
)}
{formattedTime && (
<span className="text-text-subtlest font-mono text-editor ml-2">{formattedTime}</span>
)}
{onClose != null && (
<div
className={classNames(
copyText != null ||
formattedTime ||
((actions ?? []).length > 0 && "border-l border-l-surface-highlight ml-2 pl-3"),
)}
>
<IconButton
color="custom"
className="text-text-subtle -mr-3"
size="xs"
icon="x"
title="Close event panel"
onClick={onClose}
/>
</div>
)}
</HStack>
</div>
);
}
export type { EventDetailAction } from "@yaakapp-internal/ui";
export { EventDetailHeader, EventViewer } from "@yaakapp-internal/ui";
@@ -1,38 +1 @@
import classNames from "classnames";
import { format } from "date-fns";
import type { ReactNode } from "react";
interface EventViewerRowProps {
isActive: boolean;
onClick: () => void;
icon: ReactNode;
content: ReactNode;
timestamp?: string;
}
export function EventViewerRow({
isActive,
onClick,
icon,
content,
timestamp,
}: EventViewerRowProps) {
return (
<div className="px-1">
<button
type="button"
onClick={onClick}
className={classNames(
"w-full grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 items-center text-left",
"px-1.5 h-xs font-mono text-editor cursor-default group focus:outline-hidden focus:text-text rounded-sm",
isActive && "bg-surface-active text-text!",
"text-text-subtle hover:text",
)}
>
{icon}
<div className="w-full truncate">{content}</div>
{timestamp && <div className="opacity-50">{format(`${timestamp}Z`, "HH:mm:ss.SSS")}</div>}
</button>
</div>
);
}
export { EventViewerRow } from "@yaakapp-internal/ui";
+1 -45
View File
@@ -1,45 +1 @@
import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { HotkeyAction } from "../../hooks/useHotKey";
import { useFormattedHotkey } from "../../hooks/useHotKey";
interface Props {
action: HotkeyAction | null;
className?: string;
variant?: "text" | "with-bg";
}
export function Hotkey({ action, className, variant }: Props) {
const labelParts = useFormattedHotkey(action);
if (labelParts === null) {
return null;
}
return <HotkeyRaw labelParts={labelParts} className={className} variant={variant} />;
}
interface HotkeyRawProps {
labelParts: string[];
className?: string;
variant?: "text" | "with-bg";
}
export function HotkeyRaw({ labelParts, className, variant }: HotkeyRawProps) {
return (
<HStack
className={classNames(
className,
variant === "with-bg" &&
"rounded-sm bg-surface-highlight px-1 border border-border text-text-subtle",
variant === "text" && "text-text-subtlest",
)}
>
{labelParts.map((char, index) => (
// oxlint-disable-next-line react/no-array-index-key
<div key={index} className="min-w-[1em] text-center">
{char}
</div>
))}
</HStack>
);
}
export { Hotkey, HotkeyRaw } from "@yaakapp-internal/ui";
@@ -1,31 +1 @@
import classNames from "classnames";
import type { ReactNode } from "react";
import { Fragment } from "react";
import type { HotkeyAction } from "../../hooks/useHotKey";
import { Hotkey } from "./Hotkey";
import { HotkeyLabel } from "./HotkeyLabel";
interface Props {
hotkeys: HotkeyAction[];
bottomSlot?: ReactNode;
className?: string;
}
export const HotkeyList = ({ hotkeys, bottomSlot, className }: Props) => {
return (
<div className={classNames(className, "h-full flex items-center justify-center")}>
<div className="grid gap-2 grid-cols-[auto_auto]">
{hotkeys.map((hotkey) => (
<Fragment key={hotkey}>
<HotkeyLabel className="truncate" action={hotkey} />
{/* Keep this grid cell when Hotkey renders nothing so later rows stay aligned. */}
<div className="ml-4">
<Hotkey action={hotkey} />
</div>
</Fragment>
))}
{bottomSlot}
</div>
</div>
);
};
export { HotkeyList } from "@yaakapp-internal/ui";
+28 -32
View File
@@ -3,16 +3,43 @@ import { platform } from "@yaakapp-internal/platform";
import classNames from "classnames";
import type { HTMLAttributes } from "react";
import { appInfo } from "../../lib/appInfo";
import { Icon } from "@yaakapp-internal/ui";
import { ExternalLink, isYaakUrl } from "@yaakapp-internal/ui";
interface Props extends HTMLAttributes<HTMLAnchorElement> {
href: string;
noUnderline?: boolean;
}
/** Tags a link to yaak.app with which app it came from */
export function withYaakRef(href: string): string {
if (!isYaakUrl(href)) return href;
const url = new URL(href);
url.searchParams.set("ref", appInfo.identifier);
return url.toString();
}
/** Opens a link outside the app, tagged as `Link` tags it */
export function openExternalUrl(href: string) {
void platform.openUrl(withYaakRef(href));
}
export function Link({ href, children, noUnderline, className, ...other }: Props) {
const isExternal = href.match(/^https?:\/\//);
if (isExternal) {
return (
<ExternalLink
href={withYaakRef(href)}
noUnderline={noUnderline}
onOpen={openExternalUrl}
className={className}
{...other}
>
{children}
</ExternalLink>
);
}
className = classNames(
className,
"relative",
@@ -20,37 +47,6 @@ export function Link({ href, children, noUnderline, className, ...other }: Props
!noUnderline && "underline",
);
if (isExternal) {
const isYaakLink = href.startsWith("https://yaak.app");
let finalHref = href;
if (isYaakLink) {
const url = new URL(href);
url.searchParams.set("ref", appInfo.identifier);
finalHref = url.toString();
}
return (
// eslint-disable-next-line react/jsx-no-target-blank
<a
href={finalHref}
target="_blank"
rel={isYaakLink ? undefined : "noopener noreferrer"}
onClick={(e) => {
e.preventDefault();
void platform.openUrl(finalHref);
}}
className={className}
{...other}
>
<span className="pr-5">{children}</span>
<Icon
className="inline absolute right-0.5 top-[0.3em] opacity-70 group-hover:opacity-100"
size="xs"
icon="external_link"
/>
</a>
);
}
return (
<RouterLink to={href} className={className} {...other}>
{children}
+7 -969
View File
@@ -1,970 +1,8 @@
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core";
import {
DndContext,
DragOverlay,
PointerSensor,
pointerWithin,
useDraggable,
useDroppable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import classNames from "classnames";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { WrappedEnvironmentVariable } from "../../hooks/useEnvironmentVariables";
import { useRandomKey } from "../../hooks/useRandomKey";
import { useToggle } from "../../hooks/useToggle";
import { showDialog } from "../../lib/dialog";
import { computeSideForDragMove, DropMarker, languageFromContentType } from "@yaakapp-internal/ui";
import { showPrompt } from "../../lib/prompt";
import { SelectFile } from "../SelectFile";
import { Button } from "./Button";
import { Checkbox } from "./Checkbox";
import type { DropdownItem } from "./Dropdown";
import { Dropdown } from "./Dropdown";
import type { EditorProps } from "./Editor/Editor";
import type { GenericCompletionConfig } from "./Editor/genericCompletion";
import { Editor } from "./Editor/LazyEditor";
import { Icon } from "@yaakapp-internal/ui";
import { IconButton } from "./IconButton";
import type { InputHandle, InputProps } from "./Input";
import { Input } from "./Input";
import { ensurePairId } from "./PairEditor.util";
import type { RadioDropdownItem } from "./RadioDropdown";
import { RadioDropdown } from "./RadioDropdown";
import { platform } from "@yaakapp-internal/platform";
export interface PairEditorHandle {
/**
* Focus a row's name field once it's able to take focus. Focus can't land immediately when the
* row isn't mounted yet or the editor is hidden — eg. sitting in a tab that's still becoming
* active — so this retries for up to ~1s. A newer focus request cancels a pending one.
*/
focusName(id: string): void;
/** Focus a row's value field. See {@link PairEditorHandle.focusName} for timing. */
focusValue(id: string): void;
}
/** ~1s at 60fps, plenty for a tab switch to land without spinning forever if it never does */
const MAX_FOCUS_ATTEMPTS = 60;
export type PairEditorProps = {
allowFileValues?: boolean;
allowMultilineValues?: boolean;
className?: string;
forcedEnvironmentId?: string;
forceUpdateKey?: string;
nameAutocomplete?: GenericCompletionConfig;
nameAutocompleteFunctions?: boolean;
nameAutocompleteVariables?: boolean;
namePlaceholder?: string;
nameValidate?: InputProps["validate"];
noScroll?: boolean;
onChange: (pairs: PairWithId[]) => void;
pairs: EditablePair[];
stateKey: InputProps["stateKey"];
setRef?: (n: PairEditorHandle) => void;
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
valueAutocompleteFunctions?: boolean;
valueAutocompleteVariables?: boolean | "environment";
valuePlaceholder?: string;
valueType?: InputProps["type"] | ((pair: Pair) => InputProps["type"]);
valueValidate?: InputProps["validate"];
};
export type Pair = {
id?: string;
enabled?: boolean;
name: string;
value: string;
contentType?: string;
filename?: string;
isFile?: boolean;
};
export type PairWithId = Pair & {
id: string;
};
/**
* A pair as handed to the editor. Adds behaviour that only the editor cares about, so the plain
* `Pair` stays the shape that gets written to models.
*/
export type EditablePair = Pair & {
/**
* When set, name edits are held until the field blurs and then committed through this, instead
* of calling `onChange` on every keystroke. Return false to reject the new name, which reverts
* the field. For names that can't be written directly, like a URL path placeholder that lives
* in the URL itself.
*/
commitName?: (name: string) => boolean;
};
type EditablePairWithId = EditablePair & {
id: string;
};
/** Strip the editor-only fields, so they can never reach a model write */
function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): PairWithId {
return pair;
}
/** Max number of pairs to show before prompting the user to reveal the rest */
const MAX_INITIAL_PAIRS = 30;
// Keyed on `stateKey` so no state survives a change of owner. Row ids alone can't tell owners
// apart — two pair sets can share ids (eg. one duplicated from the other), and the same-rows
// fast path below would swap in the new data without rebuilding the row editors, leaving any
// still-focused input showing the old owner's text.
export function PairEditor(props: PairEditorProps) {
return <PairEditorInner key={props.stateKey} {...props} />;
}
function PairEditorInner({
allowFileValues,
allowMultilineValues,
className,
forcedEnvironmentId,
forceUpdateKey,
nameAutocomplete,
nameAutocompleteFunctions,
nameAutocompleteVariables,
namePlaceholder,
nameValidate,
noScroll,
onChange,
pairs: originalPairs,
stateKey,
valueAutocomplete,
valueAutocompleteFunctions,
valueAutocompleteVariables,
valuePlaceholder,
valueType,
valueValidate,
setRef,
}: PairEditorProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState<EditablePairWithId | null>(null);
const [pairs, setPairs] = useState<EditablePairWithId[]>([]);
const [showAll, toggleShowAll] = useToggle(false);
// NOTE: Use local force update key because we trigger an effect on forceUpdateKey change. If
// we simply pass forceUpdateKey to the editor, the data set by useEffect will be stale.
const [localForceUpdateKey, regenerateLocalForceUpdateKey] = useRandomKey();
const rowsRef = useRef<Record<string, RowHandle | null>>({});
const pendingFocusFrame = useRef<number | null>(null);
useEffect(
() => () => {
if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current);
},
[],
);
const focusWhenReady = useCallback((id: string, field: "name" | "value") => {
if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current);
let attemptsLeft = MAX_FOCUS_ATTEMPTS;
const attempt = () => {
pendingFocusFrame.current = null;
const row = rowsRef.current[id];
const landed = field === "name" ? row?.focusName() : row?.focusValue();
if (landed || --attemptsLeft <= 0) return;
pendingFocusFrame.current = requestAnimationFrame(attempt);
};
attempt();
}, []);
const handle = useMemo<PairEditorHandle>(
() => ({
focusName(id: string) {
focusWhenReady(id, "name");
},
focusValue(id: string) {
focusWhenReady(id, "value");
},
}),
[focusWhenReady],
);
const initPairEditorRow = useCallback(
(id: string, n: RowHandle | null) => {
const isLast = id === pairs[pairs.length - 1]?.id;
if (isLast) return; // Never add the last pair
rowsRef.current[id] = n;
const validHandles = Object.values(rowsRef.current).filter((v) => v != null);
// Use >= because more might be added if an ID of one changes (eg. editing placeholder in URL regenerates fresh pairs every keystroke)
const ready = validHandles.length >= pairs.length - 1;
if (ready) {
setRef?.(handle);
}
},
[handle, pairs, setRef],
);
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Only care about forceUpdateKey
useEffect(() => {
// Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs)
const newPairs: EditablePairWithId[] = [];
for (let i = 0; i < originalPairs.length; i++) {
const p = originalPairs[i];
if (!p) continue; // Make TS happy
if (isPairEmpty(p)) continue;
newPairs.push(ensurePairId(p));
}
// When the reset holds the exact same rows (eg. renaming a URL path placeholder, which keeps
// every row id), swap the data in without rebuilding the row editors. Unfocused inputs re-seed
// themselves when `defaultValue` changes, and a rebuild would drop the user's focus and
// selection — like tabbing from a placeholder's name into its value.
const trailingPair = pairs[pairs.length - 1];
const sameRows =
trailingPair != null &&
isPairEmpty(trailingPair) &&
pairs.length === newPairs.length + 1 &&
newPairs.every((p, i) => p.id === pairs[i]?.id);
if (sameRows) {
setPairs([...newPairs, trailingPair]);
return;
}
// Add empty last pair if there is none
const lastPair = newPairs[newPairs.length - 1];
if (lastPair == null || !isPairEmpty(lastPair)) {
newPairs.push(emptyPair());
}
setPairs(newPairs);
regenerateLocalForceUpdateKey();
}, [forceUpdateKey]);
const setPairsAndSave = useCallback(
(fn: (pairs: EditablePairWithId[]) => EditablePairWithId[]) => {
setPairs((oldPairs) => {
const pairs = fn(oldPairs);
onChange(pairs.map(toPairData));
return pairs;
});
},
[onChange],
);
const handleChange = useCallback(
(pair: EditablePairWithId) =>
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
[setPairsAndSave],
);
const handleDelete = useCallback(
(pair: Pair, focusPrevious: boolean) => {
if (focusPrevious) {
const index = pairs.findIndex((p) => p.id === pair.id);
const id = pairs[index - 1]?.id ?? null;
rowsRef.current[id ?? "n/a"]?.focusName();
}
return setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id));
},
[setPairsAndSave, pairs],
);
const handleFocusName = useCallback(
(pair: Pair) => {
const isLast = pair.id === pairs[pairs.length - 1]?.id;
if (isLast) setPairs([...pairs, emptyPair()]);
},
[pairs],
);
const handleFocusValue = useCallback(
(pair: Pair) => {
const isLast = pair.id === pairs[pairs.length - 1]?.id;
if (isLast) setPairs([...pairs, emptyPair()]);
},
[pairs],
);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
// dnd-kit: show the “between rows” marker while hovering
const onDragMove = useCallback(
(e: DragMoveEvent) => {
const overId = e.over?.id as string | undefined;
if (!overId) return setHoveredIndex(null);
const overPair = pairs.find((p) => p.id === overId);
if (overPair == null) return setHoveredIndex(null);
const side = computeSideForDragMove(overPair.id, e);
const overIndex = pairs.findIndex((p) => p.id === overId);
const hoveredIndex = overIndex + (side === "before" ? 0 : 1);
setHoveredIndex(hoveredIndex);
},
[pairs],
);
const onDragStart = useCallback(
(e: DragStartEvent) => {
const pair = pairs.find((p) => p.id === e.active.id);
setIsDragging(pair ?? null);
},
[pairs],
);
const onDragCancel = useCallback(() => setIsDragging(null), []);
const onDragEnd = useCallback(
(e: DragEndEvent) => {
setIsDragging(null);
setHoveredIndex(null);
const activeId = e.active.id as string | undefined;
const overId = e.over?.id as string | undefined;
if (!activeId || !overId) return;
const from = pairs.findIndex((p) => p.id === activeId);
const baseTo = pairs.findIndex((p) => p.id === overId);
const to = hoveredIndex ?? (baseTo === -1 ? from : baseTo);
if (from !== -1 && to !== -1 && from !== to) {
setPairsAndSave((ps) => {
const next = [...ps];
const [moved] = next.splice(from, 1);
if (moved === undefined) return ps; // Make TS happy
next.splice(to > from ? to - 1 : to, 0, moved);
return next;
});
}
},
[pairs, hoveredIndex, setPairsAndSave],
);
return (
<div
className={classNames(
className,
"@container relative",
"pb-2 mb-auto h-full",
!noScroll && "overflow-y-auto max-h-full",
// Move over the width of the drag handle
"-mr-2 pr-2",
// Pad to make room for the drag divider
"pt-0.5",
"grid grid-rows-[auto_1fr]",
)}
>
<div>
<DndContext
autoScroll
sensors={sensors}
onDragMove={onDragMove}
onDragEnd={onDragEnd}
onDragStart={onDragStart}
onDragCancel={onDragCancel}
collisionDetection={pointerWithin}
>
{pairs.map((p, i) => {
if (!showAll && i > MAX_INITIAL_PAIRS) return null;
const isLast = i === pairs.length - 1;
return (
<Fragment key={p.id}>
{hoveredIndex === i && <DropMarker />}
<PairEditorRow
setRef={initPairEditorRow}
allowFileValues={allowFileValues}
allowMultilineValues={allowMultilineValues}
className="py-1"
forcedEnvironmentId={forcedEnvironmentId}
forceUpdateKey={localForceUpdateKey}
index={i}
isLast={isLast}
isDraggingGlobal={!!isDragging}
nameAutocomplete={nameAutocomplete}
nameAutocompleteFunctions={nameAutocompleteFunctions}
nameAutocompleteVariables={nameAutocompleteVariables}
namePlaceholder={namePlaceholder}
nameValidate={nameValidate}
onChange={handleChange}
onDelete={handleDelete}
onFocusName={handleFocusName}
onFocusValue={handleFocusValue}
pair={p}
stateKey={stateKey}
valueAutocomplete={valueAutocomplete}
valueAutocompleteFunctions={valueAutocompleteFunctions}
valueAutocompleteVariables={valueAutocompleteVariables}
valuePlaceholder={valuePlaceholder}
valueType={valueType}
valueValidate={valueValidate}
/>
</Fragment>
);
})}
{!showAll && pairs.length > MAX_INITIAL_PAIRS && (
<Button onClick={toggleShowAll} variant="border" className="m-2" size="xs">
Show {pairs.length - MAX_INITIAL_PAIRS} More
</Button>
)}
<DragOverlay dropAnimation={null}>
{isDragging && (
<PairEditorRow
namePlaceholder={namePlaceholder}
valuePlaceholder={valuePlaceholder}
className="opacity-80"
pair={isDragging}
index={0}
stateKey={null}
/>
)}
</DragOverlay>
</DndContext>
</div>
<div
// There's a weird bug where clicking below one of the above Codemirror inputs will cause
// it to focus. Putting this element here prevents that
aria-hidden
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
/>
</div>
);
}
type PairEditorRowProps = {
className?: string;
pair: EditablePairWithId;
forceFocusNamePairId?: string | null;
forceFocusValuePairId?: string | null;
onChange?: (pair: EditablePairWithId) => void;
onDelete?: (pair: EditablePairWithId, focusPrevious: boolean) => void;
onFocusName?: (pair: EditablePairWithId) => void;
onFocusValue?: (pair: EditablePairWithId) => void;
onSubmit?: (pair: EditablePairWithId) => void;
isLast?: boolean;
disabled?: boolean;
disableDrag?: boolean;
index: number;
isDraggingGlobal?: boolean;
setRef?: (id: string, n: RowHandle | null) => void;
} & Pick<
export type {
EditablePair,
Pair,
PairEditorHandle,
PairEditorProps,
| "allowFileValues"
| "allowMultilineValues"
| "forcedEnvironmentId"
| "forceUpdateKey"
| "nameAutocomplete"
| "nameAutocompleteVariables"
| "namePlaceholder"
| "nameValidate"
| "nameAutocompleteFunctions"
| "stateKey"
| "valueAutocomplete"
| "valueAutocompleteFunctions"
| "valueAutocompleteVariables"
| "valuePlaceholder"
| "valueType"
| "valueValidate"
>;
interface RowHandle {
focusName(): boolean;
focusValue(): boolean;
}
export function PairEditorRow({
allowFileValues,
allowMultilineValues,
className,
disableDrag,
disabled,
forceUpdateKey,
forcedEnvironmentId,
index,
isLast,
nameAutocomplete,
nameAutocompleteFunctions,
nameAutocompleteVariables,
namePlaceholder,
nameValidate,
isDraggingGlobal,
onChange,
onDelete,
onFocusName,
onFocusValue,
pair,
stateKey,
valueAutocomplete,
valueAutocompleteFunctions,
valueAutocompleteVariables,
valuePlaceholder,
valueType,
valueValidate,
setRef,
}: PairEditorRowProps) {
const nameInputRef = useRef<InputHandle>(null);
const valueInputRef = useRef<InputHandle>(null);
const handle = useRef<RowHandle>({
focusName() {
nameInputRef.current?.focus();
return nameInputRef.current?.isFocused() ?? false;
},
focusValue() {
valueInputRef.current?.focus();
return valueInputRef.current?.isFocused() ?? false;
},
});
const initNameInputRef = useCallback(
(n: InputHandle | null) => {
nameInputRef.current = n;
if (nameInputRef.current && valueInputRef.current) {
setRef?.(pair.id, handle.current);
}
},
[pair.id, setRef],
);
const initValueInputRef = useCallback(
(n: InputHandle | null) => {
valueInputRef.current = n;
if (nameInputRef.current && valueInputRef.current) {
setRef?.(pair.id, handle.current);
}
},
[pair.id, setRef],
);
const handleFocusName = useCallback(() => onFocusName?.(pair), [onFocusName, pair]);
const handleFocusValue = useCallback(() => onFocusValue?.(pair), [onFocusValue, pair]);
const handleDelete = useCallback(() => onDelete?.(pair, false), [onDelete, pair]);
const handleChangeEnabled = useMemo(
() => (enabled: boolean) => onChange?.({ ...pair, enabled }),
[onChange, pair],
);
// The name being typed into a deferred-commit field, before it's committed or reverted
const pendingName = useRef<string | null>(null);
const handleChangeName = useMemo(
() => (name: string) => {
// Keep the edit local until commit. Writing on every keystroke would reset the editor from
// beneath the cursor, since the pairs are derived from the name being edited.
if (pair.commitName != null) pendingName.current = name;
else onChange?.({ ...pair, name });
},
[onChange, pair],
);
const revertName = useCallback(() => {
const nameInput = nameInputRef.current;
if (nameInput != null) {
const changes = { from: 0, to: nameInput.value().length, insert: pair.name };
nameInput.dispatch({ changes });
}
pendingName.current = null;
}, [pair.name]);
const handleBlurName = useCallback(() => {
if (pair.commitName == null) return;
const name = pendingName.current;
pendingName.current = null;
if (name == null || name === pair.name) return;
if (!pair.commitName(name)) revertName();
}, [pair, revertName]);
const handleChangeValueText = useMemo(
() => (value: string) => onChange?.({ ...pair, value, isFile: false }),
[onChange, pair],
);
const handleChangeValueFile = useMemo(
() =>
({ filePath }: { filePath: string | null }) =>
onChange?.({ ...pair, value: filePath ?? "", isFile: true }),
[onChange, pair],
);
const handleChangeValueContentType = useMemo(
() => (contentType: string) => onChange?.({ ...pair, contentType }),
[onChange, pair],
);
const handleChangeValueFilename = useMemo(
() => (filename: string) => onChange?.({ ...pair, filename }),
[onChange, pair],
);
const handleEditMultiLineValue = useCallback(
() =>
showDialog({
id: "pair-edit-multiline",
size: "dynamic",
title: <>Edit {pair.name}</>,
render: ({ hide }) => (
<MultilineEditDialog
hide={hide}
onChange={handleChangeValueText}
defaultValue={pair.value}
contentType={pair.contentType ?? null}
/>
),
}),
[handleChangeValueText, pair.contentType, pair.name, pair.value],
);
const defaultItems = useMemo(
(): DropdownItem[] => [
{
label: "Edit Multi-line",
onSelect: handleEditMultiLineValue,
hidden: !allowMultilineValues,
},
{
label: "Delete",
onSelect: handleDelete,
color: "danger",
},
],
[allowMultilineValues, handleDelete, handleEditMultiLineValue],
);
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: pair.id });
const { setNodeRef: setDroppableRef } = useDroppable({ id: pair.id });
// Filter out the current pair name
const valueAutocompleteVariablesFiltered = useMemo<EditorProps["autocompleteVariables"]>(() => {
if (valueAutocompleteVariables === "environment") {
return (v: WrappedEnvironmentVariable): boolean => v.variable.name !== pair.name;
}
return valueAutocompleteVariables;
}, [pair.name, valueAutocompleteVariables]);
const handleSetRef = useCallback(
(n: HTMLDivElement | null) => {
setDraggableRef(n);
setDroppableRef(n);
},
[setDraggableRef, setDroppableRef],
);
// Skip the trailing placeholder row. It exists to start a new pair, so a picker there would
// imply it's already a real row. `Input` handles the rest, including having no options to show.
const showOptionsPicker = !isLast;
return (
<div
ref={handleSetRef}
className={classNames(
className,
"group/pair-row grid grid-cols-[auto_auto_minmax(0,1fr)_auto]",
"grid-rows-1 items-center",
!pair.enabled && "opacity-60",
)}
>
<Checkbox
hideLabel
title={pair.enabled ? "Disable item" : "Enable item"}
disabled={isLast || disabled}
checked={isLast ? false : !!pair.enabled}
className={classNames(isLast && "opacity-disabled!")}
onChange={handleChangeEnabled}
/>
{!isLast && !disableDrag ? (
<div
{...attributes}
{...listeners}
className={classNames(
"py-2 h-7 w-4 flex items-center",
"justify-center opacity-0 group-hover/pair-row:opacity-70",
)}
>
<Icon size="sm" icon="grip_vertical" className="pointer-events-none" />
</div>
) : (
<span className="w-4" />
)}
<div
className={classNames(
"grid items-center",
"@xs:gap-2 @xs:grid-rows-1! @xs:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]!",
"gap-0.5 grid-cols-1 grid-rows-2",
)}
>
<Input
setRef={initNameInputRef}
hideLabel
stateKey={`name.${pair.id}.${stateKey}`}
disabled={disabled}
wrapLines={false}
readOnly={isDraggingGlobal}
size="sm"
required={!isLast && !!pair.enabled && !!pair.value}
validate={nameValidate}
forcedEnvironmentId={forcedEnvironmentId}
forceUpdateKey={forceUpdateKey}
containerClassName={classNames("bg-surface", isLast && "border-dashed")}
defaultValue={pair.name}
label="Name"
name={`name[${index}]`}
onBlur={handleBlurName}
onChange={handleChangeName}
onFocus={handleFocusName}
placeholder={namePlaceholder ?? "name"}
autocomplete={nameAutocomplete}
autocompleteVariables={nameAutocompleteVariables}
autocompleteFunctions={nameAutocompleteFunctions}
showOptionsPicker={showOptionsPicker}
/>
<div className="w-full grid grid-cols-[minmax(0,1fr)_auto] gap-1 items-center">
{pair.isFile ? (
<SelectFile
disabled={disabled}
inline
size="xs"
filePath={pair.value}
nameOverride={pair.filename || null}
onChange={handleChangeValueFile}
/>
) : pair.value.includes("\n") ? (
<Button
color="secondary"
size="sm"
onClick={handleEditMultiLineValue}
title={pair.value}
className="text-xs font-mono"
>
{pair.value.split("\n").join(" ")}
</Button>
) : (
<Input
setRef={initValueInputRef}
hideLabel
stateKey={`value.${pair.id}.${stateKey}`}
wrapLines={false}
size="sm"
disabled={disabled}
readOnly={isDraggingGlobal}
containerClassName={classNames("bg-surface", isLast && "border-dashed")}
validate={valueValidate}
forcedEnvironmentId={forcedEnvironmentId}
forceUpdateKey={forceUpdateKey}
defaultValue={pair.value}
label="Value"
name={`value[${index}]`}
onChange={handleChangeValueText}
onFocus={handleFocusValue}
type={isLast ? "text" : typeof valueType === "function" ? valueType(pair) : valueType}
placeholder={valuePlaceholder ?? "value"}
autocomplete={valueAutocomplete?.(pair.name)}
autocompleteFunctions={valueAutocompleteFunctions}
autocompleteVariables={valueAutocompleteVariablesFiltered}
showOptionsPicker={showOptionsPicker}
/>
)}
</div>
</div>
{allowFileValues ? (
<FileActionsDropdown
pair={pair}
onChangeFile={handleChangeValueFile}
onChangeText={handleChangeValueText}
onChangeContentType={handleChangeValueContentType}
onChangeFilename={handleChangeValueFilename}
onDelete={handleDelete}
editMultiLine={handleEditMultiLineValue}
/>
) : (
<Dropdown items={defaultItems}>
<IconButton
iconSize="sm"
size="xs"
// Ellipsis rather than a chevron: the name and value fields now carry chevrons for
// picking a suggestion, and this menu acts on the row instead of filling in a field.
icon={isLast || disabled ? "empty" : "ellipsis_vertical"}
title="More actions"
className="text-text-subtlest"
/>
</Dropdown>
)}
</div>
);
}
const fileItems: RadioDropdownItem<string>[] = [
{ label: "Text", value: "text" },
{ label: "File", value: "file" },
];
function FileActionsDropdown({
pair,
onChangeFile,
onChangeText,
onChangeContentType,
onChangeFilename,
onDelete,
editMultiLine,
}: {
pair: Pair;
onChangeFile: ({ filePath }: { filePath: string | null }) => void;
onChangeText: (text: string) => void;
onChangeContentType: (contentType: string) => void;
onChangeFilename: (filename: string) => void;
onDelete: () => void;
editMultiLine: () => void;
}) {
const onChange = useCallback(
(v: string) => {
if (v === "file") onChangeFile({ filePath: "" });
else onChangeText("");
},
[onChangeFile, onChangeText],
);
const itemsAfter = useMemo<DropdownItem[]>(
() => [
{
label: "Edit Multi-Line",
leftSlot: <Icon icon="file_code" />,
hidden: pair.isFile,
onSelect: editMultiLine,
},
{
label: "Set Content-Type",
leftSlot: <Icon icon="pencil" />,
onSelect: async () => {
const contentType = await showPrompt({
id: "content-type",
title: "Override Content-Type",
label: "Content-Type",
required: false,
placeholder: "text/plain",
defaultValue: pair.contentType ?? "",
confirmText: "Set",
description: "Leave blank to auto-detect",
});
if (contentType == null) return;
onChangeContentType(contentType);
},
},
{
label: "Set File Name",
leftSlot: <Icon icon="file_code" />,
onSelect: async () => {
console.log("PAIR", pair);
const defaultFilename = await platform.files.basename(pair.value ?? "");
const filename = await showPrompt({
id: "filename",
title: "Override Filename",
label: "Filename",
required: false,
placeholder: defaultFilename ?? "myfile.png",
defaultValue: pair.filename,
confirmText: "Set",
description: "Leave blank to use the name of the selected file",
});
if (filename == null) return;
onChangeFilename(filename);
},
},
{
label: "Unset File",
leftSlot: <Icon icon="x" />,
hidden: pair.isFile,
onSelect: async () => {
onChangeFile({ filePath: null });
},
},
{
label: "Delete",
onSelect: onDelete,
variant: "danger",
leftSlot: <Icon icon="trash" />,
color: "danger",
},
],
[
editMultiLine,
onChangeContentType,
onChangeFile,
onDelete,
pair.contentType,
pair.isFile,
onChangeFilename,
pair.filename,
pair,
],
);
return (
<RadioDropdown
value={pair.isFile ? "file" : "text"}
onChange={onChange}
items={fileItems}
itemsAfter={itemsAfter}
>
<IconButton
iconSize="sm"
size="xs"
// Matches the plain row menu, so the actions column reads the same for file and text rows
icon="ellipsis_vertical"
title="Select form data type"
className="text-text-subtlest"
/>
</RadioDropdown>
);
}
function emptyPair(): EditablePairWithId {
return ensurePairId({ enabled: true, name: "", value: "" });
}
function isPairEmpty(pair: Pair): boolean {
return !pair.name && !pair.value;
}
function MultilineEditDialog({
defaultValue,
contentType,
onChange,
hide,
}: {
defaultValue: string;
contentType: string | null;
onChange: (value: string) => void;
hide: () => void;
}) {
const [value, setValue] = useState<string>(defaultValue);
const language = languageFromContentType(contentType, value);
return (
<div className="w-screen max-w-160 h-[50vh] max-h-full grid grid-rows-[minmax(0,1fr)_auto]">
<Editor
heightMode="auto"
defaultValue={defaultValue}
language={language}
onChange={setValue}
stateKey={null}
autocompleteFunctions
autocompleteVariables
/>
<div>
<Button
color="primary"
className="ml-auto my-2"
onClick={() => {
onChange(value);
hide();
}}
>
Done
</Button>
</div>
</div>
);
}
PairWithId,
} from "@yaakapp-internal/ui";
export { PairEditor, PairEditorRow } from "@yaakapp-internal/ui";
@@ -1,10 +1 @@
import type { Pair, PairWithId } from "./PairEditor";
import { generateId } from "@yaakapp-internal/lib/generateId";
// NOTE: Generic so callers keep whatever they passed in (eg. an EditablePair stays editable)
export function ensurePairId<T extends Pair>(p: T): T & PairWithId {
if (typeof p.id === "string") {
return p as T & PairWithId;
}
return { ...p, id: p.id ?? generateId() };
}
export { ensurePairId } from "@yaakapp-internal/ui";
@@ -1,38 +1 @@
import classNames from "classnames";
import { useKeyValue } from "../../hooks/useKeyValue";
import { BulkPairEditor } from "./BulkPairEditor";
import { IconButton } from "./IconButton";
import type { PairEditorProps } from "./PairEditor";
import { PairEditor } from "./PairEditor";
interface Props extends PairEditorProps {
preferenceName: string;
forcedEnvironmentId?: string;
}
export function PairOrBulkEditor({ preferenceName, ...props }: Props) {
const { value: useBulk, set: setUseBulk } = useKeyValue<boolean>({
namespace: "global",
key: ["bulk_edit", preferenceName],
fallback: false,
});
return (
<div className="relative h-full w-full group/wrapper">
{useBulk ? <BulkPairEditor {...props} /> : <PairEditor {...props} />}
<div className="absolute right-0 bottom-0">
<IconButton
size="sm"
variant="border"
title={useBulk ? "Enable form edit" : "Enable bulk edit"}
className={classNames(
"transition-opacity opacity-0 group-hover:opacity-80 hover:opacity-100! shadow",
"bg-surface hover:text group-hover/wrapper:opacity-100",
)}
onClick={() => setUseBulk((b) => !b)}
icon={useBulk ? "table" : "file_code"}
/>
</div>
</div>
);
}
export { PairOrBulkEditor } from "@yaakapp-internal/ui";
@@ -1,14 +1 @@
import classNames from "classnames";
import type { ButtonProps } from "./Button";
import { Button } from "./Button";
export function PillButton({ className, ...props }: ButtonProps) {
return (
<Button
size="2xs"
variant="border"
className={classNames(className, "rounded-full! mx-1 px-3!")}
{...props}
/>
);
}
export { PillButton } from "@yaakapp-internal/ui";
+2 -242
View File
@@ -1,242 +1,2 @@
import { HStack, useStateWithDeps, Label } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { FocusEvent, InputHTMLAttributes, ReactNode } from "react";
import {
forwardRef,
useCallback,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useRandomKey } from "../../hooks/useRandomKey";
import { IconButton } from "./IconButton";
import type { InputProps } from "./Input";
import { generateId } from "@yaakapp-internal/lib/generateId";
export type PlainInputProps = Omit<
InputProps,
| "wrapLines"
| "onKeyDown"
| "type"
| "stateKey"
| "autocompleteVariables"
| "autocompleteFunctions"
| "autocomplete"
| "extraExtensions"
| "forcedEnvironmentId"
> &
Pick<InputHTMLAttributes<HTMLInputElement>, "inputMode" | "onKeyDownCapture" | "step"> & {
onFocusRaw?: InputHTMLAttributes<HTMLInputElement>["onFocus"];
type?: "text" | "password" | "number";
hideObscureToggle?: boolean;
labelRightSlot?: ReactNode;
};
export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(function PlainInput(
{
autoFocus,
autoSelect,
className,
containerClassName,
defaultValue,
disabled,
forceUpdateKey: forceUpdateKeyFromAbove,
help,
hideLabel,
hideObscureToggle,
label,
labelClassName,
labelPosition = "top",
labelRightSlot,
inputMode,
leftSlot,
name,
onBlur,
onChange,
onFocus,
onFocusRaw,
onKeyDownCapture,
onPaste,
placeholder,
required,
rightSlot,
size = "md",
step,
tint,
type = "text",
validate,
},
ref,
) {
// Track a local key for updates. If the default value is changed when the input is not in focus,
// regenerate this to force the field to update.
const [focusedUpdateKey, regenerateFocusedUpdateKey] = useRandomKey();
const forceUpdateKey = `${forceUpdateKeyFromAbove}::${focusedUpdateKey}`;
const [obscured, setObscured] = useStateWithDeps(type === "password", [type]);
const [focused, setFocused] = useState(false);
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle<{ focus: () => void } | null, { focus: () => void } | null>(
ref,
() => inputRef.current,
);
const handleFocus = useCallback(
(e: FocusEvent<HTMLInputElement>) => {
onFocusRaw?.(e);
setFocused(true);
if (autoSelect) {
inputRef.current?.select();
textareaRef.current?.select();
}
onFocus?.();
},
[autoSelect, onFocus, onFocusRaw],
);
const handleBlur = useCallback(() => {
setFocused(false);
onBlur?.();
}, [onBlur]);
// Force input to update when receiving change and not in focus
useLayoutEffect(() => {
const isFocused = document.activeElement === inputRef.current;
if (defaultValue != null && !isFocused) {
regenerateFocusedUpdateKey();
}
}, [regenerateFocusedUpdateKey, defaultValue]);
const id = useRef(`input-${generateId()}`);
const commonClassName = classNames(
className,
"bg-transparent! min-w-0 w-full focus:outline-hidden placeholder:text-placeholder",
"px-2 text-xs font-mono cursor-text",
);
const handleChange = useCallback(
(value: string) => {
onChange?.(value);
setHasChanged(true);
const isValid = (value: string) => {
if (required && !validateRequire(value)) return false;
if (typeof validate === "boolean") return validate;
if (typeof validate === "function" && !validate(value)) return false;
return true;
};
inputRef.current?.setCustomValidity(isValid(value) ? "" : "Invalid value");
},
[onChange, required, setHasChanged, validate],
);
const wrapperRef = useRef<HTMLDivElement>(null);
return (
<div
ref={wrapperRef}
className={classNames(
"w-full",
"pointer-events-auto", // Just in case we're placing in disabled parent
labelPosition === "left" && "flex items-center gap-2",
labelPosition === "top" && "flex-row gap-0.5",
)}
>
<Label
htmlFor={id.current}
className={labelClassName}
visuallyHidden={hideLabel}
required={required}
help={help}
rightSlot={labelRightSlot}
>
{label}
</Label>
<HStack
alignItems="stretch"
className={classNames(
containerClassName,
"x-theme-input",
"relative w-full rounded-md text",
"border",
"overflow-hidden",
focused && !disabled ? "border-border-focus" : "border-border-subtle",
disabled && "border-dotted",
hasChanged && "has-invalid:border-danger", // For built-in HTML validation
size === "md" && "min-h-md",
size === "sm" && "min-h-sm",
size === "xs" && "min-h-xs",
size === "2xs" && "min-h-2xs",
)}
>
{tint != null && (
<div
aria-hidden
className={classNames(
"absolute inset-0 opacity-5 pointer-events-none",
tint === "info" && "bg-info",
tint === "warning" && "bg-warning",
)}
/>
)}
{leftSlot}
<HStack
className={classNames(
"w-full min-w-0",
leftSlot ? "pl-0.5 -ml-2" : null,
rightSlot ? "pr-0.5 -mr-2" : null,
)}
>
<input
id={id.current}
ref={inputRef}
key={forceUpdateKey}
type={type === "password" && !obscured ? "text" : type}
name={name}
// oxlint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
defaultValue={defaultValue ?? undefined}
disabled={disabled}
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
writingsuggestions="false"
inputMode={inputMode}
onChange={(e) => handleChange(e.target.value)}
onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
className={classNames(commonClassName, "h-full disabled:opacity-disabled")}
onFocus={handleFocus}
onBlur={handleBlur}
required={required}
step={step}
placeholder={placeholder}
onKeyDownCapture={onKeyDownCapture}
/>
</HStack>
{type === "password" && !hideObscureToggle && (
<IconButton
title={
obscured
? `Show ${typeof label === "string" ? label : "field"}`
: `Obscure ${typeof label === "string" ? label : "field"}`
}
size="xs"
className="mr-0.5 group/obscure h-auto! my-0.5"
iconClassName="group-hover/obscure:text"
iconSize="sm"
icon={obscured ? "eye" : "eye_closed"}
onClick={() => setObscured((o) => !o)}
/>
)}
{rightSlot}
</HStack>
</div>
);
});
function validateRequire(v: string) {
return v.length > 0;
}
export type { PlainInputProps } from "@yaakapp-internal/ui";
export { PlainInput } from "@yaakapp-internal/ui";
@@ -1,69 +1,2 @@
import type { ReactNode } from "react";
import { useMemo } from "react";
import type { DropdownItem, DropdownItemSeparator, DropdownProps } from "./Dropdown";
import { Dropdown } from "./Dropdown";
import { Icon } from "@yaakapp-internal/ui";
export type RadioDropdownItem<T = string | null> =
| {
type?: "default";
label: ReactNode;
shortLabel?: ReactNode;
value: T;
rightSlot?: ReactNode;
}
| DropdownItemSeparator;
export interface RadioDropdownProps<T = string | null> {
value: T;
onChange: (value: T) => void;
itemsBefore?: DropdownItem[];
items: RadioDropdownItem<T>[];
itemsAfter?: DropdownItem[];
children: DropdownProps["children"];
}
export function RadioDropdown<T = string | null>({
value,
items,
itemsAfter,
itemsBefore,
onChange,
children,
}: RadioDropdownProps<T>) {
const dropdownItems = useMemo(
() => [
...((itemsBefore
? [
...itemsBefore,
{
type: "separator",
hidden: itemsBefore[itemsBefore.length - 1]?.type === "separator",
},
]
: []) as DropdownItem[]),
...items.map((item) => {
if (item.type === "separator") {
return item;
}
return {
key: item.value,
label: item.label,
rightSlot: item.rightSlot,
onSelect: () => onChange(item.value),
leftSlot: <Icon icon={value === item.value ? "check" : "empty"} />,
} as DropdownItem;
}),
...((itemsAfter
? [{ type: "separator", hidden: itemsAfter[0]?.type === "separator" }, ...itemsAfter]
: []) as DropdownItem[]),
],
[itemsBefore, items, itemsAfter, value, onChange],
);
return (
<Dropdown fullWidth items={dropdownItems}>
{children}
</Dropdown>
);
}
export type { RadioDropdownItem, RadioDropdownProps } from "@yaakapp-internal/ui";
export { RadioDropdown } from "@yaakapp-internal/ui";
+2 -148
View File
@@ -1,148 +1,2 @@
import { platform } from "@yaakapp-internal/platform";
import { HStack, Label } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { CSSProperties, ReactNode } from "react";
import { useState } from "react";
import type { ButtonProps } from "./Button";
import { Button } from "./Button";
import type { RadioDropdownItem } from "./RadioDropdown";
import { RadioDropdown } from "./RadioDropdown";
export interface SelectProps<T extends string> {
name: string;
label: string;
labelPosition?: "top" | "left";
labelClassName?: string;
hideLabel?: boolean;
value: T;
help?: ReactNode;
leftSlot?: ReactNode;
options: RadioDropdownItem<T>[];
onChange: (value: T) => void;
defaultValue?: T;
size?: ButtonProps["size"];
className?: string;
disabled?: boolean;
filterable?: boolean;
}
export function Select<T extends string>({
labelPosition = "top",
name,
help,
labelClassName,
disabled,
hideLabel,
label,
value,
options,
leftSlot,
onChange,
className,
defaultValue,
filterable,
size = "md",
}: SelectProps<T>) {
const [focused, setFocused] = useState<boolean>(false);
const id = `input-${name}`;
const isInvalidSelection = options.find((o) => "value" in o && o.value === value) == null;
const handleChange = (value: T) => {
onChange?.(value);
};
return (
<div
className={classNames(
className,
"x-theme-input",
"w-full",
"pointer-events-auto", // Just in case we're placing in disabled parent
labelPosition === "left" && "grid grid-cols-[auto_1fr] items-center gap-2",
labelPosition === "top" && "flex-row gap-0.5",
)}
>
<Label htmlFor={id} visuallyHidden={hideLabel} className={labelClassName} help={help}>
{label}
</Label>
{platform.osType() === "macos" && !filterable ? (
<HStack
space={2}
className={classNames(
"w-full rounded-md text text-sm font-mono",
"pl-2",
"border",
focused && !disabled ? "border-border-focus" : "border-border",
disabled && "border-dotted",
isInvalidSelection && "border-danger",
size === "xs" && "h-xs",
size === "sm" && "h-sm",
size === "md" && "h-md",
)}
>
{leftSlot && <div>{leftSlot}</div>}
<select
value={value}
style={selectBackgroundStyles}
onChange={(e) => handleChange(e.target.value as T)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
disabled={disabled}
className={classNames(
"pr-7 w-full outline-hidden bg-transparent disabled:opacity-disabled",
"leading-none rounded-none", // Center the text better vertically
)}
>
{isInvalidSelection && <option value={"__NONE__"}>-- Select an Option --</option>}
{options.map((o) => {
if (o.type === "separator") return null;
return (
<option key={o.value} value={o.value}>
{o.label}
{o.value === defaultValue && " (default)"}
</option>
);
})}
</select>
</HStack>
) : (
// Use custom "select" component until Tauri can be configured to have select menus not always appear in
// light mode
<RadioDropdown
value={value}
onChange={handleChange}
items={options.map((o) =>
o.type === "separator" || o.value !== defaultValue
? o
: { ...o, label: <>{o.label} (default)</> },
)}
>
<Button
className={classNames(
"w-full text-sm font-mono",
disabled && "border-dotted",
isInvalidSelection && "border-danger",
)}
justify="start"
variant="input"
size={size}
leftSlot={leftSlot}
disabled={disabled}
forDropdown
>
{options.find((o) => o.type !== "separator" && o.value === value)?.label ?? "--"}
</Button>
</RadioDropdown>
)}
</div>
);
}
const selectBackgroundStyles: CSSProperties = {
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`,
backgroundPosition: "right 0.3rem center",
backgroundRepeat: "no-repeat",
backgroundSize: "1.5em 1.5em",
appearance: "none",
printColorAdjust: "exact",
};
export type { SelectProps } from "@yaakapp-internal/ui";
export { Select } from "@yaakapp-internal/ui";
+2 -77
View File
@@ -1,77 +1,2 @@
import type { Color } from "@yaakapp-internal/plugins";
import type { IconProps } from "@yaakapp-internal/ui";
import { IconButton } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ReactNode } from "react";
/**
* A single control attached to a labelled separator, rendered between the label
* and the rule.
*
* Declared rather than passed as a node so the separator keeps ownership of the
* things that are easy to get wrong by hand: matching the label's colour, and
* staying out of the rule's way when the label is long.
*/
export interface SeparatorAction {
icon: IconProps["icon"];
/** Tooltip and accessible name. Required — the control is icon-only. */
title: string;
onClick: () => void;
}
interface Props {
orientation?: "horizontal" | "vertical";
dashed?: boolean;
className?: string;
children?: ReactNode;
color?: Color;
action?: SeparatorAction;
}
export function Separator({
color,
className,
dashed,
orientation = "horizontal",
children,
action,
}: Props) {
return (
<div role="presentation" className={classNames(className, "flex items-center w-full")}>
{children && (
<div className="text-sm text-text-subtlest mr-2 whitespace-nowrap">{children}</div>
)}
{action && (
<IconButton
size="2xs"
iconSize="xs"
className="shrink-0 mr-2 -ml-1"
// Forced, because the button itself sets `text-text` at full strength.
iconClassName="text-text-subtlest!"
icon={action.icon}
title={action.title}
onClick={action.onClick}
/>
)}
<div
className={classNames(
"opacity-60",
// Keep a stub of the line visible no matter how long the label is —
// `w-full` alone gets squeezed to nothing by a wide label.
orientation === "horizontal" && "min-w-8",
color == null && "border-border",
color === "primary" && "border-primary",
color === "secondary" && "border-secondary",
color === "success" && "border-success",
color === "notice" && "border-notice",
color === "warning" && "border-warning",
color === "danger" && "border-danger",
color === "info" && "border-info",
dashed && "border-dashed",
orientation === "horizontal" && "w-full h-0 border-t",
orientation === "vertical" && "h-full w-0 border-l",
)}
/>
</div>
);
}
export type { SeparatorAction } from "@yaakapp-internal/ui";
export { Separator } from "@yaakapp-internal/ui";
+25 -365
View File
@@ -1,175 +1,36 @@
import type { AnyModel } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import classNames from "classnames";
import type { ReactNode } from "react";
import { CopyIconButton } from "../CopyIconButton";
import { Checkbox } from "./Checkbox";
import { IconButton, type IconButtonProps } from "./IconButton";
import { PlainInput } from "./PlainInput";
import type { RadioDropdownItem } from "./RadioDropdown";
import { Select } from "./Select";
import {
SettingRow,
type SettingRowBaseProps,
SettingRowBoolean,
SettingRowNumber,
SettingRowSelect,
SettingRowText,
SettingSelectControl,
} from "@yaakapp-internal/ui";
import { SelectFile } from "../SelectFile";
export type { SettingRowBaseProps, SettingSnippetLanguage } from "@yaakapp-internal/ui";
export {
SettingCommand,
SettingOverrideRow,
SettingRow,
SettingRowBoolean,
SettingRowNumber,
SettingRowSelect,
SettingRowText,
SettingSelectControl,
SettingSnippet,
SettingsList,
SettingsSection,
SettingValue,
} from "@yaakapp-internal/ui";
type ModelKeyOfValue<T, V> = {
[K in keyof T]-?: T[K] extends V ? K : never;
}[keyof T];
type SettingRowBaseProps = {
className?: string;
controlClassName?: string;
description?: ReactNode;
disabled?: boolean;
title: ReactNode;
};
export function SettingsList({ children, className }: { children: ReactNode; className?: string }) {
return <div className={classNames("w-full", className)}>{children}</div>;
}
export function SettingsSection({
children,
className,
description,
title,
}: {
children: ReactNode;
className?: string;
description?: ReactNode;
title: ReactNode | null;
}) {
const showHeader = title != null || description != null;
return (
<section className={classNames(className, "w-full")}>
{showHeader && (
<div className="border-b border-border-subtle pb-2">
{title != null && <div className="text-text-subtle">{title}</div>}
{description != null && <p className="mt-1 text-sm text-text-subtlest">{description}</p>}
</div>
)}
<div className="[&>*:last-child]:border-b-0">{children}</div>
</section>
);
}
export function SettingRow({
children,
className,
controlClassName,
description,
disabled,
title,
}: {
children: ReactNode;
} & SettingRowBaseProps) {
return (
<div
aria-disabled={disabled || undefined}
className={classNames(
className,
"@container border-b border-border-subtle py-4",
disabled && "opacity-disabled",
)}
>
<div
className={classNames(
"grid grid-cols-1 gap-2",
"@[30rem]:grid-cols-[minmax(0,1fr)_auto] items-center",
)}
>
<div className="min-w-0">
<div className="text text-text">{title}</div>
{description != null && (
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
)}
</div>
<div
className={classNames(
"flex min-w-0 items-center justify-start @[40rem]:justify-end",
controlClassName,
)}
>
{children}
</div>
</div>
</div>
);
}
export function SettingValue({
actions,
className,
copyText,
enableCopy = true,
value,
}: {
actions?: SettingValueAction[];
className?: string;
copyText?: string;
enableCopy?: boolean;
value: ReactNode;
}) {
const textValue = typeof value === "string" || typeof value === "number" ? `${value}` : null;
const textToCopy = copyText ?? textValue;
return (
<>
<span
className={classNames(
className,
"cursor-text select-text truncate font-mono text-editor text-text-subtle pr-1.5",
)}
>
{value}
</span>
{actions?.map((action) => (
<IconButton
key={action.title}
icon={action.icon}
title={action.title}
size="2xs"
iconSize="sm"
onClick={action.onClick}
/>
))}
{enableCopy && textToCopy != null && (
<CopyIconButton size="2xs" text={textToCopy} title="Copy value" />
)}
</>
);
}
type SettingValueAction = {
icon: IconButtonProps["icon"];
onClick: () => void;
title: string;
};
export function SettingRowBoolean({
checked,
checkboxSize = "md",
onChange,
title,
...props
}: {
checked: boolean;
checkboxSize?: "sm" | "md";
onChange: (checked: boolean) => void;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<Checkbox
hideLabel
size={checkboxSize}
checked={checked}
disabled={props.disabled}
title={title}
onChange={onChange}
/>
</SettingRow>
);
}
export function ModelSettingRowBoolean<M extends AnyModel, K extends ModelKeyOfValue<M, boolean>>({
model,
modelKey,
@@ -187,50 +48,6 @@ export function ModelSettingRowBoolean<M extends AnyModel, K extends ModelKeyOfV
);
}
export function SettingRowNumber({
inputClassName,
inputWidthClassName = "w-48!",
name,
onChange,
placeholder,
required,
title,
type = "number",
validate,
value,
...props
}: {
inputClassName?: string;
inputWidthClassName?: string;
name: string;
onChange: (value: number) => void;
placeholder?: string;
required?: boolean;
type?: "number";
validate?: (value: string) => boolean;
value: number;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<PlainInput
required={required}
hideLabel
size="sm"
name={name}
label={typeof title === "string" ? title : name}
placeholder={placeholder}
defaultValue={`${value}`}
validate={validate}
onChange={(value) => onChange(Number.parseInt(value, 10) || 0)}
type={type}
className={inputClassName}
containerClassName={inputWidthClassName}
disabled={props.disabled}
/>
</SettingRow>
);
}
export function ModelSettingRowNumber<M extends AnyModel, K extends ModelKeyOfValue<M, number>>({
model,
modelKey,
@@ -249,47 +66,6 @@ export function ModelSettingRowNumber<M extends AnyModel, K extends ModelKeyOfVa
);
}
export function SettingRowText({
inputClassName,
inputWidthClassName = "w-80!",
name,
onChange,
placeholder,
required,
title,
type = "text",
value,
...props
}: {
inputClassName?: string;
inputWidthClassName?: string;
name: string;
onChange: (value: string) => void;
placeholder?: string;
required?: boolean;
type?: "text" | "password";
value: string;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<PlainInput
required={required}
hideLabel
size="sm"
name={name}
label={typeof title === "string" ? title : name}
placeholder={placeholder}
defaultValue={value}
onChange={onChange}
type={type}
className={inputClassName}
containerClassName={inputWidthClassName}
disabled={props.disabled}
/>
</SettingRow>
);
}
export function ModelSettingRowText<M extends AnyModel, K extends ModelKeyOfValue<M, string>>({
model,
modelKey,
@@ -353,74 +129,6 @@ export function SettingRowDirectory({
return <SettingRowFile directory noun={noun} {...props} />;
}
export function SettingRowSelect<T extends string>({
defaultValue,
name,
onChange,
options,
selectClassName = "w-48!",
title,
value,
...props
}: {
defaultValue?: T;
name: string;
onChange: (value: T) => void;
options: RadioDropdownItem<T>[];
selectClassName?: string;
value: T;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<SettingSelectControl
name={name}
label={typeof title === "string" ? title : name}
value={value}
defaultValue={defaultValue}
selectClassName={selectClassName}
disabled={props.disabled}
onChange={onChange}
options={options}
/>
</SettingRow>
);
}
export function SettingSelectControl<T extends string>({
defaultValue,
disabled,
label,
name,
onChange,
options,
selectClassName = "w-48!",
value,
}: {
defaultValue?: T;
disabled?: boolean;
label: string;
name: string;
onChange: (value: T) => void;
options: RadioDropdownItem<T>[];
selectClassName?: string;
value: T;
}) {
return (
<Select
hideLabel
name={name}
value={value}
defaultValue={defaultValue}
label={label}
size="sm"
className={selectClassName}
disabled={disabled}
onChange={onChange}
options={options}
/>
);
}
export function ModelSettingSelectControl<
M extends AnyModel,
K extends ModelKeyOfValue<M, string>,
@@ -464,51 +172,3 @@ export function ModelSettingRowSelect<
/>
);
}
export function SettingOverrideRow({
children,
className,
controlClassName,
description,
disabled,
onResetOverride,
overridden,
resetTitle = "Reset override",
title,
}: {
children: ReactNode;
className?: string;
controlClassName?: string;
description?: ReactNode;
disabled?: boolean;
onResetOverride: () => void;
overridden: boolean;
resetTitle?: string;
title: ReactNode;
}) {
return (
<SettingRow
className={className}
controlClassName={controlClassName}
description={description}
disabled={disabled}
title={
<span className="inline-flex items-center gap-1.5">
{title}
{overridden && (
<IconButton
icon="undo_2"
size="2xs"
iconSize="sm"
title={resetTitle}
className="text-text-subtle"
onClick={onResetOverride}
/>
)}
</span>
}
>
{children}
</SettingRow>
);
}
+10 -81
View File
@@ -1,99 +1,28 @@
import {
Icon,
type StoredValue,
TabContent as BaseTabContent,
type TabContentProps,
type TabItem as BaseTabItem,
type TabItem,
Tabs as BaseTabs,
type TabsProps as BaseTabsProps,
type TabsProps,
type TabsRef,
type TabsStorage,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ReactNode } from "react";
import { forwardRef, memo, useMemo } from "react";
import { useKeyValue } from "../../../hooks/useKeyValue";
import { forwardRef, memo } from "react";
import { ErrorBoundary } from "../../ErrorBoundary";
import { Button } from "../Button";
import type { RadioDropdownProps } from "../RadioDropdown";
import { RadioDropdown } from "../RadioDropdown";
export type { TabsRef };
export type { TabItem, TabsRef };
export type TabItem =
| Extract<BaseTabItem, { label: string }>
| {
value: string;
options: Omit<RadioDropdownProps, "children">;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
};
type Props = Omit<BaseTabsProps, "tabs" | "useStoredValue"> & { tabs: TabItem[] };
export const Tabs = forwardRef<TabsRef, Props>(function Tabs({ tabs, ...props }, ref) {
const baseTabs = useMemo(() => tabs.map(toBaseTab), [tabs]);
return <BaseTabs ref={ref} tabs={baseTabs} useStoredValue={useTabsStoredValue} {...props} />;
});
// The client's EditorHostProvider keeps tab order and the active tab in its key-value store
export const Tabs = forwardRef<TabsRef, Omit<TabsProps, "useStoredValue">>(
function Tabs(props, ref) {
return <BaseTabs ref={ref} {...props} />;
},
);
export const TabContent = memo(function TabContent(props: Omit<TabContentProps, "ErrorBoundary">) {
return <BaseTabContent {...props} ErrorBoundary={ErrorBoundary} />;
});
function toBaseTab(tab: TabItem): BaseTabItem {
if (!("options" in tab)) return tab;
const { options } = tab;
return {
value: tab.value,
leftSlot: tab.leftSlot,
rightSlot: tab.rightSlot,
renderButton: ({ buttonProps, isActive }) => {
const option = options.items.find((i) => "value" in i && i.value === options.value);
return (
<RadioDropdown
key={tab.value}
items={options.items}
itemsAfter={options.itemsAfter}
itemsBefore={options.itemsBefore}
value={options.value}
onChange={options.onChange}
>
<Button
leftSlot={tab.leftSlot}
rightSlot={
<div className="flex items-center">
{tab.rightSlot}
<Icon
size="sm"
icon="chevron_down"
className={classNames(
"ml-1",
isActive ? "text-text-subtle" : "text-text-subtlest",
)}
/>
</div>
}
{...buttonProps}
>
{option && "shortLabel" in option && option.shortLabel
? option.shortLabel
: (option?.label ?? "Unknown")}
</Button>
</RadioDropdown>
);
},
};
}
function useTabsStoredValue<T extends StoredValue>(key: string | string[] | null, fallback: T) {
const { value, set } = useKeyValue<T>({
namespace: "no_sync",
key: key ?? ["tabs", "default"],
fallback,
});
return [value ?? fallback, set] as const;
}
/**
* Programmatically set the active tab for a Tabs component that uses storageKey + activeTabKey.
* This is useful when you need to change the tab from outside the component (e.g., in response to an event).
+2 -148
View File
@@ -1,148 +1,2 @@
import type { ShowToastRequest } from "@yaakapp-internal/plugins";
import { Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import * as m from "motion/react-m";
import type { ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useKey } from "react-use";
import { IconButton } from "./IconButton";
export interface ToastProps {
children: ReactNode;
open: boolean;
onClose: () => void;
className?: string;
timeout: number | null;
action?: (args: { hide: () => void }) => ReactNode;
icon?: ShowToastRequest["icon"] | null;
color?: ShowToastRequest["color"];
// Grow with the content (up to the viewport) instead of scrolling internally
// past the default max height
dynamicHeight?: boolean;
// Hide the close button, for toasts that render their own dismiss action.
// Escape still closes the toast
hideDismiss?: boolean;
}
const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, IconProps["icon"] | null> = {
custom: null,
danger: "alert_triangle",
info: "info",
notice: "alert_triangle",
primary: "info",
secondary: "info",
success: "check_circle",
warning: "alert_triangle",
};
export function Toast({
children,
open,
onClose,
timeout,
action,
icon,
color,
dynamicHeight,
hideDismiss,
}: ToastProps) {
const onCloseRef = useRef(onClose);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [autoHideCanceled, setAutoHideCanceled] = useState(false);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
const cancelAutoHide = useCallback(() => {
if (timeoutRef.current == null) return;
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
setAutoHideCanceled(true);
}, []);
useEffect(() => {
if (!open || timeout == null || autoHideCanceled) return;
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null;
onCloseRef.current();
}, timeout);
return () => {
if (timeoutRef.current == null) return;
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
};
}, [autoHideCanceled, open, timeout]);
useKey(
"Escape",
() => {
if (!open) return;
onClose();
},
{},
[open],
);
const toastIcon = icon === null ? null : (icon ?? (color && color in ICONS && ICONS[color]));
return (
<m.div
initial={{ opacity: 0, right: "-10%" }}
animate={{ opacity: 100, right: 0 }}
exit={{ opacity: 0, right: "-100%" }}
transition={{ duration: 0.2 }}
className={classNames("bg-surface m-2 rounded-lg")}
>
<div
className={classNames(
`x-theme-toast x-theme-toast--${color}`,
"pointer-events-auto overflow-hidden",
"relative pointer-events-auto bg-surface text-text rounded-lg",
"border border-border shadow-lg w-100",
)}
onFocusCapture={cancelAutoHide}
onKeyDownCapture={cancelAutoHide}
onPointerDownCapture={cancelAutoHide}
>
<div
className={classNames(
"pl-3 py-3 flex items-start gap-2 w-full overflow-auto",
hideDismiss ? "pr-3" : "pr-10",
dynamicHeight ? "max-h-[80vh]" : "max-h-44",
)}
>
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 shrink-0" />}
<VStack space={2} className="w-full min-w-0">
<div className="select-auto">{children}</div>
{action?.({ hide: onClose })}
</VStack>
</div>
{!hideDismiss && (
<IconButton
color={color}
variant="border"
className="opacity-60 border-0 absolute! top-2 right-2"
title="Dismiss"
icon="x"
onClick={onClose}
/>
)}
{timeout != null && !autoHideCanceled && (
<div className="w-full absolute bottom-0 left-0 right-0">
<m.div
className="bg-surface-highlight h-[3px]"
initial={{ width: "100%" }}
animate={{ width: "0%", opacity: 0.2 }}
transition={{ duration: timeout / 1000, ease: "linear" }}
/>
</div>
)}
</div>
</m.div>
);
}
export type { ToastProps } from "@yaakapp-internal/ui";
export { Toast } from "@yaakapp-internal/ui";
@@ -1,6 +1,15 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { extractSseValueAtPath, type ServerSentEvent } from "@yaakapp-internal/sse";
import { HStack, Icon, InlineCode, SplitLayout, VStack, IconTooltip } from "@yaakapp-internal/ui";
import {
EventStreamViewer as SharedEventStreamViewer,
HStack,
Icon,
InlineCode,
SplitLayout,
type StreamEvent,
VStack,
IconTooltip,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { CSSProperties, ReactNode } from "react";
import { Fragment, useMemo, useState } from "react";
@@ -20,8 +29,7 @@ import type { DropdownItem } from "../core/Dropdown";
import { Dropdown } from "../core/Dropdown";
import type { EditorProps } from "../core/Editor/Editor";
import { Editor } from "../core/Editor/LazyEditor";
import { EventDetailHeader, EventViewer } from "../core/EventViewer";
import { EventViewerRow } from "../core/EventViewerRow";
import { EventDetailHeader } from "../core/EventViewer";
import { IconButton } from "../core/IconButton";
import { Input } from "../core/Input";
import { Select } from "../core/Select";
@@ -62,6 +70,7 @@ function ActualEventStreamViewer({ response }: Props) {
});
const summarySettings = useSseSummaryResultKeyPath({ response });
const events = useResponseBodyEventSource(response);
const streamEvents = useMemo(() => (events.data ?? []).map(toStreamEvent), [events.data]);
const summary = useResponseBodySseSummary(response, summarySettings.resultKeyPath);
const showExtractedText = summarySettings.resultKeyPath != null;
const showResultKeyPathWarning =
@@ -105,10 +114,7 @@ function ActualEventStreamViewer({ response }: Props) {
),
},
],
[
applyToDetailsSetting,
filterEventPreviewsSetting,
],
[applyToDetailsSetting, filterEventPreviewsSetting],
);
return (
@@ -175,30 +181,21 @@ function ActualEventStreamViewer({ response }: Props) {
resizeHandleClassName="hover:bg-surface-highlight active:bg-surface-highlight"
firstSlot={({ style }) => (
<div style={style} className="min-h-0">
<EventViewer
events={events.data ?? []}
getEventKey={(_, index) => String(index)}
<SharedEventStreamViewer
events={streamEvents}
error={events.error ? String(events.error) : null}
splitLayoutStorageKey="sse_events"
defaultRatio={0.4}
renderRow={({ event, index, isActive, onClick }) => (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />}
content={
<HStack space={2} className="items-center">
<EventLabels event={event} index={index} isActive={isActive} />
<span className="truncate text-xs">
{getEventPreview(event, summarySettings.resultKeyPath, filterEventPreviews)}
</span>
</HStack>
}
/>
)}
getPreview={(event) =>
getEventPreview(
sseEventOf(events.data, event),
summarySettings.resultKeyPath,
filterEventPreviews,
)
}
renderDetail={({ event, index, onClose }) => (
<EventDetail
event={event}
event={sseEventOf(events.data, event)}
index={index}
applyJsonPath={applyToDetails}
resultKeyPath={summarySettings.resultKeyPath}
@@ -312,6 +309,23 @@ function SseSummaryFooter({
);
}
/** An SSE event as the shared viewer lists it: received, with its id (or place) and type */
function toStreamEvent(event: ServerSentEvent, index: number): StreamEvent {
return {
key: String(index),
direction: "received",
kind: event.eventType || null,
labels: [String(event.id ?? index), ...(event.eventType ? [event.eventType] : [])],
timestamp: null,
size: null,
preview: event.data,
};
}
function sseEventOf(events: ServerSentEvent[] | undefined, event: StreamEvent): ServerSentEvent {
return events?.[Number(event.key)] ?? { data: "", eventType: "", id: null, retry: null };
}
function getEventPreview(
event: ServerSentEvent,
resultKeyPath: string | null,
@@ -1,118 +0,0 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { useQueryClient } from "@tanstack/react-query";
import { languageFromContentType, type TextFilterApi, WebPageViewer } from "@yaakapp-internal/ui";
import type { CodeEditorProps } from "@yaakapp-internal/ui/editor";
import { useCallback } from "react";
import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse";
import { useRecentFilters } from "../../hooks/useRecentFilters";
import { responseBodyTextQuery, useResponseBodyText } from "../../hooks/useResponseBodyText";
import { useSaveResponse } from "../../hooks/useSaveResponse";
import { getContentTypeFromHeaders } from "../../lib/model_util";
import { IconButton } from "../core/IconButton";
import { EmptyStateText } from "../EmptyStateText";
import { RecentFiltersDropdown } from "./RecentFiltersDropdown";
import { TextViewer } from "./TextViewer";
interface Props {
response: HttpResponse;
pretty: boolean;
textViewerClassName?: string;
}
export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Props) {
const rawTextBody = useResponseBodyText({ response, filter: null });
const contentType = getContentTypeFromHeaders(response.headers);
const language = languageFromContentType(contentType, rawTextBody.data ?? "");
if (rawTextBody.isLoading || response.state === "initialized") {
return null;
}
if (language === "html" && pretty) {
return <WebPageViewer html={rawTextBody.data ?? ""} baseUrl={response.url} />;
}
if (rawTextBody.data == null) {
return <EmptyStateText>Empty response</EmptyStateText>;
}
return (
<HttpTextViewer
response={response}
text={rawTextBody.data}
language={language}
pretty={pretty}
className={textViewerClassName}
/>
);
}
interface HttpTextViewerProps {
response: HttpResponse;
text: string;
language: CodeEditorProps["language"];
pretty: boolean;
className?: string;
}
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
const queryClient = useQueryClient();
const filterStateKey = `response.body.${response.requestId}`;
// Shares the response body query's cache, so applying and showing a filter cost one RPC
const filterText = useCallback(
(f: string) => queryClient.fetchQuery(responseBodyTextQuery({ response, filter: f })),
[queryClient, response],
);
const { recentFilters, addFilter, removeFilter, togglePin, clearFilters } =
useRecentFilters(filterStateKey);
const filterLeftSlot = useCallback(
(filter: TextFilterApi) => (
<div className="py-0.5 flex">
<RecentFiltersDropdown
recentFilters={recentFilters}
activeFilter={filter.appliedFilter}
onSelect={filter.replaceFilter}
onRemove={removeFilter}
onTogglePin={togglePin}
onClear={clearFilters}
/>
</div>
),
[recentFilters, removeFilter, togglePin, clearFilters],
);
const saveResponse = useSaveResponse(response);
const copyResponse = useCopyHttpResponse(response);
const actionsDisabled = response.state !== "closed" && response.status >= 100;
return (
<TextViewer
text={text}
language={language}
stateKey={`response.body.${response.id}`}
pretty={pretty}
className={className}
footerActions={[
<IconButton
key="save"
size="sm"
icon="save"
title="Save response to file"
disabled={actionsDisabled}
onClick={() => saveResponse.mutate()}
className="border !border-border-subtle"
/>,
<IconButton
key="copy"
size="sm"
icon="copy"
title="Copy response body"
disabled={actionsDisabled}
onClick={() => copyResponse.mutate()}
className="border !border-border-subtle"
/>,
]}
filterText={filterText}
filterStateKey={filterStateKey}
onFilterApplied={addFilter}
filterLeftSlot={filterLeftSlot}
/>
);
}
+1 -41
View File
@@ -1,41 +1 @@
import type { RefObject } from "react";
import { useEffect, useRef } from "react";
/**
* Get notified when a mouse click happens outside the target ref
* @param ref The element to be notified when a mouse click happens outside it
* @param onClickAway
* @param ignored Optional outside element to ignore (useful for dropdown triggers)
*/
export function useClickOutside(
ref: RefObject<HTMLElement | null>,
onClickAway: (event: MouseEvent) => void,
ignored?: RefObject<HTMLElement | null>,
) {
const savedCallback = useRef(onClickAway);
useEffect(() => {
savedCallback.current = onClickAway;
}, [onClickAway]);
useEffect(() => {
const handler = (event: MouseEvent) => {
if (ref.current == null || !(event.target instanceof HTMLElement)) {
return;
}
const isIgnored = ignored?.current?.contains(event.target);
const clickedOutside = !ref.current.contains(event.target);
if (!isIgnored && clickedOutside) {
savedCallback.current(event);
}
};
// NOTE: We're using mousedown instead of click to handle some edge cases like when a context
// menu is open with the ctrl key.
document.addEventListener("mousedown", handler, { capture: true });
document.addEventListener("contextmenu", handler, { capture: true });
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("contextmenu", handler);
};
}, [ignored, ref]);
}
export { useClickOutside } from "@yaakapp-internal/ui";
+44 -84
View File
@@ -1,11 +1,26 @@
import { platform } from "@yaakapp-internal/platform";
import { debounce } from "@yaakapp-internal/lib";
import { settingsAtom } from "@yaakapp-internal/models";
import {
formatHotkey,
type HotkeyContextValue,
type HotKeyOptions,
useFormattedHotkey,
useHotKey,
} from "@yaakapp-internal/ui";
import { atom, useAtomValue } from "jotai";
import { useEffect } from "react";
import { capitalize } from "../lib/capitalize";
import { useEffect, useMemo } from "react";
import { jotaiStore } from "../lib/jotai";
export type { HotKeyOptions };
export { useFormattedHotkey, useHotKey };
declare module "@yaakapp-internal/ui" {
interface HotkeyActionRegistry {
action: HotkeyAction;
}
}
const HOLD_KEYS = ["Shift", "Control", "Command", "Alt", "Meta"];
const SINGLE_WHITELIST = ["Delete", "Enter", "Backspace"];
@@ -188,12 +203,6 @@ export const hotkeyActions: HotkeyAction[] = (
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
});
export type HotKeyOptions = {
enable?: boolean | (() => boolean);
priority?: number;
allowDefault?: boolean;
};
interface Callback {
action: HotkeyAction;
callback: (e: KeyboardEvent) => void;
@@ -210,25 +219,35 @@ const clearCurrentKeysDebounced = debounce(() => {
jotaiStore.set(currentKeysAtom, new Set([]));
}, 5000);
export function useHotKey(
action: HotkeyAction | null,
function registerHotkey(
action: HotkeyAction,
callback: (e: KeyboardEvent) => void,
options: HotKeyOptions = {},
options: HotKeyOptions,
) {
useEffect(() => {
if (action == null) return;
jotaiStore.set(callbacksAtom, (prev) => {
const without = prev.filter((cb) => {
const isTheSame = cb.action === action && cb.options.priority === options.priority;
return !isTheSame;
});
const newCb: Callback = { action, callback, options };
return [...without, newCb];
jotaiStore.set(callbacksAtom, (prev) => {
const without = prev.filter((cb) => {
const isTheSame = cb.action === action && cb.options.priority === options.priority;
return !isTheSame;
});
return () => {
jotaiStore.set(callbacksAtom, (prev) => prev.filter((cb) => cb.callback !== callback));
};
}, [action, callback, options]);
const newCb: Callback = { action, callback, options };
return [...without, newCb];
});
return () => {
jotaiStore.set(callbacksAtom, (prev) => prev.filter((cb) => cb.callback !== callback));
};
}
export function useHotkeyContextValue(): HotkeyContextValue {
const hotkeys = useAtomValue(hotkeysAtom);
return useMemo(
() => ({
os: platform.osType(),
bindingFor: (action: HotkeyAction) => hotkeys[action] ?? null,
labelFor: (action: HotkeyAction) => hotkeyLabels[action],
register: registerHotkey,
}),
[hotkeys],
);
}
export function useSubscribeHotKeys() {
@@ -330,66 +349,7 @@ export function getHotkeyScope(action: HotkeyAction): string {
}
export function formatHotkeyString(trigger: string): string[] {
const os = platform.osType();
const parts = trigger.split("+");
const labelParts: string[] = [];
for (const p of parts) {
if (os === "macos") {
if (p === "Meta") {
labelParts.push("⌘");
} else if (p === "Shift") {
labelParts.push("⇧");
} else if (p === "Control") {
labelParts.push("⌃");
} else if (p === "Alt") {
labelParts.push("⌥");
} else if (p === "Enter") {
// U+21A9 has an emoji presentation, which Chromium's font fallback picks
// (a blue glyph among monochrome ones). U+FE0E forces the text form.
labelParts.push("↩︎");
} else if (p === "Tab") {
labelParts.push("⇥");
} else if (p === "Backspace") {
labelParts.push("⌫");
} else if (p === "Delete") {
labelParts.push("⌦");
} else if (p === "Minus") {
labelParts.push("-");
} else if (p === "Plus") {
labelParts.push("+");
} else if (p === "Equal") {
labelParts.push("=");
} else if (p === "Space") {
labelParts.push("Space");
} else {
labelParts.push(capitalize(p));
}
} else {
if (p === "Control") {
labelParts.push("Ctrl");
} else if (p === "Space") {
labelParts.push("Space");
} else {
labelParts.push(capitalize(p));
}
}
}
if (os === "macos") {
return labelParts;
}
return [labelParts.join("+")];
}
export function useFormattedHotkey(action: HotkeyAction | null): string[] | null {
const hotkeys = useAtomValue(hotkeysAtom);
const trigger = action != null ? (hotkeys[action]?.[0] ?? null) : null;
if (trigger == null) {
return null;
}
return formatHotkeyString(trigger);
return formatHotkey(trigger, platform.osType());
}
function compareKeys(keysA: string[], keysB: string[]) {
@@ -0,0 +1,64 @@
import { useQueryClient } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import type { HttpBodyLoader, HttpBodyResult } from "@yaakapp-internal/ui";
import { useMemo } from "react";
import { getResponseBodyText } from "../lib/responseBody";
import { getRequestBodyText, useHttpRequestBody } from "./useHttpRequestBody";
import {
responseBodyTextQuery,
useResponseBodyBytes,
useResponseBodyText,
} from "./useResponseBodyText";
import { useResponseBodyUrl } from "./useResponseBodyUrl";
interface QueryLike<T> {
data: T | undefined;
isLoading: boolean;
error: Error | null;
}
function result<T>({ data, isLoading, error }: QueryLike<T>): HttpBodyResult<T> {
return { data, isLoading, error };
}
const noUrl: HttpBodyResult<string | null> = { data: null, isLoading: false, error: null };
/** Reads a response's stored body the ways the shared viewers ask for it */
export function useResponseBodyLoader(response: HttpResponse | null): HttpBodyLoader | null {
const queryClient = useQueryClient();
return useMemo(() => {
if (response == null) return null;
return {
useText: () => result(useResponseBodyText({ response, filter: null })),
useBytes: () => result(useResponseBodyBytes({ response })),
useUrl: (enabled: boolean) => {
// Waiting on a URL that isn't asked for yet still counts as loading
const url = useResponseBodyUrl(enabled ? response : null);
return { data: url.data, isLoading: url.isPending, error: url.error };
},
// Shares the response body query's cache, so applying and showing a filter cost one RPC
filterText: (filter: string) =>
queryClient.fetchQuery(responseBodyTextQuery({ response, filter })),
getText: () => getResponseBodyText({ response, filter: null }),
};
}, [queryClient, response]);
}
/** Reads the body a response's request was sent with */
export function useRequestBodyLoader(response: HttpResponse | null): HttpBodyLoader | null {
return useMemo(() => {
if (response == null) return null;
return {
useText: () => {
const body = useHttpRequestBody(response);
return { ...result(body), data: body.data?.bodyText ?? null };
},
useBytes: () => {
const body = useHttpRequestBody(response);
return { ...result(body), data: body.data?.body ?? null };
},
useUrl: () => noUrl,
getText: () => getRequestBodyText(response).then((d) => d?.bodyText ?? ""),
};
}, [response]);
}
+3 -1
View File
@@ -1,5 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import { decodeBodyText } from "@yaakapp-internal/lib/decodeBodyText";
import { getContentTypeFromHeaders } from "../lib/model_util";
import { rpc } from "../lib/rpc";
export function useHttpRequestBody(response: HttpResponse | null) {
@@ -27,6 +29,6 @@ export async function getRequestBodyText(response: HttpResponse | null) {
}
const body = new Uint8Array(data);
const bodyText = new TextDecoder("utf-8", { fatal: false }).decode(body);
const bodyText = decodeBodyText(body, getContentTypeFromHeaders(response.requestHeaders));
return { body, bodyText };
}
+17 -1
View File
@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useQueries, useQuery } from "@tanstack/react-query";
import type { Plugin } from "@yaakapp-internal/models";
import { pluginsAtom } from "@yaakapp-internal/models";
import type { PluginMetadata } from "@yaakapp-internal/plugins";
@@ -24,6 +24,22 @@ export function usePluginInfo(id: string | null) {
});
}
/** Every plugin with its metadata, once loaded; the ones still loading are left out. */
export function usePluginInfos() {
const plugins = useAtomValue(pluginsAtom);
return useQueries({
queries: plugins.map((plugin) => ({
queryKey: pluginInfoKey(plugin.id, plugin),
queryFn: () => rpc<PluginMetadata>("cmd_plugin_info", { id: plugin.id }),
})),
combine: (results) =>
results.flatMap((result, i) => {
const plugin = plugins[i];
return result.data == null || plugin == null ? [] : [{ plugin, info: result.data }];
}),
});
}
export function invalidateAllPluginInfo() {
queryClient.invalidateQueries({ queryKey: ["plugin_info"] }).catch(console.error);
}
+1 -8
View File
@@ -1,8 +1 @@
import { useCallback, useState } from "react";
import { generateId } from "@yaakapp-internal/lib/generateId";
export function useRandomKey(initialValue?: string) {
const [value, setValue] = useState<string>(initialValue ?? generateId());
const regenerate = useCallback(() => setValue(generateId()), []);
return [value, regenerate] as const;
}
export { useRandomKey } from "@yaakapp-internal/ui";
@@ -1,4 +1,9 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
// Loaded once as the file is collected (the mocks below are hoisted above it), so the first
// transform of its module graph, the shared UI package among it, is not timed against whichever
// test imports it first. Each test still gets the module fresh after `vi.resetModules()`, which
// reuses that transform.
import "./useSendAnyHttpRequest";
const mocks = vi.hoisted(() => ({
platform: { httpProxyUrl: "https://web.yaak.test" as string | null },
+76
View File
@@ -0,0 +1,76 @@
import { useFonts } from "@yaakapp-internal/fonts";
import { useLicense } from "@yaakapp-internal/license";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
import type { SettingsCallbacks, SettingsView } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { openExternalUrl } from "../components/core/Link";
import { appInfo } from "../lib/appInfo";
import { pricingUrl } from "../lib/pricingUrl";
import { revealInFinderText } from "../lib/reveal";
import { rpc } from "../lib/rpc";
import {
licenseStatusFromCheck,
settingsValuesFromModel,
themesViewFromResolved,
} from "../lib/settingsView";
import { useCheckForUpdates } from "./useCheckForUpdates";
import { useResolvedAppearance } from "./useResolvedAppearance";
import { useResolvedTheme } from "./useResolvedTheme";
/** Binds the shared settings tabs to the client's settings model, themes, fonts and license */
export function useSettingsView(): { view: SettingsView; callbacks: SettingsCallbacks } | null {
const settings = useAtomValue(settingsAtom);
const appearance = useResolvedAppearance();
const activeTheme = useResolvedTheme();
const fonts = useFonts();
const { check, activate, deactivate } = useLicense();
const checkForUpdates = useCheckForUpdates();
if (settings == null) {
return null;
}
const view: SettingsView = {
app: {
name: "Yaak",
version: appInfo.version,
dataDir: appInfo.appDataDir,
logDir: appInfo.appLogDir,
revealPathLabel: revealInFinderText,
},
values: settingsValuesFromModel(settings),
themes: themesViewFromResolved(activeTheme.data, appearance),
fonts: fonts.data ?? null,
windowControlsSetting: platform.osType() !== "macos",
updates: appInfo.featureUpdater ? { checking: checkForUpdates.isPending } : null,
feedbackPrompts: appInfo.featureLicense,
license: appInfo.featureLicense
? {
pending: check.isPending,
status: licenseStatusFromCheck(check.data),
checkError: check.error ?? null,
activateError: activate.error ?? null,
activating: activate.isPending,
}
: null,
};
const callbacks: SettingsCallbacks = {
onChange: async (patch) => {
await patchModel(settings, patch);
},
onApplyNativeTitlebar: async (useNativeTitlebar) => {
await patchModel(settings, { useNativeTitlebar });
await rpc("cmd_restart");
},
onCheckForUpdates: () => checkForUpdates.mutate(),
onActivateLicense: (licenseKey) => activate.mutateAsync({ licenseKey }),
onDeactivateLicense: () => deactivate.mutate(),
onOpenUrl: openExternalUrl,
onRevealPath: (path) => platform.revealItemInDir(path),
pricingUrl,
};
return { view, callbacks };
}
+2 -7
View File
@@ -1,7 +1,2 @@
import { useCallback, useState } from "react";
export function useToggle(initialValue = false) {
const [value, setValue] = useState<boolean>(initialValue);
const toggle = useCallback(() => setValue((v) => !v), []);
return [value, toggle, setValue] as const;
}
// From its own entry rather than the package index, so tests that mock the index keep it
export { useToggle } from "@yaakapp-internal/ui/useToggle";
@@ -0,0 +1,31 @@
import { useMutation } from "@tanstack/react-query";
import { uninstallPlugin } from "@yaakapp-internal/plugins";
import { InlineCode } from "@yaakapp-internal/ui";
import { showConfirmDelete } from "../lib/confirm";
import { minPromiseMillis } from "../lib/minPromiseMillis";
/** Confirms, then uninstalls. Null until there is a plugin to uninstall. */
export function usePromptUninstallPlugin(pluginId: string | null, name: string) {
const mut = useMutation({
mutationKey: ["uninstall_plugin", pluginId],
mutationFn: async () => {
if (pluginId == null) return;
const confirmed = await showConfirmDelete({
id: `uninstall-plugin-${pluginId}`,
title: "Uninstall Plugin",
confirmText: "Uninstall",
description: (
<>
Permanently uninstall <InlineCode>{name}</InlineCode>?
</>
),
});
if (confirmed) {
await minPromiseMillis(uninstallPlugin(pluginId), 700);
}
},
});
return pluginId == null ? null : mut;
}
+24 -34
View File
@@ -1,15 +1,20 @@
import { describe, expect, test } from "vite-plus/test";
import { AGENT_CLIENTS, appSetup, cliSetup } from "./agentSetup";
const URL = "http://127.0.0.1:64343/mcp";
const TOKEN = "abc123";
import { AGENT_CLIENTS, cliSetup, skillInstallCommand } from "./agentSetup";
describe("cliSetup", () => {
test("uses each client's own add command", () => {
expect(cliSetup("claude_code").snippet).toBe("claude mcp add yaak -- yaak mcp");
expect(cliSetup("claude_code").snippet).toBe("claude mcp add -s user yaak -- yaak mcp");
expect(cliSetup("codex").snippet).toBe("codex mcp add yaak -- yaak mcp");
});
test("names a file only when the snippet goes in one", () => {
expect(cliSetup("claude_code").where).toBeNull();
expect(cliSetup("codex").where).toBeNull();
expect(cliSetup("cursor").where).toBe("Add to ~/.cursor/mcp.json.");
expect(cliSetup("vscode").where).toContain("mcp.json");
expect(cliSetup("other").where).not.toBeNull();
});
test("writes the config shape each client reads", () => {
expect(JSON.parse(cliSetup("cursor").snippet)).toEqual({
mcpServers: { yaak: { command: "yaak", args: ["mcp"] } },
@@ -21,42 +26,27 @@ describe("cliSetup", () => {
mcpServers: { yaak: { command: "yaak", args: ["mcp"] } },
});
});
});
describe("appSetup", () => {
test("passes the token as a bearer header", () => {
expect(appSetup("claude_code", URL, TOKEN).snippet).toBe(
`claude mcp add --transport http yaak ${URL} --header "Authorization: Bearer ${TOKEN}"`,
);
expect(appSetup("codex", URL, TOKEN).snippet).toBe(
`[mcp_servers.yaak]\nurl = "${URL}"\nhttp_headers = { "Authorization" = "Bearer ${TOKEN}" }`,
);
expect(JSON.parse(appSetup("cursor", URL, TOKEN).snippet)).toEqual({
mcpServers: { yaak: { url: URL, headers: { Authorization: `Bearer ${TOKEN}` } } },
});
expect(JSON.parse(appSetup("vscode", URL, TOKEN).snippet)).toEqual({
servers: { yaak: { type: "http", url: URL, headers: { Authorization: `Bearer ${TOKEN}` } } },
});
expect(JSON.parse(appSetup("other", URL, TOKEN).snippet)).toEqual({
mcpServers: {
yaak: {
command: "npx",
args: ["-y", "mcp-remote", URL, "--header", `Authorization: Bearer ${TOKEN}`],
},
},
});
});
test("keeps argument lists on one line", () => {
expect(appSetup("other", URL, TOKEN).snippet).toContain(
`"args": ["-y", "mcp-remote", "${URL}", "--header", "Authorization: Bearer ${TOKEN}"]`,
);
expect(cliSetup("cursor").snippet).toContain(`"args": ["mcp"]`);
});
test("covers every listed client", () => {
for (const { value } of AGENT_CLIENTS) {
expect(cliSetup(value).snippet).not.toBe("");
expect(appSetup(value, URL, TOKEN).snippet).toContain(TOKEN);
}
});
});
describe("skillInstallCommand", () => {
test("targets the agents the installer knows by name", () => {
expect(skillInstallCommand("claude_code")).toBe("yaak agent install --agent claude-code");
expect(skillInstallCommand("codex")).toBe("yaak agent install --agent codex");
expect(skillInstallCommand("cursor")).toBe("yaak agent install --agent cursor");
});
test("installs everywhere for agents it cannot target", () => {
expect(skillInstallCommand("vscode")).toBe("yaak agent install");
expect(skillInstallCommand("other")).toBe("yaak agent install");
});
});
+21 -56
View File
@@ -1,4 +1,6 @@
export type AgentClient = "claude_code" | "codex" | "cursor" | "vscode" | "other";
import type { AgentClient } from "@yaakapp-internal/rpc-schema";
export type { AgentClient };
export const AGENT_CLIENTS: { label: string; value: AgentClient }[] = [
{ label: "Claude Code", value: "claude_code" },
@@ -9,21 +11,34 @@ export const AGENT_CLIENTS: { label: string; value: AgentClient }[] = [
];
export interface AgentSetup {
/** Where the snippet goes, as a sentence */
where: string;
/** The file the snippet goes in, as a sentence, when it is a file rather than a command */
where: string | null;
snippet: string;
language: "shell" | "json" | "text";
}
const RUN_IN_TERMINAL = "Run in a terminal.";
/** `yaak agent install`, targeted at `client` when the installer knows it by name. */
export function skillInstallCommand(client: AgentClient): string {
switch (client) {
case "claude_code":
return "yaak agent install --agent claude-code";
case "codex":
return "yaak agent install --agent codex";
case "cursor":
return "yaak agent install --agent cursor";
case "vscode":
case "other":
return "yaak agent install";
}
}
/** Setup for `yaak mcp`, which the client launches over stdio. */
export function cliSetup(client: AgentClient): AgentSetup {
switch (client) {
case "claude_code":
return { where: RUN_IN_TERMINAL, snippet: "claude mcp add yaak -- yaak mcp", language: "shell" };
return { where: null, snippet: "claude mcp add -s user yaak -- yaak mcp", language: "shell" };
case "codex":
return { where: RUN_IN_TERMINAL, snippet: "codex mcp add yaak -- yaak mcp", language: "shell" };
return { where: null, snippet: "codex mcp add yaak -- yaak mcp", language: "shell" };
case "cursor":
return {
where: "Add to ~/.cursor/mcp.json.",
@@ -45,56 +60,6 @@ export function cliSetup(client: AgentClient): AgentSetup {
}
}
/** Setup for the app's MCP server at `url`, which needs `token` as a bearer token. */
export function appSetup(client: AgentClient, url: string, token: string): AgentSetup {
const authorization = `Bearer ${token}`;
switch (client) {
case "claude_code":
return {
where: RUN_IN_TERMINAL,
snippet: `claude mcp add --transport http yaak ${url} --header "Authorization: ${authorization}"`,
language: "shell",
};
case "codex":
return {
where: "Add to ~/.codex/config.toml.",
snippet: [
"[mcp_servers.yaak]",
`url = ${JSON.stringify(url)}`,
`http_headers = { "Authorization" = ${JSON.stringify(authorization)} }`,
].join("\n"),
language: "text",
};
case "cursor":
return {
where: "Add to ~/.cursor/mcp.json.",
snippet: json({ mcpServers: { yaak: { url, headers: { Authorization: authorization } } } }),
language: "json",
};
case "vscode":
return {
where: "Add to your user mcp.json (MCP: Open User Configuration).",
snippet: json({
servers: { yaak: { type: "http", url, headers: { Authorization: authorization } } },
}),
language: "json",
};
case "other":
return {
where: "For clients that read an mcpServers config, through the mcp-remote bridge.",
snippet: json({
mcpServers: {
yaak: {
command: "npx",
args: ["-y", "mcp-remote", url, "--header", `Authorization: ${authorization}`],
},
},
}),
language: "json",
};
}
}
/** Pretty JSON with arrays of plain values kept on one line. */
function json(value: unknown): string {
return JSON.stringify(value, null, 2).replace(
+1 -1
View File
@@ -1,5 +1,5 @@
import deepEqual from "@gilbarbara/deep-equal";
import type { UpdateInfo } from "@yaakapp-internal/tauri-client";
import type { UpdateInfo } from "@yaakapp-internal/tauri-updater";
import type { Atom } from "jotai";
import { atom } from "jotai";
import { selectAtom } from "jotai/utils";
@@ -1,6 +1,11 @@
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
import type { DialogInstance } from "../components/Dialogs";
// Loaded once as the file is collected (the mocks below are hoisted above it), so the first
// transform of its module graph, the shared UI package among it, is not timed against whichever
// test imports it first. Each test still gets the module fresh after `vi.resetModules()`, which
// reuses that transform.
import "./confirmWebProxy";
const mocks = vi.hoisted(() => ({
platform: { httpProxyUrl: "https://web.yaak.app" },
+1 -44
View File
@@ -1,44 +1 @@
import { MIMEType } from "whatwg-mimetype";
export function isJSON(content: string | null | undefined): boolean {
if (typeof content !== "string") return false;
try {
JSON.parse(content);
return true;
} catch {
return false;
}
}
export function isProbablyTextContentType(contentType: string | null): boolean {
if (contentType == null) return false;
const mimeType = getMimeTypeFromContentType(contentType).essence;
const normalized = mimeType.toLowerCase();
// Check if it starts with "text/"
if (normalized.startsWith("text/")) {
return true;
}
// Common text mimetypes and suffixes
return [
"application/json",
"application/xml",
"application/javascript",
"application/yaml",
"+json",
"+xml",
"+yaml",
"+text",
].some((textType) => normalized === textType || normalized.endsWith(textType));
}
export function getMimeTypeFromContentType(contentType: string): MIMEType {
try {
return new MIMEType(contentType);
} catch {
return new MIMEType("text/plain");
}
}
export { getMimeTypeFromContentType, isJSON, isProbablyTextContentType } from "@yaakapp-internal/ui/mimeType";
+1 -22
View File
@@ -1,22 +1 @@
import type { ReactNode } from "react";
/**
* Get the text content from a ReactNode
* https://stackoverflow.com/questions/50428910/get-text-content-from-node-in-react
*/
export function getNodeText(node: ReactNode): string {
if (typeof node === "string" || typeof node === "number") {
return String(node);
}
if (Array.isArray(node)) {
return node.map(getNodeText).join("");
}
if (typeof node === "object" && node) {
// oxlint-disable-next-line no-explicit-any
return getNodeText((node as any).props.children);
}
return "";
}
export { getNodeText } from "@yaakapp-internal/ui";
+83
View File
@@ -0,0 +1,83 @@
import type { HttpRequest, HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
import { describe, expect, test } from "vite-plus/test";
import { cookieHeadersFromEvents, httpRequestToView, httpResponseToView } from "./httpViews";
function event(e: HttpResponseEvent["event"]): HttpResponseEvent {
return { event: e } as HttpResponseEvent;
}
describe("httpRequestToView", () => {
test("maps the request and reads its content type from the headers", () => {
const request = {
id: "rq_1",
method: "POST",
url: "https://yaak.app",
headers: [{ name: "content-type", value: "application/json", enabled: true }],
urlParameters: [{ name: "a", value: "1", enabled: true }],
bodyType: "application/json",
body: { text: "{}" },
} as unknown as HttpRequest;
expect(httpRequestToView(request)).toEqual({
id: "rq_1",
method: "POST",
url: "https://yaak.app",
headers: request.headers,
urlParameters: request.urlParameters,
bodyType: "application/json",
body: { text: "{}" },
contentType: "application/json",
auth: null,
});
});
});
describe("httpResponseToView", () => {
test("keys state to the request and puts the stored time in UTC", () => {
const response = {
id: "rs_1",
requestId: "rq_1",
state: "closed",
status: 200,
statusReason: "OK",
url: "https://yaak.app",
headers: [{ name: "Content-Type", value: "text/plain" }],
requestHeaders: [],
contentLength: 10,
contentLengthCompressed: 4,
requestContentLength: 0,
createdAt: "2026-11-04T22:10:00.123",
elapsed: 30,
elapsedHeaders: 20,
elapsedDns: 5,
error: null,
remoteAddr: null,
version: "HTTP/1.1",
} as unknown as HttpResponse;
expect(httpResponseToView(response, undefined)).toMatchObject({
id: "rs_1",
ownerId: "rq_1",
contentType: "text/plain",
startedAt: "2026-11-04T22:10:00.123Z",
elapsedMs: 30,
elapsedHeadersMs: 20,
elapsedDnsMs: 5,
contentLengthCompressed: 4,
cookieHeaders: { sent: [], received: [] },
});
});
});
describe("cookieHeadersFromEvents", () => {
test("collects Cookie headers sent and Set-Cookie headers received", () => {
const events = [
event({ type: "header_up", name: "Cookie", value: "a=1" }),
event({ type: "header_up", name: "Accept", value: "*/*" }),
event({ type: "header_down", name: "set-cookie", value: "b=2" }),
event({ type: "header_down", name: "Content-Type", value: "text/plain" }),
];
expect(cookieHeadersFromEvents(events)).toEqual({ sent: ["a=1"], received: ["b=2"] });
expect(cookieHeadersFromEvents(undefined)).toEqual({ sent: [], received: [] });
});
});
+64
View File
@@ -0,0 +1,64 @@
import type { HttpRequest, HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
import type { HttpRequestView, HttpResponseView } from "@yaakapp-internal/ui";
import { getContentTypeFromHeaders } from "./model_util";
/** A request as the shared request pane shows it. Auth stays with the client's own editor */
export function httpRequestToView(request: HttpRequest): HttpRequestView {
return {
id: request.id,
method: request.method,
url: request.url,
headers: request.headers,
urlParameters: request.urlParameters,
bodyType: request.bodyType,
body: request.body,
contentType: getContentTypeFromHeaders(request.headers),
auth: null,
};
}
/** A response as the shared response pane shows it, with its cookies from the send's events */
export function httpResponseToView(
response: HttpResponse,
events: HttpResponseEvent[] | undefined,
): HttpResponseView {
return {
id: response.id,
ownerId: response.requestId,
state: response.state,
status: response.status,
statusReason: response.statusReason,
url: response.url,
headers: response.headers,
requestHeaders: response.requestHeaders,
contentType: getContentTypeFromHeaders(response.headers),
contentLength: response.contentLength,
contentLengthCompressed: response.contentLengthCompressed,
requestContentLength: response.requestContentLength,
// Stored as UTC without a zone
startedAt: `${response.createdAt}Z`,
elapsedMs: response.elapsed,
elapsedHeadersMs: response.elapsedHeaders,
elapsedDnsMs: response.elapsedDns,
error: response.error,
remoteAddr: response.remoteAddr,
version: response.version,
cookieHeaders: cookieHeadersFromEvents(events),
};
}
/** The `Cookie` headers sent and `Set-Cookie` headers received, from a send's events */
export function cookieHeadersFromEvents(
events: HttpResponseEvent[] | undefined,
): HttpResponseView["cookieHeaders"] {
const sent: string[] = [];
const received: string[] = [];
for (const { event } of events ?? []) {
if (event.type === "header_up" && event.name.toLowerCase() === "cookie") {
sent.push(event.value);
} else if (event.type === "header_down" && event.name.toLowerCase() === "set-cookie") {
received.push(event.value);
}
}
return { sent, received };
}
+35 -123
View File
@@ -6,16 +6,19 @@ import type {
ShowToastRequest,
} from "@yaakapp-internal/plugins";
import { updateAllPlugins } from "@yaakapp-internal/plugins";
import type {
PluginUpdateNotification,
UpdateInfo,
UpdateResponse,
YaakNotification,
} from "@yaakapp-internal/tauri-client";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import type { PluginUpdateNotification, YaakNotification } from "@yaakapp-internal/tauri-client";
import type { UpdateInfo, UpdateResponse, UpdateState } from "@yaakapp-internal/tauri-updater";
import {
ButtonInfiniteLoading,
HStack,
Icon,
showUpdateAvailableToast as showUpdateAvailableToastIn,
showUpdateInstalledToast as showUpdateInstalledToastIn,
showUpdateStateToast as showUpdateStateToastIn,
VStack,
} from "@yaakapp-internal/ui";
import { openSettings } from "../commands/openSettings";
import { Button } from "../components/core/Button";
import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading";
// Listen for toasts
import { platform } from "@yaakapp-internal/platform";
@@ -136,6 +139,11 @@ export function initGlobalListeners() {
void showUpdateAvailableToast(payload);
});
// A check asked for while an update is already in hand says where it stands
platform.listen<UpdateState>("update_state", (state) => {
showUpdateStateToastIn(jotaiStore, { appName: "Yaak", state });
});
platform.listen<YaakNotification>("notification", (payload) => {
console.log("Got notification event", payload);
showNotificationToast(payload);
@@ -149,131 +157,35 @@ export function initGlobalListeners() {
}
function showUpdateInstalledToast(version: string) {
const UPDATE_TOAST_ID = "update-info";
showToast({
id: UPDATE_TOAST_ID,
color: "primary",
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">Yaak {version} was installed</h2>
<p className="text-text-subtle text-sm">Start using the new version now?</p>
</VStack>
),
action: ({ hide }) => (
<ButtonInfiniteLoading
size="xs"
className="mr-auto min-w-20"
color="primary"
loadingChildren="Restarting..."
onClick={() => {
hide();
setTimeout(() => rpc("cmd_restart", {}), 200);
}}
>
Relaunch Yaak
</ButtonInfiniteLoading>
),
showUpdateInstalledToastIn(jotaiStore, {
appName: "Yaak",
version,
onRelaunch: () => rpc("cmd_restart", {}),
});
}
async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
const UPDATE_TOAST_ID = "update-info";
const { version, replyEventId, downloaded, install } = updateInfo;
jotaiStore.set(updateAvailableAtom, { version, downloaded, install });
const whatsNewButton = (
<Button
size="xs"
color="info"
variant="border"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
await platform.openUrl(`https://yaak.app/changelog/${version}`);
}}
>
What&apos;s New
</Button>
);
if (install !== "integrated") {
// Nothing to reply to here; the backend only told us so we can say how to update
const flatpak = install === "flatpak";
showToast({
id: UPDATE_TOAST_ID,
color: "info",
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">Yaak {version} is available</h2>
<p className="text-text-subtle text-sm">
{flatpak ? (
<>
Update with <InlineCode>flatpak update</InlineCode> or your software center.
</>
) : (
"Download the new version to upgrade."
)}
</p>
</VStack>
),
action: () => (
<HStack space={1.5}>
{!flatpak && (
<Button
size="xs"
color="info"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
await platform.openUrl("https://yaak.app/download");
}}
>
Download
</Button>
)}
{whatsNewButton}
</HStack>
),
});
return;
// Nothing to reply to otherwise; the backend only told us so we can say how to update
if (install === "integrated") {
// Acknowledge the event, so we don't time out and try the fallback update logic
await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse);
}
// Acknowledge the event, so we don't time out and try the fallback update logic
await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse);
showToast({
id: UPDATE_TOAST_ID,
color: "info",
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">Yaak {version} is available</h2>
<p className="text-text-subtle text-sm">
{downloaded ? "Do you want to install" : "Download and install"} the update?
</p>
</VStack>
),
action: () => (
<HStack space={1.5}>
<ButtonInfiniteLoading
size="xs"
color="info"
className="min-w-40"
loadingChildren={downloaded ? "Installing..." : "Downloading..."}
onClick={async () => {
await platform.emit(replyEventId, {
type: "action",
action: "install",
} satisfies UpdateResponse);
}}
>
{downloaded ? "Install Now" : "Download and Install"}
</ButtonInfiniteLoading>
{whatsNewButton}
</HStack>
),
showUpdateAvailableToastIn(jotaiStore, {
appName: "Yaak",
update: updateInfo,
whatsNewUrl: `https://yaak.app/changelog/${version}`,
downloadUrl: "https://yaak.app/download",
onOpenUrl: (url) => platform.openUrl(url),
onInstall: () =>
platform.emit(replyEventId, {
type: "action",
action: "install",
} satisfies UpdateResponse),
});
}
+10 -17
View File
@@ -5,16 +5,17 @@ import type {
HttpResponseEvent,
HttpResponseHeader,
} from "@yaakapp-internal/models";
import { getMimeTypeFromContentType } from "./contentType";
export const BODY_TYPE_NONE = null;
export const BODY_TYPE_GRAPHQL = "graphql";
export const BODY_TYPE_JSON = "application/json";
export const BODY_TYPE_BINARY = "binary";
export const BODY_TYPE_OTHER = "other";
export const BODY_TYPE_FORM_URLENCODED = "application/x-www-form-urlencoded";
export const BODY_TYPE_FORM_MULTIPART = "multipart/form-data";
export const BODY_TYPE_XML = "text/xml";
export {
BODY_TYPE_BINARY,
BODY_TYPE_FORM_MULTIPART,
BODY_TYPE_FORM_URLENCODED,
BODY_TYPE_GRAPHQL,
BODY_TYPE_JSON,
BODY_TYPE_NONE,
BODY_TYPE_OTHER,
BODY_TYPE_XML,
} from "@yaakapp-internal/ui/httpTypes";
export function cookieDomain(cookie: Cookie): string {
if (cookie.domain === "NotPresent" || cookie.domain === "Empty") {
@@ -46,14 +47,6 @@ export function getContentTypeFromHeaders(headers: HttpResponseHeader[] | null):
return headers?.find((h) => h.name.toLowerCase() === "content-type")?.value ?? null;
}
export function getCharsetFromContentType(headers: HttpResponseHeader[]): string | null {
const contentType = getContentTypeFromHeaders(headers);
if (contentType == null) return null;
const mimeType = getMimeTypeFromContentType(contentType);
return mimeType.parameters.get("charset") ?? null;
}
export function isBaseEnvironment(environment: Environment): boolean {
return environment.parentModel === "workspace";
}
+2 -36
View File
@@ -1,43 +1,9 @@
import type { AnyModel } from "@yaakapp-internal/models";
import { foldersAtom } from "@yaakapp-internal/models";
import { resolvedModelName } from "@yaakapp-internal/ui/resolvedModelName";
import { jotaiStore } from "./jotai";
import { replaceTemplateTags } from "./templateTags";
export function resolvedModelName(r: AnyModel | null): string {
if (r == null) return "";
if (!("url" in r) || r.model === "plugin") {
return "name" in r ? r.name : "";
}
// Return name if it has one
if ("name" in r && r.name) {
return r.name;
}
// Replace variable syntax with variable name
const withoutVariables = replaceTemplateTags(r.url, (m) => m.inner.trim());
if (withoutVariables.trim() === "") {
return r.model === "http_request"
? r.bodyType && r.bodyType === "graphql"
? "GraphQL Request"
: "HTTP Request"
: r.model === "websocket_request"
? "WebSocket Request"
: "gRPC Request";
}
// GRPC gets nice short names
if (r.model === "grpc_request" && r.service != null && r.method != null) {
const shortService = r.service.split(".").pop();
return `${shortService}/${r.method}`;
}
// Strip unnecessary protocol
const withoutProto = withoutVariables.replace(/^(http|https|ws|wss):\/\//, "");
return withoutProto;
}
export { resolvedModelName };
export function resolvedModelNameWithFolders(model: AnyModel | null): string {
return resolvedModelNameWithFoldersArray(model).join(" / ");
+4 -1
View File
@@ -3,7 +3,9 @@ import type { FilterResponse } from "@yaakapp-internal/plugins";
import type { ServerSentEvent, SseSummary } from "@yaakapp-internal/sse";
import { candidateJsonPayloadsFromSseText, computeSseSummary } from "@yaakapp-internal/sse";
import { rpc } from "./rpc";
import { decodeBodyText } from "@yaakapp-internal/lib/decodeBodyText";
import { platform } from "@yaakapp-internal/platform";
import { getContentTypeFromHeaders } from "./model_util";
/**
* Reading a response body means naming the response, never the file it lives
@@ -74,5 +76,6 @@ export async function getResponseBodyBytes(
async function getResponseBodyDecoded(response: HttpResponse): Promise<string | null> {
const bytes = await getResponseBodyBytes(response);
return bytes == null ? null : new TextDecoder("utf-8").decode(bytes);
if (bytes == null) return null;
return decodeBodyText(bytes, getContentTypeFromHeaders(response.headers));
}
+126
View File
@@ -0,0 +1,126 @@
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
import type { Settings } from "@yaakapp-internal/models";
import { describe, expect, test } from "vite-plus/test";
import {
licenseStatusFromCheck,
settingsValuesFromModel,
themesViewFromResolved,
} from "./settingsView";
describe("settingsValuesFromModel", () => {
test("picks the shared tabs' values and nothing client-only", () => {
const settings = {
model: "settings",
id: "st_1",
appearance: "dark",
themeLight: "yaak-light",
themeDark: "hotdog",
interfaceFont: "Inter",
interfaceFontSize: 15,
editorFont: null,
editorFontSize: 13,
editorKeymap: "emacs",
editorSoftWrap: false,
coloredMethods: true,
useNativeTitlebar: true,
hideWindowControls: true,
updateChannel: "beta",
autoupdate: false,
autoDownloadUpdates: false,
checkNotifications: false,
promptFeedback: false,
openWorkspaceNewWindow: true,
hideLicenseBadge: true,
clientCertificates: [],
} as unknown as Settings;
expect(settingsValuesFromModel(settings)).toEqual({
appearance: "dark",
themeLight: "yaak-light",
themeDark: "hotdog",
interfaceFont: "Inter",
interfaceFontSize: 15,
editorFont: null,
editorFontSize: 13,
editorKeymap: "emacs",
editorSoftWrap: false,
coloredMethods: true,
useNativeTitlebar: true,
hideWindowControls: true,
updateChannel: "beta",
autoupdate: false,
autoDownloadUpdates: false,
checkNotifications: false,
promptFeedback: false,
});
});
});
describe("licenseStatusFromCheck", () => {
test("is null without a check", () => {
expect(licenseStatusFromCheck(null)).toBeNull();
expect(licenseStatusFromCheck(undefined)).toBeNull();
});
test("keeps what each state's copy needs", () => {
const cases: [LicenseCheckStatus, unknown][] = [
[
{ status: "active", data: { periodEnd: "2027-01-01", cancelAt: null } },
{ status: "active" },
],
[
{ status: "trialing", data: { end: "2026-11-20" } },
{ status: "trialing", trialEnd: "2026-11-20" },
],
[{ status: "personal_use", data: { trial_ended: "2026-01-01" } }, { status: "personal_use" }],
[{ status: "inactive", data: { status: "canceled" } }, { status: "inactive" }],
[
{
status: "expired",
data: {
changes: 3,
changesUrl: "https://yaak.app/changes",
billingUrl: "https://yaak.app/billing",
periodEnd: "2026-03-04",
},
},
{ status: "expired", periodEnd: "2026-03-04", changesUrl: "https://yaak.app/changes" },
],
[
{ status: "past_due", data: { billingUrl: "https://yaak.app/billing", periodEnd: "x" } },
{ status: "past_due", billingUrl: "https://yaak.app/billing" },
],
[
{ status: "error", data: { message: "Boom", code: "E1" } },
{ status: "error", message: "Boom", code: "E1" },
],
];
for (const [check, expected] of cases) {
expect(licenseStatusFromCheck(check)).toEqual(expected);
}
});
});
describe("themesViewFromResolved", () => {
const light = { id: "yaak-light", label: "Yaak", dark: false, base: {} };
const dark = { id: "yaak-dark", label: "Yaak", dark: true, base: {} };
test("is null while the themes load", () => {
expect(themesViewFromResolved(undefined, "dark")).toBeNull();
});
test("keeps each theme's id, label and darkness, and the resolved appearance", () => {
expect(
themesViewFromResolved({ themes: [dark, light], light, dark, active: dark }, "dark"),
).toEqual({
themes: [
{ id: "yaak-dark", label: "Yaak", dark: true },
{ id: "yaak-light", label: "Yaak", dark: false },
],
light: { id: "yaak-light", label: "Yaak", dark: false },
dark: { id: "yaak-dark", label: "Yaak", dark: true },
active: { id: "yaak-dark", label: "Yaak", dark: true },
resolvedAppearance: "dark",
});
});
});
+84
View File
@@ -0,0 +1,84 @@
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
import type { Settings } from "@yaakapp-internal/models";
import type {
SettingsLicenseStatus,
SettingsThemeOption,
SettingsThemesView,
SettingsValues,
} from "@yaakapp-internal/ui";
/** The values the shared settings tabs read, picked off the client's `Settings` model */
export function settingsValuesFromModel(settings: Settings): SettingsValues {
return {
appearance: settings.appearance,
themeLight: settings.themeLight,
themeDark: settings.themeDark,
interfaceFont: settings.interfaceFont,
interfaceFontSize: settings.interfaceFontSize,
editorFont: settings.editorFont,
editorFontSize: settings.editorFontSize,
editorKeymap: settings.editorKeymap,
editorSoftWrap: settings.editorSoftWrap,
coloredMethods: settings.coloredMethods,
useNativeTitlebar: settings.useNativeTitlebar,
hideWindowControls: settings.hideWindowControls,
updateChannel: settings.updateChannel,
autoupdate: settings.autoupdate,
autoDownloadUpdates: settings.autoDownloadUpdates,
checkNotifications: settings.checkNotifications,
promptFeedback: settings.promptFeedback,
};
}
/** A license check, as the shared License tab's copy needs it */
export function licenseStatusFromCheck(
check: LicenseCheckStatus | null | undefined,
): SettingsLicenseStatus | null {
if (check == null) return null;
switch (check.status) {
case "active":
return { status: "active" };
case "trialing":
return { status: "trialing", trialEnd: check.data.end };
case "personal_use":
return { status: "personal_use" };
case "inactive":
return { status: "inactive" };
case "expired":
return {
status: "expired",
periodEnd: check.data.periodEnd,
changesUrl: check.data.changesUrl,
};
case "past_due":
return { status: "past_due", billingUrl: check.data.billingUrl };
case "error":
return { status: "error", message: check.data.message, code: check.data.code };
}
}
/** The fields of a theme the settings show */
interface ThemeLike {
id: string;
label: string;
dark: boolean;
}
function themeOption({ id, label, dark }: ThemeLike): SettingsThemeOption {
return { id, label, dark };
}
/** The resolved themes, as the shared Theme tab shows them; null while they load */
export function themesViewFromResolved(
data: { themes: ThemeLike[]; light: ThemeLike; dark: ThemeLike; active: ThemeLike } | undefined,
resolvedAppearance: "light" | "dark",
): SettingsThemesView | null {
if (data == null) return null;
return {
themes: data.themes.map(themeOption),
light: themeOption(data.light),
dark: themeOption(data.dark),
active: themeOption(data.active),
resolvedAppearance,
};
}
@@ -0,0 +1,263 @@
import { readFile } from "node:fs/promises";
import type { TemplateFunction } from "@yaakapp-internal/plugins";
import type { FnArg, Tokens } from "@yaakapp-internal/templates";
import { beforeAll, describe, expect, test, vi } from "vite-plus/test";
import {
collectEditableArgumentValues,
preserveUnchangedArgumentExpressions,
} from "./templateFunctionArguments";
// Use the shipped WASM parser without the browser-only `?init` loader.
let parse: (text: string) => Tokens;
beforeAll(async () => {
const base = new URL("../../../crates/yaak-templates/pkg/", import.meta.url);
const bindings = await import(new URL("yaak_templates_bg.js", base).href);
const { instance } = await WebAssembly.instantiate(
await readFile(new URL("yaak_templates_bg.wasm", base)),
{ "./yaak_templates_bg.js": bindings },
);
bindings.__wbg_set_wasm(instance.exports);
(instance.exports.__wbindgen_start as () => void)();
parse = bindings.parse_template;
});
const definition: TemplateFunction = {
name: "timestamp.iso8601",
args: [{ type: "text", name: "date", optional: true }],
};
function args(tokens: Tokens): FnArg[] {
const first = tokens.tokens[0];
if (first?.type !== "tag" || first.val.type !== "fn") throw new Error("Expected a function");
return first.val.args;
}
function stringArg(name: string, text: string): FnArg {
return { name, value: { type: "str", text } };
}
// Printing is an existing async RPC. Assert the exact expression handed to it and return
// its expected text, including real parser round-tripping of quoted/escaped fixtures.
function printer(text: string) {
return vi.fn(async (tokens: Tokens) => {
expect(tokens.tokens).toEqual(parse(text).tokens.filter((t) => t.type !== "eof"));
return text;
});
}
describe("editable template function arguments", () => {
test.each([
[
"reported call",
"${[timestamp.iso8601(date=timestamp.offset(expression='-1h')) ]}",
"${[ timestamp.offset(expression='-1h') ]}",
],
["variable", "${[ timestamp.iso8601(date=date_var) ]}", "${[ date_var ]}"],
["dotted variable", "${[ timestamp.iso8601(date=dates.previous) ]}", "${[ dates.previous ]}"],
[
"nested escaping",
String.raw`${"${["} timestamp.iso8601(date=outer(value=inner(text='it\'s \\ nested'))) ]}`,
String.raw`${"${["} outer(value=inner(text='it\'s \\ nested')) ]}`,
],
])("loads and preserves %s on unchanged Save", async (_, source, display) => {
const initialTokens = parse(source);
const snapshot = structuredClone(initialTokens);
const print = printer(display);
const initial = await collectEditableArgumentValues(initialTokens, definition, print);
expect(initial).toEqual({ date: display });
expect(print).toHaveBeenCalledOnce();
const saved = preserveUnchangedArgumentExpressions(
[stringArg("date", display)],
initialTokens,
initial,
{ ...initial },
);
expect(saved).toEqual(args(initialTokens));
expect(initialTokens).toEqual(snapshot);
});
test.each([
String.raw`${"${["} timestamp.iso8601(date='${"${["} timestamp.offset(expression=\'-1h\') ]}') ]}`,
"${[ timestamp.iso8601(date='${[ date_var ]}') ]}",
"${[ timestamp.iso8601(date='${[ dates.previous ]}') ]}",
"${[ timestamp.iso8601(date='2026-10-27T11:00:00.000Z') ]}",
])("leaves existing quoted arguments unchanged: %s", async (source) => {
const initialTokens = parse(source);
const print = vi.fn<(_: Tokens) => Promise<string>>();
const initial = await collectEditableArgumentValues(initialTokens, definition, print);
const value = args(initialTokens)[0]?.value;
if (value?.type !== "str") throw new Error("Expected a string fixture");
expect(initial).toEqual({ date: value.text });
expect(print).not.toHaveBeenCalled();
expect(
preserveUnchangedArgumentExpressions(
[stringArg("date", value.text)],
initialTokens,
initial,
initial,
),
).toEqual(args(initialTokens));
});
test("editing a sibling preserves the original expression", async () => {
const initialTokens = parse(
"${[ timestamp.format(date=timestamp.offset(expression='-1h'), format='HH:mm') ]}",
);
const initial = await collectEditableArgumentValues(
initialTokens,
{
name: "timestamp.format",
args: [...definition.args, { type: "text", name: "format" }],
},
printer("${[ timestamp.offset(expression='-1h') ]}"),
);
const values: Record<string, string | boolean> = { ...initial, format: "yyyy-MM-dd" };
const saved = preserveUnchangedArgumentExpressions(
[stringArg("date", String(values.date)), stringArg("format", String(values.format))],
initialTokens,
initial,
values,
);
expect(saved).toEqual([args(initialTokens)[0], stringArg("format", "yyyy-MM-dd")]);
});
test.each(["${[ timestamp.offset(expression='-2h') ]}", "2026-10-26", ""])(
"intentional edits use the form's quoted-string convention: %s",
async (edited) => {
const initialTokens = parse("${[ timestamp.iso8601(date=date_var) ]}");
const initial = await collectEditableArgumentValues(
initialTokens,
definition,
printer("${[ date_var ]}"),
);
const changed = [stringArg("date", edited)];
expect(
preserveUnchangedArgumentExpressions(changed, initialTokens, initial, { date: edited }),
).toEqual(changed);
// Returning the input to its original text restores the original expression type.
expect(
preserveUnchangedArgumentExpressions(
[stringArg("date", String(initial.date))],
initialTokens,
initial,
{ ...initial },
),
).toEqual(args(initialTokens));
},
);
test("does not resurrect removed or explicitly cleared expression arguments", async () => {
const initialTokens = parse("${[ timestamp.iso8601(date=date_var) ]}");
const initial = await collectEditableArgumentValues(
initialTokens,
definition,
printer("${[ date_var ]}"),
);
expect(preserveUnchangedArgumentExpressions([], initialTokens, initial, {})).toEqual([]);
const nullArg: FnArg = { name: "date", value: { type: "null" } };
expect(
preserveUnchangedArgumentExpressions([nullArg], initialTokens, initial, { date: "__NULL__" }),
).toEqual([nullArg]);
// DynamicForm clears some inputs by leaving a key with undefined; the dialog turns it into ''.
expect(
preserveUnchangedArgumentExpressions([stringArg("date", "")], initialTokens, initial, {}),
).toEqual([stringArg("date", "")]);
expect(
preserveUnchangedArgumentExpressions([stringArg("date", "")], initialTokens, {}, {}),
).toEqual([stringArg("date", "")]);
});
test("keeps absence, defaults, explicit empty strings, and false distinct", async () => {
const fn: TemplateFunction = {
name: "test",
args: [
{ type: "text", name: "absent" },
{ type: "text", name: "defaulted", defaultValue: "fallback" },
{ type: "text", name: "empty", defaultValue: "fallback" },
{ type: "checkbox", name: "flag", defaultValue: "true" },
{ type: "checkbox", name: "defaultFlag", defaultValue: "false" },
],
};
const initialTokens = parse("${[ test(empty='', flag=false) ]}");
const print = vi.fn<(_: Tokens) => Promise<string>>();
const initial = await collectEditableArgumentValues(initialTokens, fn, print);
expect(initial).toEqual({
defaulted: "fallback",
empty: "",
flag: false,
defaultFlag: "false",
});
expect(print).not.toHaveBeenCalled();
const formArgs: FnArg[] = [
stringArg("defaulted", "fallback"),
stringArg("empty", ""),
{ name: "flag", value: { type: "bool", value: false } },
stringArg("defaultFlag", "false"),
];
expect(preserveUnchangedArgumentExpressions(formArgs, initialTokens, initial, initial)).toEqual(
formArgs,
);
});
test("an expression overrides defaults, including inputs in a group", async () => {
const initialTokens = parse("${[ test(date=dates.previous) ]}");
const initial = await collectEditableArgumentValues(
initialTokens,
{
name: "test",
args: [
{ type: "h_stack", inputs: [{ type: "text", name: "date", defaultValue: "fallback" }] },
],
},
printer("${[ dates.previous ]}"),
);
expect(initial).toEqual({ date: "${[ dates.previous ]}" });
});
test("explicit null keeps the existing default and omission behavior", async () => {
const initialTokens = parse("${[ test(defaulted=null, omitted=null) ]}");
const print = vi.fn<(_: Tokens) => Promise<string>>();
const initial = await collectEditableArgumentValues(
initialTokens,
{
name: "test",
args: [
{ type: "text", name: "defaulted", defaultValue: "fallback" },
{ type: "text", name: "omitted" },
],
},
print,
);
expect(initial).toEqual({ defaulted: "fallback" });
expect(print).not.toHaveBeenCalled();
expect(
preserveUnchangedArgumentExpressions(
[stringArg("defaulted", "fallback")],
initialTokens,
initial,
initial,
),
).toEqual([stringArg("defaulted", "fallback")]);
});
test("keeps a deeply nested expression's AST without adding string wrappers", async () => {
// This shape reaches the native renderer's depth limit. A quoted wrapper around
// the date expression would exceed it, even though shallow calls render identically.
const expression =
"timestamp.iso8601(date=".repeat(44) + "'2026-10-26T10:20:30.000Z'" + ")".repeat(44);
const initialTokens = parse(`\${[ timestamp.iso8601(date=${expression}) ]}`);
const initial = await collectEditableArgumentValues(
initialTokens,
definition,
printer(`\${[ ${expression} ]}`),
);
const saved = preserveUnchangedArgumentExpressions(
[stringArg("date", String(initial.date))],
initialTokens,
initial,
initial,
);
expect(saved).toEqual(args(initialTokens));
});
});
@@ -0,0 +1,59 @@
import type { TemplateFunction } from "@yaakapp-internal/plugins";
import type { FnArg, Tokens } from "@yaakapp-internal/templates";
import { collectArgumentValues } from "../components/core/Editor/twig/util";
type ArgumentValues = Record<string, string | boolean>;
function functionArgs(tokens: Tokens): FnArg[] {
const first = tokens.tokens[0];
return first?.type === "tag" && first.val.type === "fn" ? first.val.args : [];
}
/** Adapt expressions to the template text that the form's editors understand. */
export async function collectEditableArgumentValues(
tokens: Tokens,
templateFunction: TemplateFunction,
tokensToString: (tokens: Tokens) => Promise<string>,
): Promise<ArgumentValues> {
const first = tokens.tokens[0];
if (first?.type !== "tag" || first.val.type !== "fn") {
return collectArgumentValues(tokens, templateFunction);
}
const args = await Promise.all(
first.val.args.map(async (arg): Promise<FnArg> => {
if (arg.value.type !== "fn" && arg.value.type !== "var") return arg;
const text = await tokensToString({ tokens: [{ type: "tag", val: arg.value }] });
return { ...arg, value: { type: "str", text } };
}),
);
return collectArgumentValues(
{ tokens: [{ ...first, val: { ...first.val, args } }] },
templateFunction,
);
}
/**
* Displaying an expression as template text must not change its stored type: a string
* wrapper adds rendering depth. Only deliberately changed inputs use the form's new values.
*/
export function preserveUnchangedArgumentExpressions(
args: FnArg[],
initialTokens: Tokens,
initialValues: ArgumentValues,
values: ArgumentValues,
): FnArg[] {
const initialArgs = functionArgs(initialTokens);
return args.map((arg) => {
const initial = initialArgs.find((a) => a.name === arg.name);
if (
(initial?.value.type === "fn" || initial?.value.type === "var") &&
Object.hasOwn(initialValues, arg.name) &&
values[arg.name] === initialValues[arg.name]
) {
return initial;
}
return arg;
});
}
+2 -137
View File
@@ -1,137 +1,2 @@
/*
* Finding template tags in text.
*
* A tag closes at `]}`, but a quoted argument is allowed to contain `]}` of its own — the
* printer in `crates/yaak-templates` emits `${[ fn(a='x]}y') ]}` verbatim — so the scan has to
* step over quoted strings. It mirrors the Lezer grammar in
* `components/core/Editor/twig/twig.grammar`: a terminated `'…'` (where `\` escapes whatever
* follows it) is consumed whole, and an unterminated quote is just another character, so a
* half-typed tag still closes at the next `]}` instead of swallowing the rest of the document.
*
* This was a regex until CodeQL flagged it (js/polynomial-redos): matching an unterminated
* quote needed a negative lookahead that rescanned to the end of the input at every unpaired
* quote, which is quadratic on text an attacker picks. The scan below is linear instead — see
* the notes on `closingQuoteTable` and on the dead-end set in `findTemplateTags` for why.
*/
const QUOTE = 0x27; // '
const BACKSLASH = 0x5c; // \
const CLOSE_BRACKET = 0x5d; // ]
const CLOSE_BRACE = 0x7d; // }
/** The `${[`…`]}` bounds of one tag, and what sits between them. */
export interface TemplateTagMatch {
/** Index of the `$` that opens the tag. */
start: number;
/** Index just past the closing `]}`. */
end: number;
/** Everything between `${[` and `]}`, not trimmed. */
inner: string;
}
/**
* For every index `i`, the index of the `'` that closes a string whose body starts at `i`, or
* `-1` when nothing does.
*
* Built right to left in a single pass, which is the trick that replaces the regex lookahead:
* whether a quote opens a terminated string depends only on the text after it, so the answer
* can be computed once for every position instead of re-derived at each quote. Skipping a
* string of any length then costs one lookup.
*/
function closingQuoteTable(text: string): Int32Array {
// Two extra slots so a `\` in the last position can read past the end and find `-1`
const table = new Int32Array(text.length + 2).fill(-1);
for (let i = text.length - 1; i >= 0; i--) {
const c = text.charCodeAt(i);
// A backslash escapes whatever follows it, including a quote
const next = c === BACKSLASH ? table[i + 2] : table[i + 1];
table[i] = c === QUOTE ? i : (next ?? -1);
}
return table;
}
/**
* Finds every template tag in `text`, in order and without overlaps.
*
* A `${[` with no `]}` after it isn't a tag, and neither is a `${[` nested inside a tag body —
* both are ordinary content, same as the regex this replaced treated them.
*/
export function findTemplateTags(text: string): TemplateTagMatch[] {
const matches: TemplateTagMatch[] = [];
// Both built only once a scan actually needs them, so short values allocate nothing
let closingQuotes: Int32Array | null = null;
/*
* Body positions a previous scan already ran off the end of the text from. Where a scan ends
* up depends only on where it starts, so a scan that walks into one of these would repeat
* that same walk and fail the same way, and can stop right there.
*
* That bound is what keeps the whole pass linear: every position enters this set at most
* once, and every step of every scan either visits a position for the first time or ends
* that scan.
*/
let deadEnds: Set<number> | null = null;
let searchFrom = 0;
while (searchFrom < text.length) {
const start = text.indexOf("${[", searchFrom);
if (start < 0) break;
const visited: number[] = [];
let end = -1;
let i = start + 3;
while (i < text.length) {
if (deadEnds?.has(i)) break;
visited.push(i);
const c = text.charCodeAt(i);
if (c === QUOTE) {
closingQuotes ??= closingQuoteTable(text);
const close = closingQuotes[i + 1] ?? -1;
// An unterminated quote is an ordinary character, so only step over a closed string
i = close < 0 ? i + 1 : close + 1;
continue;
}
if (c === CLOSE_BRACKET && text.charCodeAt(i + 1) === CLOSE_BRACE) {
end = i + 2;
break;
}
i += 1;
}
if (end < 0) {
// Not a tag. Resume past the opener: a real tag may start inside a string this scan
// stepped over.
deadEnds ??= new Set();
for (const position of visited) deadEnds.add(position);
searchFrom = start + 3;
continue;
}
matches.push({ start, end, inner: text.slice(start + 3, end - 2) });
searchFrom = end;
}
return matches;
}
/** Rewrites every template tag in `text` with whatever `replace` returns for it. */
export function replaceTemplateTags(
text: string,
replace: (match: TemplateTagMatch) => string,
): string {
const matches = findTemplateTags(text);
if (matches.length === 0) return text;
let result = "";
let copiedTo = 0;
for (const match of matches) {
result += text.slice(copiedTo, match.start) + replace(match);
copiedTo = match.end;
}
return result + text.slice(copiedTo);
}
export type { TemplateTagMatch } from "@yaakapp-internal/ui/templateTags";
export { findTemplateTags, replaceTemplateTags } from "@yaakapp-internal/ui/templateTags";
+13 -38
View File
@@ -1,50 +1,25 @@
import { atom } from "jotai";
import type { ToastInstance } from "../components/Toasts";
import { generateId } from "@yaakapp-internal/lib/generateId";
// The toast store alone, without the components, which tests in a plain runtime can't load
import {
hideToast as hideToastIn,
hideToastById as hideToastByIdIn,
type ShowToastArgs,
showToast as showToastIn,
type ToastInstance,
} from "@yaakapp-internal/ui/toast";
import { jotaiStore } from "./jotai";
export const toastsAtom = atom<ToastInstance[]>([]);
export { toastsAtom } from "@yaakapp-internal/ui/toast";
export function showToast({
id,
timeout = 5000,
...props
}: Omit<ToastInstance, "id" | "timeout" | "uniqueKey"> & {
id?: ToastInstance["id"];
timeout?: ToastInstance["timeout"];
}) {
id = id ?? generateId();
const uniqueKey = generateId();
const toasts = jotaiStore.get(toastsAtom);
const toastWithSameId = toasts.find((t) => t.id === id);
let delay = 0;
if (toastWithSameId) {
hideToast(toastWithSameId);
// Allow enough time for old toast to animate out
delay = 200;
}
setTimeout(() => {
const newToast: ToastInstance = { id, uniqueKey, timeout, ...props };
jotaiStore.set(toastsAtom, (prev) => [...prev, newToast]);
}, delay);
return id;
export function showToast(args: ShowToastArgs) {
return showToastIn(jotaiStore, args);
}
export function hideToastById(id: string) {
const toast = jotaiStore.get(toastsAtom).find((t) => t.id === id);
if (toast) hideToast(toast);
hideToastByIdIn(jotaiStore, id);
}
export function hideToast(toHide: ToastInstance) {
jotaiStore.set(toastsAtom, (all) => {
const t = all.find((t) => t.uniqueKey === toHide.uniqueKey);
t?.onClose?.();
return all.filter((t) => t.uniqueKey !== toHide.uniqueKey);
});
hideToastIn(jotaiStore, toHide);
}
export function showErrorToast<T>({
+21 -21
View File
@@ -16,7 +16,7 @@
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.13.1",
"@codemirror/merge": "^6.11.2",
"@codemirror/merge": "^6.12.3",
"@codemirror/search": "^6.5.11",
"@dnd-kit/core": "^6.3.1",
"@gilbarbara/deep-equal": "^0.4.1",
@@ -27,17 +27,17 @@
"@replit/codemirror-vim": "^6.3.0",
"@replit/codemirror-vscode-keymap": "^6.0.2",
"@shopify/lang-jsonc": "^1.0.1",
"@tanstack/react-query": "^5.104.2",
"@tanstack/react-router": "^1.170.42",
"@tanstack/react-virtual": "^3.14.17",
"@tauri-apps/api": "^2.12.3",
"@tauri-apps/plugin-clipboard-manager": "^2.4.2",
"@tauri-apps/plugin-dialog": "^2.8.2",
"@tauri-apps/plugin-fs": "^2.7.0",
"@tanstack/react-query": "^5.105.0",
"@tanstack/react-router": "^1.170.46",
"@tanstack/react-virtual": "^3.14.24",
"@tauri-apps/api": "^2.12.4",
"@tauri-apps/plugin-clipboard-manager": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.8.4",
"@tauri-apps/plugin-fs": "^2.7.1",
"@tauri-apps/plugin-log": "^2.10.1",
"@tauri-apps/plugin-opener": "^2.7.1",
"@tauri-apps/plugin-opener": "^2.7.2",
"@tauri-apps/plugin-os": "^2.4.0",
"@tauri-apps/plugin-shell": "^2.5.0",
"@tauri-apps/plugin-shell": "^2.6.0",
"buffer": "^6.0.3",
"classnames": "^2.5.1",
"cm6-graphql": "^0.2.1",
@@ -48,21 +48,20 @@
"focus-trap-react": "^12.0.3",
"fuzzbunny": "^1.0.1",
"graphql": "^17.0.2",
"hexy": "^0.4.0",
"history": "^5.3.0",
"jotai": "^2.18.0",
"jotai-family": "^1.0.1",
"js-md5": "^0.9.2",
"jsonc-parser": "^3.3.1",
"lucide-react": "^1.55.0",
"mime": "^4.1.1",
"motion": "^14.0.0",
"lucide-react": "^1.64.0",
"mime": "^4.1.2",
"motion": "^14.8.0",
"nanoid": "^6.0.2",
"papaparse": "^5.4.1",
"parse-color": "^1.0.0",
"react": "^19.2.0",
"react": "^19.3.1",
"react-colorful": "^5.6.1",
"react-dom": "^19.2.0",
"react-dom": "^19.3.1",
"react-markdown": "^10.1.0",
"react-pdf": "^11.0.0",
"react-syntax-highlighter": "^16.1.2",
@@ -78,8 +77,8 @@
},
"devDependencies": {
"@lezer/generator": "^1.8.1",
"@rolldown/plugin-babel": "^0.2.3",
"@tanstack/router-plugin": "^1.168.42",
"@rolldown/plugin-babel": "^0.3.0",
"@tanstack/router-plugin": "^1.168.48",
"@types/babel__core": "^7.20.5",
"@types/node": "^22.20.5",
"@types/papaparse": "^5.3.16",
@@ -93,10 +92,11 @@
"@yaakapp-internal/theme": "^1.0.0",
"@yaakapp-internal/ui": "^1.0.0",
"babel-plugin-react-compiler": "^1.0.0",
"rollup": "^4.64.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.3.3",
"happy-dom": "^21.0.4",
"rollup": "^4.66.0",
"vite": "npm:@voidzero-dev/vite-plus-core@^1.4.0",
"vite-plugin-static-copy": "^4.1.1",
"vite-plugin-wasm": "^3.5.0",
"vite-plus": "^0.3.3"
"vite-plus": "^1.4.0"
}
}
Loaded 100 of 566 files, more files were not shown because too many files have changed in this diff. Show more