Compare 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
442 changed files with 62422 additions and 8783 deletions

No files matched your search

+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
+119
View File
@@ -4156,6 +4156,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a"
dependencies = [
"cc",
"openssl-sys",
"pkg-config",
"vcpkg",
]
@@ -6480,6 +6481,70 @@ dependencies = [
"prost",
]
[[package]]
name = "protoc-bin-vendored"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8760a25b6ff9c620324822737e468478fa092234190d2e449760344354896ed9"
dependencies = [
"protoc-bin-vendored-linux-aarch_64",
"protoc-bin-vendored-linux-ppcle_64",
"protoc-bin-vendored-linux-s390_64",
"protoc-bin-vendored-linux-x86_32",
"protoc-bin-vendored-linux-x86_64",
"protoc-bin-vendored-macos-aarch_64",
"protoc-bin-vendored-macos-x86_64",
"protoc-bin-vendored-win32",
]
[[package]]
name = "protoc-bin-vendored-linux-aarch_64"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73fa2624782ca04cd44f51554566717377acd240e4c0016d757dd74fccc9324f"
[[package]]
name = "protoc-bin-vendored-linux-ppcle_64"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2417e9817fa237dab803ad4dda7357a111656e242959cc6b8f9a1a583367d42"
[[package]]
name = "protoc-bin-vendored-linux-s390_64"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d189c34636356a46a7ed3188233dc8a88c431278cc54d4a19b096a2d270e985"
[[package]]
name = "protoc-bin-vendored-linux-x86_32"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "171e39f1e846e5f322ced1ac3b8d4cd3a3833ca24b6e5d58b3632574fe6204fa"
[[package]]
name = "protoc-bin-vendored-linux-x86_64"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "873cdcc097593432086661aa432b8078f1cd87bfb02847c332e98ae2c119e966"
[[package]]
name = "protoc-bin-vendored-macos-aarch_64"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eeb72df001783b8297847fe8f5f874ee400fd742c843d60583e8c23d96977c7f"
[[package]]
name = "protoc-bin-vendored-macos-x86_64"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b04652167eca899dda05f32f5481adeaf25c623a98ce2fc146a001cc59a2add7"
[[package]]
name = "protoc-bin-vendored-win32"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "263a3f48f01e7309e857138bd47f785585b4a005e8e56c6d2824ce91195999c3"
[[package]]
name = "quick-error"
version = "1.2.3"
@@ -11442,6 +11507,7 @@ dependencies = [
"yaak-tauri-utils",
"yaak-templates",
"yaak-tls",
"yaak-updater",
"yaak-window",
"yaak-ws",
]
@@ -11457,9 +11523,12 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-os",
"tauri-plugin-updater",
"tokio",
"yaak-mac-window",
"yaak-proxy-lib",
"yaak-rpc",
"yaak-updater",
"yaak-window",
]
@@ -11512,6 +11581,7 @@ dependencies = [
name = "yaak-commands"
version = "0.0.0"
dependencies = [
"rusqlite",
"serde_json",
"tempfile",
"thiserror 2.0.21",
@@ -11548,9 +11618,11 @@ dependencies = [
"base32",
"base64 0.23.1",
"chacha20poly1305",
"dbus-secret-service",
"keyring",
"log 0.4.34",
"serde",
"tempfile",
"thiserror 2.0.21",
"yaak-models",
]
@@ -11564,6 +11636,7 @@ dependencies = [
"include_dir",
"log 0.4.34",
"nanoid",
"openssl-sys",
"r2d2",
"r2d2_sqlite",
"rusqlite",
@@ -11571,6 +11644,7 @@ dependencies = [
"sea-query-rusqlite",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.21",
"ts-rs",
"uuid",
@@ -11813,12 +11887,17 @@ dependencies = [
name = "yaak-proxy"
version = "0.1.0"
dependencies = [
"base64 0.23.1",
"block2 0.6.1",
"bytes",
"dispatch2",
"flate2",
"futures-util",
"http",
"http-body-util",
"hyper",
"hyper-util",
"libc",
"log 0.4.34",
"pem",
"rcgen",
@@ -11830,6 +11909,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tokio-tungstenite",
"tower-service",
]
[[package]]
@@ -11852,8 +11932,10 @@ dependencies = [
"base64 0.23.1",
"bytes",
"chrono",
"core-foundation 0.9.4",
"encoding_rs",
"flate2",
"futures-util",
"http-body-util",
"hyper",
"hyper-util",
@@ -11865,29 +11947,49 @@ dependencies = [
"objc2-foundation 0.3.2",
"objc2-service-management",
"plist",
"protoc-bin-vendored",
"r2d2",
"r2d2_sqlite",
"reqwest",
"rusqlite",
"rustls",
"sea-query",
"serde",
"serde_json",
"system-configuration",
"tempfile",
"tokio",
"tokio-rustls",
"tonic",
"tonic-reflection",
"ts-rs",
"url",
"yaak-common",
"yaak-crypto",
"yaak-database",
"yaak-filter",
"yaak-grpc",
"yaak-http",
"yaak-lifecycle",
"yaak-models",
"yaak-proxy",
"yaak-proxy-helper",
"yaak-query",
"yaak-rpc",
"yaak-templates",
]
[[package]]
name = "yaak-query"
version = "0.1.0"
dependencies = [
"serde",
"serde-wasm-bindgen",
"serde_json",
"ts-rs",
"wasm-bindgen",
]
[[package]]
name = "yaak-rpc"
version = "0.0.0"
@@ -11995,6 +12097,23 @@ dependencies = [
"yasna 0.6.0",
]
[[package]]
name = "yaak-updater"
version = "0.1.0"
dependencies = [
"log 0.4.34",
"md5",
"nanoid",
"serde",
"serde_json",
"tauri",
"tauri-plugin-dialog",
"tauri-plugin-updater",
"thiserror 2.0.21",
"tokio",
"ts-rs",
]
[[package]]
name = "yaak-wasm"
version = "0.1.0"
+4
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",
@@ -43,6 +44,7 @@ members = [
"crates-tauri/yaak-proxy-helper",
"crates-tauri/yaak-system-appearance",
"crates-tauri/yaak-tauri-utils",
"crates-tauri/yaak-updater",
"crates-tauri/yaak-window",
]
@@ -96,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" }
@@ -114,6 +117,7 @@ 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";
@@ -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 -106
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,105 +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_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,7 +1,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { platform } from "@yaakapp-internal/platform";
import type { AgentSetupStatus, AppMetaData } from "@yaakapp-internal/rpc-schema";
import { Banner, Heading, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { Banner, Icon, InlineCode, SegmentedControl, SettingsPage } from "@yaakapp-internal/ui";
import { useEffect } from "react";
import { useKeyValue } from "../../hooks/useKeyValue";
import { usePluginInfos } from "../../hooks/usePluginInfo";
@@ -14,13 +14,7 @@ import {
import { appInfo } from "../../lib/appInfo";
import { rpc } from "../../lib/rpc";
import { Link } from "../core/Link";
import { SegmentedControl } from "../core/SegmentedControl";
import {
SettingCommand,
SettingSnippet,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
import { SettingCommand, SettingSnippet, SettingsList, SettingsSection } from "../core/SettingRow";
import { DeprecatedPluginBanner } from "./SettingsPlugins";
const CLI_INSTALL_COMMAND = "npm install -g @yaakapp/cli";
@@ -57,13 +51,10 @@ export function SettingsAgents() {
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.
</p>
</div>
<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">
@@ -135,6 +126,6 @@ export function SettingsAgents() {
/>
</SettingsSection>
</SettingsList>
</VStack>
</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} />;
}
@@ -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) => {
+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}
/>
);
}
+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",
@@ -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,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";
@@ -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";
+25 -450
View File
@@ -1,260 +1,36 @@
import type { AnyModel } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { Icon } from "@yaakapp-internal/ui";
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" />
)}
</>
);
}
/**
* A command or config to copy, set as a block: its title, an optional line
* saying which file it goes in, then the snippet at full width with the copy
* button on it. One-line shell commands and multi-line JSON get the same block.
*/
export function SettingCommand({
className,
description,
disabled,
done,
language,
snippet,
title,
}: {
description?: ReactNode;
disabled?: boolean;
/** A short word shown with a check beside the title once the step is detected as done */
done?: ReactNode;
language: SettingSnippetLanguage;
snippet: string;
title: ReactNode;
} & Pick<SettingRowBaseProps, "className">) {
return (
<div
aria-disabled={disabled || undefined}
className={classNames(
className,
"border-b border-border-subtle py-4",
disabled && "opacity-disabled",
)}
>
<div className="flex min-w-0 items-center gap-2 text text-text">
<span>{title}</span>
{done != null && done !== false && (
<span className="inline-flex items-center gap-1 text-sm text-success">
<Icon icon="check" size="sm" />
{done}
</span>
)}
</div>
{description != null && (
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
)}
<SettingSnippet className="mt-3" language={language} snippet={snippet} />
</div>
);
}
export type SettingSnippetLanguage = "shell" | "json" | "text";
/** The snippet half of a `SettingCommand`, for places with their own title, like a banner. */
export function SettingSnippet({
className,
language,
snippet,
}: {
className?: string;
language: SettingSnippetLanguage;
snippet: string;
}) {
const prompt = language === "shell";
return (
<div
className={classNames(
className,
"flex items-start gap-2 rounded-md border border-border-subtle bg-surface-highlight/40 py-1.5 pr-1.5 pl-3",
)}
>
<div className="flex min-h-sm min-w-0 flex-1 items-center font-mono text-editor text-text">
<span className="cursor-text select-text whitespace-pre-wrap [overflow-wrap:anywhere]">
{prompt && (
<span aria-hidden className="mr-2 select-none text-text-subtlest">
$
</span>
)}
{snippet}
</span>
</div>
<CopyIconButton size="sm" variant="border" title="Copy" text={snippet} className="shrink-0" />
</div>
);
}
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,
@@ -272,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,
@@ -334,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,
@@ -438,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>,
@@ -549,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).
@@ -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
View File
@@ -243,6 +243,7 @@ export function useHotkeyContextValue(): HotkeyContextValue {
() => ({
os: platform.osType(),
bindingFor: (action: HotkeyAction) => hotkeys[action] ?? null,
labelFor: (action: HotkeyAction) => hotkeyLabels[action],
register: registerHotkey,
}),
[hotkeys],
@@ -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]);
}
@@ -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";
+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";
+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 -8
View File
@@ -6,14 +6,16 @@ import type {
HttpResponseHeader,
} from "@yaakapp-internal/models";
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") {
+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,
};
}
+14 -11
View File
@@ -6,6 +6,7 @@ import classNames from "classnames";
import { Provider as JotaiProvider } from "jotai";
import { LazyMotion, MotionConfig } from "motion/react";
import { lazy, type ReactNode, Suspense } from "react";
import { ClientEditorHost } from "../components/ClientEditorHost";
import { GlobalHooks } from "../components/GlobalHooks";
import RouteError from "../components/RouteError";
import { useHotkeyContextValue } from "../hooks/useHotKey";
@@ -30,17 +31,19 @@ function RouteComponent() {
<JotaiProvider store={jotaiStore}>
<QueryClientProvider client={queryClient}>
<Hotkeys>
<LazyMotion strict features={motionFeatures}>
<MotionConfig transition={{ duration: 0.1 }}>
<Suspense>
<Toasts />
<Dialogs />
<ContextMenus />
</Suspense>
<Layout />
<GlobalHooks />
</MotionConfig>
</LazyMotion>
<ClientEditorHost>
<LazyMotion strict features={motionFeatures}>
<MotionConfig transition={{ duration: 0.1 }}>
<Suspense>
<Toasts />
<Dialogs />
<ContextMenus />
</Suspense>
<Layout />
<GlobalHooks />
</MotionConfig>
</LazyMotion>
</ClientEditorHost>
</Hotkeys>
</QueryClientProvider>
</JotaiProvider>
@@ -0,0 +1,132 @@
import { startCompletion } from "@codemirror/autocomplete";
import { Compartment, EditorState, type Extension } from "@codemirror/state";
import { EditorView, ViewPlugin } from "@codemirror/view";
import type { Schema } from "@yaakapp-internal/query";
import {
Dialog,
DialogFooter,
Input,
type InputHandle,
LazyCodeEditor,
} from "@yaakapp-internal/ui";
import { queryFilter } from "@yaakapp-internal/ui/editor";
import { atom, useAtom, useSetAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRpcQuery } from "../hooks/useRpcQuery";
import {
addCustomColumn,
customPath,
customPathProblem,
fieldReferenceChanges,
} from "../lib/columns";
import { loadSeenValues } from "../lib/searchBox";
import { columnPrefsAtom } from "../lib/store";
/** The Add Column dialog is open */
export const addColumnDialogAtom = atom(false);
const FORM_ID = "add-column";
/** Keeps the field a reference to a field, `@header.x-cache`, whatever was typed or pasted */
const asFieldReference = EditorState.transactionFilter.of((tr) => {
if (!tr.docChanged) return tr;
const changes = fieldReferenceChanges(tr.newDoc.toString());
return changes.length === 0 ? tr : [tr, { changes, sequential: true }];
});
/** Opens completion on the fields while the field is focused and empty */
const completeWhenEmpty = [
ViewPlugin.define((view) => {
if (view.hasFocus && view.state.doc.length === 0) {
queueMicrotask(() => startCompletion(view));
}
return {};
}),
EditorView.updateListener.of((update) => {
if (update.focusChanged && update.view.hasFocus && update.state.doc.length === 0) {
startCompletion(update.view);
}
}),
];
/**
* Adds a column for a header or query parameter, named by a reference to its field as the search
* names it, `@header.x-request-id` or `@query.page`, completed from the fields and the names seen
*/
export function AddColumnDialog() {
const [open, setOpen] = useAtom(addColumnDialogAtom);
if (!open) return null;
return <AddColumnForm onClose={() => setOpen(false)} />;
}
function AddColumnForm({ onClose }: { onClose: () => void }) {
const [text, setText] = useState("");
const setPrefs = useSetAtom(columnPrefsAtom);
const handleRef = useRef<InputHandle | null>(null);
const schema = useRpcQuery("query_schema", {}, { staleTime: Infinity }).data?.schema;
// Only the fields a column can show: the maps of headers and query parameters. It goes in
// once the schema is read, through a compartment, as the search box's does.
const compartment = useRef(new Compartment());
const filter = useMemo<Extension>(() => {
if (schema == null) return [];
const maps: Schema = { ...schema, fields: schema.fields.filter((f) => f.map) };
return [
queryFilter({ schema: maps, loadValues: loadSeenValues, fieldsOnly: true }),
completeWhenEmpty,
];
}, [schema]);
const extensions = useMemo(() => [asFieldReference, compartment.current.of(filter)], [filter]);
useEffect(() => {
handleRef.current?.dispatch({ effects: compartment.current.reconfigure(filter) });
}, [filter]);
const path = customPath(text);
const problem = customPathProblem(text);
const submit = () => {
if (path == null) return;
setPrefs((p) => addCustomColumn(p, path));
onClose();
};
return (
<Dialog open onClose={onClose} size="dynamic" title="Add Column" className="w-[26rem]">
<form
id={FORM_ID}
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<Input
renderEditor={(props) => <LazyCodeEditor {...props} />}
setRef={(h) => {
handleRef.current = h;
}}
label="Field"
autoFocus
className="font-mono text-xs"
stateKey={null}
language={null}
placeholder="@header.x-request-id"
defaultValue=""
onChange={setText}
validate={problem == null}
extensions={extensions}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.defaultPrevented) {
e.preventDefault();
submit();
}
}}
/>
<div className="h-5 pt-1 text-xs text-danger">{problem}</div>
</form>
<DialogFooter
actions={[
{ label: "Cancel", variant: "border", onClick: onClose },
{ label: "Add", color: "primary", form: FORM_ID, disabled: path == null },
]}
/>
</Dialog>
);
}
+9 -4
View File
@@ -24,8 +24,8 @@ function loadIcon(path: string): Promise<string | null> {
return promise;
}
/** An app's icon, read once per path for the whole session */
export function AppIcon({ path, className }: { path: string; className?: string }) {
/** An app's icon, read once per path for the whole session; `className` sets its size */
export function AppIcon({ path, className = "size-4" }: { path: string; className?: string }) {
const [url, setUrl] = useState(() => icons.get(path));
useEffect(() => {
if (icons.has(path)) {
@@ -42,8 +42,13 @@ export function AppIcon({ path, className }: { path: string; className?: string
}, [path]);
return url == null ? (
<span aria-hidden className={classNames(className, "inline-block w-4 h-4 shrink-0")} />
<span aria-hidden className={classNames(className, "inline-block shrink-0 aspect-square")} />
) : (
<img alt="" src={url} className={classNames(className, "w-4 h-4 shrink-0")} />
// Square whatever squeezes it, beside text that truncates
<img
alt=""
src={url}
className={classNames(className, "shrink-0 aspect-square object-contain")}
/>
);
}
+92 -35
View File
@@ -1,49 +1,106 @@
import { Dropdown, type DropdownItem, IconButton } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { flowTotalAtom } from "../lib/store";
import {
Checkbox,
Dialog,
DialogFooter,
IconButton,
useFormattedHotkey,
useHotKey,
} from "@yaakapp-internal/ui";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { type FormEvent, useCallback, useId, useState } from "react";
import { clearFlowsAction, confirmClearFlowsAtom } from "../lib/clearFlows";
import { clearFlowsAtom, flowTotalAtom, lockedAtom } from "../lib/store";
import { useShowToast } from "../lib/toast";
/** Deletes every flow, once confirmed in its menu, which its hotkey opens too */
/**
* Clears every flow, from its button or hotkey, once confirmed in a dialog unless "Don't ask
* again" was checked there. The list empties at once; the flows are deleted in the background.
* Disabled with none to clear, or while captured traffic is locked.
*/
export function ClearFlowsButton() {
const total = useAtomValue(flowTotalAtom);
const clear = useRpcMutation("clear_flows");
const locked = useAtomValue(lockedAtom);
const [confirm, setConfirm] = useAtom(confirmClearFlowsAtom);
const clearFlows = useSetAtom(clearFlowsAtom);
const showToast = useShowToast();
const [confirming, setConfirming] = useState(false);
const action = clearFlowsAction({ total, locked: locked != null, confirm });
const hotkey = useFormattedHotkey("flows_clear")?.join("");
const items: DropdownItem[] = [
{
type: "content",
label: (
<div className="px-1.5 py-1 max-w-[17rem]">
<div className="text-sm text-text">
{total === 1 ? "Clear 1 flow?" : `Clear all ${total} flows?`}
</div>
<div className="text-xs text-text-subtle mt-0.5">
{clear.error != null ? (
<span className="text-danger">{clear.error.message}</span>
) : (
"They are deleted from disk with their bodies, for good."
)}
</div>
</div>
),
},
{ type: "separator" },
{
label: "Clear",
icon: "trash",
color: "danger",
onSelect: () => clear.mutate({}),
},
];
const clear = useCallback(() => {
clearFlows().catch((err: unknown) => {
const message = (err as { message?: unknown } | null)?.message;
showToast({ message: typeof message === "string" ? message : String(err), color: "danger" });
});
}, [clearFlows, showToast]);
const start = useCallback(() => {
if (action === "confirm") setConfirming(true);
else if (action === "clear") clear();
}, [action, clear]);
// Bound even when disabled, so the key never falls through to the backend's clear unasked
useHotKey("flows_clear", start);
return (
<Dropdown items={items} hotKeyAction="flows_clear">
<>
<IconButton
className="pointer-events-auto"
icon="trash"
title="Clear flows"
title={action === "disabled" || hotkey == null ? "Clear flows" : `Clear flows ${hotkey}`}
tooltip
size="sm"
iconColor="secondary"
disabled={action === "disabled"}
onClick={start}
/>
</Dropdown>
<Dialog open={confirming} onClose={() => setConfirming(false)} size="sm" title="Clear Flows">
{confirming && (
<ConfirmClear
total={total}
onCancel={() => setConfirming(false)}
onClear={(dontAskAgain) => {
setConfirming(false);
if (dontAskAgain) setConfirm(false);
clear();
}}
/>
)}
</Dialog>
</>
);
}
function ConfirmClear({
total,
onCancel,
onClear,
}: {
total: number;
onCancel: () => void;
onClear: (dontAskAgain: boolean) => void;
}) {
const formId = useId();
const [dontAskAgain, setDontAskAgain] = useState(false);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
onClear(dontAskAgain);
};
return (
<form id={formId} className="flex flex-col gap-3" onSubmit={handleSubmit}>
<p>
{total === 1
? "Permanently clear 1 flow?"
: `Permanently clear ${total.toLocaleString()} flows?`}
</p>
<Checkbox checked={dontAskAgain} title="Don't ask again" onChange={setDontAskAgain} />
<DialogFooter
inline
actions={[
{ label: "Cancel", onClick: onCancel },
{ label: "Clear", color: "danger", form: formId, autoFocus: true },
]}
/>
</form>
);
}
@@ -0,0 +1,319 @@
import { Prec } from "@codemirror/state";
import { type EditorView, keymap } from "@codemirror/view";
import type { DecryptListEntry, SeenTargets } from "@yaakapp-internal/proxy-lib";
import {
Button,
CONTROL_TOOLTIP_DELAY,
Icon,
IconButton,
Input,
SettingRow,
SettingRowBoolean,
Tooltip,
} from "@yaakapp-internal/ui";
import { filterAutocomplete } from "@yaakapp-internal/ui/editor";
import classNames from "classnames";
import { useMemo, useRef, useState } from "react";
import { canTrustHere, useDecrypt, useTrustCertificate, useTrusting } from "../hooks/useDecrypt";
import { useDecryptList } from "../hooks/useDecryptList";
import { useRpcQuery } from "../hooks/useRpcQuery";
import {
decryptFilterOptions,
decryptsEverything,
entryLabel,
entrySubject,
EVERYTHING,
everythingEdit,
normalizeEntry,
} from "../lib/decrypt";
import { revealLabel, revealPath } from "../lib/opener";
import { getOsType } from "../lib/tauri";
import { useShowToast } from "../lib/toast";
/**
* Settings' Decryption section: the certificate, Decrypt everything as a switch over `*`, then
* the person's own hosts and apps in order, each removable, an input that adds one (suggesting
* `host:`, `app:` and `!`, then the hosts and apps seen), and the built-in exclusions folded
* into one row. Every change goes through Decrypt, so the first that decrypts something trusts
* the certificate first.
*/
export function DecryptionSettings() {
const list = useDecryptList();
const { apply } = useDecrypt();
const [busy, setBusy] = useState(false);
// Read when the input opens its suggestions, so new traffic shows without rebuilding it.
const seen = useRpcQuery("list_seen", {}, { staleTime: 0, refetchOnMount: "always" });
const seenRef = useRef<SeenTargets | undefined>(seen.data);
seenRef.current = seen.data;
const everything = decryptsEverything(list);
const own = list.filter((e) => !e.builtIn && e.entry !== EVERYTHING);
const builtIns = list.filter((e) => e.builtIn);
// One change at a time, each applied to the list as it is when it lands.
const change = async (
edit: { add: string[]; remove: string[] },
message: string,
sub?: string,
) => {
if (busy) return;
setBusy(true);
try {
await apply({ edit, message, sub });
} finally {
setBusy(false);
}
};
const remove = (entry: string) =>
void change(
{ add: [], remove: [entry] },
entry.startsWith("!")
? `Stopped excluding ${entrySubject(entry.slice(1))}`
: `Stopped decrypting ${entrySubject(entry)}`,
);
return (
<>
<CertificateRow />
<SettingRowBoolean
title="Decrypt everything"
description="Excluded hosts and apps stay encrypted."
checked={everything}
disabled={busy}
onChange={(on) =>
void change(
everythingEdit(on),
on ? "Decrypting everything" : "Stopped decrypting everything",
on ? "Excluded hosts and apps stay encrypted." : undefined,
)
}
/>
<div className="py-4 grid gap-2">
<div className="flex items-center text-text">
Hosts and apps
<span className="ml-auto text-sm text-text-subtlest">
Changes apply to new connections.
</span>
</div>
<EntryInput
seenRef={seenRef}
list={list}
disabled={busy}
onAdd={(entry) =>
void change(
{ add: [entry], remove: [] },
entry.startsWith("!")
? `Excluding ${entrySubject(entry.slice(1))}`
: `Decrypting ${entrySubject(entry)}`,
)
}
/>
<div className="rounded-md border border-border-subtle divide-y divide-border-subtle">
{own.length === 0 && (
<div className="px-3 py-2 text-sm text-text-subtle">No hosts or apps</div>
)}
{own.map((e) => (
<EntryRow key={e.entry} entry={e} disabled={busy} onRemove={() => remove(e.entry)} />
))}
{builtIns.length > 0 && <BuiltIns entries={builtIns} disabled={busy} onRemove={remove} />}
</div>
</div>
</>
);
}
/** The certificate: trusted, with Reveal for devices and tools with their own stores, or Trust */
function CertificateRow() {
const caStatus = useRpcQuery("ca_status", {});
const trust = useTrustCertificate();
const trusting = useTrusting();
const showToast = useShowToast();
const status = caStatus.data;
if (status == null)
return (
<SettingRow title="Certificate" description="Yaak Proxy CA">
{null}
</SettingRow>
);
return (
<SettingRow title="Certificate" description="Yaak Proxy CA">
<div className="flex items-center gap-2">
{status.trusted ? (
<span className="flex items-center gap-1 text-sm text-success">
<Icon icon="badge_check" size="sm" />
Trusted
</span>
) : (
<span className="text-sm text-text-subtle">Not trusted</span>
)}
{!status.trusted && canTrustHere() ? (
<Button size="2xs" color="primary" isLoading={trusting} onClick={() => trust.mutate({})}>
{trusting ? "Waiting for macOS" : "Trust"}
</Button>
) : (
<Button
size="2xs"
variant="border"
onClick={() =>
void revealPath(status.certPath, (message) =>
showToast({ id: "reveal-ca", color: "danger", message }),
)
}
>
{revealLabel(getOsType())}
</Button>
)}
</div>
</SettingRow>
);
}
/** The built-in exclusions, folded into one row that opens to each, with its kind */
function BuiltIns({
entries,
disabled,
onRemove,
}: {
entries: DecryptListEntry[];
disabled: boolean;
onRemove: (entry: string) => void;
}) {
const [open, setOpen] = useState(false);
const kinds = new Set(entries.map((e) => e.kind));
const summary = [kinds.has("apple") ? "Apple" : null, kinds.has("pinned") ? "pinned apps" : null]
.filter((k) => k != null)
.join(", ");
return (
<>
<button
type="button"
className="w-full flex items-center gap-2 h-xs pl-3 pr-2 text-sm text-text-subtle hover:bg-surface-highlight/50"
onClick={() => setOpen(!open)}
>
<Icon icon={open ? "chevron_down" : "chevron_right"} size="sm" className="shrink-0" />
{entries.length} built-in exclusions
<span className="ml-auto text-xs text-text-subtlest">{summary}</span>
</button>
{open &&
entries.map((e) => (
<EntryRow
key={e.entry}
entry={e}
disabled={disabled}
onRemove={() => onRemove(e.entry)}
/>
))}
</>
);
}
function EntryRow({
entry,
disabled,
onRemove,
}: {
entry: DecryptListEntry;
disabled: boolean;
onRemove: () => void;
}) {
const negated = entry.entry.startsWith("!");
return (
<div
className={classNames(
"group/entry flex items-center gap-2 h-xs pl-3 pr-1 text-sm",
entry.builtIn && "text-text-subtlest",
)}
>
<Tooltip wrap content={negated ? "Excluded" : "Decrypted"} delay={CONTROL_TOOLTIP_DELAY}>
<Icon
icon={negated ? "lock" : "lock_open"}
size="sm"
className={classNames("shrink-0", entry.builtIn ? "opacity-60" : "text-text-subtle")}
/>
</Tooltip>
<span className={classNames("truncate", entry.entry !== "*" && "font-mono text-xs")}>
{entryLabel(entry.entry)}
</span>
{entry.builtIn && entry.kind != null && (
<span className="shrink-0 text-2xs rounded px-1 border border-border-subtle">
{entry.kind === "apple" ? "Apple" : "Pinned"}
</span>
)}
<IconButton
size="xs"
icon="x"
iconColor="secondary"
title={`Remove ${entryLabel(entry.entry)}`}
tooltip
disabled={disabled}
className="ml-auto opacity-0 group-hover/entry:opacity-100 focus-visible:opacity-100"
onClick={onRemove}
/>
</div>
);
}
/** The input that adds an entry on Enter, once the suggestions are closed or none is picked */
function EntryInput({
seenRef,
list,
disabled,
onAdd,
}: {
seenRef: React.RefObject<SeenTargets | undefined>;
list: DecryptListEntry[];
/** A change is being saved; Enter waits for it, keeping what was typed */
disabled: boolean;
onAdd: (entry: string) => void;
}) {
const [error, setError] = useState<string | null>(null);
const onAddRef = useRef(onAdd);
onAddRef.current = onAdd;
const listRef = useRef(list);
listRef.current = list;
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
const extensions = useMemo(() => {
const options = decryptFilterOptions(null);
// The suggestions read the capture as they open, not as the input was made.
for (const field of options.fields ?? []) {
field.values = () => {
const fresh = decryptFilterOptions(seenRef.current).fields ?? [];
const values = fresh.find((f) => f.name === field.name)?.values;
return Array.isArray(values) ? values : [];
};
}
const add = (view: EditorView) => {
const text = view.state.doc.toString();
if (text.trim() === "") return false;
if (disabledRef.current) return true;
const entry = normalizeEntry(text);
if (entry == null) {
setError("Invalid host or app");
return true;
}
setError(null);
if (!listRef.current.some((e) => e.entry === entry)) onAddRef.current(entry);
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: "" } });
return true;
};
// Low, so Enter picks a suggestion while they are open.
return [filterAutocomplete(options), Prec.low(keymap.of([{ key: "Enter", run: add }]))];
}, [seenRef]);
return (
<div className="grid gap-1">
<Input
label="Add entry"
hideLabel
size="sm"
stateKey={null}
language={null}
placeholder="Add host or app"
extensions={extensions}
onChange={() => setError(null)}
/>
{error != null && <div className="text-xs text-danger">{error}</div>}
</div>
);
}
@@ -0,0 +1,309 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import { useQueryClient } from "@tanstack/react-query";
import type { Flow, PassthroughResponse } from "@yaakapp-internal/proxy-lib";
import {
Button,
Dropdown,
formatMillis,
Icon,
IconButton,
type IconProps,
InlineCode,
Tooltip,
CONTROL_TOOLTIP_DELAY,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { type ReactNode, useEffect, useState } from "react";
import { canTrustHere, useDecrypt, usePassthrough, useTrustCertificate } from "../hooks/useDecrypt";
import { useDecryptList } from "../hooks/useDecryptList";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { useRpcQuery } from "../hooks/useRpcQuery";
import {
type CardAction,
connectionOf,
encryptedCard,
isOpen,
isRejection,
type Line,
} from "../lib/encryptedCard";
import { useFlowActions } from "../lib/flowActions";
import { utcTimestamp } from "../lib/flowViews";
import { useShowToast } from "../lib/toast";
import { AppIcon } from "./AppIcon";
import { useCopyEnvironment } from "./ProxyStatus";
/**
* The card for HTTPS the proxy did not decrypt, or a client's refused handshake: the flow card's
* header lines, with ENCRYPTED or REJECTED where the status goes, then why in one line and the
* changes that would decrypt the next connection like it, each showing the entry it writes.
*/
export function EncryptedCard({ flow }: { flow: Flow }) {
const list = useDecryptList();
const caStatus = useRpcQuery("ca_status", {});
const passthrough = usePassthrough();
const rejection = isRejection(flow);
// A tunnel passed through after a rejection names the client that refused.
const rejectedById = flow.tlsRejected == null ? flow.tunnelRejectedBy : null;
const rejectedBy = useRpcQuery(
"get_flows",
{ ids: rejectedById == null ? [] : [rejectedById] },
{ enabled: rejectedById != null },
).data?.[0];
const model = encryptedCard({
flow,
list,
trusted: caStatus.data?.trusted ?? null,
canTrust: canTrustHere(),
passthrough,
rejectedBy,
});
const { apply, trusting } = useDecrypt();
const trust = useTrustCertificate();
const copyEnvironment = useCopyEnvironment();
const showToast = useShowToast();
const fail = (err: Error) =>
showToast({ id: "encrypted-card", color: "danger", message: err.message });
const queryClient = useQueryClient();
// Read from the answer too: an engine the CLI owns tells this app nothing of the change.
const forget = useRpcMutation("forget_passthrough", {
onError: fail,
onSuccess: ({ addresses }) =>
queryClient.setQueryData<PassthroughResponse>(["list_passthrough", {}], { addresses }),
});
const reconnect = useRpcMutation("reconnect_tunnels", { onError: fail });
const connection = connectionOf(flow);
const [busy, setBusy] = useState<string | null>(null);
const run = async (action: CardAction) => {
switch (action.kind) {
case "decrypt":
case "stop":
if (action.edit == null) return;
setBusy(action.label);
await apply({
edit: action.edit,
message: action.toast?.message ?? action.label,
sub: action.toast?.sub,
});
setBusy(null);
return;
case "reconnect":
reconnect.mutate({ flowId: flow.id });
return;
case "trust":
trust.mutate({});
return;
case "try_again":
forget.mutate({ address: connection.address });
return;
case "copy_environment":
await copyEnvironment();
forget.mutate({ address: connection.address });
return;
}
};
return (
<div className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)] min-h-0">
<div
className={classNames(
"flex items-center gap-2 px-3 min-h-[calc(var(--height-sm)+0.25rem)]",
"font-mono text-sm whitespace-nowrap overflow-x-auto hide-scrollbars",
)}
>
<Icon
icon={rejection ? "shield_off" : "lock"}
size="sm"
className={rejection ? "text-warning" : "text-text-subtlest"}
/>
<span className="text-text select-text cursor-text">
{connection.host.includes(":") ? `[${connection.host}]` : connection.host}
<span className="text-text-subtlest">:{connection.port}</span>
</span>
</div>
<StatusLine flow={flow} rejected={flow.tlsRejected != null} />
<div className="mx-3 border-t border-border-subtle overflow-auto">
<div className="mx-auto max-w-[27rem] py-10 grid gap-2">
<div className="text-sm text-text mb-1">
<LineText line={model.why} />
<Tooltip wrap content={model.tooltip ?? ABOUT} delay={CONTROL_TOOLTIP_DELAY}>
<IconButton
size="2xs"
icon="info"
iconColor="secondary"
title="About decryption"
className="ml-1 align-middle inline-flex!"
/>
</Tooltip>
</div>
{model.actions.map((action) => (
<ActionButton
key={action.label}
action={
busy === action.label && trusting
? { ...action, label: "Waiting for macOS" }
: action
}
flow={flow}
loading={
busy === action.label ||
(action.kind === "trust" && trust.isPending) ||
(action.kind === "try_again" && forget.isPending) ||
(action.kind === "reconnect" && reconnect.isPending)
}
disabled={busy != null && busy !== action.label}
onClick={() => void run(action)}
/>
))}
{model.foot.length > 0 && (
<div className="text-xs text-text-subtlest mt-0.5">{model.foot.join(" ")}</div>
)}
{flow.error != null && flow.tlsRejected == null && (
<div className="text-xs text-danger mt-1">{flow.error}</div>
)}
</div>
</div>
</div>
);
}
/** What the info button beside the why line says */
const ABOUT =
"HTTPS is encrypted between the app and the server. Yaak Proxy shows requests only for hosts and apps you decrypt, from their next connection on.";
/** A why or fact line, its hosts, apps and entries shown as code */
function LineText({ line }: { line: Line }) {
return (
<>
{line.map((part, i) =>
typeof part === "string" ? (
// oxlint-disable-next-line react/no-array-index-key -- the parts of one fixed line
<span key={i}>{part}</span>
) : "bold" in part ? (
// oxlint-disable-next-line react/no-array-index-key -- the parts of one fixed line
<span key={i} className="font-semibold">
{part.bold}
</span>
) : (
// oxlint-disable-next-line react/no-array-index-key -- the parts of one fixed line
<InlineCode key={i}>{part.code}</InlineCode>
),
)}
</>
);
}
const ICONS: Record<Exclude<CardAction["icon"], "app">, IconProps["icon"]> = {
host: "globe",
everything: "layers",
lock: "lock",
lock_open: "lock_open",
refresh: "refresh",
shield: "shield",
terminal: "square_terminal",
};
function ActionButton({
action,
flow,
loading,
disabled,
onClick,
}: {
action: CardAction;
flow: Flow;
loading: boolean;
disabled: boolean;
onClick: () => void;
}) {
const icon: ReactNode =
action.icon === "app" ? (
flow.clientPath == null ? (
<Icon icon="box" size="sm" />
) : (
<AppIcon path={flow.clientPath} className="size-4 min-w-4" />
)
) : (
<Icon icon={ICONS[action.icon]} size="sm" />
);
return (
<Button
size="xs"
variant="border"
color={action.primary ? "primary" : "default"}
justify="start"
className={classNames("w-full", action.dashed && "border-dashed!")}
isLoading={loading}
disabled={disabled}
leftSlot={loading ? undefined : icon}
rightSlot={
action.hint == null ? undefined : (
<span
className={classNames(
"block ml-auto pl-3 font-mono text-xs text-text-subtlest truncate max-w-[11rem]",
action.removes && "line-through",
)}
>
{action.hint}
</span>
)
}
onClick={onClick}
>
{action.label}
</Button>
);
}
/** ENCRYPTED or REJECTED, the duration, or how long it has been open, and the bytes each way */
function StatusLine({ flow, rejected }: { flow: Flow; rejected: boolean }) {
const flowActions = useFlowActions();
const open = isOpen(flow);
const openFor = useOpenFor(flow, open);
return (
<div
className={classNames(
"grid grid-cols-[minmax(0,1fr)_auto] items-center pl-3",
"min-h-[calc(var(--height-sm)+0.25rem)] font-mono text-sm text-text-subtle whitespace-nowrap",
)}
>
<div className="flex items-center gap-2 overflow-x-auto hide-scrollbars">
<span className={rejected ? "text-warning" : undefined}>
{rejected ? "REJECTED" : "ENCRYPTED"}
</span>
<span>&bull;</span>
{open ? (
<span>open {openFor == null ? "" : formatMillis(openFor)}</span>
) : (
<>
<span>{flow.elapsedMs == null ? "--" : formatMillis(flow.elapsedMs)}</span>
<span>&bull;</span>
<span className="flex items-center gap-1">
<Icon icon="arrow_up" size="xs" className="text-text-subtlest" />
{formatSize(flow.requestSize)}
<Icon icon="arrow_down" size="xs" className="text-text-subtlest ml-1" />
{formatSize(flow.responseSize)}
</span>
</>
)}
</div>
<div className="m-0.5 pr-2.5">
<Dropdown items={flowActions(flow).slice(2)}>
<IconButton size="sm" icon="ellipsis" iconColor="secondary" title="Actions" tooltip />
</Dropdown>
</div>
</div>
);
}
/** How long an open tunnel has been open, ticking each second */
function useOpenFor(flow: Flow, open: boolean): number | null {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!open) return;
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [open]);
const started = Date.parse(utcTimestamp(flow.startedAt));
return Number.isFinite(started) ? Math.max(0, now - started) : null;
}
+50 -63
View File
@@ -1,22 +1,24 @@
import { Button, Icon, LoadingIcon } from "@yaakapp-internal/ui";
import { Button, Icon, IconTooltip, InlineCode, LoadingIcon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { ReactNode } from "react";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { encryptingPercent } from "../lib/encrypting";
import { proxyStateAtom } from "../lib/store";
import { useCopyEnvironment, useProxySetup, useSystemProxySwitch } from "./ProxyStatus";
type StepState = "todo" | "next" | "wait" | "done" | "fail";
/**
* The list's empty state until traffic arrives: trust the certificate, turn on the system proxy,
* then see traffic. Not dismissable and nothing remembered; the first flow replaces it. Copy
* environment beside it serves someone who wants one terminal proxied and nothing system-wide.
* The list's empty state until traffic arrives: turn on the system proxy, then see traffic. The
* certificate is trusted at the first Decrypt, not here, as nothing needs it before. Not
* dismissable and nothing remembered; the first flow replaces it. Copy environment beside it
* serves someone who wants one terminal proxied and nothing system-wide.
*/
export function FirstRun() {
const proxyState = useAtomValue(proxyStateAtom);
const setup = useProxySetup();
const { caStatus, trust, systemProxy, setSystemProxy } = setup;
const { systemProxy, setSystemProxy } = setup;
const systemSwitch = useSystemProxySwitch(setup);
const execute = useRpcMutation("execute_action");
const retry = useRpcMutation("take_over");
@@ -26,74 +28,41 @@ export function FirstRun() {
// Changed only by the process that owns the engine, so not from here while the CLI does.
const otherOwner = proxyState?.otherOwner != null;
const recording = proxyState?.recording !== false;
const trusted = caStatus.data?.trusted === true;
const systemSupported = systemProxy.data?.supported !== false;
const systemOn = systemProxy.data?.enabled === true;
const trustState: StepState = trusted
? "done"
: trust.isPending
? "wait"
: trust.error != null
? "fail"
: "next";
const systemState: StepState = setSystemProxy.isPending
? "wait"
: systemOn
? "done"
: systemSwitch.failed
? "fail"
: trusted
? "next"
: "todo";
const ready = trusted && (systemOn || !systemSupported);
const trafficState: StepState = !running && !otherOwner ? "fail" : ready ? "wait" : "todo";
: "next";
const ready = systemOn || !systemSupported;
const encrypting = !running && !otherOwner ? encryptingPercent(proxyState) : null;
const trafficState: StepState =
encrypting != null ? "wait" : !running && !otherOwner ? "fail" : ready ? "wait" : "todo";
return (
<div className="grid place-items-center h-full px-10 pb-16 pt-6">
<div className="w-full max-w-[40rem]">
<div className="text-xl font-semibold mb-1.5">Set up Yaak Proxy</div>
<div className="text-text-subtle text-sm leading-relaxed mb-4">
Three steps, once. Afterwards every app on this Mac sends its traffic here, and HTTPS
reads as plain text.
{systemSupported
? "Traffic from every app on this Mac will be recorded."
: "Traffic appears here as apps send it."}
</div>
<div className="rounded-lg border border-border-subtle bg-surface divide-y divide-border-subtle">
<Step
n={1}
state={trustState}
title={trusted ? "Certificate trusted" : "Trust the certificate"}
description={
trusted
? "Yaak Proxy CA, in your login keychain."
: trustState === "wait"
? "Waiting for your password in the macOS prompt."
: trustState === "fail"
? (trust.error?.message ?? "The certificate was not trusted.")
: "So HTTPS reads as plain text. macOS asks for your login password once."
}
action={
trustState === "next" || trustState === "fail" ? (
<Button
size="2xs"
variant={trustState === "fail" ? "border" : "solid"}
color={trustState === "fail" ? "default" : "primary"}
onClick={() => trust.mutate({})}
>
{trustState === "fail" ? "Try again" : "Trust certificate"}
</Button>
) : null
}
/>
{systemSupported && (
<Step
n={2}
n={1}
state={systemState}
title={systemOn ? "System proxy on" : "Turn on the system proxy"}
title={systemOn ? "System proxy overridden" : "Override system proxy"}
description={
otherOwner && !systemOn
? proxyState?.otherOwner?.owner === "cli"
? "The CLI owns the proxy. Take over to turn this on from here."
: "Another Yaak Proxy window owns the engine. Quit it to use this one."
? "Take over from the CLI to change this."
: "Quit the other Yaak Proxy to use this one."
: systemSwitch.description
}
action={
@@ -104,24 +73,32 @@ export function FirstRun() {
color={systemState === "fail" ? "default" : "primary"}
onClick={() => setSystemProxy.mutate({ enabled: true })}
>
{systemState === "fail" ? "Try again" : "Turn on"}
{systemState === "fail" ? "Retry" : "Enable"}
</Button>
) : null
}
/>
)}
<Step
n={systemSupported ? 3 : 2}
n={systemSupported ? 2 : 1}
state={trafficState}
title={trafficState === "wait" ? "Waiting for traffic" : "See traffic"}
title={
encrypting != null
? `Encrypting recorded traffic, ${encrypting}`
: trafficState === "wait"
? "Waiting for traffic"
: "See traffic"
}
description={
trafficState === "fail"
? (retry.error?.message ?? proxyState?.startError ?? "The proxy has not started.")
: !recording
? "Recording is paused, so traffic passes through without being stored."
: trafficState === "wait"
? "Open a website or send a request, and its flow appears here."
: "Flows appear here as apps send requests."
encrypting != null
? "Recording will start once encryption finishes."
: trafficState === "fail"
? (retry.error?.message ?? proxyState?.startError ?? "The proxy failed to start.")
: !recording
? "Recording is paused."
: trafficState === "wait"
? "Open a website or send a request to begin."
: "Flows appear here as apps send requests."
}
action={
trafficState === "fail" ? (
@@ -139,14 +116,14 @@ export function FirstRun() {
color="primary"
onClick={() => execute.mutate({ scope: "global", action: "recording_toggle" })}
>
Resume recording
Resume Recording
</Button>
) : null
}
/>
</div>
<div className="flex items-center gap-3 mt-4 text-sm text-text-subtle">
<span>Only need one terminal or one app? Copy the environment instead.</span>
<span>To proxy a single terminal instead, copy the environment.</span>
<Button
size="2xs"
variant="border"
@@ -154,9 +131,19 @@ export function FirstRun() {
leftSlot={<Icon icon="square_terminal" size="sm" />}
onClick={() => void copyEnvironment()}
>
Copy environment
Copy Environment
</Button>
</div>
<div className="mt-2 text-sm text-text-subtle">
Agents can connect through <InlineCode>yaak mcp</InlineCode>.
</div>
<div className="flex items-center gap-1.5 mt-2 text-sm text-text-subtle">
Recorded traffic is encrypted on disk.
<IconTooltip
iconSize="sm"
content="The key is kept in the system keychain, so the system may ask to let Yaak Proxy use it."
/>
</div>
</div>
</div>
);
@@ -0,0 +1,80 @@
import { describe, expect, test, vi } from "vite-plus/test";
import { flowPrettyView, shownViewMode } from "./FlowBodyViewer";
// The package's index reaches for the window as it loads; the view rule is all this needs.
vi.mock("@yaakapp-internal/ui", async () => {
const { prettyViewFor } = await vi.importActual<
typeof import("../../../packages/ui/src/components/http/BodyViewModeSwitch")
>("../../../packages/ui/src/components/http/BodyViewModeSwitch");
return { prettyViewFor, LARGE_BODY_BYTES: 2_000_000 };
});
const text = (mime: string, language: string | null, which: "request" | "response" = "response") =>
flowPrettyView({
which,
mime,
text: "x",
binary: false,
language: language as never,
hasForm: false,
});
describe("flowPrettyView", () => {
test("names the formatted view per format, the same on both halves", () => {
for (const which of ["request", "response"] as const) {
expect(text("application/json", "json", which)).toBe("formatted");
expect(text("text/csv", "text", which)).toBe("table");
expect(text("image/svg+xml", "xml", which)).toBe("preview");
}
expect(text("text/plain", "text")).toBeNull();
});
test("HTML previews as a response and formats as a request", () => {
expect(text("text/html", "html", "response")).toBe("preview");
expect(text("text/html", "html", "request")).toBe("formatted");
});
test("multipart has parts whether or not it reads as text", () => {
const base = { which: "request" as const, mime: "multipart/form-data", language: null };
expect(flowPrettyView({ ...base, text: null, binary: false, hasForm: false })).toBe("parts");
});
test("a URL-encoded form's fields are its formatted view, where the tab has them", () => {
const base = {
which: "request" as const,
mime: "application/x-www-form-urlencoded",
text: "a=1",
binary: false,
language: "text" as const,
};
expect(flowPrettyView({ ...base, hasForm: true })).toBe("form");
expect(flowPrettyView({ ...base, hasForm: false })).toBeNull();
});
test("images, binary and undecodable bodies have one view", () => {
const base = { which: "response" as const, language: null, hasForm: false };
expect(flowPrettyView({ ...base, mime: "image/png", text: null, binary: true })).toBeNull();
expect(
flowPrettyView({ ...base, mime: "application/json", text: null, binary: true }),
).toBeNull();
expect(
flowPrettyView({ ...base, mime: "application/octet-stream", text: null, binary: false }),
).toBeNull();
});
});
describe("shownViewMode", () => {
test("follows the preference", () => {
expect(shownViewMode({ key: "a", large: false, preferred: "pretty", chosen: null })).toBe(
"pretty",
);
expect(shownViewMode({ key: "a", large: false, preferred: "raw", chosen: null })).toBe("raw");
});
test("a large body opens raw, and formats once asked, for that body alone", () => {
expect(shownViewMode({ key: "a", large: true, preferred: "pretty", chosen: null })).toBe("raw");
const chosen = { key: "a", mode: "pretty" as const };
expect(shownViewMode({ key: "a", large: true, preferred: "pretty", chosen })).toBe("pretty");
expect(shownViewMode({ key: "b", large: true, preferred: "pretty", chosen })).toBe("raw");
});
});
+171 -23
View File
@@ -2,27 +2,39 @@ import { useQuery } from "@tanstack/react-query";
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow, FlowBody } from "@yaakapp-internal/proxy-lib";
import {
ACTIONS_BAR_CONTAINER,
ActionsBar,
Banner,
BodyViewModeSwitch,
Button,
CsvViewer,
HexdumpViewer,
Icon,
IconTooltip,
ImageViewer,
LARGE_BODY_BYTES,
languageFromContentType,
LoadingIcon,
MultipartViewer,
type PrettyView,
prettyViewFor,
type ResponseViewMode,
SvgViewer,
TextViewer,
WebPageViewer,
} from "@yaakapp-internal/ui";
import type { CodeEditorProps } from "@yaakapp-internal/ui/editor";
import classNames from "classnames";
import { useAtom } from "jotai";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { bodyViewModeAtom } from "../lib/atoms";
import {
bodyAsText,
bodyImageType,
filterText,
findHeader,
emptyBodyText,
flowBodyQueryOptions,
formatText,
mimeEssence,
@@ -32,15 +44,97 @@ import { useSaveFlowBody } from "../lib/flowActions";
interface Props {
flow: Flow;
which: FlowBody;
pretty: boolean;
/** A URL-encoded body's fields, its formatted view, with the switch in the fields' own bar */
form?: (actions: ReactNode) => ReactNode;
/** Leaves saying the body was cut short to the tab, which says it its own way */
hideCutNote?: boolean;
}
export function useFlowBody(flow: Flow, which: FlowBody, enabled = true) {
return useQuery({ ...flowBodyQueryOptions(flow, which), enabled });
}
export function FlowBodyViewer({ flow, which, pretty }: Props) {
/** A mode chosen for one large body, which holds for it alone */
interface ChosenViewMode {
key: string;
mode: ResponseViewMode;
}
/** The mode a body shows in: the preference, except raw for a large one until chosen for it */
export function shownViewMode({
key,
large,
preferred,
chosen,
}: {
key: string;
large: boolean;
preferred: ResponseViewMode;
chosen: ChosenViewMode | null;
}): ResponseViewMode {
if (chosen?.key === key) return chosen.mode;
return large ? "raw" : preferred;
}
/**
* Formatted or raw, as last chosen, except that a body big enough to slow the card opens raw,
* and choosing for it holds for that body alone
*/
export function useBodyViewMode(
key: string,
large: boolean,
): [ResponseViewMode, (mode: ResponseViewMode) => void] {
const [preferred, setPreferred] = useAtom(bodyViewModeAtom);
const [chosen, setChosen] = useState<ChosenViewMode | null>(null);
const mode = shownViewMode({ key, large, preferred, chosen });
const setMode = useCallback(
(next: ResponseViewMode) => {
if (large) {
setChosen({ key, mode: next });
} else {
setChosen(null);
setPreferred(next);
}
},
[key, large, setPreferred],
);
return [mode, setMode];
}
/**
* The formatted view a body has, or null when it has only the one: parts for multipart, the
* fields for a URL-encoded form, or else what the shared rule gives its text. Images and binary
* bodies have only their one view.
*/
export function flowPrettyView({
which,
mime,
text,
binary,
language,
hasForm,
}: {
which: FlowBody;
mime: string;
text: string | null;
/** An image, or what failed to decode */
binary: boolean;
language: CodeEditorProps["language"] | null;
hasForm: boolean;
}): PrettyView | null {
if (binary) return null;
if (mime.startsWith("multipart/")) return "parts";
if (text == null) return null;
if (hasForm && mime === "application/x-www-form-urlencoded") return "form";
return prettyViewFor({ mimeType: mime, language, previewHtml: which === "response" });
}
export function FlowBodyViewer({ flow, which, form, hideCutNote = false }: Props) {
const body = useFlowBody(flow, which);
const [mode, setMode] = useBodyViewMode(
`${flow.id}.${which}`,
(body.data?.bytes.length ?? 0) > LARGE_BODY_BYTES,
);
if (body.isPending) {
return (
@@ -53,9 +147,9 @@ export function FlowBodyViewer({ flow, which, pretty }: Props) {
return <Banner color="danger">{body.error.message}</Banner>;
}
const { bytes, truncated, storedSize, decodeError } = body.data;
const { bytes, truncated, storedSize, decodeError, note } = body.data;
if (bytes.length === 0 && decodeError == null) {
return <EmptyState>{which === "request" ? "No request body" : "Empty"}</EmptyState>;
return <EmptyState>{emptyBodyText(which, note)}</EmptyState>;
}
const headers = which === "request" ? flow.requestHeaders : flow.responseHeaders;
@@ -68,12 +162,28 @@ export function FlowBodyViewer({ flow, which, pretty }: Props) {
const imageType = decodeError == null ? bodyImageType(bytes, truncated, contentType) : null;
const text =
decodeError == null && imageType == null ? bodyAsText(bytes, truncated, contentType) : null;
const language = text == null ? null : languageFromContentType(contentType, text);
const prettyView = flowPrettyView({
which,
mime,
text,
binary: decodeError != null || imageType != null,
language,
hasForm: form != null,
});
// The one view a body without a formatted one has is its raw one
const pretty = prettyView != null && mode === "pretty";
const modeSwitch =
prettyView == null ? null : (
<BodyViewModeSwitch pretty={prettyView} value={mode} onChange={setMode} />
);
let viewer: ReactNode;
let ownsBar = false;
if (decodeError != null) {
// What failed to decode is as it was on the wire, compressed, so never text.
viewer = <BinaryBody flow={flow} which={which} bytes={bytes} />;
} else if (mime.startsWith("multipart/") && pretty) {
} else if (prettyView === "parts" && pretty) {
const boundary = contentType?.match(/;\s*boundary="?([^";]+)"?/i)?.[1] ?? "unknown";
viewer = (
<MultipartViewer
@@ -87,35 +197,62 @@ export function FlowBodyViewer({ flow, which, pretty }: Props) {
viewer = <ImageBody bytes={bytes} mime={imageType} />;
} else if (text == null) {
viewer = <BinaryBody flow={flow} which={which} bytes={bytes} />;
} else if (mime.startsWith("image/svg")) {
viewer = <SvgViewer text={text} />;
} else if (/csv|tab-separated/.test(mime) && pretty) {
} else if (prettyView === "form" && pretty && form != null) {
ownsBar = true;
viewer = form(modeSwitch);
} else if (prettyView === "table" && pretty) {
viewer = <CsvViewer text={text} className="pb-2" />;
} else if (prettyView === "preview" && pretty) {
viewer = mime.startsWith("image/svg") ? (
<SvgViewer text={text} />
) : (
<WebPageViewer html={text} baseUrl={flow.url} />
);
} else {
const language = languageFromContentType(contentType, text);
if (language === "html" && pretty && which === "response") {
viewer = <WebPageViewer html={text} baseUrl={flow.url} />;
} else {
viewer = (
<FlowTextViewer flow={flow} which={which} text={text} language={language} pretty={pretty} />
);
}
ownsBar = true;
viewer = (
<FlowTextViewer
flow={flow}
which={which}
text={text}
language={language}
pretty={pretty}
modeSwitch={modeSwitch}
/>
);
}
if (!truncated && decodeError == null) return viewer;
// The text viewer and the form draw the switch in their own bar, beside their own actions; the
// rest get a bar here. Each fills the tab content, so every bar has the same inset.
if (modeSwitch != null && !ownsBar) {
viewer = (
<div className={classNames(ACTIONS_BAR_CONTAINER, "h-full w-full")}>
{viewer}
<ActionsBar>{modeSwitch}</ActionsBar>
</div>
);
}
if ((!truncated || hideCutNote) && decodeError == null) return viewer;
return (
<div className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)]">
{decodeError != null && (
<Banner color="danger" className="text-sm mb-2">
{decodeError}. Showing the body as it was on the wire.
{decodeError}. The body is shown as it was received.
</Banner>
)}
{truncated && (
{truncated && !hideCutNote && (
<Banner color="notice" className="text-sm mb-2">
{wireTruncated || decodeError != null
? `Showing the first ${formatSize(storedSize)} of ${formatSize(wireSize)}, the capture limit. The app received all of it.`
: `Showing the first ${formatSize(bytes.length)} of the decoded body, the capture limit. The app received all of it.`}
<span className="flex items-center gap-1.5">
{wireTruncated || decodeError != null
? `The first ${formatSize(storedSize)} of ${formatSize(wireSize)} is shown.`
: `The first ${formatSize(bytes.length)} of the decoded body is shown.`}
<IconTooltip
iconSize="sm"
content="The body was truncated at the capture limit. The app received all of it."
/>
</span>
</Banner>
)}
<div className="min-h-0 row-start-3">{viewer}</div>
@@ -167,7 +304,16 @@ function FlowTextViewer({
text,
language,
pretty,
}: Props & { text: string; language: CodeEditorProps["language"] }) {
modeSwitch,
}: {
flow: Flow;
which: FlowBody;
text: string;
language: CodeEditorProps["language"];
pretty: boolean;
/** Formatted or raw, in the viewer's bar after its filter; null for a body with one view */
modeSwitch: ReactNode;
}) {
const runFilter = useCallback(
(expression: string) => filterText(flow.id, which, expression, language),
[flow.id, which, language],
@@ -182,6 +328,8 @@ function FlowTextViewer({
className="-mr-2 bg-surface"
formatText={formatText}
filterText={runFilter}
ownActionsBar
pinnedActions={modeSwitch}
/>
);
}
+349
View File
@@ -0,0 +1,349 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow, FlowBody } from "@yaakapp-internal/proxy-lib";
import {
CONTROL_TOOLTIP_DELAY,
CountBadge,
Dropdown,
EmptyStateText,
type ExtraPaneTab,
FormUrlencodedEditor,
type HttpBodyResult,
HttpRequestPaneView,
HttpResponsePaneView,
Icon,
IconButton,
parseCookieHeaders,
ResponseCookies,
SplitLayout,
type TabItem,
Tooltip,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue, useSetAtom, useStore } from "jotai";
import { useCallback, useMemo } from "react";
import { BODY_TYPE_FORM_URLENCODED } from "@yaakapp-internal/ui/httpTypes";
import { placeTabsAtom } from "../lib/atoms";
import { bodyTypeBadge, findHeader } from "../lib/body";
import {
browserTabMemory,
defaultTabFor,
type FlowPane,
learnTab,
REQUEST_TAB_AUTH,
REQUEST_TAB_BODY,
REQUEST_TAB_COOKIES,
REQUEST_TAB_HEADERS,
REQUEST_TAB_INFO,
REQUEST_TAB_PARAMS,
RESPONSE_TAB_BODY,
RESPONSE_TAB_COOKIES,
RESPONSE_TAB_HEADERS,
RESPONSE_TAB_MESSAGES,
} from "../lib/defaultTab";
import { saveDialogFlowAtom, useFlowActions } from "../lib/flowActions";
import {
type FlowBodyData,
flowBodyLoader,
flowCookiesView,
flowRequestToView,
flowResponseToView,
} from "../lib/flowViews";
import { messagesTabLabel } from "../lib/messages";
import { goToPlace } from "../lib/navigation";
import { FlowBodyViewer, useFlowBody } from "./FlowBodyViewer";
import { FlowInfo } from "./FlowInfo";
import { FlowRequestBody } from "./FlowRequestBody";
import { FlowMessages } from "./FlowMessages";
import { ProxyEditorHost } from "./ProxyEditorHost";
/**
* One card for a flow: the request half (its URL line and tabs), a hairline, and the response
* half (its status line and tabs). The halves sit perpendicular to the list: stacked when the
* list is beside the card, side by side when it is above.
*/
export function FlowCard({ flow, listBeside }: { flow: Flow; listBeside: boolean }) {
return (
<ProxyEditorHost>
<SplitLayout
storageKey={listBeside ? "proxy_card_stacked" : "proxy_card_side"}
layout={listBeside ? "vertical" : "horizontal"}
minHeightPx={120}
minWidthPx={240}
firstSlot={({ style }) => (
<div style={style}>
<FlowRequestHalf flow={flow} />
</div>
)}
secondSlot={({ style, orientation }) => (
<div style={style}>
<FlowResponseHalf
flow={flow}
className={orientation === "vertical" ? "border-t" : "border-l"}
/>
</div>
)}
/>
</ProxyEditorHost>
);
}
/**
* The tab a half opens on, and learning from the ones chosen by hand. The tab chosen is part of
* the window's place, so going back to it, or reloading, opens it again.
*/
function useShapeTab(pane: FlowPane, flow: Flow, available: string[]) {
const store = useStore();
const memory = browserTabMemory();
const activeTab = useAtomValue(placeTabsAtom)[pane];
// Read again for every flow, so a tab learned on one opens the next of its shape
const defaultTab = defaultTabFor(pane, flow, available, memory);
const onTabChange = useCallback(
(tab: string) => {
learnTab(pane, flow, tab, memory);
goToPlace(store, "update", { tabs: { ...store.get(placeTabsAtom), [pane]: tab } });
},
[pane, flow, memory, store],
);
return { activeTab, defaultTab, onTabChange };
}
/** A body read through the engine's body RPC, as the shared panes' loaders ask for it */
function useLoadedBody(
flow: Flow,
which: FlowBody,
enabled = true,
): HttpBodyResult<FlowBodyData | null> {
const size = which === "request" ? flow.requestSize : flow.responseSize;
const load = enabled && size > 0;
const body = useFlowBody(flow, which, load);
if (!load) return { data: null, isLoading: false, error: null };
return { data: body.data ?? undefined, isLoading: body.isLoading, error: body.error };
}
function FlowRequestHalf({ flow }: { flow: Flow }) {
const body = useLoadedBody(flow, "request");
const request = useMemo(() => flowRequestToView(flow, body.data ?? null), [flow, body.data]);
const loader = useMemo(
() =>
flowBodyLoader({
useBody: () => useLoadedBody(flow, "request"),
contentType: findHeader(flow.requestHeaders, "content-type"),
}),
[flow],
);
const cookies = useMemo(() => flowCookiesView(flow), [flow]);
const sentCount = useMemo(
() => parseCookieHeaders(cookies.cookieHeaders).sentCookies.length,
[cookies],
);
const extraTabs = useMemo<ExtraPaneTab[]>(
() => [
{
tab: {
value: REQUEST_TAB_COOKIES,
label: "Cookies",
rightSlot: <CountBadge count={sentCount} />,
},
content: <ResponseCookies response={cookies} side="sent" />,
},
],
[cookies, sentCount],
);
const leadingTabs = useMemo<ExtraPaneTab[]>(
() => [{ tab: { value: REQUEST_TAB_INFO, label: "Info" }, content: <FlowInfo flow={flow} /> }],
[flow],
);
const contentType = findHeader(flow.requestHeaders, "content-type");
const badge = bodyTypeBadge(contentType, flow.requestSize, flow.graphqlOperation != null);
const bodyTab = useMemo(() => bodyTabItem(REQUEST_TAB_BODY, badge), [badge]);
const available = [
REQUEST_TAB_INFO,
REQUEST_TAB_BODY,
REQUEST_TAB_PARAMS,
REQUEST_TAB_HEADERS,
...(request.auth == null ? [] : [REQUEST_TAB_AUTH]),
REQUEST_TAB_COOKIES,
];
const { activeTab, defaultTab, onTabChange } = useShapeTab("request", flow, available);
const forceUpdateKey = `${flow.updatedAt}.${body.data == null ? "" : "loaded"}`;
return (
<HttpRequestPaneView
// Padded below as the response half's tabs are, so their content, and the actions bar
// over it, sit the same distance from the card's edges
className="px-3 pb-3"
request={request}
fullHeight
forceUpdateKey={forceUpdateKey}
// Info joined as the first tab: an order dragged before it would put it last
tabsStorageKey="proxy_request_tabs.2"
bodyLoader={loader}
bodyTab={bodyTab}
leadingTabs={leadingTabs}
extraTabs={extraTabs}
defaultTab={defaultTab}
activeTab={activeTab}
onTabChange={onTabChange}
urlLine={<UrlLine flow={flow} />}
renderBody={() => (
<FlowRequestBody
body={body}
// The response's viewer choice, with the form's fields as a URL-encoded body's
// formatted view, the switch in one bar with their bulk-view toggle
viewer={
<FlowBodyViewer
flow={flow}
which="request"
form={
request.bodyType === BODY_TYPE_FORM_URLENCODED
? (actions) => (
<FormUrlencodedEditor
readOnly
requestId={request.id}
form={request.body.form}
forceUpdateKey={forceUpdateKey}
actions={actions}
/>
)
: undefined
}
hideCutNote
/>
}
requestSize={flow.requestSize}
/>
)}
/>
);
}
/** A half's body tab: Body, with the body's type beside it as the Headers count is, if any */
function bodyTabItem(value: string, badge: string | null): TabItem {
return {
value,
label: "Body",
rightSlot: badge == null ? null : <CountBadge count={badge} />,
};
}
/** The method and URL, at the status line's height so the two header lines align */
function UrlLine({ flow }: { flow: Flow }) {
return (
// The whole URL on hover, when it runs past the card
<Tooltip wrap onlyWhenTruncated content={flow.url} delay={CONTROL_TOOLTIP_DELAY}>
<div
className={classNames(
"flex items-center gap-2 min-h-[calc(var(--height-sm)+0.25rem)] -mb-1.5",
"font-mono text-sm whitespace-nowrap overflow-x-auto hide-scrollbars",
)}
>
<span className="text-text-subtle shrink-0">{flow.method}</span>
<span className="text-text select-text cursor-text">{flow.url}</span>
</div>
</Tooltip>
);
}
function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }) {
// A stream's body is its messages, read by the Messages tab; reading it whole again on every
// message would only hold copies of it
const loadBody = flow.streamKind == null;
const body = useLoadedBody(flow, "response", loadBody);
const response = useMemo(() => flowResponseToView(flow, body.data ?? null), [flow, body.data]);
const loader = useMemo(
() =>
flowBodyLoader({
useBody: () => useLoadedBody(flow, "response", loadBody),
contentType: findHeader(flow.responseHeaders, "content-type"),
}),
[flow, loadBody],
);
const openSave = useSetAtom(saveDialogFlowAtom);
const flowActions = useFlowActions();
const messagesLabel = messagesTabLabel(flow);
const badge = bodyTypeBadge(flow.contentType, flow.responseSize);
const bodyTab = useMemo(() => bodyTabItem(RESPONSE_TAB_BODY, badge), [badge]);
const extraTabs = useMemo<ExtraPaneTab[]>(
() =>
messagesLabel == null
? []
: [
{
tab: {
value: RESPONSE_TAB_MESSAGES,
label: messagesLabel,
rightSlot: (
<CountBadge
count={flow.messageCount}
color="info"
showZero
className="tabular-nums"
/>
),
},
content: <FlowMessages flow={flow} />,
},
],
[flow, messagesLabel],
);
const available = [
RESPONSE_TAB_BODY,
RESPONSE_TAB_HEADERS,
RESPONSE_TAB_COOKIES,
...(messagesLabel == null ? [] : [RESPONSE_TAB_MESSAGES]),
];
const { activeTab, defaultTab, onTabChange } = useShapeTab("response", flow, available);
const saved = flow.savedRequestId != null;
return (
<HttpResponsePaneView
bare
requestShownApart
className={classNames(className, "border-border-subtle")}
response={response}
body={loader}
requestBody={null}
emptyState={<EmptyStateText>Waiting for a response</EmptyStateText>}
tabsStorageKey="proxy_response_tabs"
activeTabKey={flow.id}
defaultTab={defaultTab}
activeTab={activeTab}
onTabChange={onTabChange}
extraTabs={extraTabs}
bodyTab={bodyTab}
renderBodyViewer={() => <FlowBodyViewer flow={flow} which="response" />}
statusLineCenter={
flow.responseTruncated ? (
<span
className={classNames(
"justify-self-end flex items-center gap-1.5 h-5 px-2.5 ml-3 rounded-full",
"border border-notice/50 text-notice font-sans text-xs",
)}
>
<Icon icon="scissors" size="xs" />
{body.data == null
? "Body truncated"
: `First ${formatSize(body.data.storedSize)} recorded`}
</span>
) : undefined
}
statusLineRight={
<div className="flex items-center gap-0.5 m-0.5">
<IconButton
size="sm"
icon="bookmark"
iconColor={saved ? "primary" : "secondary"}
title={saved ? "Saved to Yaak" : "Save to Yaak"}
tooltip
onClick={() => openSave(flow)}
/>
<Dropdown items={flowActions(flow).slice(2)}>
<IconButton size="sm" icon="ellipsis" iconColor="secondary" title="Actions" tooltip />
</Dropdown>
</div>
}
/>
);
}
+14 -274
View File
@@ -1,45 +1,23 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow } from "@yaakapp-internal/proxy-lib";
import {
Banner,
Button,
CountBadge,
Dropdown,
type DropdownItem,
formatMillis,
HStack,
HttpStatusTagRaw,
Icon,
IconButton,
SizeTag,
type TabButtonRenderProps,
type TabItem,
TabContent,
Tabs,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue, useSetAtom } from "jotai";
import { useAtomValue } from "jotai";
import type { CSSProperties } from "react";
import { useMemo, useState } from "react";
import { selectedFlowAtom } from "../lib/atoms";
import { findHeader, mimeEssence } from "../lib/body";
import { saveDialogFlowAtom, useFlowActions } from "../lib/flowActions";
import { statusReason } from "../lib/statusReason";
import { EmptyState, FlowBodyViewer, useFlowBody } from "./FlowBodyViewer";
import { FlowHeaders, FlowQuery, queryParams } from "./FlowHeaders";
const TAB_RESPONSE = "response";
const TAB_REQUEST = "request";
const TAB_HEADERS = "headers";
const TAB_QUERY = "query";
import { isRejection } from "../lib/encryptedCard";
import { EncryptedCard } from "./EncryptedCard";
import { EmptyState } from "./FlowBodyViewer";
import { FlowCard } from "./FlowCard";
/**
* The detail pane: the selected flow's card, or what the pane holds before one is selected. The
* split's gutter separates it from the list; side by side it sits a little under the header.
*/
export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideBySide: boolean }) {
const flow = useAtomValue(selectedFlowAtom);
return (
<div
style={style}
className={classNames("min-h-0 min-w-0 pb-2", sideBySide ? "pr-2 pt-2" : "px-2")}
className={classNames("min-h-0 min-w-0 pb-2", sideBySide ? "pr-2 pt-3" : "px-2")}
>
<div
className={classNames(
@@ -55,257 +33,19 @@ export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideB
<span>Pause or resume recording</span>
<Keys keys={["⌘", "R"]} />
<span>Clear flows</span>
<Keys keys={["⌘", "K"]} />
<Keys keys={["⌘", "⇧", "K"]} />
</div>
</EmptyState>
) : flow.tunnelled || isRejection(flow) ? (
<EncryptedCard key={flow.id} flow={flow} />
) : (
<FlowPane flow={flow} />
<FlowCard flow={flow} listBeside={sideBySide} />
)}
</div>
</div>
);
}
function FlowPane({ flow }: { flow: Flow }) {
const [pretty, setPretty] = useState(true);
const responseBody = useFlowBody(flow, "response", flow.responseTruncated);
// Failed or cancelled before any response; after the headers, the body was cut short mid-way.
const noResponse = (flow.error != null || flow.cancelled) && flow.status == null;
const mime = mimeEssence(findHeader(flow.responseHeaders, "content-type"));
const queryCount = useMemo(() => queryParams(flow.url).length, [flow.url]);
const openSave = useSetAtom(saveDialogFlowAtom);
const flowActions = useFlowActions();
const tabs = useMemo<TabItem[]>(
() => [
{
value: TAB_RESPONSE,
renderButton: ({ buttonProps, isActive }) => (
<ViewModeButton
buttonProps={buttonProps}
isActive={isActive}
pretty={pretty}
onChangePretty={setPretty}
prettyLabel={mime === "text/html" ? "Preview" : "Response"}
allowRaw={!mime?.startsWith("image/")}
/>
),
},
{
value: TAB_REQUEST,
label: "Request",
rightSlot: flow.requestSize > 0 ? <CountBadge count={true} /> : null,
},
{
value: TAB_HEADERS,
label: "Headers",
rightSlot: (
<CountBadge
count={flow.requestHeaders.length}
count2={flow.responseHeaders.length}
showZero
/>
),
},
{
value: TAB_QUERY,
label: "Query",
rightSlot: <CountBadge count={queryCount} showZero />,
},
],
[
pretty,
mime,
flow.requestSize,
flow.requestHeaders.length,
flow.responseHeaders.length,
queryCount,
],
);
return (
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
<div
className={classNames(
"grid grid-cols-[auto_minmax(4rem,1fr)_auto] items-center h-sm -mb-1.5",
"cursor-default select-none whitespace-nowrap w-full px-3",
"overflow-x-auto font-mono text-sm text-text-subtle hide-scrollbars",
)}
>
<HStack space={2} className="w-full shrink-0">
{flow.cancelled && flow.status == null ? (
<span>Cancelled</span>
) : (
<HttpStatusTagRaw
showReason
status={flow.status ?? 0}
statusReason={flow.status == null ? null : statusReason(flow.status)}
/>
)}
{flow.elapsedMs != null && (
<>
<span>&bull;</span>
<span
title={
flow.elapsedHeadersMs == null
? undefined
: `HEADERS: ${formatMillis(flow.elapsedHeadersMs)}\nTOTAL: ${formatMillis(flow.elapsedMs)}`
}
>
{formatMillis(flow.elapsedMs)}
</span>
</>
)}
{!noResponse && (
<>
<span>&bull;</span>
<SizeTag contentLength={flow.responseSize} />
</>
)}
</HStack>
{flow.responseTruncated ? (
<span
className={classNames(
"justify-self-end flex items-center gap-1.5 h-5 px-2.5 ml-3 rounded-full",
"border border-notice/50 text-notice font-sans text-xs",
)}
>
<Icon icon="scissors" size="xs" />
{responseBody.data == null
? "Body cut"
: `First ${formatSize(responseBody.data.storedSize)} kept`}
</span>
) : (
<span />
)}
<HStack space={1} className="font-sans ml-3">
<Button
size="2xs"
variant="border"
color={flow.savedRequestId == null ? "default" : "primary"}
title={flow.savedRequestId == null ? undefined : "Saved to Yaak. Click to save again."}
leftSlot={<Icon icon="bookmark" size="xs" />}
onClick={() => openSave(flow)}
>
{flow.savedRequestId == null ? "Save to Yaak" : "Saved"}
</Button>
<Dropdown items={flowActions(flow).slice(2)}>
<IconButton size="xs" icon="ellipsis" iconColor="secondary" title="Copy and export" />
</Dropdown>
</HStack>
</div>
<div className="overflow-hidden flex flex-col min-h-0">
{flow.error != null && (
<Banner color="danger" className="mx-3 mt-1 shrink-0 font-mono text-sm">
{flow.error}
</Banner>
)}
{flow.cancelled && (
<Banner color="secondary" className="mx-3 mt-1 shrink-0 text-sm">
{flow.status == null
? "The app cancelled the request before a response came back."
: "The app cancelled the request before the response finished."}
</Banner>
)}
<Tabs
tabs={tabs}
label="Flow"
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
tabListClassName="mt-0.5 -mb-1.5"
>
<TabContent value={TAB_RESPONSE}>
{noResponse ? (
<EmptyState>
<div className="text-text-subtle mb-1">
{flow.cancelled ? "Cancelled" : "No response"}
</div>
<div className="text-xs">
{flow.cancelled
? "The app cancelled the request before a response came back."
: "The request never got a response."}{" "}
The Request tab has what the app sent.
</div>
</EmptyState>
) : (
<FlowBodyViewer flow={flow} which="response" pretty={pretty} />
)}
</TabContent>
<TabContent value={TAB_REQUEST}>
<FlowBodyViewer flow={flow} which="request" pretty />
</TabContent>
<TabContent value={TAB_HEADERS}>
<FlowHeaders flow={flow} />
</TabContent>
<TabContent value={TAB_QUERY}>
<FlowQuery flow={flow} />
</TabContent>
</Tabs>
</div>
</div>
);
}
interface ViewModeButtonProps {
buttonProps: TabButtonRenderProps["buttonProps"];
isActive: boolean;
pretty: boolean;
onChangePretty: (pretty: boolean) => void;
prettyLabel: string;
allowRaw: boolean;
}
/** The client's Response tab: a click on the active tab opens its view menu */
function ViewModeButton({
buttonProps,
isActive,
pretty,
onChangePretty,
prettyLabel,
allowRaw,
}: ViewModeButtonProps) {
const showPretty = pretty || !allowRaw;
const items: DropdownItem[] = [
{
label: prettyLabel,
leftSlot: <CheckMark visible={showPretty} />,
onSelect: () => onChangePretty(true),
},
{
label: `${prettyLabel} (Raw)`,
hidden: !allowRaw,
leftSlot: <CheckMark visible={!showPretty} />,
onSelect: () => onChangePretty(false),
},
];
return (
<Dropdown items={items}>
<Button
{...buttonProps}
onClick={(e) => {
if (isActive) return;
e.preventDefault();
buttonProps.onClick?.(e);
}}
rightSlot={
<Icon
size="sm"
icon="chevron_down"
className={classNames("ml-1", isActive ? "text-text-subtle" : "text-text-subtlest")}
/>
}
>
{showPretty ? prettyLabel : "Raw"}
</Button>
</Dropdown>
);
}
function CheckMark({ visible }: { visible: boolean }) {
return <Icon icon={visible ? "check" : "empty"} size="sm" />;
}
function Keys({ keys }: { keys: string[] }) {
return (
<span className="flex gap-1 justify-end">
-109
View File
@@ -1,109 +0,0 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow } from "@yaakapp-internal/proxy-lib";
import {
DetailsBanner,
formatMillis,
HeadersTable,
KeyValueRow,
KeyValueRows,
} from "@yaakapp-internal/ui";
import { useMemo } from "react";
import { EmptyState } from "./FlowBodyViewer";
const startedFormat = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
timeZoneName: "short",
});
const bytesFormat = new Intl.NumberFormat("en-US");
export function FlowHeaders({ flow }: { flow: Flow }) {
const versions =
flow.upstreamHttpVersion == null
? `${flow.clientHttpVersion} from the app`
: `${flow.clientHttpVersion} from the app, ${flow.upstreamHttpVersion} to the server`;
return (
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
<DetailsBanner defaultOpen storageKey="flow.info" summary={<h2>Info</h2>}>
<KeyValueRows>
<KeyValueRow label="Started">
{startedFormat.format(new Date(`${flow.startedAt}Z`))}
</KeyValueRow>
<KeyValueRow label="URL">{flow.url}</KeyValueRow>
<KeyValueRow label="Remote Address">{flow.remoteAddr ?? <Missing />}</KeyValueRow>
<KeyValueRow label="Version">{versions}</KeyValueRow>
<KeyValueRow label="Timing">
{flow.elapsedMs == null ? (
<Missing />
) : flow.elapsedHeadersMs == null ? (
`${formatMillis(flow.elapsedMs)} in all`
) : (
`${formatMillis(flow.elapsedMs)} in all, headers after ${formatMillis(flow.elapsedHeadersMs)}`
)}
</KeyValueRow>
<KeyValueRow label="Size">
<span>
{formatSize(flow.requestSize)} sent, {formatSize(flow.responseSize)} received{" "}
<span className="text-text-subtlest">
({bytesFormat.format(flow.responseSize)} B)
</span>
</span>
</KeyValueRow>
</KeyValueRows>
</DetailsBanner>
<HeadersTable
title="Request Headers"
headers={flow.requestHeaders}
labelColor="primary"
storageKey="flow.request_headers"
/>
<HeadersTable
defaultOpen
title="Response Headers"
headers={flow.responseHeaders}
labelColor="info"
storageKey="flow.response_headers"
/>
</div>
);
}
export function FlowQuery({ flow }: { flow: Flow }) {
const params = useMemo(() => queryParams(flow.url), [flow.url]);
if (params.length === 0) {
return <EmptyState>No query parameters</EmptyState>;
}
return (
<div className="overflow-auto h-full pb-4">
<KeyValueRows>
{params.map(([name, value], i) => (
// oxlint-disable-next-line react/no-array-index-key -- a name can repeat
<KeyValueRow key={i} label={name} labelColor="primary">
{value}
</KeyValueRow>
))}
</KeyValueRows>
</div>
);
}
export function queryParams(url: string): [string, string][] {
try {
return [...new URL(url).searchParams.entries()];
} catch {
return [];
}
}
function Missing() {
return <span className="text-text-subtlest">--</span>;
}
+180
View File
@@ -0,0 +1,180 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow } from "@yaakapp-internal/proxy-lib";
import {
CONTROL_TOOLTIP_DELAY,
DetailsBanner,
formatMillis,
Icon,
type IconProps,
KeyValueRow,
KeyValueRows,
Tooltip,
} from "@yaakapp-internal/ui";
import { format } from "date-fns";
import { Fragment, type HTMLAttributes, type ReactElement, type ReactNode, useMemo } from "react";
import { utcTimestamp } from "../lib/flowViews";
import { flowPhases, type PhaseKind } from "../lib/phases";
import { folderOf } from "../lib/search";
import { AppIcon } from "./AppIcon";
/**
* Where a flow came from and how it travelled: in a proxy you didn't send the request, so the
* connection on each leg, the app and its process, timing, sizes and the flow's record are part
* of the answer. Then the timeline: each phase of the exchange and how long it took.
*/
export function FlowInfo({ flow }: { flow: Flow }) {
const folder = folderOf(flow);
const clientPort = portOf(flow.clientAddr);
return (
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
<Section title="Connection">
{[
row("remote", "Remote Address", flow.remoteAddr, { copy: true }),
row("client_version", "Client Version", flow.clientHttpVersion),
row("server_version", "Server Version", flow.upstreamHttpVersion),
row("tls", "TLS", flow.upstreamTlsVersion),
row("cipher", "Cipher", flow.upstreamTlsCipher),
row("client_port", "Client Port", clientPort),
]}
</Section>
<Section title="App">
{[
row(
"app",
"Name",
flow.clientName == null ? null : (
<span className="flex items-center gap-1.5">
{flow.clientPath != null && <AppIcon path={flow.clientPath} className="size-5" />}
{flow.clientName}
{folder != null && <span className="text-text-subtlest">{folder}</span>}
</span>
),
),
flow.clientBundleId != null
? row("bundle", "Bundle ID", flow.clientBundleId, { copy: true })
: row("path", "Path", flow.clientPath, { copy: true }),
row("pid", "PID", flow.clientPid == null ? null : `${flow.clientPid}`),
row("cwd", "Directory", flow.clientCwd, { copy: true }),
]}
</Section>
<Section title="Timing">
{[
row(
"started",
"Started",
format(utcTimestamp(flow.startedAt), "MMM d, yyyy, h:mm:ss.SSS a"),
),
row("headers", "Latency", ms(flow.elapsedHeadersMs)),
row("total", "Time", ms(flow.elapsedMs)),
]}
</Section>
<Section title="Size">
{[
row("request_size", "Request", formatSize(flow.requestSize)),
row("response_size", "Response", formatSize(flow.responseSize)),
]}
</Section>
<Section title="Flow">
{[
row("number", "Number", `#${flow.number}`, { copy: true }),
row("id", "ID", flow.id, { copy: true }),
row("saved", "Yaak Request ID", flow.savedRequestId, { copy: true }),
]}
</Section>
<Timeline flow={flow} />
</div>
);
}
const PHASE_ICONS: Record<PhaseKind, { icon: IconProps["icon"]; color: IconProps["color"] }> = {
start: { icon: "send_horizontal", color: "secondary" },
step: { icon: "arrow_right", color: "secondary" },
end: { icon: "check", color: "success" },
error: { icon: "alert_triangle", color: "danger" },
cancelled: { icon: "circle_off", color: "secondary" },
};
/** The flow's phases, each with how long it took and when, from the start */
function Timeline({ flow }: { flow: Flow }) {
const phases = useMemo(() => flowPhases(flow), [flow]);
if (phases.length === 0) return null;
return (
<DetailsBanner defaultOpen summary={<h2>Timeline</h2>}>
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto_auto] gap-x-3 gap-y-1.5 items-center text-sm py-1">
{phases.map((p) => {
const { icon, color } = PHASE_ICONS[p.kind];
return (
<Fragment key={p.key}>
<Icon icon={icon} color={color} size="sm" />
{p.detail == null ? (
<span className="truncate">{p.label}</span>
) : (
<Tooltip wrap content={p.detail} delay={CONTROL_TOOLTIP_DELAY}>
<span className="truncate">
{p.label}
<span className="text-text-subtlest">{` ${p.detail}`}</span>
</span>
</Tooltip>
)}
<span className="font-mono text-xs tabular-nums text-right">
{p.kind === "start" ? "" : `+${formatMillis(p.tookMs)}`}
</span>
<span className="font-mono text-xs tabular-nums text-right text-text-subtlest">
{formatMillis(p.atMs)}
</span>
</Fragment>
);
})}
</div>
</DetailsBanner>
);
}
/** A row as `KeyValueRows` takes it */
type Row = ReactElement<HTMLAttributes<HTMLTableColElement>>;
function Section({ title, children }: { title: string; children: (Row | null)[] }) {
const rows = children.filter((r): r is Row => r != null);
if (rows.length === 0) return null;
return (
<DetailsBanner defaultOpen summary={<h2>{title}</h2>}>
<KeyValueRows>{rows}</KeyValueRows>
</DetailsBanner>
);
}
/** A row, or nothing when there's no value for it */
function row(
key: string,
label: string,
value: ReactNode | null | undefined,
opts: { copy?: boolean } = {},
): Row | null {
if (value == null || value === "") return null;
return (
<KeyValueRow
key={key}
labelColor="secondary"
label={label}
enableCopy={opts.copy}
copy={copyText}
>
{value}
</KeyValueRow>
);
}
function ms(value: number | null): string | null {
return value == null ? null : formatMillis(value);
}
/** The port of `127.0.0.1:54321` or `[::1]:54321` */
export function portOf(addr: string | null | undefined): string | null {
const port = addr?.match(/:(\d+)$/)?.[1];
return port ?? null;
}
function copyText(text: string) {
navigator.clipboard.writeText(text).catch(console.error);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,120 @@
import { EmptyStateText, Hotkey, Icon, InlineCode } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue, useStore } from "jotai";
import { useEffect, useState } from "react";
import { appliedFilterAtom } from "../lib/atoms";
import { applyFilter } from "../lib/navigation";
import { rpc } from "../lib/rpc";
import { activeQuery, widerQueries } from "../lib/query";
import {
hideEncryptedAtom,
hideSystemAtom,
pausedAtAtom,
proxyStateAtom,
searchWindowAtom,
} from "../lib/store";
/** Nothing to list: no flows yet, recording paused, or a query nothing matches */
export function FlowListEmpty() {
const text = useAtomValue(appliedFilterAtom);
const search = useAtomValue(searchWindowAtom);
const proxyState = useAtomValue(proxyStateAtom);
if (search?.loading) return null;
if (text.trim() !== "") return <NoResults text={text} />;
const other = proxyState?.otherOwner ?? null;
const port = proxyState?.port ?? other?.proxyPort ?? null;
const running = proxyState?.state === "running" || other != null;
const recording = proxyState?.recording !== false;
return (
<EmptyStateText wrapperClassName="p-1.5" className="not-italic flex-col gap-1 text-center px-6">
<div className="text-text text-sm">{recording ? "No flows yet" : "Recording is paused"}</div>
<div className="text-sm text-text-subtle max-w-[26rem]">
{!running
? "Retry from the status menu to start the proxy."
: !recording
? "Traffic passes through without being recorded."
: `Traffic sent to port ${port ?? "--"} will be recorded.`}
</div>
</EmptyStateText>
);
}
/**
* The list's last row while recording is paused, where the next flow would appear: since when.
*/
export function PausedRow({ style }: { style?: React.CSSProperties }) {
const pausedAt = useAtomValue(pausedAtAtom);
return (
<div
style={style}
className={classNames(
"absolute left-1.5 right-1.5 flex items-center gap-2 h-xs px-3 mt-1",
"rounded-md border border-dashed border-border-subtle text-text-subtle text-sm",
)}
>
<Icon icon="circle_pause" size="sm" className="text-text-subtlest" />
<span className="truncate">
{pausedAt == null ? "Recording is paused." : `Recording was paused at ${timeOf(pausedAt)}.`}
</span>
<Hotkey action="recording_toggle" variant="text" className="ml-auto" />
</div>
);
}
/**
* A query nothing matches: wider ones that do, each the query with one term left out, as Yaak's
* sidebar answers, with how many each would match.
*/
function NoResults({ text }: { text: string }) {
const hideSystem = useAtomValue(hideSystemAtom);
const hideEncrypted = useAtomValue(hideEncryptedAtom);
const store = useStore();
const setText = (wider: string) => applyFilter(store, wider);
const [wider, setWider] = useState<{ text: string; count: number }[]>([]);
useEffect(() => {
let current = true;
const candidates = widerQueries(text);
void Promise.all(
candidates.map(async (candidate) => {
const query = activeQuery(candidate, hideSystem, hideEncrypted);
const res = await rpc("list_flows", { ...query, limit: 0 });
return { text: candidate, count: query == null ? res.total : (res.matching ?? res.total) };
}),
)
.then((found) => {
if (current) setWider(found.filter((f) => f.count > 0).slice(0, 3));
})
.catch(() => {
if (current) setWider([]);
});
return () => {
current = false;
};
}, [text, hideSystem, hideEncrypted]);
return (
<EmptyStateText
wrapperClassName="p-1.5 !h-auto"
className="not-italic !h-auto py-3 px-4 text-sm text-text-subtle"
>
<div className="text-center leading-relaxed">
{wider.length === 0 ? "No results" : "No results, but found matches for "}
{wider.map((w, i) => (
<span key={w.text}>
{i > 0 && " or "}
<button type="button" className="hover:underline" onClick={() => setText(w.text)}>
<InlineCode>{w.text === "" ? "all traffic" : w.text}</InlineCode>
</button>{" "}
({w.count.toLocaleString()})
</span>
))}
</div>
</EmptyStateText>
);
}
function timeOf(ms: number): string {
return new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
}
@@ -0,0 +1,550 @@
import {
DndContext,
type DragStartEvent,
PointerSensor,
useDraggable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import type { FlowSortKey } from "@yaakapp-internal/proxy-lib";
import {
ContextMenu,
Dropdown,
type DropdownItem,
Icon,
IconButton,
ResizeHandle,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue, useSetAtom } from "jotai";
import {
memo,
type MouseEvent,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import {
addSortKey,
allColumns,
type Column,
type ColumnDef,
type ColumnId,
type ColumnPrefs,
type CustomColumnId,
canHide,
dropBoundary,
hideOtherColumns,
isCustomColumn,
moveColumn,
moveColumnBy,
removeCustomColumn,
resetOrder,
resetWidth,
resetWidths,
resizeColumn,
resizeDistance,
toggleColumn,
unavailableColumns,
} from "../lib/columns";
import { columnPrefsAtom, hasAppsAtom } from "../lib/store";
import { addColumnDialogAtom } from "./AddColumnDialog";
/** Pixels a press on a label moves before it drags the column rather than sorting by it */
const DRAG_DISTANCE = 4;
/** How near the list's side edge, in pixels, a dragged column scrolls the list that way */
const EDGE = 40;
/** The most the list scrolls sideways a frame, at the very edge */
const EDGE_SPEED = 18;
interface Props {
columns: Column[];
template: string;
/** The rows' width, as CSS, so the header scrolls sideways with them */
width: string;
prefs: ColumnPrefs;
sort: FlowSortKey[];
/** A click on a label, or with `add`, an Option-click */
onSort: (column: ColumnId, add: boolean) => void;
/** Sets the sort from the menus; empty goes back to arrival order */
onSetSort: (sort: FlowSortKey[]) => void;
onResetSort: () => void;
/** The rows' scrolling element, which a column dragged to its edge scrolls sideways */
scrollElement: () => HTMLElement | null;
/** A column's drag, for the list to show: its ghost, and the columns making room for it */
onColumnDrag: (event: ColumnDragEvent) => void;
}
/**
* A column's drag: its start, the pointer's moves (`x`, across the window), the boundary it
* would drop at when that changes (`to`, null where it would stay), and its end, dropped there
* or put back
*/
export type ColumnDragEvent =
| { type: "start"; id: ColumnId; x: number }
| { type: "move"; x: number }
| { type: "target"; to: number | null }
| { type: "end"; dropped: boolean };
/**
* The list's only control surface: a click on a label sorts by it alone, a second reverses it
* and a third goes back to arrival order; Option-click adds it to the sort as a further key.
* Dragging a label moves its column, and Escape puts it back. Dragging the edge after a label
* resizes its column and a double-click on it resets it. A right-click on a label opens the
* column's menu; the picker sits in the empty corner on hover.
*/
export const FlowListHeader = memo(function FlowListHeader({
columns,
template,
width,
prefs,
sort,
onSort,
onSetSort: setSort,
onResetSort,
scrollElement,
onColumnDrag,
}: Props) {
const setPrefs = useSetAtom(columnPrefsAtom);
const unavailable = unavailableColumns(useAtomValue(hasAppsAtom));
const openAddColumn = useSetAtom(addColumnDialogAtom);
const [menu, setMenu] = useState<{ x: number; y: number; column: ColumnDef | null } | null>(null);
// Widths when a drag began, so each move sets the width from where it started.
const startWidth = useRef(0);
const { sensors, dndHandlers, gridRef, onPointerDownCapture } = useColumnDrag({
columns,
scrollElement,
onColumnDrag,
onDrop: (id, to) => setPrefs((p) => moveColumn(p, id, to, unavailable)),
});
// Taking out the sort's only key goes back to arrival order, as Reset Sort does
const removeColumn = (id: CustomColumnId) => {
if (sort.length > 0 && sort.every((k) => k.column === id)) onResetSort();
setPrefs((p) => removeCustomColumn(p, id));
};
const pickerItems: DropdownItem[] = [
{
type: "content",
label: <div className="px-1.5 pt-1 text-xs text-text-subtlest">Columns</div>,
},
...allColumns(prefs).map((c): DropdownItem => {
const shown = prefs.visible[c.id] ?? c.defaultVisible;
return {
label: c.label,
keepOpenOnSelect: true,
disabled: shown && !canHide(prefs, c.id, unavailable),
leftSlot: shown ? (
<Icon icon="check" size="sm" />
) : (
<span aria-hidden className="inline-block w-4 h-4" />
),
onSelect: () => setPrefs((p) => toggleColumn(p, c.id, unavailable)),
...(isCustomColumn(c.id)
? {
submenuTrigger: "button" as const,
submenu: [
{
label: "Remove Column",
icon: "trash" as const,
color: "danger" as const,
onSelect: () => removeColumn(c.id as CustomColumnId),
},
],
}
: {}),
};
}),
{ label: "Add Column", icon: "plus", onSelect: () => openAddColumn(true) },
{ type: "separator" },
{ label: "Reset Sort", disabled: sort.length === 0, onSelect: onResetSort },
{ label: "Reset Column Widths", onSelect: () => setPrefs((p) => resetWidths(p)) },
{
label: "Reset Column Order",
disabled: prefs.order.length === 0,
onSelect: () => setPrefs((p) => resetOrder(p)),
},
];
const columnItems = (c: ColumnDef): DropdownItem[] => {
const sortBy = (descending: boolean) => setSort([{ column: c.id, descending }]);
const addTo = (descending: boolean) => setSort(addSortKey(sort, c.id, descending));
const at = columns.findIndex((col) => col.id === c.id);
return [
{ label: "Sort Ascending", onSelect: () => sortBy(false) },
{ label: "Sort Descending", onSelect: () => sortBy(true) },
{ label: "Add to Sort Ascending", onSelect: () => addTo(false) },
{ label: "Add to Sort Descending", onSelect: () => addTo(true) },
{ label: "Reset Sort", disabled: sort.length === 0, onSelect: onResetSort },
{ type: "separator" },
{
label: "Move Left",
disabled: at <= 0,
onSelect: () => setPrefs((p) => moveColumnBy(p, c.id, -1, unavailable)),
},
{
label: "Move Right",
disabled: at === -1 || at >= columns.length - 1,
onSelect: () => setPrefs((p) => moveColumnBy(p, c.id, 1, unavailable)),
},
{ type: "separator" },
{
label: "Hide Column",
disabled: !canHide(prefs, c.id, unavailable),
onSelect: () => setPrefs((p) => toggleColumn(p, c.id, unavailable)),
},
{
label: "Hide Other Columns",
disabled: columns.length <= 1,
onSelect: () => setPrefs((p) => hideOtherColumns(p, c.id)),
},
...(isCustomColumn(c.id)
? [
{
label: "Remove Column",
color: "danger" as const,
onSelect: () => removeColumn(c.id as CustomColumnId),
},
]
: []),
{ type: "separator" },
{ label: "Reset Column Widths", onSelect: () => setPrefs((p) => resetWidths(p)) },
];
};
// A right-click on a label is for its column; anywhere else in the header, the picker.
const openMenu = useCallback(
(e: MouseEvent) => {
e.preventDefault();
const el = (e.target as HTMLElement).closest<HTMLElement>("[data-column]");
const id = el?.dataset.column as ColumnId | undefined;
const column = columns.find((c) => c.id === id) ?? null;
setMenu({ x: e.clientX, y: e.clientY, column });
},
[columns],
);
return (
// The same margins and padding as the rows, so each label sits over its cells.
<div style={{ width, minWidth: "100%" }} className="px-1.5">
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the column's menu */}
<div
ref={gridRef}
data-flow-header
onPointerDownCapture={onPointerDownCapture}
onContextMenu={openMenu}
style={{ gridTemplateColumns: template }}
className={classNames(
"group/header grid items-center h-xs px-1.5 select-none",
"text-xs text-text-subtlest border-b border-border-subtle",
)}
>
<span className="flex items-center justify-center">
<Dropdown items={pickerItems}>
<IconButton
size="xs"
icon="settings_2"
title="Columns"
tooltip
iconColor="secondary"
className="opacity-0 group-hover/header:opacity-100 focus-visible:opacity-100 !h-5 !w-5"
/>
</Dropdown>
</span>
<DndContext sensors={sensors} autoScroll={false} {...dndHandlers}>
{columns.map((c) => (
<HeaderCell
key={c.id}
column={c}
sort={sort}
onSort={onSort}
onResizeStart={() => {
startWidth.current = c.current;
}}
onResizeMove={(dx) => {
const by = resizeDistance(c, dx, rootFontSize());
setPrefs((p) => resizeColumn(p, c.id, startWidth.current + by));
}}
onResetWidth={() => setPrefs((p) => resetWidth(p, c.id))}
/>
))}
</DndContext>
<ContextMenu
triggerPosition={menu}
items={menu == null ? [] : menu.column == null ? pickerItems : columnItems(menu.column)}
onClose={() => setMenu(null)}
/>
</div>
</div>
);
});
/** The interface font size now, which numeric columns' widths grow with */
function rootFontSize(): number {
return Number.parseFloat(getComputedStyle(document.documentElement).fontSize);
}
interface HeaderCellProps {
column: Column;
sort: FlowSortKey[];
onSort: (column: ColumnId, add: boolean) => void;
onResizeStart: () => void;
/** How far the edge moved since the resize began */
onResizeMove: (dx: number) => void;
onResetWidth: () => void;
}
function HeaderCell({
column: c,
sort,
onSort,
onResizeStart,
onResizeMove,
onResetWidth,
}: HeaderCellProps) {
// The label drags the column; the resize handle beside it is outside it, so it still resizes.
const { listeners, setNodeRef } = useDraggable({ id: c.id });
const sorted = sort.some((k) => k.column === c.id);
return (
<span data-column={c.id} className="relative h-full flex items-center min-w-0 px-1.5">
<button
ref={setNodeRef}
type="button"
{...listeners}
// A press that moves drags instead; dnd-kit swallows the click that ends a drag.
onClick={(e) => onSort(c.id, e.altKey)}
// No tooltip: the header sits under the search field, and a tooltip here covered it
aria-label={`Sort by ${c.label}`}
className={classNames(
"flex items-center gap-0.5 min-w-0 w-full h-full whitespace-nowrap",
"hover:text-text-subtle",
c.align === "right" && "justify-end",
sorted && "text-text-subtle",
)}
>
<ColumnLabel column={c} sort={sort} />
</button>
{/* On the column's right edge, the boundary with the next, resizing this one */}
<ResizeHandle
side="right"
justify="center"
className="absolute top-0 translate-x-1/2"
onResizeStart={onResizeStart}
onResizeMove={(e) => onResizeMove(e.x - e.xStart)}
onReset={onResetWidth}
/>
</span>
);
}
/** A column's name, after its sort direction and, sorted by several, its place in the sort */
function ColumnLabel({ column: c, sort }: { column: Column; sort: FlowSortKey[] }) {
const at = sort.findIndex((k) => k.column === c.id);
const key = sort[at];
return (
<>
{key != null && (
<Icon
icon="chevron_down"
size="xs"
className={classNames("shrink-0", !key.descending && "rotate-180")}
/>
)}
{key != null && sort.length > 1 && (
<span className="shrink-0 -ml-0.5 text-2xs tabular-nums">{at + 1}</span>
)}
<span className="truncate">{c.id === "app" ? "" : c.label}</span>
</>
);
}
/**
* Dragging a column's label: where it would drop, the boundary between the labels nearest the
* pointer as they were laid out when the drag began, and the list scrolling sideways while the
* pointer is near its edge. The list shows the drag; the columns only change order on the drop,
* so the rows lay out once.
*/
function useColumnDrag({
columns,
scrollElement,
onColumnDrag,
onDrop,
}: {
columns: Column[];
scrollElement: () => HTMLElement | null;
onColumnDrag: (event: ColumnDragEvent) => void;
/** Moves the column to the boundary `to` between those shown */
onDrop: (id: ColumnId, to: number) => void;
}) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: DRAG_DISTANCE } }),
);
const gridRef = useRef<HTMLDivElement>(null);
// Live while dragging, read each frame
const state = useRef<{
/** The column dragged, and its place among those shown */
id: ColumnId;
from: number;
/** The labels' edges when the drag began, from the header's left, as the columns slide */
cells: { left: number; right: number }[];
/** The columns shown when it began, which the edges and `from` describe */
shown: string;
/** The pointer, across the window */
x: number;
/** The boundary it would move to, null where it would stay */
to: number | null;
/** The pointer the ghost was last moved to */
ghostX: number;
frame: number;
} | null>(null);
const latest = useRef({ columns, scrollElement, onColumnDrag, onDrop });
useLayoutEffect(() => {
latest.current = { columns, scrollElement, onColumnDrag, onDrop };
});
/** Ends the drag, dropped or put back */
const stop = useCallback((dropped: boolean) => {
const s = state.current;
state.current = null;
if (s == null) return null;
cancelAnimationFrame(s.frame);
latest.current.onColumnDrag({ type: "end", dropped });
return s;
}, []);
const onDragStart = useCallback((e: DragStartEvent) => {
const grid = gridRef.current;
const from = latest.current.columns.findIndex((c) => c.id === e.active.id);
if (grid == null || from === -1) return;
const id = e.active.id as ColumnId;
const x = pointer.current;
// First, as it ends the last drag's columns sliding home, so the edges read below are where
// the columns are laid out, not where they were sliding
latest.current.onColumnDrag({ type: "start", id, x });
const gridLeft = grid.getBoundingClientRect().left;
const cells = Array.from(grid.querySelectorAll<HTMLElement>(":scope > [data-column]")).map(
(el) => {
const box = el.getBoundingClientRect();
return { left: box.left - gridLeft, right: box.right - gridLeft };
},
);
const shown = shownKey(latest.current.columns);
state.current = { id, from, cells, shown, x, to: null, ghostX: x, frame: 0 };
target(state.current);
const tick = () => {
const s = state.current;
if (s == null) return;
// Near an edge, the list scrolls that way, faster the nearer; the header follows it.
const list = latest.current.scrollElement();
if (list != null) {
const { left, right } = list.getBoundingClientRect();
const near =
s.x < left + EDGE ? s.x - left - EDGE : s.x > right - EDGE ? s.x - right + EDGE : 0;
if (near !== 0) {
const speed = Math.min(1, Math.abs(near) / EDGE) * EDGE_SPEED;
list.scrollLeft += Math.sign(near) * Math.max(1, speed);
}
}
target(s);
if (s.x !== s.ghostX) {
s.ghostX = s.x;
latest.current.onColumnDrag({ type: "move", x: s.x });
}
s.frame = requestAnimationFrame(tick);
};
state.current.frame = requestAnimationFrame(tick);
// oxlint-disable-next-line react-hooks/exhaustive-deps -- `target` reads only refs
}, []);
/** Drops the column where the pointer is, once; a second call finds no drag */
const drop = useCallback(() => {
// Where the pointer is now, as a release can come before the next frame reads it
if (state.current != null) target(state.current);
// The list lets go of the drag and the order changes in the same task, so nothing jumps.
const s = stop(true);
if (s?.to != null) latest.current.onDrop(s.id, s.to);
// oxlint-disable-next-line react-hooks/exhaustive-deps -- `target` reads only refs
}, [stop]);
const cancel = useCallback(() => {
stop(false);
}, [stop]);
// Columns shown or hidden under a drag, as App is when the first flow with an app arrives,
// leave its edges and target describing columns that are gone; it is put back instead.
const shown = shownKey(columns);
useEffect(() => {
if (state.current != null && state.current.shown !== shown) cancel();
}, [shown, cancel]);
// The pointer from a press on the header until its release, read before dnd-kit sees it.
// dnd-kit starts a drag on the move that crosses `DRAG_DISTANCE` and reports moves and the
// release only once it has rendered the drag, so a release right after a move would drop
// where the pointer was a frame before, or not at all. The release drops it here instead.
const pointer = useRef(0);
const track = useCallback((e: PointerEvent) => {
pointer.current = e.clientX;
if (state.current != null) state.current.x = e.clientX;
}, []);
const unlisten = useRef<(() => void) | null>(null);
const onPointerDownCapture = useCallback(
(e: ReactPointerEvent) => {
unlisten.current?.();
pointer.current = e.clientX;
const up = (e: PointerEvent) => {
unlisten.current?.();
track(e);
drop();
};
const lost = () => {
unlisten.current?.();
cancel();
};
window.addEventListener("pointermove", track, true);
window.addEventListener("pointerup", up, true);
window.addEventListener("pointercancel", lost, true);
unlisten.current = () => {
unlisten.current = null;
window.removeEventListener("pointermove", track, true);
window.removeEventListener("pointerup", up, true);
window.removeEventListener("pointercancel", lost, true);
};
},
[track, drop, cancel],
);
// A drag never outlives the header
useEffect(
() => () => {
unlisten.current?.();
cancel();
},
[cancel],
);
/** Sets where the drag would drop, telling the list when that changes */
function target(s: NonNullable<typeof state.current>) {
const gridLeft = gridRef.current?.getBoundingClientRect().left ?? 0;
const cells = s.cells.map((c) => ({ left: gridLeft + c.left, right: gridLeft + c.right }));
const to = dropBoundary(s.x, cells, s.from);
if (to === s.to) return;
s.to = to;
latest.current.onColumnDrag({ type: "target", to });
}
return {
sensors,
gridRef,
onPointerDownCapture,
dndHandlers: { onDragStart, onDragEnd: drop, onDragCancel: cancel },
};
}
/** Which columns show, in order, to tell when that changes */
function shownKey(columns: Column[]): string {
return columns.map((c) => c.id).join(" ");
}
+133
View File
@@ -0,0 +1,133 @@
import type { Flow } from "@yaakapp-internal/proxy-lib";
import {
type EventStreamColumns,
EventStreamViewer,
HStack,
type StreamEvent,
type StreamPayloadView,
} from "@yaakapp-internal/ui";
import { useCallback, useMemo } from "react";
import { useFlowMessages, useMessageSource } from "../hooks/useFlowMessages";
import { base64ToBytes } from "../lib/body";
import { grpcStatusText, messageToStreamEvent, streamIsOpen } from "../lib/messages";
const WEBSOCKET_COLUMNS: EventStreamColumns = {
direction: true,
kind: "Type",
preview: "Message",
size: true,
time: true,
};
const SSE_COLUMNS: EventStreamColumns = {
kind: "Event",
kindWidth: "9.5rem",
preview: "Data",
size: true,
time: true,
};
const GRPC_COLUMNS: EventStreamColumns = {
direction: true,
kind: "Type",
preview: "Message",
size: true,
time: true,
};
/**
* A streaming flow's messages: a WebSocket's frames, an event stream's events, a gRPC call's
* messages and status, following new ones while the stream is open. The selected message's
* payload opens below as text or a hexdump; a gRPC message's also decoded, with its schema or
* without one.
*/
export function FlowMessages({ flow }: { flow: Flow }) {
const source = useMessageSource();
const { messages, loading, error } = useFlowMessages(flow.id, source, flow.messageCount);
const events = useMemo(() => messages.map(messageToStreamEvent), [messages]);
const kind = flow.streamKind;
const grpc = kind === "grpc" || kind === "grpc_web";
const columns = kind === "sse" ? SSE_COLUMNS : grpc ? GRPC_COLUMNS : WEBSOCKET_COLUMNS;
const noun = kind === "sse" ? "Event" : "Message";
const catchUp = useMemo(
() => ({
label: (count: number) => `${count} new ${noun.toLowerCase()}${count === 1 ? "" : "s"}`,
}),
[noun],
);
const loadPayload = useCallback(
async (event: StreamEvent) => {
const res = await source.payload(flow.id, Number(event.key));
return { bytes: base64ToBytes(res.data) };
},
[flow.id, source],
);
const payloadViews = useCallback(
(event: StreamEvent): StreamPayloadView[] | null => {
if (!grpc || event.direction === "info") return null;
const decode = (schemaless: boolean) => async () => {
const decoded = await source.decodeGrpc(flow.id, Number(event.key), schemaless);
const note = [
decoded.messageType == null ? null : `Decoded as ${decoded.messageType}`,
decoded.note,
]
.filter(Boolean)
.join(". ");
return { text: decoded.json, language: "json" as const, note: note || null };
};
return [
{ value: "decoded", label: "Decoded", type: "text", load: decode(false) },
{ value: "schemaless", label: "Schemaless", type: "text", load: decode(true) },
{ value: "raw", label: "Raw bytes", type: "hexdump" },
];
},
[flow.id, grpc, source],
);
const open = streamIsOpen(flow);
const count = Math.max(flow.messageCount, messages.length);
const header = (
<HStack space={2} className="pl-2 mb-1 h-xs font-mono text-xs text-text-subtle">
{kind === "websocket" && (
<>
{open ? (
<span className="text-success">CONNECTED</span>
) : flow.error != null ? (
<span className="text-danger">ERROR</span>
) : (
<span className="text-text-subtle">CLOSED</span>
)}
<span>&bull;</span>
</>
)}
<span>
{count} {count === 1 ? noun : `${noun}s`}
</span>
{grpc && flow.grpcStatus != null && (
<>
<span>&bull;</span>
<span className={flow.grpcStatus === 0 ? "text-success" : "text-danger"}>
{grpcStatusText(flow.grpcStatus, flow.grpcMessage)}
</span>
</>
)}
</HStack>
);
return (
<EventStreamViewer
events={events}
header={header}
error={error ?? (open ? null : flow.error)}
isLoading={loading && events.length === 0}
emptyMessage={
open ? `Waiting for the first ${noun.toLowerCase()}` : `No ${noun.toLowerCase()}s`
}
splitLayoutStorageKey="proxy_flow_messages"
columns={columns}
loadPayload={loadPayload}
payloadViews={payloadViews}
catchUp={catchUp}
/>
);
}
@@ -0,0 +1,56 @@
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, test, vi } from "vite-plus/test";
import type { FlowBodyData } from "../lib/flowViews";
import { FlowRequestBody } from "./FlowRequestBody";
// The package's index reaches for the window as it loads; these four are all this renders.
vi.mock("@yaakapp-internal/ui", () => ({
Banner: ({ children, color }: { children: ReactNode; color?: string }) => (
<div data-banner={color}>{children}</div>
),
EmptyStateText: ({ children }: { children: ReactNode }) => <div>{children}</div>,
LoadingIcon: () => <span>LOADING</span>,
IconTooltip: ({ content }: { content: string }) => <span data-tooltip={content} />,
}));
const data = (truncated = false): FlowBodyData => ({
bytes: new TextEncoder().encode('{"a":1}'),
truncated,
storedSize: 7,
});
function render(body: Parameters<typeof FlowRequestBody>[0]["body"]) {
return renderToStaticMarkup(
<FlowRequestBody body={body} viewer={<div>EDITOR</div>} requestSize={2048} />,
);
}
describe("FlowRequestBody", () => {
test("a body that failed to load says why, not that there is none", () => {
const html = render({ data: undefined, isLoading: false, error: new Error("Key unavailable") });
expect(html).toContain('data-banner="danger"');
expect(html).toContain("Key unavailable");
expect(html).not.toContain("EDITOR");
});
test("shows it is loading while the body is read", () => {
const html = render({ data: undefined, isLoading: true, error: null });
expect(html).not.toContain("EDITOR");
expect(html).toContain("LOADING");
});
test("the viewer once loaded", () => {
expect(render({ data: data(), isLoading: false, error: null })).toContain("EDITOR");
});
test("a truncated body says how much is shown, and why behind its info icon", () => {
const html = render({ data: data(true), isLoading: false, error: null });
expect(html).toContain("EDITOR");
expect(html).toContain('data-banner="notice"');
expect(html.replaceAll("<!-- -->", "")).toContain("The first 7 B of 2 KB is shown.");
expect(html).toContain(
'data-tooltip="The body was truncated at the capture limit. The app sent all of it."',
);
});
});
@@ -0,0 +1,52 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import { Banner, EmptyStateText, IconTooltip, LoadingIcon } from "@yaakapp-internal/ui";
import type { HttpBodyResult } from "@yaakapp-internal/ui/httpTypes";
import type { ReactNode } from "react";
import type { FlowBodyData } from "../lib/flowViews";
interface Props {
/** The request body as the engine's body RPC returned it */
body: HttpBodyResult<FlowBodyData | null>;
/** The body in the viewer for its type, formatted or raw */
viewer: ReactNode;
/** Bytes of the request body on the wire */
requestSize: number;
}
/**
* The request half's Body tab: the body in its viewer, or why it isn't there yet. A body that
* failed to load says why, as the response's viewer does, rather than reading as no body.
*/
export function FlowRequestBody({ body, viewer, requestSize }: Props) {
if (body.error != null) {
return (
<Banner color="danger" className="text-sm">
{body.error.message}
</Banner>
);
}
if (body.isLoading) {
return (
<EmptyStateText>
<LoadingIcon />
</EmptyStateText>
);
}
if (body.data?.truncated) {
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<Banner color="notice" className="text-sm mb-2">
<span className="flex items-center gap-1.5">
The first {formatSize(body.data.storedSize)} of {formatSize(requestSize)} is shown.
<IconTooltip
iconSize="sm"
content="The body was truncated at the capture limit. The app sent all of it."
/>
</span>
</Banner>
<div className="min-h-0">{viewer}</div>
</div>
);
}
return viewer;
}
+2 -2
View File
@@ -48,8 +48,8 @@ export function HelperDialog() {
open={open}
onClose={close}
size="dynamic"
title="Turn on Yaak Proxy under Allow in the Background"
description="In System Settings > General > Login Items. Newer macOS calls the list Background App Activity."
title="Approve Helper"
description="Turn on Yaak Proxy in System Settings > General > Login Items."
className="w-[34rem]"
>
<div className="grid gap-3 pb-4">
+174
View File
@@ -0,0 +1,174 @@
import {
Button,
CONTROL_TOOLTIP_DELAY,
Dropdown,
type DropdownItem,
Icon,
IconButton,
Tooltip,
} from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useMemo, useState } from "react";
import { useLocalStorage } from "react-use";
import { canTrustHere, useDecrypt, useTrustCertificate, useTrusting } from "../hooks/useDecrypt";
import { useDecryptList } from "../hooks/useDecryptList";
import { useRpcQuery } from "../hooks/useRpcQuery";
import { everythingEdit, listEdit, setDecrypted } from "../lib/decrypt";
import { chooseOptions, connectionCount, stripState } from "../lib/httpsStrip";
import { sidebarFlowsAtom } from "../lib/store";
import { AppIcon } from "./AppIcon";
import { SETTINGS_TAB_PROXY, useOpenSettings } from "./Settings";
const STRIP =
"flex items-center gap-2.5 h-[2.2rem] mx-2 mb-1 pl-3 pr-1.5 rounded-md bg-surface-highlight/45 border border-border-subtle text-sm text-text-subtle min-w-0";
/**
* Above the list while nothing is decrypted: says once that HTTPS is encrypted, and offers
* Decrypt Everything, or Choose for apps and sites one at a time. Goes once anything is on
* the list, or when dismissed; comes back, with Trust, while the list decrypts something the
* certificate isn't trusted for.
*/
export function HttpsStrip() {
const list = useDecryptList();
const flows = useAtomValue(sidebarFlowsAtom);
const caStatus = useRpcQuery("ca_status", {});
const [dismissed, setDismissed] = useLocalStorage("https_strip_dismissed", false);
// Kept while Choose is open, so checking a row there leaves the menu where it is.
const [choosing, setChoosing] = useState(false);
const { apply } = useDecrypt();
const trusting = useTrusting();
const trust = useTrustCertificate();
const openSettings = useOpenSettings();
const encrypted = useMemo(() => flows.filter((f) => f.tunnelled).length, [flows]);
const state = stripState({
list,
encrypted,
dismissed: dismissed ?? false,
trusted: caStatus.data?.trusted ?? null,
canTrust: canTrustHere(),
});
const options = useMemo(() => chooseOptions(flows, list), [flows, list]);
if (state === "hidden" && !choosing) return null;
if (state === "broken") {
return (
<div className={STRIP}>
<Icon icon="shield_off" size="sm" color="warning" className="shrink-0" />
<span className="text-text shrink-0">Certificate not trusted</span>
<span className="truncate">The certificate must be trusted to decrypt traffic.</span>
<Button
size="2xs"
color="primary"
className="ml-auto shrink-0"
isLoading={trusting}
onClick={() => trust.mutate({})}
>
{trusting ? "Waiting for macOS" : "Trust"}
</Button>
</div>
);
}
const items: DropdownItem[] = [
{ type: "separator", label: "Apps", hidden: options.apps.length === 0 },
...options.apps.map((app): DropdownItem => ({
label:
app.path == null ? (
app.name
) : (
<span className="flex items-center gap-2 min-w-0">
<AppIcon path={app.path} className="size-4 min-w-4" />
<span className="truncate">{app.name}</span>
</span>
),
keepOpenOnSelect: true,
leftSlot: <Check on={app.checked} />,
rightSlot: <Count n={app.count} />,
onSelect: async () => {
const subject = { kind: "app", name: app.name, bundleId: app.bundleId } as const;
await apply({
edit: listEdit(list, setDecrypted(list, [subject], !app.checked)),
message: `${app.checked ? "Stopped decrypting" : "Decrypting"} ${app.name}`,
});
},
})),
{ type: "separator", label: "Domains", hidden: options.sites.length === 0 },
...options.sites.map((site): DropdownItem => ({
label: <span className="font-mono text-xs">*.{site.site}</span>,
keepOpenOnSelect: true,
leftSlot: <Check on={site.checked} />,
rightSlot: <Count n={site.count} />,
onSelect: async () => {
const subject = { kind: "site", site: site.site } as const;
await apply({
edit: listEdit(list, setDecrypted(list, [subject], !site.checked)),
message: `${site.checked ? "Stopped decrypting" : "Decrypting"} *.${site.site}`,
});
},
})),
{ type: "separator" },
{
label: "Decryption Settings",
leftSlot: <Icon icon="settings" />,
onSelect: () => openSettings(SETTINGS_TAB_PROXY),
},
];
return (
<div className={STRIP}>
<Icon icon="lock" size="sm" className="shrink-0 text-text-subtle" />
<span className="text-text shrink-0">HTTPS is encrypted</span>
<span className="font-mono text-xs text-text-subtlest truncate">
{connectionCount(encrypted)}
</span>
<div className="ml-auto flex items-center gap-1.5 shrink-0">
<Tooltip
wrap
content="New connections will be decrypted, except for excluded hosts and apps."
delay={CONTROL_TOOLTIP_DELAY}
>
<Button
size="2xs"
variant={trusting ? "border" : "solid"}
color={trusting ? "default" : "primary"}
isLoading={trusting}
onClick={() =>
void apply({
edit: everythingEdit(true),
message: "Decrypting everything",
sub: "Excluded hosts and apps stay encrypted.",
})
}
>
{trusting ? "Waiting for macOS" : "Decrypt Everything"}
</Button>
</Tooltip>
<Dropdown items={items} onOpen={() => setChoosing(true)} onClose={() => setChoosing(false)}>
<Button size="2xs" variant="border">
Choose
</Button>
</Dropdown>
<IconButton
size="xs"
icon="x"
iconColor="secondary"
title="Dismiss"
tooltip
onClick={() => setDismissed(true)}
/>
</div>
</div>
);
}
function Check({ on }: { on: boolean }) {
return on ? (
<Icon icon="check" size="sm" />
) : (
<span aria-hidden className="inline-block w-4 h-4" />
);
}
function Count({ n }: { n: number }) {
return <span className="pl-4 font-mono text-xs text-text-subtlest">{n.toLocaleString()}</span>;
}
@@ -0,0 +1,63 @@
import type { ProxyLock } from "@yaakapp-internal/proxy-lib";
import { Button, Icon, LoadingIcon } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { encryptingPercent } from "../lib/encrypting";
import { lockedLine } from "../lib/locked";
import { proxyStateAtom } from "../lib/store";
import { getOsType } from "../lib/tauri";
/**
* Over the list and the detail pane while captured traffic can't be read: its key is
* unavailable, or traffic from before encryption is being encrypted. Retry reads the key again
* and opens it.
*/
export function LockedState({ lock }: { lock: ProxyLock }) {
const proxyState = useAtomValue(proxyStateAtom);
const retry = useRpcMutation("take_over");
const encrypting = encryptingPercent(proxyState);
return (
<div className="grid place-items-center h-full px-10 pb-16">
<div className="flex flex-col items-center text-center gap-2 max-w-[28rem]">
{encrypting != null ? (
<LoadingIcon size="lg" className="text-text-subtle mb-1" />
) : (
<Icon icon="lock" size="lg" className="text-text-subtle mb-1" />
)}
<div className="text-lg font-semibold whitespace-nowrap">
{encrypting != null ? (
<>
Encrypting recorded traffic,{" "}
{/* Sized for "100%" and right-aligned, so the centred line never changes width as
the number ticks: a changing width re-rasterizes every glyph at a new
sub-pixel offset, and the line shakes */}
<span className="inline-block w-[4.5ch] text-right tabular-nums">{encrypting}</span>
</>
) : (
"Traffic is locked"
)}
</div>
<div className="text-sm text-text-subtle">
{encrypting != null
? "Earlier traffic shows here once it's encrypted."
: lockedLine(lock, getOsType())}
</div>
{encrypting == null && (
<Button
size="xs"
variant="border"
className="mt-2"
isLoading={retry.isPending}
onClick={() => retry.mutate({})}
>
Retry
</Button>
)}
{encrypting == null && retry.error != null && (
<div className="text-xs text-danger">{retry.error.message}</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,35 @@
import {
type EditorHost,
EditorHostProvider,
MultipartViewer,
type MultipartViewerProps,
TextViewer,
type TextViewerProps,
} from "@yaakapp-internal/ui";
import { type ReactNode, useMemo } from "react";
import { formatText as engineFormatText } from "../lib/body";
/**
* Gives the shared panes the proxy's formatting. Everything else keeps the shared defaults:
* plain editors with no templating, state kept for the session, no dialogs.
*/
export function ProxyEditorHost({
children,
formatText = engineFormatText,
}: {
children: ReactNode;
/** Pretty-prints a body; the engine's formatter unless replaced */
formatText?: TextViewerProps["formatText"];
}) {
// Components made once per formatter, so viewers aren't remounted on every render
const host = useMemo<Partial<EditorHost>>(
() => ({
TextViewer: (props: TextViewerProps) => <TextViewer formatText={formatText} {...props} />,
MultipartViewer: (props: MultipartViewerProps) => (
<MultipartViewer textViewerProps={{ formatText }} {...props} />
),
}),
[formatText],
);
return <EditorHostProvider value={host}>{children}</EditorHostProvider>;
}
+69 -30
View File
@@ -1,5 +1,6 @@
import {
HeaderSize,
HeaderTint,
IconButton,
SidebarLayout,
SplitLayout,
@@ -11,22 +12,34 @@ import { useAtom, useAtomValue } from "jotai";
import { useState } from "react";
import { useLocalStorage } from "react-use";
import { flowLayoutAtom } from "../lib/atoms";
import { searchWindowAtom, showFirstRunAtom, shownTotalAtom } from "../lib/store";
import { setupIncomplete } from "../lib/firstRun";
import { useMenuExportHar } from "../lib/flowActions";
import { useNavigationControls } from "../lib/navigation";
import { proxyStatusOf, statusTintColor } from "../lib/proxyStatus";
import { lockedAtom, noFlowsAtom, proxyStateAtom, systemProxyEverOnAtom } from "../lib/store";
import { getOsType } from "../lib/tauri";
import { AddColumnDialog } from "./AddColumnDialog";
import { ClearFlowsButton } from "./ClearFlowsButton";
import { FirstRun } from "./FirstRun";
import { FlowDetail } from "./FlowDetail";
import { FlowList } from "./FlowList";
import { ProxyStatus } from "./ProxyStatus";
import { LockedState } from "./LockedState";
import { ProxyStatus, useProxySetup } from "./ProxyStatus";
import { HelperDialog } from "./HelperDialog";
import { SaveDialog } from "./SaveDialog";
import { selectedSidebarLabelAtom, Sidebar } from "./Sidebar";
import { SavedSearchDialog } from "./SavedSearchDialog";
import { SearchBar } from "./SearchBar";
import { SettingsDialogFallback, useOpenSettings } from "./Settings";
import { Sidebar } from "./Sidebar";
import { HttpsStrip } from "./HttpsStrip";
import { TakeOverBanner } from "./TakeOverBanner";
export function ProxyLayout() {
const os = getOsType();
const title = useAtomValue(selectedSidebarLabelAtom);
const [flowLayout, setFlowLayout] = useAtom(flowLayoutAtom);
const openSettings = useOpenSettings();
useMenuExportHar();
useNavigationControls();
const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar_width", 250);
const [sidebarHidden, setSidebarHidden] = useLocalStorage("sidebar_hidden", false);
const [floatingSidebarHidden, setFloatingSidebarHidden] = useLocalStorage(
@@ -35,6 +48,7 @@ export function ProxyLayout() {
);
const [floating, setFloating] = useState(false);
const isHidden = floating ? (floatingSidebarHidden ?? true) : (sidebarHidden ?? false);
const tint = statusTintColor(proxyStatusOf(useAtomValue(proxyStateAtom)));
return (
<div
@@ -52,11 +66,21 @@ export function ProxyLayout() {
interfaceScale={1}
className="x-theme-appHeader bg-surface"
>
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center w-full h-full pointer-events-none">
<div className="flex items-center pl-1 pointer-events-auto">
<HeaderTint color={tint} animate />
{/* Yaak's silhouette: the sidebar toggle and the state pill where Yaak has the toggle and
the environment, the search where it names the request, then its icons. Only the
controls take the pointer: everything around them, the traffic lights' corner
included, falls through to the header, which drags the window. */}
<div
data-tauri-drag-region
className="grid grid-cols-[minmax(auto,1fr)_minmax(0,44rem)_minmax(auto,1fr)] gap-2 items-center w-full h-full pointer-events-none"
>
<div data-tauri-drag-region className="flex items-center gap-1 pl-1 whitespace-nowrap">
<IconButton
className="pointer-events-auto"
size="sm"
title="Toggle sidebar"
tooltip
icon={isHidden ? "left_panel_hidden" : "left_panel_visible"}
iconColor="secondary"
onClick={() => {
@@ -67,27 +91,35 @@ export function ProxyLayout() {
}
}}
/>
</div>
<div
data-tauri-drag-region
className="pointer-events-none flex items-center justify-center gap-2 text-sm px-2 min-w-0"
>
<span className="truncate">{title}</span>
<FlowCount />
</div>
<div className="flex items-center gap-1 pr-1">
<ProxyStatus />
</div>
<div data-tauri-drag-region className="min-w-0">
<div className="pointer-events-auto">
<SearchBar />
</div>
</div>
<div data-tauri-drag-region className="flex items-center justify-end gap-1 pr-1">
<ClearFlowsButton />
<IconButton
className="pointer-events-auto"
icon={flowLayout === "horizontal" ? "columns_2" : "rows_2"}
title={`Change to ${flowLayout === "horizontal" ? "vertical" : "horizontal"} layout`}
tooltip
size="sm"
iconColor="secondary"
onClick={() =>
setFlowLayout((prev) => (prev === "horizontal" ? "vertical" : "horizontal"))
}
/>
<IconButton
className="pointer-events-auto"
icon="settings"
title="Settings"
tooltip
size="sm"
iconColor="secondary"
onClick={() => openSettings()}
/>
</div>
</div>
</HeaderSize>
@@ -120,6 +152,7 @@ export function ProxyLayout() {
<IconButton
size="sm"
title="Toggle sidebar"
tooltip
icon="left_panel_visible"
iconColor="secondary"
onClick={() => setFloatingSidebarHidden(true)}
@@ -134,41 +167,47 @@ export function ProxyLayout() {
>
<div className="h-full flex flex-col min-h-0">
<TakeOverBanner />
<HttpsStrip />
<div className="flex-1 min-h-0 grid grid-rows-[minmax(0,1fr)]">
<FlowsOrFirstRun flowLayout={flowLayout} />
</div>
</div>
</SidebarLayout>
<SaveDialog />
<SavedSearchDialog />
<AddColumnDialog />
<HelperDialog />
<SettingsDialogFallback />
<Toasts />
</div>
);
}
/** Its own component, so new traffic re-renders the count rather than the whole layout */
function FlowCount() {
const count = useAtomValue(shownTotalAtom);
const searching = useAtomValue(searchWindowAtom) != null;
const noun = searching ? (count === 1 ? "match" : "matches") : count === 1 ? "flow" : "flows";
return <span className="text-text-subtlest shrink-0">{`${count} ${noun}`}</span>;
}
/**
* The flow list above the detail pane, or, while the database has no flows at all, the first-run
* card across the whole area: it is the empty state until traffic replaces it.
* The flow list beside or above the detail pane, or, while setup is incomplete and there are no
* flows, the first-run card across the whole area. Once setup is done an empty list says so
* itself, after a Clear too. While captured traffic is locked, the locked state takes the whole
* area, or the first-run card does, saying why, while setup is incomplete too.
*/
function FlowsOrFirstRun({ flowLayout }: { flowLayout: SplitLayoutLayout }) {
if (useAtomValue(showFirstRunAtom)) return <FirstRun />;
const locked = useAtomValue(lockedAtom);
const noFlows = useAtomValue(noFlowsAtom) || locked != null;
const { systemProxy } = useProxySetup();
const systemProxyEverOn = useAtomValue(systemProxyEverOnAtom);
const incomplete = setupIncomplete({
systemProxySupported: systemProxy.data?.supported ?? null,
systemProxyEverOn,
});
if (noFlows && incomplete) return <FirstRun />;
if (locked != null) return <LockedState lock={locked} />;
return (
<SplitLayout
storageKey="proxy_detail"
className="gap-1"
// The card sits a wider gutter from the list side by side; the handle fills the gutter.
gutter={flowLayout === "vertical" ? 8 : 20}
layout={flowLayout}
defaultRatio={0.55}
firstSlot={({ style, orientation }) => (
<FlowList sideBySide={orientation === "horizontal"} style={style} className="pt-1" />
)}
firstSlot={({ style }) => <FlowList style={style} className="pt-1" />}
secondSlot={({ style, orientation }) => (
<FlowDetail style={style} sideBySide={orientation === "horizontal"} />
)}
+190 -228
View File
@@ -1,5 +1,5 @@
import { useQueryClient } from "@tanstack/react-query";
import type { CaStatus, SystemProxyStatus } from "@yaakapp-internal/proxy-lib";
import type { SystemProxyStatus } from "@yaakapp-internal/proxy-lib";
import {
Button,
Checkbox,
@@ -7,66 +7,78 @@ import {
type DropdownItem,
type DropdownRef,
Icon,
LoadingIcon,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue, useSetAtom, useStore } from "jotai";
import { useAtom, useAtomValue } from "jotai";
import type { HTMLAttributes, ReactNode, Ref } from "react";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { canTrustHere, useDecrypt, useTrustCertificate } from "../hooks/useDecrypt";
import { useDecryptList } from "../hooks/useDecryptList";
import { useRpcEvent } from "../hooks/useRpcEvent";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { useRpcQuery } from "../hooks/useRpcQuery";
import { helperDialogOpenAtom, helperRegisteredAtAtom } from "../lib/atoms";
import { helperRegisteredAtAtom } from "../lib/atoms";
import { decryptsAnything, decryptsEverything, everythingEdit, listSummary } from "../lib/decrypt";
import { encryptingPercent } from "../lib/encrypting";
import { environmentExports } from "../lib/env";
import { proxyStateAtom, refreshProxyState } from "../lib/store";
import {
helperPollInterval,
helperRow,
recordingLabel,
systemProxySwitch,
} from "../lib/systemProxy";
import { proxyStatusOf } from "../lib/proxyStatus";
import { proxyStateAtom, systemProxyEverOnAtom } from "../lib/store";
import { helperPollInterval, recordingLabel, systemProxySwitch } from "../lib/systemProxy";
import { onWindowFocus } from "../lib/tauri";
import { useShowToast } from "../lib/toast";
/**
* The header's state pill and its menu: recording, the system proxy, the certificate and HTTP/2. The
* engine runs for as long as the app does, so the pill shows whether flows are being stored. A
* certificate that is not trusted steps out of the menu beside the pill until it is.
* The header's state pill and its menu: the listening address, which copies itself, the state,
* Pause or Resume Recording, Override system proxy, and Decrypt everything. The engine runs for
* as long as the app does, so the pill shows whether flows are being stored. A certificate that
* is not trusted while something is listed to decrypt steps out of the menu beside the pill
* until it is. Everything set once and left alone lives in Settings.
*/
export function ProxyStatus() {
const dropdownRef = useRef<DropdownRef>(null);
const [copied, setCopied] = useCopied();
const proxyState = useAtomValue(proxyStateAtom);
const setup = useProxySetup();
const execute = useRpcMutation("execute_action");
const retry = useRpcMutation("take_over");
const setAtLaunch = useSetSystemProxyAtLaunch();
const setHttpVersion = useSetHttpVersion();
const copyEnvironment = useCopyEnvironment();
const setHelperDialogOpen = useSetAtom(helperDialogOpenAtom);
const { caStatus, trust, systemProxy, setSystemProxy } = setup;
const { caStatus, systemProxy, setSystemProxy } = setup;
const systemSwitch = useSystemProxySwitch(setup);
const running = proxyState?.state === "running";
const recording = proxyState?.recording !== false;
const other = proxyState?.otherOwner ?? null;
const port = proxyState?.port ?? other?.proxyPort ?? null;
const untrusted = caStatus.data != null && !caStatus.data.trusted;
const decryptList = useDecryptList();
// Something is listed to decrypt but the certificate isn't trusted, so it can't be.
// Only where the app can trust it itself: elsewhere the status always reads untrusted.
const broken =
canTrustHere() &&
caStatus.data != null &&
!caStatus.data.trusted &&
decryptsAnything(decryptList);
// The same status the header's tint takes its colour from
const status = proxyStatusOf(proxyState);
const encrypting = status === "encrypting" ? encryptingPercent(proxyState) : null;
const stateRow =
other != null ? (
status === "encrypting" ? (
<MenuRow
icon={<LoadingIcon size="sm" className="text-text-subtle" />}
title={`Encrypting recorded traffic, ${encrypting}`}
description="Recording will start once encryption finishes."
/>
) : status === "cli" && other != null ? (
<MenuRow
icon={<StateDot state="cli" />}
title="Running from the CLI"
description={`Started by yaak proxy start, pid ${other.pid}, since ${timeOf(other.startedAt)}.${other.proxyPort == null ? "" : ` Port ${other.proxyPort}.`}`}
description={`The proxy was started by the CLI at ${timeOf(other.startedAt)}.`}
/>
) : running ? (
) : status === "recording" || status === "paused" ? (
<MenuRow
icon={<StateDot state={recording ? "recording" : "paused"} />}
icon={<StateDot state={status} />}
title={recording ? `Recording on port ${port}` : `Paused on port ${port}`}
description={
recording
? "Listening on 127.0.0.1, and storing every flow."
: "Traffic passes through untouched, and no flows are stored."
}
description={recording ? null : "Traffic passes through without being recorded."}
/>
) : (
<MenuRow
@@ -74,7 +86,7 @@ export function ProxyStatus() {
title="Not running"
description={
<span className="text-danger">
{retry.error?.message ?? proxyState?.startError ?? "The proxy has not started."}
{retry.error?.message ?? proxyState?.startError ?? "The proxy failed to start."}
</span>
}
action={
@@ -93,9 +105,40 @@ export function ProxyStatus() {
/>
);
// Until the helper is approved, every change asks for the password; said where it is felt.
const helperNotice =
systemProxy.data != null &&
systemProxy.data.backend !== "helper" &&
systemProxy.data.helper !== "enabled" &&
systemProxy.data.helper !== "unavailable";
const address = port == null ? null : `127.0.0.1:${port}`;
const items: DropdownItem[] = [
{
label: <span className="font-mono text-editor">{address}</span>,
hidden: address == null,
keepOpenOnSelect: true,
rightSlot: copied ? (
<span className="text-xs text-text-subtle">Copied</span>
) : (
<Icon icon="copy" size="sm" className="text-text-subtlest" />
),
onSelect: () => {
if (address == null) return;
navigator.clipboard.writeText(address).then(setCopied, console.error);
},
},
{ type: "separator", hidden: address == null },
{ type: "content", label: stateRow },
{ type: "separator", hidden: systemProxy.data?.supported !== true },
{
label: recordingLabel(recording),
icon: recording ? "circle_pause" : "circle_play",
hidden: !running,
hotKeyAction: "recording_toggle",
hotKeyLabelOnly: true,
onSelect: () => execute.mutate({ scope: "global", action: "recording_toggle" }),
},
{ type: "separator" },
{
type: "content",
hidden: systemProxy.data?.supported !== true,
@@ -104,9 +147,14 @@ export function ProxyStatus() {
icon={<Icon icon="wifi" size="sm" color="secondary" />}
title="Override system proxy"
description={
<span className={classNames(systemSwitch.failed && "text-danger")}>
{systemSwitch.description}
</span>
<>
<span className={classNames(systemSwitch.failed && "text-danger")}>
{systemSwitch.description}
</span>
{helperNotice && (
<span className="block text-notice">macOS will ask for a password each time.</span>
)}
</>
}
action={
<KeepMenuOpen>
@@ -123,136 +171,28 @@ export function ProxyStatus() {
/>
),
},
{
type: "content",
hidden: systemProxy.data?.supported !== true,
label: (
<KeepMenuOpen className="block pl-[2.125rem] pr-1.5 pb-1.5">
<Checkbox
title={
<span className="text-xs text-text-subtle">Override system proxy at launch</span>
}
checked={proxyState?.systemProxyAtLaunch ?? true}
disabled={setAtLaunch.isPending}
onChange={(enabled) => setAtLaunch.mutate({ enabled })}
/>
</KeepMenuOpen>
),
},
{
type: "content",
hidden: systemProxy.data?.supported !== true,
label: systemProxy.data != null && (
<HelperMenuRow
status={systemProxy.data}
onOpenDialog={() => {
dropdownRef.current?.close?.();
setHelperDialogOpen(true);
}}
/>
),
},
{
type: "content",
label: caStatus.data?.trusted ? (
<MenuRow
icon={<Icon icon="shield_check" color="success" size="sm" />}
title="Certificate trusted"
description="Yaak Proxy CA, in your login keychain."
/>
) : (
<MenuRow
icon={<Icon icon="shield_off" color="warning" size="sm" />}
title="Certificate not trusted"
description={
trust.error != null ? (
<span className="text-danger">{trust.error.message}</span>
) : trust.isPending ? (
"Waiting for your password in the macOS prompt."
) : (
"So HTTPS reads as plain text. macOS asks for your login password once."
)
}
action={
<Button
size="2xs"
variant="border"
color="primary"
isLoading={trust.isPending}
onClick={(e) => {
e.stopPropagation();
trust.mutate({});
}}
>
{trust.error != null ? "Try again" : "Trust certificate"}
</Button>
}
/>
),
},
{
type: "content",
label: (
<MenuRow
icon={<Icon icon="zap" size="sm" color="secondary" />}
title="HTTP/2"
description="Lets browsers send many requests at once. Turn off for apps that misbehave on it. Applies to new connections."
action={
<KeepMenuOpen>
<Checkbox
title="HTTP/2"
hideLabel
checked={proxyState?.httpVersion !== "http1Only"}
// The owner's engine would not see the change until it restarts.
disabled={other != null || setHttpVersion.isPending}
onChange={(enabled) =>
setHttpVersion.mutate({ httpVersion: enabled ? "auto" : "http1Only" })
}
/>
</KeepMenuOpen>
}
/>
),
},
{ type: "separator" },
{
label: "Copy Environment for a Terminal",
icon: "square_terminal",
onSelect: copyEnvironment,
},
{
label: recordingLabel(recording),
icon: recording ? "circle_pause" : "circle_play",
hidden: !running,
hotKeyAction: "recording_toggle",
hotKeyLabelOnly: true,
onSelect: () => execute.mutate({ scope: "global", action: "recording_toggle" }),
},
{ type: "content", label: <DecryptEverythingRow broken={broken} /> },
];
return (
<>
{untrusted && (
<Pill color="warning" onClick={() => dropdownRef.current?.open()}>
<Icon icon="shield_off" size="xs" />
Certificate not trusted
</Pill>
)}
<Dropdown ref={dropdownRef} items={items}>
<Pill color={!running && other == null && proxyState != null ? "warning" : undefined}>
{other != null ? (
<Pill color={status === "stopped" ? "warning" : undefined}>
{status === "encrypting" ? (
<>
<LoadingIcon size="xs" />
<span className="text-text">Encrypting</span>
<span className="font-mono text-text-subtle">{encrypting}</span>
</>
) : status === "cli" && other != null ? (
<>
<StateDot state="cli" />
<span className="text-text">CLI</span>
{other.proxyPort != null && (
<span className="font-mono text-text-subtle">:{other.proxyPort}</span>
)}
</>
) : running ? (
) : status === "recording" || status === "paused" ? (
<>
<StateDot state={recording ? "recording" : "paused"} />
<StateDot state={status} />
<span className="text-text">{recording ? "Recording" : "Paused"}</span>
{port != null && <span className="font-mono text-text-subtle">:{port}</span>}
</>
) : (
<>
@@ -263,10 +203,83 @@ export function ProxyStatus() {
<Icon icon="chevron_down" size="xs" className="text-text-subtlest" />
</Pill>
</Dropdown>
{broken && (
<Pill color="warning" onClick={() => dropdownRef.current?.open()}>
<Icon icon="shield_off" size="xs" />
Certificate not trusted
</Pill>
)}
</>
);
}
/**
* Decrypt everything, `*` on the list, as a switch: what else is listed when off, and Trust
* while something is listed and the certificate isn't trusted.
*/
function DecryptEverythingRow({ broken }: { broken: boolean }) {
const list = useDecryptList();
const { apply, trusting } = useDecrypt();
const trust = useTrustCertificate();
const on = decryptsEverything(list);
if (broken) {
return (
<MenuRow
icon={<Icon icon="shield_off" size="sm" color="warning" />}
title="Decrypt everything"
description={<span className="text-warning">Certificate not trusted</span>}
action={
<Button
size="2xs"
color="primary"
isLoading={trusting}
onClick={(e) => {
e.stopPropagation();
trust.mutate({});
}}
>
{trusting ? "Waiting for macOS" : "Trust"}
</Button>
}
/>
);
}
return (
<MenuRow
icon={<Icon icon="lock_open" size="sm" color="secondary" />}
title="Decrypt everything"
description={
trusting
? "Waiting for macOS"
: on
? "Excluded hosts and apps stay encrypted."
: (listSummary(list) ?? "No hosts or apps")
}
action={
<KeepMenuOpen>
<Checkbox
title="Decrypt everything"
hideLabel
checked={on}
disabled={trusting}
onChange={(checked) =>
void apply(
checked
? {
edit: everythingEdit(true),
message: "Decrypting everything",
sub: "Excluded hosts and apps stay encrypted.",
}
: { edit: everythingEdit(false), message: "Stopped decrypting everything" },
)
}
/>
</KeepMenuOpen>
}
/>
);
}
/**
* The certificate and the system proxy, read once and shared by the header and the first-run
* card, with the actions that change them.
@@ -287,10 +300,6 @@ export function useProxySetup() {
// The user comes back from System Settings, maybe having just allowed the helper.
const { refetch: refetchSystemProxy } = systemProxy;
useEffect(() => onWindowFocus(() => void refetchSystemProxy()), [refetchSystemProxy]);
const trust = useRpcMutation("ca_trust", {
mutationKey: ["ca_trust"],
onSuccess: (status) => queryClient.setQueryData<CaStatus>(["ca_status", {}], status),
});
const setSystemProxy = useRpcMutation("system_proxy_set", {
mutationKey: ["system_proxy_set"],
onSuccess: (status) => {
@@ -300,7 +309,13 @@ export function useProxySetup() {
// The engine turns the system proxy on as it starts, and says so once the prompt is
// answered, and turns it off as it stops.
useRpcEvent("proxy_state_changed", () => void systemProxy.refetch());
return { caStatus, trust, systemProxy, setSystemProxy };
// Setup counts as done once the system proxy has been on, whatever it is now.
const [everOn, setEverOn] = useAtom(systemProxyEverOnAtom);
const on = systemProxy.data?.enabled === true;
useEffect(() => {
if (on && !everOn) setEverOn(true);
}, [on, everOn, setEverOn]);
return { caStatus, systemProxy, setSystemProxy };
}
/** The system proxy switch's state, from where it points and the change in flight */
@@ -317,74 +332,6 @@ export function useSystemProxySwitch(setup: ReturnType<typeof useProxySetup>) {
}
/** The privileged helper: whether it is installed and approved, and which way changes go */
function HelperMenuRow({
status,
onOpenDialog,
}: {
status: SystemProxyStatus;
onOpenDialog: () => void;
}) {
const queryClient = useQueryClient();
const unregister = useRpcMutation("helper_unregister", {
onSuccess: (status) =>
queryClient.setQueryData<SystemProxyStatus>(["system_proxy_status", {}], status),
});
const row = helperRow(status.helper, status.backend);
const error = unregister.error;
return (
<MenuRow
icon={
<Icon
icon={status.helper === "enabled" ? "badge_check" : "lock"}
size="sm"
color={status.helper === "enabled" ? "success" : "secondary"}
/>
}
title={row.title}
description={
error != null ? <span className="text-danger">{error.message}</span> : row.description
}
action={
row.action != null && (
<Button
size="2xs"
variant="border"
color={row.action === "unregister" ? "default" : "primary"}
isLoading={unregister.isPending}
onClick={(e) => {
e.stopPropagation();
if (row.action === "unregister") unregister.mutate({});
else onOpenDialog();
}}
>
{row.action === "install"
? "Install"
: row.action === "approve"
? "Approve"
: "Unregister"}
</Button>
)
}
/>
);
}
/** Saves whether the system proxy turns on as the app launches */
function useSetSystemProxyAtLaunch() {
const store = useStore();
return useRpcMutation("set_system_proxy_at_launch", {
onSettled: () => void refreshProxyState(store),
});
}
/** Saves the HTTP versions offered to clients, which the engine applies to new connections */
function useSetHttpVersion() {
const store = useStore();
return useRpcMutation("set_http_version", {
onSettled: () => void refreshProxyState(store),
});
}
export function useCopyEnvironment() {
const proxyState = useAtomValue(proxyStateAtom);
const caStatus = useRpcQuery("ca_status", {});
@@ -397,13 +344,25 @@ export function useCopyEnvironment() {
try {
await navigator.clipboard.writeText(environmentExports(port, caPath));
showToast({
message: "Copied the environment. Paste it into a terminal to proxy what runs there.",
message: "Copied the environment. Paste it into a terminal.",
});
} catch (err) {
showToast({ message: `Failed to copy: ${String(err)}`, color: "danger" });
}
};
}
/** Whether something was just copied, for a moment, and the call that says it was */
function useCopied(): [boolean, () => void] {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
const timeout = setTimeout(() => setCopied(false), 1500);
return () => clearTimeout(timeout);
}, [copied]);
return [copied, () => setCopied(true)];
}
/** Keeps a click on a control inside a menu row from closing the menu, as a row's click does */
function KeepMenuOpen({ children, className }: { children: ReactNode; className?: string }) {
return (
@@ -464,6 +423,7 @@ function MenuRow({
}: {
icon: ReactNode;
title: string;
/** Under the title; null for a title that says it all */
description: ReactNode;
action?: ReactNode;
}) {
@@ -472,7 +432,9 @@ function MenuRow({
<span className="h-5 flex items-center justify-center">{icon}</span>
<div>
<div className="text-sm text-text">{title}</div>
<div className="text-xs text-text-subtle mt-0.5">{description}</div>
{description != null && (
<div className="text-xs text-text-subtle mt-0.5">{description}</div>
)}
</div>
<span className="self-center">{action}</span>
</div>
+30 -29
View File
@@ -6,11 +6,13 @@ import type {
} from "@yaakapp-internal/proxy-lib";
import {
Banner,
CONTROL_TOOLTIP_DELAY,
Dialog,
DialogFooter,
HttpMethodTagRaw,
LoadingIcon,
PlainInput,
Tooltip,
} from "@yaakapp-internal/ui";
import { unnamedRequestName } from "@yaakapp-internal/ui/resolvedModelName";
import { openUrl } from "@tauri-apps/plugin-opener";
@@ -38,8 +40,8 @@ export function SaveDialog() {
open
onClose={close}
size="dynamic"
title="Save as Yaak request"
description="A new request in Yaak with this flow's method, URL, headers and body."
title="Save as Yaak Request"
description="A new request will be created in Yaak."
className="w-[36rem]"
>
{workspaces.error != null ? (
@@ -101,7 +103,7 @@ function SaveForm({
message: (
<>
Saved <b className="font-semibold">{saved.name || unnamed}</b> to {workspace.name}
{folderName == null ? "." : `, in ${folderName}.`} Yaak picks it up within a second.
{folderName == null ? "" : `, in ${folderName}`}
</>
),
});
@@ -111,11 +113,7 @@ function SaveForm({
};
if (workspaces.length === 0) {
return (
<Banner color="notice">
Yaak has no workspaces yet. Create one in Yaak, then save here.
</Banner>
);
return <Banner color="notice">Create a workspace in Yaak first.</Banner>;
}
if (p == null) {
@@ -181,13 +179,16 @@ function SaveForm({
</div>
<div className="flex flex-wrap gap-1.5 mt-2">
{p.droppedHeaders.map((h) => (
<span
<Tooltip
key={h.name}
title={`${h.name}: ${h.value}\n${reasonLabel(h.reason)}`}
className="font-mono text-xs leading-[22px] px-1.5 rounded-sm border border-border-subtle text-text-subtle line-through decoration-text-subtlest"
wrap
content={`${h.name}: ${h.value}\n${reasonLabel(h.reason)}`}
delay={CONTROL_TOOLTIP_DELAY}
>
{h.name}
</span>
<span className="font-mono text-xs leading-[22px] px-1.5 rounded-sm border border-border-subtle text-text-subtle line-through decoration-text-subtlest">
{h.name}
</span>
</Tooltip>
))}
</div>
</div>
@@ -204,13 +205,10 @@ function SaveForm({
</div>
{save.error != null && <Banner color="danger">{save.error.message}</Banner>}
<DialogFooter
leftSlot={
<span className="text-xs text-text-subtlest">Yaak picks it up within a second</span>
}
actions={[
{ label: "Cancel", onClick: onClose },
{
label: "Save request",
label: "Save Request",
form: SAVE_FORM_ID,
color: "primary",
isLoading: save.isPending,
@@ -238,15 +236,19 @@ function NoYaakDialog({ flow, onClose }: { flow: Flow; onClose: () => void }) {
return (
<Dialog open onClose={onClose} size="dynamic" className="w-[32rem]">
<DialogFooter
leftSlot={<span className="text-xs text-text-subtlest">Or keep the flow as a file</span>}
leftSlot={
<span className="text-xs text-text-subtlest">
The flow can be exported as a file instead.
</span>
}
actions={[
{
label: "Export HAR",
label: "Export as HAR",
onClick: async () => {
try {
if ((await exportHar({ ids: [flow.id] }, "flow.har")) != null) {
onClose();
showToast({ message: "Exported the flow as HAR" });
showToast({ message: "Exported 1 flow as HAR" });
}
} catch (err) {
showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" });
@@ -257,11 +259,10 @@ function NoYaakDialog({ flow, onClose }: { flow: Flow; onClose: () => void }) {
]}
/>
<div className="pt-6 pb-2">
<div className="text-lg font-semibold mb-1.5">Saving needs Yaak</div>
<div className="text-lg font-semibold mb-1.5">Yaak Required</div>
<div className="text-sm text-text-subtle leading-relaxed">
This flow becomes a request in one of Yaak's workspaces, and Yaak has not been opened on
this Mac. Yaak is free for personal use and opens the request with the same viewers you
see here.
This flow will be saved as a request in one of Yaak's workspaces, but Yaak has not been
opened on this computer.
</div>
</div>
</Dialog>
@@ -303,18 +304,18 @@ function folderOptions(folders: FolderSummary[]): { id: string; name: string }[]
function reasonLabel(reason: DropReason): string {
switch (reason) {
case "set_by_sending":
return "Yaak sets it when it sends";
return "Yaak sets it when it sends the request.";
case "hop_by_hop":
return "Only meant for the proxy";
return "It was only meant for the proxy.";
case "body_decoded":
return "The body is saved decoded";
return "The body is saved decoded.";
}
}
function droppedSummary(reasons: DropReason[]): string {
const unique = [...new Set(reasons)];
if (unique.length === 1 && unique[0] === "set_by_sending") {
return "Yaak sets these itself when it sends";
return "Yaak sets these itself when it sends the request.";
}
return "Yaak sets these itself, or they only meant something to the proxy";
return "Yaak sets these, or they were meant for the proxy.";
}
@@ -0,0 +1,82 @@
import { Dialog, DialogFooter, PlainInput } from "@yaakapp-internal/ui";
import { atom, useAtom } from "jotai";
import { useState } from "react";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { useShowToast } from "../lib/toast";
/** Saving the search box's text under a name, or renaming a saved search */
export type SavedSearchDialog =
| { mode: "save"; query: string }
| { mode: "rename"; id: string; name: string };
export const savedSearchDialogAtom = atom<SavedSearchDialog | null>(null);
const FORM_ID = "saved-search";
/** Names a saved search, as `savedSearchDialogAtom` asks */
export function SavedSearchDialog() {
const [dialog, setDialog] = useAtom(savedSearchDialogAtom);
if (dialog == null) return null;
// Keyed, so each opening starts from its own name.
return (
<NameDialog
key={dialog.mode === "save" ? `save:${dialog.query}` : `rename:${dialog.id}`}
dialog={dialog}
onClose={() => setDialog(null)}
/>
);
}
function NameDialog({ dialog, onClose }: { dialog: SavedSearchDialog; onClose: () => void }) {
const [name, setName] = useState(dialog.mode === "rename" ? dialog.name : "");
const showToast = useShowToast();
const onError = (err: Error) =>
showToast({ id: "saved-search", message: err.message, color: "danger" });
const create = useRpcMutation("create_saved_query", { onSuccess: onClose, onError });
const rename = useRpcMutation("rename_saved_query", { onSuccess: onClose, onError });
const busy = create.isPending || rename.isPending;
const submit = () => {
if (name.trim() === "" || busy) return;
if (dialog.mode === "save") create.mutate({ name, query: dialog.query });
else rename.mutate({ id: dialog.id, name });
};
return (
<Dialog
open
onClose={onClose}
size="dynamic"
title={dialog.mode === "save" ? "Save Search" : "Rename Search"}
className="w-[26rem]"
>
<form
id={FORM_ID}
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<PlainInput
label="Name"
autoFocus
defaultValue={name}
onChange={setName}
placeholder={dialog.mode === "save" ? dialog.query : undefined}
/>
</form>
<DialogFooter
actions={[
{ label: "Cancel", variant: "border", onClick: onClose },
{
label: dialog.mode === "save" ? "Save" : "Rename",
color: "primary",
form: FORM_ID,
disabled: name.trim() === "",
isLoading: busy,
},
]}
/>
</Dialog>
);
}
+496 -78
View File
@@ -1,111 +1,529 @@
import { Icon, IconButton, PlainInput } from "@yaakapp-internal/ui";
import { useAtomValue, useSetAtom } from "jotai";
import { memo, useEffect, useRef, useState } from "react";
import { exportHar } from "../lib/flowActions";
import { searchTerms } from "../lib/search";
import { activeQueryAtom, searchTermsAtom } from "../lib/store";
import {
autocompletion,
closeCompletion,
type Completion,
completionStatus,
startCompletion,
} from "@codemirror/autocomplete";
import type { Extension } from "@codemirror/state";
import { Compartment, Prec } from "@codemirror/state";
import { EditorView, keymap, tooltips, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import {
Dropdown,
type DropdownItem,
Icon,
IconButton,
Input,
type InputHandle,
LazyCodeEditor,
} from "@yaakapp-internal/ui";
import { queryFilter } from "@yaakapp-internal/ui/editor";
import classNames from "classnames";
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
import {
type CSSProperties,
memo,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useRpcQueryWithEvent } from "../hooks/useRpcQueryWithEvent";
import { useRpcQuery } from "../hooks/useRpcQuery";
import { appliedFilterAtom } from "../lib/atoms";
import { isUnapplied, pressEnter, pressEscape, type Recall, stepHistory } from "../lib/filterBox";
import { useExportShownHar } from "../lib/flowActions";
import { applyFilter, searchDraftAtom, searchDraftVersionAtom } from "../lib/navigation";
import { rpc } from "../lib/rpc";
import { loadSeenValues, savedCompletions, searchProblems } from "../lib/searchBox";
import { allTrafficTotalAtom, lockedAtom, savedQueriesAtom, type Store } from "../lib/store";
import { useShowToast } from "../lib/toast";
import { savedSearchDialogAtom } from "./SavedSearchDialog";
/** How long typing settles before the search runs */
const SEARCH_DEBOUNCE_MS = 200;
/** Recent filters listed above the fields when the empty box is focused */
const RECENT_IN_COMPLETIONS = 5;
const NO_HISTORY: string[] = [];
/**
* Searches every flow in the database by URL, method, status and content type, within the
* sidebar's selection. ⌘F focuses it and Escape clears it.
* The header's search over every flow in the database, in the query language: bare words match
* the URL, method, status and content type, and `@field:value` terms the rest, highlighted and
* completed as Yaak's sidebar filter is, with the values the proxy has seen. The box composes a
* filter and Enter applies it, as Yaak's URL bar sends; until then a return key at its right
* says so, and Escape puts the applied filter back. The magnifier lists the filters applied
* before, which Up in the empty box steps through too. Its placeholder counts the flows. ⌘F
* focuses it. Focused, it wraps a long query over as many lines as it needs, as Yaak's URL bar
* does, over what is below; blurred, it is one line again.
*/
export const SearchBar = memo(function SearchBar() {
const [text, setText] = useState("");
const setTerms = useSetAtom(searchTermsAtom);
const query = useAtomValue(activeQueryAtom);
const showToast = useShowToast();
// PlainInput hands back its <input> as the ref
const inputRef = useRef<HTMLInputElement>(null);
// Bumped to empty the field, which only takes a value from outside when this changes
const [resetKey, setResetKey] = useState(0);
const [draft, setDraft] = useAtom(searchDraftAtom);
const version = useAtomValue(searchDraftVersionAtom);
const applied = useAtomValue(appliedFilterAtom);
const total = useAtomValue(allTrafficTotalAtom);
const store = useStore();
const setSavedDialog = useSetAtom(savedSearchDialogAtom);
const exportShown = useExportShownHar();
// Nothing to search while captured traffic is locked.
const locked = useAtomValue(lockedAtom) != null;
const schema = useRpcQuery("query_schema", {}, { staleTime: Infinity }).data?.schema;
const historyQuery = useRpcQueryWithEvent("list_filter_history", {}, "filter_history_changed", {
enabled: !locked,
});
const history = useMemo(
() => historyQuery.data?.map((e) => e.filter) ?? NO_HISTORY,
[historyQuery.data],
);
const handleRef = useRef<InputHandle | null>(null);
const boxRef = useRef<HTMLDivElement>(null);
const [focused, setFocused] = useState(false);
// Where the box is while focused, so it grows over the list from the header's place for it
const [place, setPlace] = useState<CSSProperties | null>(null);
useEffect(() => {
const timer = setTimeout(() => setTerms(searchTerms(text)), SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [text, setTerms]);
// Read by the editor's keys, which are made once
const latest = useRef({ applied, history });
useLayoutEffect(() => {
latest.current = { applied, history };
});
useLayoutEffect(() => {
if (!focused) {
setPlace(null);
return;
}
const measure = () => {
const rect = boxRef.current?.getBoundingClientRect();
if (rect != null) setPlace({ top: rect.top, left: rect.left, width: rect.width });
};
measure();
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, [focused]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "f") {
e.preventDefault();
inputRef.current?.focus();
inputRef.current?.select();
handleRef.current?.selectAll();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
// The field is replaced to empty it, so focus moves to the new one.
// The language goes in once the schema is read: into the editor there is, through a
// compartment, and into each one made after, as taking text from the sidebar makes one.
const compartment = useRef(new Compartment());
const filter = useMemo<Extension>(
() =>
schema == null
? []
: queryFilter({
schema,
loadValues: loadSeenValues,
// Read as completion opens, so saved searches are current without remaking it.
moreCompletions: (typed) => savedCompletions(store.get(savedQueriesAtom), typed),
leadCompletions: () => recentCompletions(latest.current.history),
problems: searchProblems,
}),
[schema, store],
);
const keys = useMemo(() => boxKeys(store, latest), [store]);
const extensions = useMemo(
() => [compartment.current.of(filter), completionAcrossBox, keys],
[filter, keys],
);
useEffect(() => {
handleRef.current?.dispatch({ effects: compartment.current.reconfigure(filter) });
}, [filter]);
// Applies no filter at once, leaving the box focused and empty.
const clear = () => {
setText("");
setTerms(null);
setResetKey((k) => k + 1);
requestAnimationFrame(() => inputRef.current?.focus());
const handle = handleRef.current;
handle?.dispatch({ changes: { from: 0, to: handle.value().length, insert: "" } });
applyFilter(store, "");
requestAnimationFrame(() => handleRef.current?.focus());
};
const unapplied = isUnapplied({ draft, applied });
const hasText = draft !== "" || applied !== "";
return (
<div className="flex items-center gap-1.5 px-3 pb-1.5">
<div className="flex-1 min-w-0">
<PlainInput
ref={inputRef}
// Holds the header's place for the box, which floats over the list while focused
<div ref={boxRef} className="relative h-sm">
<div
style={place ?? undefined}
className={classNames(place == null ? "absolute inset-x-0 top-0" : "fixed z-30")}
>
<Input
renderEditor={(props) => (
<LazyCodeEditor {...props} keyDownHandler={startCompletionOnCtrlSpace} />
)}
setRef={(h) => {
handleRef.current = h;
}}
label="Search flows"
hideLabel
size="xs"
placeholder="Search flows by URL, method, status or type"
defaultValue={text}
forceUpdateKey={String(resetKey)}
onChange={setText}
onKeyDownCapture={(e) => {
if (e.key === "Escape" && text !== "") {
e.stopPropagation();
clear();
}
}}
size="sm"
className="font-mono text-xs"
containerClassName={classNames(focused && "bg-surface shadow-lg")}
stateKey={null}
language={null}
wrapLines={focused}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder={locked ? "Traffic is locked" : placeholderFor(total)}
disabled={locked}
defaultValue={draft}
forceUpdateKey={String(version)}
onChange={setDraft}
extensions={extensions}
leftSlot={
<Icon icon="search" size="sm" className="ml-2 mr-2.5 self-center text-text-subtlest" />
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- keeps focus in the editor
<div
onMouseDown={(e) => e.preventDefault()}
className="flex items-center self-center ml-0.5"
>
<FilterHistory history={history} store={store} disabled={locked} />
</div>
}
rightSlot={
text === "" ? (
<kbd className="self-center mr-2 px-1 rounded-sm border border-border-subtle font-sans text-2xs text-text-subtlest">
⌘F
</kbd>
) : (
<IconButton
size="xs"
icon="x"
title="Clear search"
iconColor="secondary"
className="self-center"
onClick={clear}
/>
)
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- keeps focus in the editor
<div
// Pressing these leaves the editor focused, so a wrapped box does not collapse and
// move them before the click lands.
onMouseDown={(e) => e.preventDefault()}
className="flex items-center self-center ml-1 mr-0.5"
>
{unapplied ? (
<Key className="mr-1">↵</Key>
) : hasText ? null : (
<Key className="ml-1 mr-1.5">⌘F</Key>
)}
{hasText && (
<>
<Dropdown
items={[
{
label: "Save Search",
leftSlot: <Icon icon="bookmark" />,
onSelect: () =>
setSavedDialog({
mode: "save",
query: (draft.trim() === "" ? applied : draft).trim(),
}),
},
exportShown(),
]}
>
<IconButton
size="xs"
icon="ellipsis"
title="Search actions"
tooltip
iconColor="secondary"
/>
</Dropdown>
<IconButton
size="xs"
icon="x"
title="Clear search"
tooltip
iconColor="secondary"
onClick={clear}
/>
</>
)}
</div>
}
/>
</div>
<IconButton
size="xs"
icon="download"
iconColor="secondary"
title={query == null ? "Export every flow as HAR" : "Export the flows shown as HAR"}
onClick={async () => {
try {
const written = await exportHar(query == null ? {} : { filter: query }, "flows.har");
if (written != null) {
showToast({
message:
written === 1 ? "Exported 1 flow as HAR" : `Exported ${written} flows as HAR`,
});
}
} catch (err) {
showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" });
}
}}
/>
</div>
);
});
function Key({ children, className }: { children: string; className?: string }) {
return (
<kbd
className={classNames(
className,
"px-1 rounded-sm border border-border-subtle font-sans text-2xs text-text-subtlest",
)}
>
{children}
</kbd>
);
}
/**
* The magnifier, which lists the filters applied before, newest first: choosing one applies it
* again, and each can be saved as a search or forgotten
*/
function FilterHistory({
history,
store,
disabled,
}: {
history: string[];
store: Store;
disabled: boolean;
}) {
const setSavedDialog = useSetAtom(savedSearchDialogAtom);
const showToast = useShowToast();
const fail = (err: unknown) =>
showToast({ id: "filter-history", message: String(err), color: "danger" });
const items: DropdownItem[] =
history.length === 0
? [{ label: "No recent searches", disabled: true }]
: [
...history.map((filter): DropdownItem => ({
label: (
<span className="block font-mono text-xs truncate max-w-md" title={filter}>
{filter}
</span>
),
onSelect: () => applyFilter(store, filter, { record: true }),
actions: [
{
icon: "bookmark",
title: "Save search",
closeMenu: true,
onSelect: () => setSavedDialog({ mode: "save", query: filter }),
},
{
icon: "x",
title: "Remove",
onSelect: () => void rpc("delete_filter_history", { filter }).catch(fail),
},
],
})),
{ type: "separator" },
{
label: "Clear History",
leftSlot: <Icon icon="trash" />,
onSelect: () => void rpc("clear_filter_history", {}).catch(fail),
},
];
return (
<Dropdown items={items}>
<IconButton
size="xs"
icon="search"
title="Recent searches"
tooltip
iconColor="secondary"
disabled={disabled}
/>
</Dropdown>
);
}
/** The recent filters the empty box's completions start with */
function recentCompletions(history: string[]): Completion[] {
return history.slice(0, RECENT_IN_COMPLETIONS).map((filter) => ({
label: filter,
detail: "Recent",
type: "text",
apply: filter,
}));
}
/** The menu of completions is open, so the keys are its own */
function menuOpen(view: EditorView): boolean {
return completionStatus(view.state) === "active";
}
function replaceText(view: EditorView, text: string) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: text },
selection: { anchor: text.length },
});
}
/**
* Enter, Escape, Up and Down in the box, over the editor's own and under its completions': Enter
* applies the draft, Escape puts the applied filter back or clears it, and Up and Down step
* through the filter history from an empty box. Each is the completion menu's while it is open,
* except Up from an empty box, which the history has.
*/
function boxKeys(
store: Store,
latest: { current: { applied: string; history: string[] } },
): Extension {
let recall: Recall | null = null;
const step = (view: EditorView, key: "up" | "down") => {
const next = stepHistory(latest.current.history, recall, view.state.doc.toString(), key);
if (next == null) return false;
recall = next.recall;
closeCompletion(view);
replaceText(view, next.draft);
return true;
};
const box = (view: EditorView) => ({
draft: view.state.doc.toString(),
applied: latest.current.applied,
});
return [
Prec.high(
keymap.of([
{
key: "Enter",
stopPropagation: true,
run: (view) => {
if (menuOpen(view)) return false;
const next = pressEnter(box(view));
recall = null;
applyFilter(store, next.apply ?? "", { record: next.record });
return true;
},
},
{
key: "Escape",
stopPropagation: true,
run: (view) => {
if (menuOpen(view)) return false;
const next = pressEscape(box(view));
if (next == null) return false;
recall = null;
replaceText(view, next.draft);
if (next.apply != null) applyFilter(store, next.apply);
return true;
},
},
{
key: "ArrowUp",
run: (view) => {
const draft = view.state.doc.toString();
const stepping = recall != null && recall.text === draft;
if (menuOpen(view) && draft !== "" && !stepping) return false;
return step(view, "up");
},
},
{
key: "ArrowDown",
run: (view) => (menuOpen(view) ? false : step(view, "down")),
},
]),
),
// The empty box lists the recent filters as it is focused.
EditorView.domEventHandlers({
focus: (_e, view) => {
if (view.state.doc.length > 0 || latest.current.history.length === 0) return;
requestAnimationFrame(() => {
if (view.hasFocus && view.state.doc.length === 0) startCompletion(view);
});
},
}),
];
}
/** The search box's width, which its completions take */
const BOX_WIDTH_VAR = "--proxy-search-width";
/** The search box an editor is in */
function boxOf(view: EditorView): HTMLElement | null {
return view.dom.closest<HTMLElement>(".x-theme-input");
}
/** Pixels between a completion's info and the window's edge */
const INFO_MARGIN = 8;
/**
* Where a completion's info goes: beside the list, in the window's room either side of the box,
* or, with none, under the option as CodeMirror puts it in a narrow space
*/
function positionInfo(
view: EditorView,
list: { left: number; right: number; top: number; bottom: number },
option: { top: number; bottom: number },
info: { left: number; right: number; top: number; bottom: number },
): { style: string; class: string } {
const win = view.dom.ownerDocument.defaultView ?? window;
const width = info.right - info.left;
const height = info.bottom - info.top;
const right = win.innerWidth - list.right - INFO_MARGIN;
const left = list.left - INFO_MARGIN;
if (Math.max(left, right) >= width) {
const top = Math.max(0, Math.min(option.top, win.innerHeight - height)) - list.top;
const onRight = right >= width;
return {
style: `top: ${top}px; max-width: ${onRight ? right : left}px`,
class: onRight ? "cm-completionInfo-right" : "cm-completionInfo-left",
};
}
return {
style: `top: ${option.bottom - list.top}px; max-width: 400px`,
class: "cm-completionInfo-right-narrow",
};
}
/**
* The completions under the search box, as wide as it and level with its left edge, rather
* than a narrow list wherever the cursor is
*/
const completionAcrossBox: Extension = [
autocompletion({ positionInfo }),
tooltips({
tooltipSpace: (view) => {
const win = view.dom.ownerDocument.defaultView ?? window;
const rect = boxOf(view)?.getBoundingClientRect();
return {
top: 0,
bottom: win.innerHeight,
left: rect?.left ?? 0,
right: rect?.right ?? win.innerWidth,
};
},
}),
ViewPlugin.fromClass(
class {
observer: ResizeObserver | null = null;
constructor(view: EditorView) {
this.watch(view);
}
// The editor is put in the page after it is made, so the box may only be found later.
update(update: ViewUpdate) {
if (this.observer == null) this.watch(update.view);
}
watch(view: EditorView) {
const box = boxOf(view);
if (box == null) return;
const root = view.dom.ownerDocument.documentElement;
this.observer = new ResizeObserver(() => {
root.style.setProperty(BOX_WIDTH_VAR, `${box.getBoundingClientRect().width}px`);
});
this.observer.observe(box);
}
destroy() {
this.observer?.disconnect();
}
},
),
EditorView.theme({
".cm-tooltip.cm-tooltip-autocomplete": { width: `var(${BOX_WIDTH_VAR})` },
".cm-tooltip.cm-tooltip-autocomplete > ul": { minWidth: "0", maxWidth: "none" },
}),
];
/**
* Ctrl+Space lists what can be typed, fields and saved searches on an empty spot, as it does in
* Yaak's editors, whose shared editor leaves the key to each app
*/
function startCompletionOnCtrlSpace(e: KeyboardEvent, view: EditorView): boolean {
if (!(e.ctrlKey && e.key === " " && !e.metaKey && !e.altKey && !e.shiftKey)) return false;
e.preventDefault();
startCompletion(view);
return true;
}
/** "Search 4,102 flows", the title and count that sat in the header's middle */
function placeholderFor(total: number): string {
if (total === 0) return "Search flows";
return `Search ${total.toLocaleString()} ${total === 1 ? "flow" : "flows"}`;
}
+273
View File
@@ -0,0 +1,273 @@
import { useQuery } from "@tanstack/react-query";
import { getVersion } from "@tauri-apps/api/app";
import { appDataDir, appLogDir } from "@tauri-apps/api/path";
import {
Dialog,
EmptyStateText,
SETTINGS_TAB_GENERAL,
SETTINGS_TAB_INTERFACE,
SETTINGS_TAB_LICENSE,
SETTINGS_TAB_THEME,
type SettingsCallbacks,
SettingsDialog,
SettingsGeneral,
SettingsInterface,
SettingsTheme,
type SettingsTitlebar,
Toasts,
} from "@yaakapp-internal/ui";
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { settingsValuesAtom } from "../lib/interfaceSettings";
import { openLink, revealPath } from "../lib/opener";
import { proxySettingsView } from "../lib/settings";
import { closeWindow, command, currentWindowLabel, getOsType, subscribe } from "../lib/tauri";
import { useShowToast } from "../lib/toast";
import { checkForUpdatesNow, updateCheckingAtom, updaterEnabled } from "../lib/updates";
import {
openSettingsVia,
requestedSettingsTab,
SETTINGS_TAB_EVENT,
windowKind,
} from "../lib/windows";
import { HelperDialog } from "./HelperDialog";
import { useProxySetup } from "./ProxyStatus";
import { SettingsProxy } from "./SettingsProxy";
export const SETTINGS_TAB_PROXY = "proxy";
/** The settings dialog is open, on a tab, or closed, where Settings has no window of its own */
const settingsDialogAtom = atom<string | null>(null);
/**
* Yaak's settings, shared: its General, Theme, Interface and License tabs, plus the proxy's own
* Proxy tab. The header's gear and `⌘,` open it in a window of its own, or, where the app has
* no second window to give, such as the browser harness, in this dialog.
*/
export function SettingsDialogFallback() {
const [tab, setTab] = useAtom(settingsDialogAtom);
useSettingsHotkey();
return (
<Dialog
open={tab != null}
onClose={() => setTab(null)}
size="lg"
noPadding
noScroll
className="h-[min(44rem,90vh)]"
>
{tab != null && <SettingsContent defaultTab={tab} />}
</Dialog>
);
}
/**
* The Settings window: the settings alone, full-window under their own title bar, on the tab
* asked for, moving to another tab each time Settings is opened on one. Escape closes it. The
* dialogs the settings open, and their toasts, show over it.
*/
export function SettingsWindowContent() {
const [shown, setShown] = useState(() => ({
tab: requestedSettingsTab() ?? SETTINGS_TAB_GENERAL,
// Keys the tabs, which take their tab only as they mount
version: 0,
}));
useEffect(
() =>
subscribe<string>(SETTINGS_TAB_EVENT, (tab) =>
setShown((s) => ({ tab, version: s.version + 1 })),
),
[],
);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
// A dialog over the settings takes its own Escape and stops it here, an editor's popup
// marks it handled, and an open menu closes on it, leaving the window open
if (e.key !== "Escape" || e.defaultPrevented) return;
if (document.querySelector('[role="menu"]') != null) return;
closeWindow().catch(console.error);
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
// Reads the system proxy and certificate as the main window's header does, polling while the
// helper awaits approval, so the helper's dialog here sees it given.
useProxySetup();
const { hideWindowControls } = useAtomValue(settingsValuesAtom);
const titlebar = useMemo<SettingsTitlebar>(
() => ({
osType: getOsType(),
hideWindowControls,
// The proxy draws its own title bar always, as its main window does
useNativeTitlebar: false,
interfaceScale: 1,
}),
[hideWindowControls],
);
return (
<div className="h-full w-full bg-surface">
<SettingsContent key={shown.version} defaultTab={shown.tab} titlebar={titlebar} />
<HelperDialog />
<Toasts />
</div>
);
}
/** The settings themselves, as the window or the dialog holds them */
export function SettingsContent({
defaultTab,
titlebar = null,
}: {
defaultTab: string;
/** The window's title bar; none in the dialog, which has its own frame */
titlebar?: SettingsTitlebar | null;
}) {
const [values, change] = useAtom(settingsValuesAtom);
const app = useAppInfo();
const systemAppearance = useSystemAppearance();
const store = useStore();
const checking = useAtomValue(updateCheckingAtom);
const { data: hasUpdater = false } = useQuery({
queryKey: ["updater_enabled"],
queryFn: updaterEnabled,
retry: false,
});
const view = useMemo(
() => proxySettingsView(values, app, systemAppearance, hasUpdater ? { checking } : null),
[values, app, systemAppearance, hasUpdater, checking],
);
const showToast = useShowToast();
const callbacks = useMemo<SettingsCallbacks>(() => {
const fail = (message: string) => showToast({ message, color: "danger" });
return {
onChange: (patch) => change(patch),
// The proxy draws its own title bar always; the row isn't shown
onApplyNativeTitlebar: (useNativeTitlebar) => change({ useNativeTitlebar }),
onCheckForUpdates: () => void checkForUpdatesNow(store),
onActivateLicense: async () => undefined,
onDeactivateLicense: () => undefined,
onOpenUrl: (url) => void openLink(url, fail),
onRevealPath: (path) => void revealPath(path, fail),
pricingUrl: (intent) => `https://yaak.app/pricing?intent=${encodeURIComponent(intent)}`,
};
}, [change, showToast, store]);
const props = { view, callbacks };
return (
<SettingsDialog
titlebar={titlebar}
defaultTab={defaultTab}
tabs={[
{
value: SETTINGS_TAB_GENERAL,
label: "General",
icon: "settings",
content: <SettingsGeneral {...props} />,
},
{
value: SETTINGS_TAB_THEME,
label: "Theme",
icon: "palette",
content: <SettingsTheme {...props} />,
},
{
value: SETTINGS_TAB_INTERFACE,
label: "Interface",
icon: "columns_2",
content: <SettingsInterface {...props} />,
},
{
value: SETTINGS_TAB_PROXY,
label: "Proxy",
icon: "wifi",
content: <SettingsProxy />,
},
{
value: SETTINGS_TAB_LICENSE,
label: "License",
icon: "key_round",
content: <EmptyStateText>Coming soon</EmptyStateText>,
},
]}
/>
);
}
/** `⌘,`, or `Ctrl+,` off macOS, opens settings, as in Yaak, on the tab they were left on */
function useSettingsHotkey() {
const open = useOpenSettings();
useEffect(() => {
const mac = getOsType() === "macos";
function onKeyDown(e: KeyboardEvent) {
if (e.key !== "," || e.shiftKey || e.altKey) return;
if (mac ? !e.metaKey || e.ctrlKey : !e.ctrlKey || e.metaKey) return;
e.preventDefault();
open(null);
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open]);
}
function useAppInfo() {
const os = getOsType();
const { data } = useQuery({
queryKey: ["settings_app_info"],
queryFn: async () => {
const [version, dataDir, logDir] = await Promise.all([
getVersion(),
appDataDir(),
appLogDir(),
]);
return { version, dataDir, logDir };
},
retry: false,
});
return useMemo(
() => ({
name: "Yaak Proxy",
version: data?.version ?? "",
dataDir: data?.dataDir ?? "",
logDir: data?.logDir ?? "",
revealPathLabel:
os === "macos"
? "Reveal in Finder"
: os === "windows"
? "Show in Explorer"
: "Show in File Manager",
}),
[data, os],
);
}
function useSystemAppearance(): "light" | "dark" {
const query = "(prefers-color-scheme: dark)";
const [dark, setDark] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const media = window.matchMedia(query);
const onChange = () => setDark(media.matches);
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}, []);
return dark ? "dark" : "light";
}
/**
* Opens settings on a tab, General unless another is given. With `null`, opens it on General,
* or brings it forward on the tab it is on.
*/
export function useOpenSettings() {
const setDialog = useSetAtom(settingsDialogAtom);
const showToast = useShowToast();
return useCallback(
(tab: string | null = SETTINGS_TAB_GENERAL) => {
openSettingsVia(tab, {
kind: windowKind(currentWindowLabel()),
openWindow: (tab) => command<void>("cmd_open_settings", { tab }),
openDialog: (tab) => setDialog((open) => tab ?? open ?? SETTINGS_TAB_GENERAL),
}).catch((err) => showToast({ id: "open-settings", color: "danger", message: String(err) }));
},
[setDialog, showToast],
);
}
Loaded 100 of 442 files, more files were not shown because too many files have changed in this diff. Show more