Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

SegmentedControl moves from the client into packages/ui.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:35:17 -07:00
Gregory SchierandClaude Opus 5.5 9bbd28ce4a PR 2: proxy services (#787)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:19:50 -07:00
Gregory SchierandClaude Opus 5.5 686294939b Rescue the proxy GUI slice (#785)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 06:38:34 -07:00
Gregory Schier 6132171791 Agents pane: CLI-only MCP, yaak mcp --http, and plugin deprecation (#761) 2026-10-25 23:14:45 -07:00
Gregory SchierandClaude Opus 5.5 349e6f9197 Fix proxy passthrough on Windows (#779)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:58:29 -07:00
Gregory Schier 1248112fe0 Smooth splitter dragging (#765) 2026-10-21 12:22:45 -07:00
Zhizhen He 8a85fb3dcd Preserve literal form field names when converting to JSON
Use an object with a null prototype so inherited property names are not
mistaken for existing fields and __proto__ remains a literal JSON key.
Cover __proto__, constructor, and toString with regression cases.
2026-10-16 01:42:28 +08:00
Zhizhen HeandGregory Schier 277bbe1baa Prevent stale sync confirmations from deleting or overwriting newer data (#739)
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-10-15 09:10:33 -07:00
Gregory Schieranddependabot[bot] 62f76c5c36 Bump the npm production group (25 updates) and dedupe the lockfile (#760)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 08:49:15 -07:00
dependabot[bot] 98cf213b9e chore(deps): bump the cargo group across 1 directory with 6 updates (#758)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 08:13:22 -07:00
dependabot[bot] c5662a2851 chore(deps): bump motion from 13.5.0 to 14.0.0 (#744)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 08:12:40 -07:00
dependabot[bot] d534e88c26 chore(deps-dev): bump the npm-development group with 3 updates (#742)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 08:12:34 -07:00
dependabot[bot] ae354c3261 chore(deps): bump zstd from 0.13.3 to 0.14.0 (#749)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 08:12:27 -07:00
dependabot[bot] 26cbd45f76 chore(deps): bump tower-http from 0.6.10 to 0.7.1 (#748)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 08:12:21 -07:00
dependabot[bot] c81e8ffd5c chore(deps): bump yasna from 0.5.2 to 0.6.0 (#747)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 08:12:13 -07:00
dependabot[bot] 29d54cda0d chore(deps): bump csscolorparser from 0.7.2 to 0.9.0 (#746)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 08:12:06 -07:00
Gregory Schier b01c9c34c7 Add Claude Code and Codex plugin manifests and MCP registry metadata (#756) 2026-10-15 07:14:22 -07:00
Gregory Schier 9b3715024d Move response filters into Rust (#755) 2026-10-15 06:22:23 -07:00
Gregory Schier 19908b1902 Add a Connect an agent section with setup for Claude Code, Codex, Cursor, and VS Code 2026-10-14 22:16:55 -07:00
Gregory Schier 72b43f7110 Move the MCP server into Rust, hosted by the app and yaak mcp (#752) 2026-10-14 21:51:40 -07:00
Gregory Schier b6c1f700f1 Proxy viewer extraction: response viewers into packages/ui (#754) 2026-10-14 21:32:19 -07:00
Gregory Schier b5508e6bb1 Proxy storage: flows table, bodies in blobs.sqlite, settings (#751) 2026-10-14 13:50:26 -07:00
Gregory Schier 694c0272cd Proxy engine: persistent CA, capped capture, upgrade tunnel (#750) 2026-10-14 13:10:51 -07:00
Gregory Schier 602c6e1083 Bump @yaak/mcp-server to 0.2.2 2026-10-14 09:33:55 -07:00
dependabot[bot] 80a707ccc0 chore(deps): bump fast-uri from 3.1.7 to 3.1.8 (#731)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 11:20:30 -07:00
dependabot[bot] 9d59577dcd chore(deps): bump jsonpath-plus from 10.4.0 to 11.0.0 (#717)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:47:32 -07:00
dependabot[bot] 290fdc3f93 chore(deps): bump proxy-addr from 2.0.7 to 2.0.8 (#736)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:47:10 -07:00
dependabot[bot] c03b483fde chore(deps-dev): bump shell-quote from 1.10.0 to 1.12.0 (#735)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:47:00 -07:00
dependabot[bot] 2c097eba2e chore(deps-dev): bump source-map-js from 1.2.1 to 1.2.2 (#734)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:46:49 -07:00
dependabot[bot] 5664f9b0bb chore(deps): bump brace-expansion (#732)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:46:32 -07:00
dependabot[bot] ce2064cab6 chore(deps): bump ip-address from 10.7.0 to 10.7.3 (#729)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:46:21 -07:00
dependabot[bot] 80bf2240f8 chore(deps-dev): bump the npm-development group across 1 directory with 4 updates (#726)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:46:10 -07:00
dependabot[bot] 23fb222957 chore(deps): bump the npm-production group across 1 directory with 19 updates (#727)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:45:58 -07:00
dependabot[bot] c29a62ba6f chore(deps): bump rustls-platform-verifier from 0.6.2 to 0.7.1 (#719)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:45:30 -07:00
dependabot[bot] f8b1785866 chore(deps): bump dirs from 6.0.0 to 7.0.0 (#720)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:45:16 -07:00
dependabot[bot] d2f14c65b3 chore(deps): bump base64 from 0.22.1 to 0.23.1 (#721)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:45:03 -07:00
dependabot[bot] f467e22f3f chore(deps): bump dark-light from 2.0.0 to 3.0.0 (#722)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:44:53 -07:00
dependabot[bot] 0ca5fc1e2d chore(deps): bump xxhash-rust from 0.8.15 to 0.8.19 (#737)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:44:44 -07:00
dependabot[bot] 9bda8cea1e chore(deps): bump the cargo group across 1 directory with 10 updates (#738)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 10:44:30 -07:00
Zhizhen He 919e58fc9d Prevent sync data loss from incomplete tracked files (#725)
Co-authored-by: Zhizhen He <hezhizhen.yi@gmail.com>
2026-10-06 21:45:26 -07:00
Gregory Schier b4417add33 Fix PDF viewer crash on older macOS WebKit (#730) 2026-10-06 20:50:07 -07:00
M kashif 7890281db3 Trim leading/trailing whitespace from request URLs (#724) 2026-10-06 20:50:03 -07:00
Ngo Quoc Viet d44d75dcef fix(template-function-xml): return attribute and text() values from XPath (#723) 2026-10-06 20:31:36 -07:00
Gregory Schier 7f30253616 Select new sub-environments and keep enabled state through bulk edit (#714) 2026-09-26 09:40:33 -07:00
Gregory Schier 6c2a18d506 Attach CodeMirror editors after building them to batch layout work (#713) 2026-09-26 08:54:30 -07:00
Gregory Schier 0a57d8eb61 Convert the remaining Insomnia tags to native Yaak functions (#712) 2026-09-25 10:50:22 -07:00
Gregory Schier 411aa262c7 Test the plugin's copy of the template tag scanner (#711) 2026-09-25 10:06:11 -07:00
Gregory Schier 34815f327a Fix Insomnia template and GraphQL import fidelity (#693) 2026-09-25 09:58:31 -07:00
Gregory Schier 6fc43a60e2 Make template tag scanners quote-aware (#710) 2026-09-25 09:32:59 -07:00
Gregory Schier 23d369d84e Print template strings in plain quotes unless they can't round-trip (#708) 2026-09-25 09:07:28 -07:00
dependabot[bot]andGregory Schier 6e7f23a778 chore(deps-dev): bump @yaakapp/cli from 0.5.1 to 2026.8.0 (#698)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-09-24 22:09:37 -07:00
dependabot[bot]andGregory Schier b03c41b767 chore(deps): bump git2 from 0.20.4 to 0.21.0 (#703)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-09-24 22:09:33 -07:00
dependabot[bot]andGregory Schier a0d71eb486 chore(deps-dev): bump the npm-development group across 1 directory with 3 updates (#695)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-09-24 22:09:26 -07:00
dependabot[bot]andGregory Schier 38c562f775 chore(deps): bump whatwg-mimetype and @types/whatwg-mimetype (#697)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-09-24 22:06:53 -07:00
dependabot[bot] 1249d15f2c chore(deps): bump cocoa from 0.26.1 to 0.27.0 (#702)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 21:42:30 -07:00
dependabot[bot] 1707c387ea chore(deps): bump tokio-tungstenite from 0.26.2 to 0.30.0 (#701)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 21:42:23 -07:00
dependabot[bot] a497d9f018 chore(deps): bump brotli from 7.0.0 to 8.0.1 (#700)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 21:42:16 -07:00
dependabot[bot] c13e26656b chore(deps): bump the cargo group with 4 updates (#699)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 21:42:08 -07:00
dependabot[bot] dfc974db95 chore(deps): bump focus-trap-react from 11.0.6 to 12.0.3 (#696)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 21:42:01 -07:00
dependabot[bot] 0b86d3ca3f chore(deps): bump the npm-production group across 1 directory with 10 updates (#707)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-24 21:41:54 -07:00
Gregory Schier 9e51e95f9e Polish the import source list
The URL link and menu item stay enabled and focus the field on repeat
clicks, waiting out the menu's focus return. Larger empty-state icon,
inline links inherit the text color with a translucent underline, and
card rows use a muted trash icon for removal.
2026-09-24 15:40:58 -07:00
Gregory Schier aa10c44a45 Add Bruno imports and a file-tree importer API (#706) 2026-09-24 14:50:44 -07:00
Gregory Schier 46464cfacf Switch to the imported workspace after a deep link import 2026-09-24 08:01:58 -07:00
Gregory Schier 263a215f7a Fix sidebar ordering for items with equal sort priorities (#705) 2026-09-23 08:00:08 -07:00
Gregory Schier 646d969649 Fix PDF preview worker initialization (#704) 2026-09-23 07:41:55 -07:00
Gregory Schier 4913711968 Fix autocomplete for incomplete template tags (#692) 2026-09-18 09:16:36 -07:00
Gregory Schier 35d4c9d79d Allow six hours for release builds and uploads (#691) 2026-09-17 11:41:35 -07:00
Gregory Schier bbe8d1eef5 fix: retry release uploads and retain artifacts for seven days (#690) 2026-09-17 10:11:23 -07:00
Gregory Schier 1e3dfff01e Fix JSON breadcrumb filters for literal keys (#689) 2026-09-17 08:18:15 -07:00
dependabot[bot] fccff5b7db chore(deps): bump motion from 12.43.0 to 13.2.0 (#683)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-17 07:24:13 -07:00
Vlad Solomon 6ed25b9f42 JSON breadcrumbs for the response viewer (YK-724) (#653) 2026-09-17 06:42:32 -07:00
dependabot[bot] 4a4d3b4301 chore(deps): bump react-pdf from 10.5.0 to 11.0.0 (#687)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-16 09:02:04 -07:00
dependabot[bot] 25a464546a chore(deps): bump lucide-react from 0.525.0 to 1.45.0 (#688)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-16 09:01:52 -07:00
Gregory Schier 2c51fc92d6 Create the example workspace in the browser build 2026-09-15 16:32:20 -07:00
Gregory SchierandClaude Opus 5 f827aae46d Share one exporter, and flush the file before saying it is written (#686)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:56:23 -07:00
Gregory SchierandClaude Fable 5 caeebebc75 Create a workspace in the environment test fixtures
Fresh databases stopped getting a default workspace with the home screen
(#660), so the fixtures from #654 that took the first workspace panicked
on an empty list. They now create one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-15 14:50:05 -07:00
Gregory Schier 621f9c3b8f Add an OG image and link preview tags to the web build 2026-09-15 14:48:07 -07:00
Gregory SchierandClaude Opus 5 77b18d3477 Export a workspace without needing a filesystem (#685)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:29:37 -07:00
Gregory Schier a8c845b091 Add a dev menu item to show the home screen 2026-09-15 14:27:11 -07:00
dependabot[bot] bcbf6d32a7 chore(deps): bump zod from 3.25.76 to 4.6.2 (#672)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 14:25:16 -07:00
dependabot[bot] e30b289ed7 chore(deps): bump nanoid from 5.1.16 to 6.0.1 (#671)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 14:25:08 -07:00
Gregory Schier 85904566e7 chore(plugins): replace deprecated zip-extract with zip 2026-09-15 14:24:13 -07:00
Gregory Schier 0b6803c6c0 fix(editor): don't flag an empty JSON editor as invalid 2026-09-15 14:16:34 -07:00
Gregory Schier edeb433c79 Point the example workspace at yaak.run (#684) 2026-09-15 14:16:13 -07:00
dependabot[bot] 12e6e8a977 chore(deps): bump sha1 from 0.10.6 to 0.11.0 (#674)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 14:07:04 -07:00
Ricardo Lopes Temoteo ab616fd915 Fix MCP send_http_request ignoring environmentId (#654) 2026-09-15 14:04:38 -07:00
Gregory SchierandClaude Opus 5 4859aba142 Add a way for a host to save bytes (#678)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:01:47 -07:00
Gregory Schier cef569ee15 Add the yaak.run playground API (#681) 2026-09-15 13:55:36 -07:00
Gregory SchierandClaude Opus 5 ae518dfb4c Convert Postman's simple dynamic variables to template functions (#680)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:48:27 -07:00
Gregory SchierandClaude Opus 5 c9990d8492 Stop queueing build tooling majors (#679)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:48:08 -07:00
Ngo Quoc Viet 309818200a fix(template-function-uuid): use the current time when uuid.v6 has no timestamp (#647) 2026-09-15 13:35:13 -07:00
Gregory Schier 06142d007a Replace the default workspace with a home screen (#660) 2026-09-15 13:33:57 -07:00
Gregory SchierandClaude Opus 5 8a6e4810fd Listen on HOST and PORT instead of a bind address (#666)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:27:07 -07:00
Gregory SchierandClaude Opus 5 5a793e1d42 Clear the inherited non-blocking flag on accepted sockets in the CLI test server (#668)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:22:01 -07:00
Gregory SchierandClaude Opus 5 3d6c31440c Work through the open Dependabot PRs (#663)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:18:54 -07:00
Gregory SchierandClaude Opus 5 31d55440c1 Import Digest, NTLM, OAuth 1.0, and folder-level auth from Postman collections (#667)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:18:34 -07:00
Gregory SchierandClaude Opus 5 a822cc93d4 Make the web dev server work from another machine (#665)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:29:07 -07:00
Gregory SchierandClaude Opus 5 48889b6550 Add scripts for running Yaak in a browser (#664)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 10:12:53 -07:00
Gregory Schier 881cff472d Show a web proxy startup message by default 2026-09-14 22:44:58 -07:00
Gregory Schier d465c5679a Make web proxy request logging opt-in 2026-09-14 22:38:07 -07:00
Gregory Schier 0a68dea133 Remove hosted embedding setup from the self-hosting README 2026-09-14 22:24:50 -07:00
Gregory Schier 17a44f34a3 Focus the confirmation action when opening dialogs 2026-09-14 22:21:43 -07:00
Gregory Schier 8c8be31aa2 Expose the web server as an embeddable Axum router 2026-09-14 22:21:43 -07:00
Gregory Schier ff5db718ce Confirm proxy use before the first web request (#657) 2026-09-14 21:29:07 -07:00
Gregory Schier fd95b4cba3 Fix responses with more than 100 headers failing on HTTP/1 (#658) 2026-09-14 21:04:47 -07:00
958 changed files with 124400 additions and 19244 deletions

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
{
"name": "yaak",
"interface": {
"displayName": "Yaak"
},
"plugins": [
{
"name": "yaak",
"source": {
"source": "local",
"path": "./crates-cli/yaak-cli"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Developer Tools"
}
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://json.schemastore.org/claude-code-marketplace.json",
"name": "yaak",
"description": "Plugins for Yaak, the desktop API client",
"owner": {
"name": "Yaak",
"url": "https://yaak.app"
},
"plugins": [
{
"name": "yaak",
"source": "./crates-cli/yaak-cli",
"description": "Build, save, and send API requests with the yaak CLI, with its MCP server for agents",
"category": "development",
"tags": ["api", "http", "rest", "graphql", "grpc", "openapi", "api-client"]
}
]
}
+1
View File
@@ -5,3 +5,4 @@ crates/yaak-templates/pkg/* linguist-generated=true
# Ensure consistent line endings for test files that check exact content # Ensure consistent line endings for test files that check exact content
crates/yaak-http/tests/test.txt text eol=lf crates/yaak-http/tests/test.txt text eol=lf
crates-proxy/yaak-proxy-lib/tests/fixtures/export.har text eol=lf
+21
View File
@@ -7,6 +7,23 @@ updates:
interval: weekly interval: weekly
day: monday day: monday
open-pull-requests-limit: 5 open-pull-requests-limit: 5
ignore:
# Build tooling majors are opted into, not queued. These never reach a
# user, so the cost of being a release behind is low and the cost of a
# surprise is a broken build. Note this also suppresses security PRs for
# these, so Dependabot alerts are the backstop.
- dependency-name: typescript
update-types: ["version-update:semver-major"]
- dependency-name: vitest
update-types: ["version-update:semver-major"]
- dependency-name: "@types/*"
update-types: ["version-update:semver-major"]
- dependency-name: "vite-plugin-*"
update-types: ["version-update:semver-major"]
# 0.8 dropped stateExtensions and updateSchema from the package root and
# exposes no subpath to reach them, which GrpcEditor needs. Unpin once
# that editor moves to the jsonSchema helper.
- dependency-name: codemirror-json-schema
groups: groups:
npm-production: npm-production:
dependency-type: production dependency-type: production
@@ -25,6 +42,10 @@ updates:
interval: weekly interval: weekly
day: monday day: monday
open-pull-requests-limit: 5 open-pull-requests-limit: 5
ignore:
# Held at 11.10.0 to pin what rolldown_resolver ("^11") resolves to; it
# does not build against 11.11. See crates-cli/yaak-cli/Cargo.toml.
- dependency-name: oxc_resolver
groups: groups:
cargo: cargo:
patterns: patterns:
@@ -0,0 +1,79 @@
// Retain only distributable bundle files, never the target tree or signing material.
import { createHash } from "node:crypto";
import {
copyFileSync,
createReadStream,
existsSync,
mkdirSync,
readdirSync,
writeFileSync,
} from "node:fs";
import { join, relative } from "node:path";
const env = process.env;
const bundle = join("target", env.RELEASE_TARGET || "", "release", "bundle");
const destination = join(env.RUNNER_TEMP, "yaak-release-artifacts");
const extensions =
/\.(dmg|deb|rpm|AppImage|exe|msi|app\.tar\.gz|AppImage\.tar\.gz|nsis\.zip|msi\.zip)(\.sig)?$/;
const files = [];
function snapshot(directory) {
if (!existsSync(directory)) return;
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const source = join(directory, entry.name);
// Only descend through the bundler's immediate output folders. This excludes
// .app trees and the extracted CEF deb contents (which contain executables).
if (entry.isDirectory() && directory === bundle) {
snapshot(source);
} else if (
entry.isFile() &&
(extensions.test(entry.name) || /^yaak-cef_.*_linux_.*\.tar\.gz$/.test(entry.name))
) {
// Match tauri-action's default macOS updater asset naming exactly.
const arch = env.RELEASE_ARCH === "arm64" ? "aarch64" : "x64";
const assetName = entry.name.replace(/\.app\.tar\.gz(\.sig)?$/, `_${arch}.app.tar.gz$1`);
mkdirSync(destination, { recursive: true });
const retained = join(destination, assetName);
// The second snapshot must not overwrite the original user installer/signature.
if (!existsSync(retained)) copyFileSync(source, retained);
files.push({ source: relative(".", source), assetName });
}
}
}
snapshot(bundle);
if (existsSync(destination)) {
const assets = readdirSync(destination)
.filter((name) => name !== "provenance.json")
.sort();
const checksums = [];
for (const assetName of assets) {
const hash = createHash("sha256");
for await (const chunk of createReadStream(join(destination, assetName))) hash.update(chunk);
checksums.push({ assetName, sha256: hash.digest("hex") });
}
writeFileSync(
join(destination, "provenance.json"),
JSON.stringify(
{
repository: env.GITHUB_REPOSITORY,
tag: env.GITHUB_REF_NAME,
sha: env.GITHUB_SHA,
runId: env.GITHUB_RUN_ID,
runAttempt: env.GITHUB_RUN_ATTEMPT,
workflowRef: env.GITHUB_WORKFLOW_REF,
workflowSha: env.GITHUB_WORKFLOW_SHA,
tauriAction: "tauri-apps/tauri-action@v0",
platform: env.RELEASE_PLATFORM,
runtime: env.RELEASE_RUNTIME,
arch: env.RELEASE_ARCH,
target: env.RELEASE_TARGET,
args: env.RELEASE_ARGS,
assets: checksums,
},
null,
2,
) + "\n",
);
}
console.log(`Found ${files.length} distributable bundle files in ${bundle}`);
+26
View File
@@ -35,3 +35,29 @@ jobs:
run: | run: |
cargo build -p yaak-cli cargo build -p yaak-cli
node plugins/importer-openapi/tests/roundtrip.mjs node plugins/importer-openapi/tests/roundtrip.mjs
test-windows:
name: Rust Tests (Windows)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: voidzero-dev/setup-vp@v1
with:
node-version: "24"
cache: true
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: ci
cache-on-failure: true
- name: Install Protoc for plugin-runtime
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- run: vp install
- run: npm run bootstrap
env:
YAAK_TARGET_ARCH: x64
- name: Run Rust Tests
run: cargo test --all --exclude yaak-cli --features yaak-app-client/wry
+241 -6
View File
@@ -2,10 +2,24 @@ name: Release App Artifacts
on: on:
push: push:
tags: [v*] 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: jobs:
build-artifacts: build-artifacts:
if: github.repository == 'mountain-loop/yaak' if: github.repository == 'mountain-loop/yaak' && github.event_name == 'push'
permissions: permissions:
contents: write contents: write
@@ -70,7 +84,7 @@ jobs:
runtime: "wry" runtime: "wry"
targets: "aarch64-pc-windows-msvc" targets: "aarch64-pc-windows-msvc"
runs-on: ${{ matrix.platform }} runs-on: ${{ matrix.platform }}
timeout-minutes: 60 timeout-minutes: 360
steps: steps:
- name: Checkout yaakapp/app - name: Checkout yaakapp/app
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -172,7 +186,8 @@ jobs:
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/protoc/yaakprotoc || true codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/protoc/yaakprotoc || true
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/node/yaaknode || true codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/node/yaaknode || true
- uses: tauri-apps/tauri-action@v0 - name: Build and upload Tauri bundles
uses: tauri-apps/tauri-action@v0
env: env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }} YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
@@ -194,6 +209,8 @@ jobs:
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }} AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }} AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
with: with:
# Also retries builds; upload retries alone cannot clean up starter assets.
retryAttempts: 3
tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js" tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js"
tagName: "v__VERSION__" tagName: "v__VERSION__"
releaseName: "Release __VERSION__" releaseName: "Release __VERSION__"
@@ -203,6 +220,17 @@ jobs:
projectPath: ./crates-tauri/yaak-app-client projectPath: ./crates-tauri/yaak-app-client
args: "${{ matrix.args }}" args: "${{ matrix.args }}"
# Snapshot before the per-machine build overwrites the regular NSIS bundle/signature.
- name: Snapshot Tauri bundles
if: ${{ !cancelled() }}
run: node .github/scripts/preserve-release-artifacts.mjs
env:
RELEASE_TARGET: ${{ matrix.targets }}
RELEASE_PLATFORM: ${{ matrix.platform }}
RELEASE_RUNTIME: ${{ matrix.runtime }}
RELEASE_ARCH: ${{ matrix.yaak_arch }}
RELEASE_ARGS: ${{ matrix.args }}
- name: Build and upload CEF tarball from deb (Linux only) - name: Build and upload CEF tarball from deb (Linux only)
if: matrix.os == 'ubuntu' && matrix.runtime == 'cef' if: matrix.os == 'ubuntu' && matrix.runtime == 'cef'
env: env:
@@ -217,7 +245,13 @@ jobs:
mkdir -p "$extract_dir" mkdir -p "$extract_dir"
dpkg-deb -x "$deb" "$extract_dir" dpkg-deb -x "$deb" "$extract_dir"
tar -C "$extract_dir" -czf "$tarball" . tar -C "$extract_dir" -czf "$tarball" .
gh release upload "${{ github.ref_name }}" "$tarball" --clobber for attempt in 1 2 3 4; do
if gh release upload "$GITHUB_REF_NAME" "$tarball" --clobber; then
break
fi
if [ "$attempt" -eq 4 ]; then exit 1; fi
sleep 10
done
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune) # Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
- name: Build and upload machine-wide installer (Windows only) - name: Build and upload machine-wide installer (Windows only)
@@ -235,6 +269,7 @@ jobs:
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
Push-Location crates-tauri/yaak-app-client Push-Location crates-tauri/yaak-app-client
npx tauri bundle ${{ matrix.args }} --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}' npx tauri bundle ${{ matrix.args }} --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Pop-Location Pop-Location
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1 $setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
$setupSig = "$($setup.FullName).sig" $setupSig = "$($setup.FullName).sig"
@@ -242,5 +277,205 @@ jobs:
$destSig = "$dest.sig" $destSig = "$dest.sig"
Copy-Item $setup.FullName $dest Copy-Item $setup.FullName $dest
Copy-Item $setupSig $destSig Copy-Item $setupSig $destSig
gh release upload "${{ github.ref_name }}" "$dest" --clobber foreach ($asset in @($dest, $destSig)) {
gh release upload "${{ github.ref_name }}" "$destSig" --clobber for ($attempt = 1; $attempt -le 4; $attempt++) {
gh release upload "$env:GITHUB_REF_NAME" "$asset" --clobber
if ($LASTEXITCODE -eq 0) { break }
if ($attempt -eq 4) { exit $LASTEXITCODE }
Start-Sleep -Seconds 10
}
}
- name: Snapshot additional release bundles
if: ${{ !cancelled() }}
run: node .github/scripts/preserve-release-artifacts.mjs
env:
RELEASE_TARGET: ${{ matrix.targets }}
RELEASE_PLATFORM: ${{ matrix.platform }}
RELEASE_RUNTIME: ${{ matrix.runtime }}
RELEASE_ARCH: ${{ matrix.yaak_arch }}
RELEASE_ARGS: ${{ matrix.args }}
- name: Retain release bundles for recovery
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: release-${{ github.ref_name }}-${{ matrix.platform }}-${{ matrix.runtime }}-${{ matrix.yaak_arch }}-${{ github.run_id }}-attempt-${{ github.run_attempt }}
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 Code local settings
.claude/settings.local.json .claude/settings.local.json
.claude/worktrees/ .claude/worktrees/
# Headless screenshots taken while working
.shots/
+2 -1
View File
@@ -3,6 +3,7 @@
"ignorePatterns": [ "ignorePatterns": [
"**/bindings/**", "**/bindings/**",
"crates/yaak-templates/pkg/**", "crates/yaak-templates/pkg/**",
"apps/yaak-client/routeTree.gen.ts" "apps/yaak-client/routeTree.gen.ts",
"plugins/importer-bruno/tests/fixtures/upstream/**"
] ]
} }
Generated
+1765 -1421
View File
File diff suppressed because it is too large. Load diff
+20 -2
View File
@@ -11,12 +11,15 @@ members = [
"crates/yaak-core", "crates/yaak-core",
"crates/yaak-common", "crates/yaak-common",
"crates/yaak-crypto", "crates/yaak-crypto",
"crates/yaak-filter",
"crates/yaak-git", "crates/yaak-git",
"crates/yaak-grpc", "crates/yaak-grpc",
"crates/yaak-http", "crates/yaak-http",
"crates/yaak-lifecycle", "crates/yaak-lifecycle",
"crates/yaak-mcp",
"crates/yaak-models", "crates/yaak-models",
"crates/yaak-plugins", "crates/yaak-plugins",
"crates/yaak-query",
"crates/yaak-sse", "crates/yaak-sse",
"crates/yaak-sync", "crates/yaak-sync",
"crates/yaak-templates", "crates/yaak-templates",
@@ -29,6 +32,7 @@ members = [
"crates-proxy/yaak-proxy-lib", "crates-proxy/yaak-proxy-lib",
# Server crates (the browser tier's hosted send executor) # Server crates (the browser tier's hosted send executor)
"crates-server/yaak-web", "crates-server/yaak-web",
"crates-server/yaak-playground",
# CLI crates # CLI crates
"crates-cli/yaak-cli", "crates-cli/yaak-cli",
# Tauri-specific crates # Tauri-specific crates
@@ -37,8 +41,10 @@ members = [
"crates-tauri/yaak-fonts", "crates-tauri/yaak-fonts",
"crates-tauri/yaak-license", "crates-tauri/yaak-license",
"crates-tauri/yaak-mac-window", "crates-tauri/yaak-mac-window",
"crates-tauri/yaak-proxy-helper",
"crates-tauri/yaak-system-appearance", "crates-tauri/yaak-system-appearance",
"crates-tauri/yaak-tauri-utils", "crates-tauri/yaak-tauri-utils",
"crates-tauri/yaak-updater",
"crates-tauri/yaak-window", "crates-tauri/yaak-window",
] ]
@@ -47,9 +53,16 @@ chrono = "0.4.42"
hex = "0.4.3" hex = "0.4.3"
keyring = "3.6.3" keyring = "3.6.3"
log = "0.4.29" log = "0.4.29"
reqwest = "0.12.20" reqwest = { version = "0.13.5", default-features = false, features = [
# reqwest 0.13 switched `default-tls` from native-tls to rustls; keep the
# rest of the old default set and stay on native-tls.
"charset",
"http2",
"system-proxy",
"native-tls",
] }
rustls = { version = "0.23.34", default-features = false } rustls = { version = "0.23.34", default-features = false }
rustls-platform-verifier = "0.6.2" rustls-platform-verifier = "0.7.1"
schemars = { version = "0.8.22", features = ["chrono"] } schemars = { version = "0.8.22", features = ["chrono"] }
serde = "1.0.228" serde = "1.0.228"
serde_json = "1.0.145" serde_json = "1.0.145"
@@ -77,12 +90,15 @@ yaak = { path = "crates/yaak" }
yaak-commands = { path = "crates/yaak-commands" } yaak-commands = { path = "crates/yaak-commands" }
yaak-common = { path = "crates/yaak-common" } yaak-common = { path = "crates/yaak-common" }
yaak-crypto = { path = "crates/yaak-crypto" } yaak-crypto = { path = "crates/yaak-crypto" }
yaak-filter = { path = "crates/yaak-filter" }
yaak-git = { path = "crates/yaak-git" } yaak-git = { path = "crates/yaak-git" }
yaak-grpc = { path = "crates/yaak-grpc" } yaak-grpc = { path = "crates/yaak-grpc" }
yaak-http = { path = "crates/yaak-http" } yaak-http = { path = "crates/yaak-http" }
yaak-lifecycle = { path = "crates/yaak-lifecycle" } yaak-lifecycle = { path = "crates/yaak-lifecycle" }
yaak-mcp = { path = "crates/yaak-mcp" }
yaak-models = { path = "crates/yaak-models" } yaak-models = { path = "crates/yaak-models" }
yaak-plugins = { path = "crates/yaak-plugins" } yaak-plugins = { path = "crates/yaak-plugins" }
yaak-query = { path = "crates/yaak-query" }
yaak-sse = { path = "crates/yaak-sse" } yaak-sse = { path = "crates/yaak-sse" }
yaak-sync = { path = "crates/yaak-sync" } yaak-sync = { path = "crates/yaak-sync" }
yaak-templates = { path = "crates/yaak-templates" } yaak-templates = { path = "crates/yaak-templates" }
@@ -98,8 +114,10 @@ yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
yaak-fonts = { path = "crates-tauri/yaak-fonts" } yaak-fonts = { path = "crates-tauri/yaak-fonts" }
yaak-license = { path = "crates-tauri/yaak-license" } yaak-license = { path = "crates-tauri/yaak-license" }
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" } 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-system-appearance = { path = "crates-tauri/yaak-system-appearance" }
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" } yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
yaak-updater = { path = "crates-tauri/yaak-updater" }
yaak-window = { path = "crates-tauri/yaak-window" } yaak-window = { path = "crates-tauri/yaak-window" }
[profile.release] [profile.release]
+15
View File
@@ -0,0 +1,15 @@
# syntax=docker/dockerfile:1
# The playground API behind yaak.run. See crates-server/yaak-playground/README.md.
FROM rust:1-bookworm AS build
WORKDIR /app
COPY . .
RUN cargo build --release -p yaak-playground
FROM debian:bookworm-slim
COPY --from=build /app/target/release/yaak-playground /usr/local/bin/yaak-playground
ENV HOST=0.0.0.0
ENV PORT=8080
EXPOSE 8080
USER nobody
CMD ["yaak-playground"]
+4 -1
View File
@@ -38,7 +38,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 && rm -rf /var/lib/apt/lists/* ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
COPY --from=server /app/target/release/yaak-web /usr/local/bin/yaak-web COPY --from=server /app/target/release/yaak-web /usr/local/bin/yaak-web
COPY --from=web /app/dist/apps/yaak-client /srv COPY --from=web /app/dist/apps/yaak-client /srv
ENV YAAK_WEB_BIND=0.0.0.0:8080 # 0.0.0.0 so the port is reachable from outside the container. PORT is the variable a
# platform-as-a-service assigns, so a deploy that sets it needs nothing else changed.
ENV HOST=0.0.0.0
ENV PORT=8080
EXPOSE 8080 EXPOSE 8080
USER nobody USER nobody
# Overriding the command (dropping --serve) leaves the stateless send executor: # Overriding the command (dropping --serve) leaves the stateless send executor:
+148
View File
@@ -0,0 +1,148 @@
import type { FunctionComponent, ReactElement } from "react";
import { beforeEach, expect, test, vi } from "vite-plus/test";
import type { SyncOp } from "@yaakapp-internal/sync/bindings/gen_sync";
import type { DialogInstance } from "../components/Dialogs";
import { syncWorkspace } from "./commands";
const mocks = vi.hoisted(() => ({
calculateSync: vi.fn(),
applySync: vi.fn(),
showDialog: vi.fn<(dialog: DialogInstance) => void>(),
hideDialog: vi.fn(),
showToast: vi.fn(),
}));
vi.mock("@yaakapp-internal/sync", () => mocks);
vi.mock("@yaakapp-internal/models", () => ({
createWorkspaceModel: vi.fn(),
modelTypeLabel: () => "Request",
}));
vi.mock("@yaakapp-internal/ui", () => ({
Banner: "div",
InlineCode: "code",
Table: "table",
TableBody: "tbody",
TableCell: "td",
TableHead: "thead",
TableHeaderCell: "th",
TableRow: "tr",
TruncatedWideTableCell: "td",
}));
vi.mock("../components/core/Button", () => ({ Button: "button" }));
vi.mock("../hooks/useActiveWorkspace", () => ({ activeWorkspaceIdAtom: null }));
vi.mock("../lib/dialog", () => mocks);
vi.mock("../lib/jotai", () => ({ jotaiStore: { get: vi.fn() } }));
vi.mock("../lib/prompt", () => ({ showPrompt: vi.fn() }));
vi.mock("../lib/resolvedModelName", () => ({ resolvedModelNameWithFolders: () => "Request" }));
vi.mock("../lib/toast", () => mocks);
const staleToast = expect.objectContaining({
message: "Files changed while syncing. Review the changes again.",
});
const params = { workspaceId: "wk_test", syncDir: "/sync" };
const deletion = {
type: "dbDelete",
model: { id: "rq_test", model: "http_request" },
state: {},
} as Extract<SyncOp, { type: "dbDelete" }>;
const update = {
type: "dbUpdate",
model: { id: "rq_test", model: "http_request" },
fs: { model: { id: "rq_test", model: "http_request", name: "Updated" } },
state: {},
} as Extract<SyncOp, { type: "dbUpdate" }>;
async function submit(dialog: DialogInstance) {
const render = dialog.render as FunctionComponent<{ hide: () => void }>;
const form = render({ hide: () => mocks.hideDialog(dialog.id) }) as ReactElement<{
onSubmit: (event: { preventDefault: () => void }) => Promise<void>;
}>;
await form.props.onSubmit({ preventDefault: vi.fn() });
}
function latestDialog() {
const dialog = mocks.showDialog.mock.calls.at(-1)?.[0];
if (dialog == null) throw new Error("Expected a sync dialog");
return dialog;
}
beforeEach(() => {
vi.resetAllMocks();
mocks.applySync.mockResolvedValue(true);
});
test("closes an obsolete dialog when the restored file leaves no changes", async () => {
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([]);
await syncWorkspace.mutateAsync(params);
mocks.applySync.mockResolvedValueOnce(false);
await submit(latestDialog());
expect(mocks.applySync).toHaveBeenCalledTimes(1);
expect(mocks.applySync).toHaveBeenCalledWith(params.workspaceId, params.syncDir, [deletion]);
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
expect(mocks.showDialog).toHaveBeenCalledTimes(1);
});
test("requires another confirmation for a changed plan", async () => {
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([update]);
await syncWorkspace.mutateAsync(params);
const original = latestDialog();
mocks.applySync.mockResolvedValueOnce(false);
await submit(original);
expect(mocks.applySync).toHaveBeenCalledTimes(1);
expect(mocks.showDialog).toHaveBeenCalledTimes(2);
expect(mocks.showToast).toHaveBeenCalledWith(staleToast);
expect(mocks.hideDialog).not.toHaveBeenCalled();
await submit(latestDialog());
expect(mocks.applySync).toHaveBeenLastCalledWith(params.workspaceId, params.syncDir, [update]);
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
});
test("applies an unchanged reviewed plan and closes the dialog", async () => {
mocks.calculateSync.mockResolvedValue([update]);
await syncWorkspace.mutateAsync(params);
await submit(latestDialog());
expect(mocks.calculateSync).toHaveBeenCalledTimes(1);
expect(mocks.applySync).toHaveBeenCalledTimes(1);
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
});
test("forced sync retries the current plan without requesting another confirmation", async () => {
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([update]);
mocks.applySync.mockResolvedValueOnce(false);
await syncWorkspace.mutateAsync({ ...params, force: true });
expect(mocks.applySync).toHaveBeenCalledTimes(2);
expect(mocks.applySync).toHaveBeenLastCalledWith(params.workspaceId, params.syncDir, [update]);
expect(mocks.showDialog).not.toHaveBeenCalled();
});
test("outbound-only changes clear the old dialog and sync automatically", async () => {
const outbound = { type: "fsUpdate", model: update.model, state: {} } as SyncOp;
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([outbound]);
await syncWorkspace.mutateAsync(params);
await syncWorkspace.mutateAsync(params);
expect(mocks.applySync).toHaveBeenCalledWith(params.workspaceId, params.syncDir, [outbound]);
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
expect(mocks.showDialog).toHaveBeenCalledTimes(1);
});
test("stops retrying a plan that keeps going stale", async () => {
mocks.calculateSync.mockResolvedValue([update]);
mocks.applySync.mockResolvedValue(false);
await syncWorkspace.mutateAsync({ ...params, force: true });
expect(mocks.calculateSync).toHaveBeenCalledTimes(3);
expect(mocks.applySync).toHaveBeenCalledTimes(3);
expect(mocks.showToast).toHaveBeenCalledTimes(1);
expect(mocks.showToast).toHaveBeenCalledWith(staleToast);
expect(mocks.showDialog).not.toHaveBeenCalled();
});
+34 -11
View File
@@ -14,11 +14,12 @@ import {
} from "@yaakapp-internal/ui"; } from "@yaakapp-internal/ui";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace"; import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation"; import { createFastMutation } from "../hooks/useFastMutation";
import { showDialog } from "../lib/dialog"; import { hideDialog, showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai"; import { jotaiStore } from "../lib/jotai";
import { pluralizeCount } from "../lib/pluralize";
import { showPrompt } from "../lib/prompt"; import { showPrompt } from "../lib/prompt";
import { resolvedModelNameWithFolders } from "../lib/resolvedModelName"; import { resolvedModelNameWithFolders } from "../lib/resolvedModelName";
import { showToast } from "../lib/toast";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
export const createFolder = createFastMutation< export const createFolder = createFastMutation<
string | null, string | null,
@@ -52,24 +53,48 @@ export const createFolder = createFastMutation<
}, },
}); });
// Files can keep changing under a plan, so stop recalculating after a few stale applies.
const MAX_SYNC_ATTEMPTS = 3;
function showStaleSyncToast() {
showToast({
id: "sync-stale",
color: "warning",
message: "Files changed while syncing. Review the changes again.",
});
}
export const syncWorkspace = createFastMutation< export const syncWorkspace = createFastMutation<
void, void,
void, void,
{ workspaceId: string; syncDir: string; force?: boolean } { workspaceId: string; syncDir: string; force?: boolean; attempt?: number }
>({ >({
mutationKey: [], mutationKey: [],
mutationFn: async ({ workspaceId, syncDir, force }) => { mutationFn: async ({ workspaceId, syncDir, force, attempt = 1 }) => {
const ops = (await calculateSync(workspaceId, syncDir)) ?? []; const ops = (await calculateSync(workspaceId, syncDir)) ?? [];
if (ops.length === 0) { if (ops.length === 0) {
console.log("Nothing to sync", workspaceId, syncDir); console.log("Nothing to sync", workspaceId, syncDir);
hideDialog("commit-sync");
return; return;
} }
console.log("Syncing workspace", workspaceId, syncDir, ops); console.log("Syncing workspace", workspaceId, syncDir, ops);
const dbOps = ops.filter((o) => o.type.startsWith("db")); const dbOps = ops.filter((o) => o.type.startsWith("db"));
if (dbOps.length === 0) { const apply = async () => {
await applySync(workspaceId, syncDir, ops); if (await applySync(workspaceId, syncDir, ops)) {
hideDialog("commit-sync");
} else if (attempt < MAX_SYNC_ATTEMPTS) {
await syncWorkspace.mutateAsync({ workspaceId, syncDir, force, attempt: attempt + 1 });
} else {
hideDialog("commit-sync");
showStaleSyncToast();
}
};
if (dbOps.length === 0 || force) {
hideDialog("commit-sync");
await apply();
return; return;
} }
@@ -79,9 +104,8 @@ export const syncWorkspace = createFastMutation<
console.log("Directory changes detected", { dbOps, ops }); console.log("Directory changes detected", { dbOps, ops });
if (force) { if (attempt > 1) {
await applySync(workspaceId, syncDir, ops); showStaleSyncToast();
return;
} }
showDialog({ showDialog({
@@ -93,8 +117,7 @@ export const syncWorkspace = createFastMutation<
className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)_auto] gap-3" className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)_auto] gap-3"
onSubmit={async (e) => { onSubmit={async (e) => {
e.preventDefault(); e.preventDefault();
await applySync(workspaceId, syncDir, ops); await apply();
hide();
}} }}
> >
{isDeletingWorkspace ? ( {isDeletingWorkspace ? (
@@ -6,11 +6,12 @@ import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai"; import { jotaiStore } from "../lib/jotai";
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams"; import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
export const createSubEnvironmentAndActivate = createFastMutation< /**
string | null, * Prompt for and create a sub-environment of the given base environment, resolving with the new
unknown, * environment's ID (or null if the dialog was dismissed). This does not change the workspace's
Environment | null * active environment.
>({ */
export const createSubEnvironment = createFastMutation<string | null, unknown, Environment | null>({
mutationKey: ["create_environment"], mutationKey: ["create_environment"],
mutationFn: async (baseEnvironment) => { mutationFn: async (baseEnvironment) => {
if (baseEnvironment == null) { if (baseEnvironment == null) {
@@ -41,6 +42,17 @@ export const createSubEnvironmentAndActivate = createFastMutation<
}); });
}); });
}, },
});
/** Same as {@link createSubEnvironment}, but also makes the new environment the active one. */
export const createSubEnvironmentAndActivate = createFastMutation<
string | null,
unknown,
Environment | null
>({
mutationKey: ["create_environment_and_activate"],
mutationFn: (baseEnvironment) => createSubEnvironment.mutateAsync(baseEnvironment),
disableToastError: true, // Already shown by createSubEnvironment
onSuccess: async (environmentId) => { onSuccess: async (environmentId) => {
if (environmentId == null) { if (environmentId == null) {
return; // Was not created return; // Was not created
@@ -3,9 +3,9 @@ import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-intern
import { MoveToWorkspaceDialog } from "../components/MoveToWorkspaceDialog"; import { MoveToWorkspaceDialog } from "../components/MoveToWorkspaceDialog";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace"; import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation"; import { createFastMutation } from "../hooks/useFastMutation";
import { pluralizeCount } from "../lib/pluralize";
import { showDialog } from "../lib/dialog"; import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai"; import { jotaiStore } from "../lib/jotai";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
export const moveToWorkspace = createFastMutation({ export const moveToWorkspace = createFastMutation({
mutationKey: ["move_workspace"], mutationKey: ["move_workspace"],
@@ -17,7 +17,7 @@ export const openWorkspaceFromSyncDir = createFastMutation<void, void, string>({
return; return;
} }
await applySync(workspace.id, dir, ops); await applySync(workspace.id, dir, ops, { fsOnly: true });
await router.navigate({ await router.navigate({
to: "/workspaces/$workspaceId", to: "/workspaces/$workspaceId",
@@ -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,12 +1,12 @@
import { gitClone } from "@yaakapp-internal/git"; import { gitClone } from "@yaakapp-internal/git";
import { Banner, VStack } from "@yaakapp-internal/ui"; import { Banner, VStack } from "@yaakapp-internal/ui";
import { useState } from "react"; import { useId, useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir"; import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { appInfo } from "../lib/appInfo"; import { appInfo } from "../lib/appInfo";
import { CommercialUseBanner } from "./CommercialUseBanner"; import { CommercialUseBanner } from "./CommercialUseBanner";
import { showErrorToast } from "../lib/toast"; import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox"; import { Checkbox } from "./core/Checkbox";
import { DialogFooter } from "./core/Dialog";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput"; import { PlainInput } from "./core/PlainInput";
import { promptCredentials } from "./git/credentials"; import { promptCredentials } from "./git/credentials";
@@ -29,6 +29,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
const [subdirectory, setSubdirectory] = useState<string>(""); const [subdirectory, setSubdirectory] = useState<string>("");
const [isCloning, setIsCloning] = useState(false); const [isCloning, setIsCloning] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const formId = useId();
const repoName = extractRepoName(url); const repoName = extractRepoName(url);
const sep = getPathSeparator(baseDirectory); const sep = getPathSeparator(baseDirectory);
@@ -83,7 +84,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
}; };
return ( return (
<VStack as="form" space={3} alignItems="start" className="pb-3" onSubmit={handleClone}> <VStack as="form" id={formId} space={3} alignItems="start" onSubmit={handleClone}>
{error && ( {error && (
<Banner color="danger" className="w-full"> <Banner color="danger" className="w-full">
{error} {error}
@@ -132,15 +133,17 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
/> />
)} )}
<Button <DialogFooter
type="submit" actions={[
color="primary" {
className="w-full mt-3" label: isCloning ? "Cloning..." : "Clone Repository",
disabled={!url || !directory || isCloning} color: "primary",
isLoading={isCloning} form: formId,
> disabled: !url || !directory || isCloning,
{isCloning ? "Cloning..." : "Clone Repository"} isLoading: isCloning,
</Button> },
]}
/>
</VStack> </VStack>
); );
} }
@@ -1,12 +1,11 @@
import type { HttpRequest } from "@yaakapp-internal/models"; import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models"; import { patchModel } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui"; import { Banner, HStack, InlineCode, SizeTag } from "@yaakapp-internal/ui";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useToggle } from "../hooks/useToggle"; import { useToggle } from "../hooks/useToggle";
import { showConfirm } from "../lib/confirm"; import { showConfirm } from "../lib/confirm";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { Link } from "./core/Link"; import { Link } from "./core/Link";
import { SizeTag } from "./core/SizeTag";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@@ -1,61 +0,0 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode } 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";
import { SizeTag } from "./core/SizeTag";
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,56 +0,0 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode } 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";
import { SizeTag } from "./core/SizeTag";
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}</>;
}
+2 -1
View File
@@ -25,12 +25,13 @@ import {
TableHeaderCell, TableHeaderCell,
TableRow, TableRow,
TruncatedWideTableCell, TruncatedWideTableCell,
KeyValueRows,
} from "@yaakapp-internal/ui"; } from "@yaakapp-internal/ui";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { Checkbox } from "./core/Checkbox"; import { Checkbox } from "./core/Checkbox";
import classNames from "classnames"; import classNames from "classnames";
import { EventDetailHeader } from "./core/EventViewer"; import { EventDetailHeader } from "./core/EventViewer";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow"; import { KeyValueRow } from "./core/KeyValueRow";
import { EmptyStateText } from "./EmptyStateText"; import { EmptyStateText } from "./EmptyStateText";
import { PlainInput } from "./core/PlainInput"; import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select"; import { Select } from "./core/Select";
+16 -25
View File
@@ -1,31 +1,22 @@
import { IconButton, type IconButtonProps, useTimedBoolean } from "@yaakapp-internal/ui"; import {
CopyIconButton as BaseCopyIconButton,
type CopyIconButtonProps,
} from "@yaakapp-internal/ui";
import { copyToClipboard } from "../lib/copy"; import { copyToClipboard } from "../lib/copy";
import { showToast } from "../lib/toast"; import { showToast } from "../lib/toast";
interface Props extends Omit<IconButtonProps, "onClick" | "icon"> { export function CopyIconButton(props: Omit<CopyIconButtonProps, "copy" | "onCopyFailed">) {
text: string | (() => Promise<string | null>); return <BaseCopyIconButton {...props} copy={copy} onCopyFailed={showCopyFailed} />;
} }
export function CopyIconButton({ text, ...props }: Props) { function copy(text: string) {
const [copied, setCopied] = useTimedBoolean(); copyToClipboard(text, { disableToast: true });
return ( }
<IconButton
{...props} function showCopyFailed() {
icon={copied ? "check" : "copy"} showToast({
showConfirm id: "failed-to-copy",
onClick={async () => { color: "danger",
const content = typeof text === "function" ? await text() : text; message: "Failed to copy",
if (content == null) { });
showToast({
id: "failed-to-copy",
color: "danger",
message: "Failed to copy",
});
} else {
copyToClipboard(content, { disableToast: true });
setCopied();
}
}}
/>
);
} }
@@ -1,12 +1,12 @@
import { createWorkspaceModel } from "@yaakapp-internal/models"; import { createWorkspaceModel } from "@yaakapp-internal/models";
import { useState } from "react"; import { useId, useState } from "react";
import { useToggle } from "../hooks/useToggle"; import { useToggle } from "../hooks/useToggle";
import { ColorIndicator } from "./ColorIndicator"; import { ColorIndicator } from "./ColorIndicator";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox"; import { Checkbox } from "./core/Checkbox";
import { ColorPickerWithThemeColors } from "./core/ColorPicker"; import { ColorPickerWithThemeColors } from "./core/ColorPicker";
import { Label } from "./core/Label"; import { DialogFooter } from "./core/Dialog";
import { PlainInput } from "./core/PlainInput"; import { PlainInput } from "./core/PlainInput";
import { Label } from "@yaakapp-internal/ui";
interface Props { interface Props {
onCreate: (id: string) => void; onCreate: (id: string) => void;
@@ -18,9 +18,11 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
const [name, setName] = useState<string>(""); const [name, setName] = useState<string>("");
const [color, setColor] = useState<string | null>(null); const [color, setColor] = useState<string | null>(null);
const [sharable, toggleSharable] = useToggle(false); const [sharable, toggleSharable] = useToggle(false);
const formId = useId();
return ( return (
<form <form
className="pb-3 flex flex-col gap-3" id={formId}
className="flex flex-col gap-3"
onSubmit={async (e) => { onSubmit={async (e) => {
e.preventDefault(); e.preventDefault();
const id = await createWorkspaceModel({ const id = await createWorkspaceModel({
@@ -59,10 +61,17 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
</Label> </Label>
<ColorPickerWithThemeColors onChange={setColor} color={color} /> <ColorPickerWithThemeColors onChange={setColor} color={color} />
</div> </div>
<Button type="submit" color="secondary" className="mt-3"> <DialogFooter
{color != null && <ColorIndicator color={color} />} inline
Create Environment actions={[
</Button> {
label: "Create Environment",
color: "secondary",
form: formId,
leftSlot: color != null ? <ColorIndicator color={color} /> : undefined,
},
]}
/>
</form> </form>
); );
} }
@@ -1,15 +1,14 @@
import { gitMutations } from "@yaakapp-internal/git"; import { gitMutations } from "@yaakapp-internal/git";
import type { WorkspaceMeta } from "@yaakapp-internal/models"; import type { WorkspaceMeta } from "@yaakapp-internal/models";
import { createGlobalModel, updateModel } from "@yaakapp-internal/models"; import { createGlobalModel, updateModel } from "@yaakapp-internal/models";
import { VStack } from "@yaakapp-internal/ui"; import { VStack, Label } from "@yaakapp-internal/ui";
import { useState } from "react"; import { useId, useState } from "react";
import { router } from "../lib/router"; import { router } from "../lib/router";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption"; import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import { rpc } from "../lib/rpc"; import { rpc } from "../lib/rpc";
import { showErrorToast } from "../lib/toast"; import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox"; import { Checkbox } from "./core/Checkbox";
import { Label } from "./core/Label"; import { DialogFooter } from "./core/Dialog";
import { PlainInput } from "./core/PlainInput"; import { PlainInput } from "./core/PlainInput";
import { EncryptionHelp } from "./EncryptionHelp"; import { EncryptionHelp } from "./EncryptionHelp";
import { gitCallbacks } from "./git/callbacks"; import { gitCallbacks } from "./git/callbacks";
@@ -26,12 +25,13 @@ export function CreateWorkspaceDialog({ hide }: Props) {
initGit?: boolean; initGit?: boolean;
}>({ filePath: null, initGit: false }); }>({ filePath: null, initGit: false });
const [setupEncryption, setSetupEncryption] = useState<boolean>(false); const [setupEncryption, setSetupEncryption] = useState<boolean>(false);
const formId = useId();
return ( return (
<VStack <VStack
as="form" as="form"
id={formId}
space={3} space={3}
alignItems="start" alignItems="start"
className="pb-3"
onSubmit={async (e) => { onSubmit={async (e) => {
e.preventDefault(); e.preventDefault();
const workspaceId = await createGlobalModel({ model: "workspace", name }); const workspaceId = await createGlobalModel({ model: "workspace", name });
@@ -89,9 +89,7 @@ export function CreateWorkspaceDialog({ hide }: Props) {
title="Enable Encryption" title="Enable Encryption"
/> />
</div> </div>
<Button type="submit" color="primary" className="w-full mt-3"> <DialogFooter actions={[{ label: "Create Workspace", color: "primary", form: formId }]} />
Create Workspace
</Button>
</VStack> </VStack>
); );
} }
+1 -2
View File
@@ -11,7 +11,7 @@ import type {
FormInputText, FormInputText,
JsonPrimitive, JsonPrimitive,
} from "@yaakapp-internal/plugins"; } from "@yaakapp-internal/plugins";
import { Banner, VStack } from "@yaakapp-internal/ui"; import { Banner, VStack, Label } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
@@ -26,7 +26,6 @@ import { Editor } from "./core/Editor/LazyEditor";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import type { InputProps } from "./core/Input"; import type { InputProps } from "./core/Input";
import { Input } from "./core/Input"; import { Input } from "./core/Input";
import { Label } from "./core/Label";
import type { Pair } from "./core/PairEditor"; import type { Pair } from "./core/PairEditor";
import { PairEditor } from "./core/PairEditor"; import { PairEditor } from "./core/PairEditor";
import { PlainInput } from "./core/PlainInput"; import { PlainInput } from "./core/PlainInput";
+1 -24
View File
@@ -1,24 +1 @@
import classNames from "classnames"; export { EmptyStateText } from "@yaakapp-internal/ui";
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>
);
}
@@ -1,11 +1,19 @@
import type { Environment, Workspace } from "@yaakapp-internal/models"; import type { Environment, Workspace } from "@yaakapp-internal/models";
import { duplicateModel, patchModel } from "@yaakapp-internal/models"; import { duplicateModel, patchModel } from "@yaakapp-internal/models";
import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui"; import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui";
import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui"; import { Banner, Icon, InlineCode, SplitLayout, Tree, IconTooltip } from "@yaakapp-internal/ui";
import { atom, useAtomValue } from "jotai"; import { atom, useAtomValue } from "jotai";
import { atomFamily } from "jotai-family"; import { atomFamily } from "jotai-family";
import { useCallback, useLayoutEffect, useRef, useState } from "react"; import {
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment"; createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createSubEnvironment } from "../commands/createEnvironment";
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace"; import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { import {
environmentsBreakdownAtom, environmentsBreakdownAtom,
@@ -22,7 +30,6 @@ import { showColorPicker } from "../lib/showColorPicker";
import type { ContextMenuProps, DropdownItem } from "./core/Dropdown"; import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
import { ContextMenu } from "./core/Dropdown"; import { ContextMenu } from "./core/Dropdown";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip";
import type { PairEditorHandle } from "./core/PairEditor"; import type { PairEditorHandle } from "./core/PairEditor";
import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator"; import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
import { EnvironmentEditor } from "./EnvironmentEditor"; import { EnvironmentEditor } from "./EnvironmentEditor";
@@ -40,6 +47,9 @@ interface Props {
type TreeModel = Environment | Workspace; type TreeModel = Environment | Workspace;
/** Lets tree row slots create a sub-environment and select it in the editor. */
const CreateSubEnvironmentContext = createContext<() => Promise<void>>(async () => {});
export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) { export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown(); const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>( const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
@@ -115,7 +125,7 @@ function EnvironmentEditDialogSidebar({
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom) ?? ""; const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom) ?? "";
const treeId = `environment.${activeWorkspaceId}.sidebar`; const treeId = `environment.${activeWorkspaceId}.sidebar`;
const treeRef = useRef<TreeHandle>(null); const treeRef = useRef<TreeHandle>(null);
const { baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown(); const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none // oxlint-disable-next-line react-hooks/exhaustive-deps -- none
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -124,6 +134,31 @@ function EnvironmentEditDialogSidebar({
treeRef.current?.focus(); treeRef.current?.focus();
}, []); }, []);
// A just-created environment to select once it reaches the model store. Selecting it before
// then would show a "failed to find" error in the editor, and the tree would drop the
// selection of an item it doesn't have yet.
const [pendingSelectionId, setPendingSelectionId] = useState<string | null>(null);
const tree = useAtomValue(treeAtom);
useEffect(() => {
if (pendingSelectionId == null) return;
if (!allEnvironments.some((e) => e.id === pendingSelectionId)) return;
setPendingSelectionId(null);
setSelectedEnvironmentId(pendingSelectionId);
// The tree leaves out sub-environments while there are multiple base environments
const node = tree == null ? null : findTreeNode(tree, pendingSelectionId);
if (node == null) return;
if (node.parent != null) {
// Expand the parent so the new item is visible to be selected
const parentId = node.parent.item.id;
jotaiStore.set(collapsedFamily(treeId), (prev) => ({ ...prev, [parentId]: false }));
}
// Wait a frame so the tree's own pending selection fixups, which may have been scheduled
// before the new item arrived, don't reset this selection
requestAnimationFrame(() => treeRef.current?.selectItem(pendingSelectionId));
}, [allEnvironments, pendingSelectionId, setSelectedEnvironmentId, tree, treeId]);
const handleDeleteEnvironment = useCallback( const handleDeleteEnvironment = useCallback(
async (environment: Environment) => { async (environment: Environment) => {
await deleteModelWithConfirm(environment); await deleteModelWithConfirm(environment);
@@ -134,6 +169,14 @@ function EnvironmentEditDialogSidebar({
[baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId], [baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId],
); );
const handleCreateSubEnvironment = useCallback(async () => {
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
if (baseEnvironment == null) return;
const id = await createSubEnvironment.mutateAsync(baseEnvironment);
// Show the new environment in the editor without changing the workspace's active environment
if (id != null) setPendingSelectionId(id);
}, []);
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []); const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
const getSelectedTreeModels = useCallback( const getSelectedTreeModels = useCallback(
@@ -195,13 +238,14 @@ function EnvironmentEditDialogSidebar({
const addEnvironmentItem: DropdownItem = { const addEnvironmentItem: DropdownItem = {
label: "Create Sub Environment", label: "Create Sub Environment",
leftSlot: <Icon icon="plus" />, leftSlot: <Icon icon="plus" />,
onSelect: async () => { onSelect: handleCreateSubEnvironment,
await createSubEnvironment();
},
}; };
// Sub-environments aren't shown while there are multiple base environments, same as the
// plus button on the base environment row
const canCreateSubEnvironment = baseEnvironments.length <= 1;
if (environment == null || environment.model !== "environment") { if (environment == null || environment.model !== "environment") {
return [addEnvironmentItem]; return canCreateSubEnvironment ? [addEnvironmentItem] : [];
} }
const singleEnvironment = items.length === 1; const singleEnvironment = items.length === 1;
@@ -257,7 +301,7 @@ function EnvironmentEditDialogSidebar({
]; ];
// Add sub environment to base environment // Add sub environment to base environment
if (isBaseEnvironment(environment) && singleEnvironment) { if (isBaseEnvironment(environment) && singleEnvironment && canCreateSubEnvironment) {
menuItems.push({ type: "separator" }); menuItems.push({ type: "separator" });
menuItems.push(addEnvironmentItem); menuItems.push(addEnvironmentItem);
} }
@@ -266,6 +310,7 @@ function EnvironmentEditDialogSidebar({
}, },
[ [
baseEnvironments.length, baseEnvironments.length,
handleCreateSubEnvironment,
handleDeleteEnvironment, handleDeleteEnvironment,
handleDuplicateSelected, handleDuplicateSelected,
handleRenameSelected, handleRenameSelected,
@@ -317,34 +362,37 @@ function EnvironmentEditDialogSidebar({
); );
const renderContextMenuFn = useCallback<NonNullable<TreeProps<TreeModel>["renderContextMenu"]>>( const renderContextMenuFn = useCallback<NonNullable<TreeProps<TreeModel>["renderContextMenu"]>>(
({ items, position, onClose }) => ( ({ items, position, onClose }) =>
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} /> // Right-clicking empty space has nothing to offer while there are multiple base environments
), items.length === 0 ? null : (
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
),
[], [],
); );
const tree = useAtomValue(treeAtom);
return ( return (
<aside className="x-theme-sidebar h-full w-full min-w-0 grid overflow-y-auto border-r border-border-subtle "> <aside className="x-theme-sidebar h-full w-full min-w-0 grid overflow-y-auto border-r border-border-subtle ">
{tree != null && ( {tree != null && (
<div className="pt-2"> <CreateSubEnvironmentContext.Provider value={handleCreateSubEnvironment}>
<Tree <div className="pt-2">
ref={treeRef} <Tree
treeId={treeId} ref={treeRef}
collapsedAtom={collapsedFamily(treeId)} treeId={treeId}
className="px-2 pb-10" collapsedAtom={collapsedFamily(treeId)}
root={tree} className="px-2 pb-10"
getContextMenu={getContextMenu} root={tree}
renderContextMenu={renderContextMenuFn} getContextMenu={getContextMenu}
onDragEnd={handleDragEnd} renderContextMenu={renderContextMenuFn}
getItemKey={(i) => `${i.id}::${i.name}`} onDragEnd={handleDragEnd}
ItemLeftSlotInner={ItemLeftSlotInner} getItemKey={(i) => `${i.id}::${i.name}`}
ItemRightSlot={ItemRightSlot} ItemLeftSlotInner={ItemLeftSlotInner}
ItemInner={ItemInner} ItemRightSlot={ItemRightSlot}
onActivate={handleActivate} ItemInner={ItemInner}
getEditOptions={getEditOptions} onActivate={handleActivate}
/> getEditOptions={getEditOptions}
</div> />
</div>
</CreateSubEnvironmentContext.Provider>
)} )}
</aside> </aside>
); );
@@ -384,6 +432,15 @@ const treeAtom = atom<TreeNode<TreeModel> | null>((get) => {
return root; return root;
}); });
function findTreeNode(node: TreeNode<TreeModel>, id: string): TreeNode<TreeModel> | null {
if (node.item.id === id) return node;
for (const child of node.children ?? []) {
const found = findTreeNode(child, id);
if (found != null) return found;
}
return null;
}
function ItemLeftSlotInner({ item }: { item: TreeModel }) { function ItemLeftSlotInner({ item }: { item: TreeModel }) {
const { baseEnvironments } = useEnvironmentsBreakdown(); const { baseEnvironments } = useEnvironmentsBreakdown();
return baseEnvironments.length > 1 ? ( return baseEnvironments.length > 1 ? (
@@ -395,6 +452,7 @@ function ItemLeftSlotInner({ item }: { item: TreeModel }) {
function ItemRightSlot({ item }: { item: TreeModel }) { function ItemRightSlot({ item }: { item: TreeModel }) {
const { baseEnvironments } = useEnvironmentsBreakdown(); const { baseEnvironments } = useEnvironmentsBreakdown();
const handleCreateSubEnvironment = useContext(CreateSubEnvironmentContext);
return ( return (
<> <>
{item.model === "environment" && baseEnvironments.length <= 1 && isBaseEnvironment(item) && ( {item.model === "environment" && baseEnvironments.length <= 1 && isBaseEnvironment(item) && (
@@ -405,7 +463,7 @@ function ItemRightSlot({ item }: { item: TreeModel }) {
icon="plus_circle" icon="plus_circle"
className="opacity-50 hover:opacity-100" className="opacity-50 hover:opacity-100"
title="Add Sub-Environment" title="Add Sub-Environment"
onClick={createSubEnvironment} onClick={handleCreateSubEnvironment}
/> />
)} )}
</> </>
@@ -425,13 +483,6 @@ function ItemInner({ item }: { item: TreeModel }) {
); );
} }
async function createSubEnvironment() {
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
if (baseEnvironment == null) return;
const id = await createSubEnvironmentAndActivate.mutateAsync(baseEnvironment);
return id;
}
function getEditOptions(item: TreeModel) { function getEditOptions(item: TreeModel) {
const options: ReturnType<NonNullable<TreeProps<TreeModel>["getEditOptions"]>> = { const options: ReturnType<NonNullable<TreeProps<TreeModel>["getEditOptions"]>> = {
defaultValue: item.name, defaultValue: item.name,
@@ -1,4 +1,4 @@
import { IconTooltip } from "./core/IconTooltip"; import { IconTooltip } from "@yaakapp-internal/ui";
export function EnvironmentSharableTooltip() { export function EnvironmentSharableTooltip() {
return ( return (
+11 -52
View File
@@ -1,60 +1,19 @@
import { Banner, Button, InlineCode } from "@yaakapp-internal/ui"; import { ErrorBoundary as BaseErrorBoundary } from "@yaakapp-internal/ui";
import type { ErrorInfo, ReactNode } from "react"; import type { ReactNode } from "react";
import { Component, useEffect } from "react"; import { useEffect } from "react";
import { showDialog } from "../lib/dialog"; import { showDialog } from "../lib/dialog";
import RouteError from "./RouteError"; import RouteError from "./RouteError";
interface ErrorBoundaryProps { export function ErrorBoundary({ name, children }: { name: string; children: ReactNode }) {
name: string; return (
children: ReactNode; <BaseErrorBoundary name={name} onShowError={showErrorDialog}>
{children}
</BaseErrorBoundary>
);
} }
interface ErrorBoundaryState { export function showErrorDialog(error: unknown) {
hasError: boolean; showDialog({ id: "error-boundary", render: () => <RouteError error={error} /> });
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.warn("Error caught by ErrorBoundary:", error, info);
}
render() {
if (this.state.hasError) {
return (
<Banner color="danger" className="flex items-center gap-2 overflow-auto">
<div>
Error rendering <InlineCode>{this.props.name}</InlineCode> component
</div>
<Button
className="inline-flex"
variant="border"
color="danger"
size="2xs"
onClick={() => {
showDialog({
id: "error-boundary",
render: () => <RouteError error={this.state.error} />,
});
}}
>
Show
</Button>
</Banner>
);
}
return this.props.children;
}
} }
export function ErrorBoundaryTestThrow() { export function ErrorBoundaryTestThrow() {
@@ -1,18 +1,18 @@
import type { Workspace } from "@yaakapp-internal/models"; import type { Workspace } from "@yaakapp-internal/models";
import { workspacesAtom } from "@yaakapp-internal/models"; import { workspacesAtom } from "@yaakapp-internal/models";
import { HStack, VStack } from "@yaakapp-internal/ui"; import { VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import slugify from "slugify"; import slugify from "slugify";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace"; import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { pluralizeCount } from "../lib/pluralize";
import { rpc } from "../lib/rpc"; import { rpc } from "../lib/rpc";
import { CommercialUseBanner } from "./CommercialUseBanner"; import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox"; import { Checkbox } from "./core/Checkbox";
import { DetailsBanner } from "./core/DetailsBanner"; import { DetailsBanner } from "./core/DetailsBanner";
import { DialogFooter } from "./core/Dialog";
import { Link } from "./core/Link"; import { Link } from "./core/Link";
import { platform } from "@yaakapp-internal/platform"; import { platform } from "@yaakapp-internal/platform";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
interface Props { interface Props {
onHide: () => void; onHide: () => void;
@@ -65,29 +65,30 @@ function ExportDataDialogContent({
const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]); const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);
const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined; const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;
const slug = workspace ? slugify(workspace.name, { lower: true }) : "workspaces"; const slug = workspace ? slugify(workspace.name, { lower: true }) : "workspaces";
const exportPath = await platform.dialog.save({ const document = await rpc<string>("cmd_export_data", {
title: "Export Data",
defaultPath: `yaak.${slug}.json`,
});
if (exportPath == null) {
return;
}
await rpc("cmd_export_data", {
workspaceIds: ids, workspaceIds: ids,
exportPath,
includePrivateEnvironments: includePrivateEnvironments, includePrivateEnvironments: includePrivateEnvironments,
}); });
const savedTo = await platform.files.save(
`yaak.${slug}.json`,
new TextEncoder().encode(document),
[{ name: "JSON", extensions: ["json"] }],
);
if (savedTo == null) {
return; // Cancelled
}
onHide(); onHide();
onSuccess(exportPath); onSuccess(savedTo);
}, [includePrivateEnvironments, onHide, onSuccess, selectedWorkspaces, workspaces]); }, [includePrivateEnvironments, onHide, onSuccess, selectedWorkspaces, workspaces]);
const allSelected = workspaces.every((w) => selectedWorkspaces[w.id]); const allSelected = workspaces.every((w) => selectedWorkspaces[w.id]);
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length; const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
const noneSelected = numSelected === 0; const noneSelected = numSelected === 0;
return ( return (
<div className="h-full w-full grid grid-rows-[minmax(0,1fr)_auto] overflow-hidden rounded-b-lg"> <>
<VStack space={3} className="overflow-auto px-5 pb-6"> <VStack space={3} className="pb-4">
<CommercialUseBanner source="data-export" title="Exporting work data?" /> <CommercialUseBanner source="data-export" title="Exporting work data?" />
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight"> <table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
@@ -140,29 +141,22 @@ function ExportDataDialogContent({
/> />
</DetailsBanner> </DetailsBanner>
</VStack> </VStack>
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface py-3 border-t border-border-subtle"> <DialogFooter
<div> leftSlot={
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest"> <Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest">
Create Run Button Create Run Button
</Link> </Link>
</div> }
<HStack space={2} justifyContent="end"> actions={[
<Button size="sm" className="focus" variant="border" onClick={onHide}> { label: "Cancel", onClick: onHide },
Cancel {
</Button> label: `Export ${pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}`,
<Button color: "primary",
size="sm" disabled: noneSelected,
type="submit" onClick: () => handleExport(),
className="focus" },
color="primary" ]}
disabled={noneSelected} />
onClick={() => handleExport()} </>
>
Export{" "}
{pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}
</Button>
</HStack>
</footer>
</div>
); );
} }
+9 -4
View File
@@ -1,6 +1,14 @@
import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models"; import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
import { foldersAtom } from "@yaakapp-internal/models"; import { foldersAtom } from "@yaakapp-internal/models";
import { Heading, HStack, Icon, LoadingIcon } from "@yaakapp-internal/ui"; import {
Heading,
HStack,
Icon,
LoadingIcon,
HttpResponseDurationTag,
HttpStatusTag,
SizeTag,
} from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import type { CSSProperties, ReactNode } from "react"; import type { CSSProperties, ReactNode } from "react";
@@ -13,11 +21,8 @@ import { showDialog } from "../lib/dialog";
import { resolvedModelName } from "../lib/resolvedModelName"; import { resolvedModelName } from "../lib/resolvedModelName";
import { router } from "../lib/router"; import { router } from "../lib/router";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { Separator } from "./core/Separator"; import { Separator } from "./core/Separator";
import { SizeTag } from "./core/SizeTag";
import { HttpResponsePane } from "./HttpResponsePane"; import { HttpResponsePane } from "./HttpResponsePane";
interface Props { interface Props {
@@ -1,5 +1,5 @@
import { createWorkspaceModel, foldersAtom, patchModel } from "@yaakapp-internal/models"; import { createWorkspaceModel, foldersAtom, patchModel } from "@yaakapp-internal/models";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui"; import { HStack, Icon, InlineCode, VStack, CountBadge } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { Fragment, useMemo } from "react"; import { Fragment, useMemo } from "react";
import { useAuthTab } from "../hooks/useAuthTab"; import { useAuthTab } from "../hooks/useAuthTab";
@@ -11,7 +11,6 @@ import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { hideDialog } from "../lib/dialog"; import { hideDialog } from "../lib/dialog";
import { CopyIconButton } from "./CopyIconButton"; import { CopyIconButton } from "./CopyIconButton";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge";
import { Input } from "./core/Input"; import { Input } from "./core/Input";
import { Link } from "./core/Link"; import { Link } from "./core/Link";
import type { TabItem } from "./core/Tabs/Tabs"; import type { TabItem } from "./core/Tabs/Tabs";
@@ -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,3 +1,4 @@
import type { NavigateWorkspace } from "@yaakapp-internal/tauri-client";
import { activeRequestAtom } from "../hooks/useActiveRequest"; import { activeRequestAtom } from "../hooks/useActiveRequest";
import { useSubscribeActiveWorkspaceId } from "../hooks/useActiveWorkspace"; import { useSubscribeActiveWorkspaceId } from "../hooks/useActiveWorkspace";
import { useActiveWorkspaceChangedToast } from "../hooks/useActiveWorkspaceChangedToast"; import { useActiveWorkspaceChangedToast } from "../hooks/useActiveWorkspaceChangedToast";
@@ -5,10 +6,13 @@ import { useHotKey, useSubscribeHotKeys } from "../hooks/useHotKey";
import { useSubscribeHttpAuthentication } from "../hooks/useHttpAuthentication"; import { useSubscribeHttpAuthentication } from "../hooks/useHttpAuthentication";
import { useSyncFontSizeSetting } from "../hooks/useSyncFontSizeSetting"; import { useSyncFontSizeSetting } from "../hooks/useSyncFontSizeSetting";
import { useSyncWorkspaceChildModels } from "../hooks/useSyncWorkspaceChildModels"; import { useSyncWorkspaceChildModels } from "../hooks/useSyncWorkspaceChildModels";
import { usePlatformEvent } from "../hooks/usePlatformEvent";
import { useSyncZoomSetting } from "../hooks/useSyncZoomSetting"; import { useSyncZoomSetting } from "../hooks/useSyncZoomSetting";
import { useSubscribeTemplateFunctions } from "../hooks/useTemplateFunctions"; import { useSubscribeTemplateFunctions } from "../hooks/useTemplateFunctions";
import { fireAndForget } from "../lib/fireAndForget";
import { jotaiStore } from "../lib/jotai"; import { jotaiStore } from "../lib/jotai";
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt"; import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
import { router } from "../lib/router";
export function GlobalHooks() { export function GlobalHooks() {
useSyncZoomSetting(); useSyncZoomSetting();
@@ -24,6 +28,20 @@ export function GlobalHooks() {
useActiveWorkspaceChangedToast(); useActiveWorkspaceChangedToast();
useSubscribeHotKeys(); useSubscribeHotKeys();
usePlatformEvent("show_home", () => {
fireAndForget(router.navigate({ to: "/", search: { home: true } }));
});
usePlatformEvent<NavigateWorkspace>("navigate_workspace", ({ workspaceId, environmentId }) => {
fireAndForget(
router.navigate({
to: "/workspaces/$workspaceId",
params: { workspaceId },
search: { environment_id: environmentId },
}),
);
});
useHotKey( useHotKey(
"request.rename", "request.rename",
async () => { async () => {
+2 -1
View File
@@ -20,13 +20,13 @@ import { showConfirm } from "../lib/confirm";
import { showDialog } from "../lib/dialog"; import { showDialog } from "../lib/dialog";
import type { JsonSchema } from "../lib/jsonSchemaExample"; import type { JsonSchema } from "../lib/jsonSchemaExample";
import { buildExampleFromSchema } from "../lib/jsonSchemaExample"; import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
import { pluralizeCount } from "../lib/pluralize";
import { queryClient } from "../lib/queryClient"; import { queryClient } from "../lib/queryClient";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { Dropdown } from "./core/Dropdown"; import { Dropdown } from "./core/Dropdown";
import type { EditorProps } from "./core/Editor/Editor"; import type { EditorProps } from "./core/Editor/Editor";
import { Editor } from "./core/Editor/LazyEditor"; import { Editor } from "./core/Editor/LazyEditor";
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog"; import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceUpdateKey"> & { type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceUpdateKey"> & {
services: ReflectResponseService[] | null; services: ReflectResponseService[] | null;
@@ -239,6 +239,7 @@ export function GrpcEditor({
showDialog({ showDialog({
title: "Configure Schema", title: "Configure Schema",
size: "md", size: "md",
className: "h-[30rem]",
id: "grpc-configure-schema", id: "grpc-configure-schema",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />, render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
}); });
@@ -1,13 +1,13 @@
import type { GrpcRequest } from "@yaakapp-internal/models"; import type { GrpcRequest } from "@yaakapp-internal/models";
import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui"; import { Banner, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useActiveRequest } from "../hooks/useActiveRequest"; import { useActiveRequest } from "../hooks/useActiveRequest";
import { useGrpc } from "../hooks/useGrpc"; import { useGrpc } from "../hooks/useGrpc";
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles"; import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
import { pluralizeCount } from "../lib/pluralize"; import { DialogFooter } from "./core/Dialog";
import { Button } from "./core/Button"; import { PathList } from "./core/PathList";
import { IconButton } from "./core/IconButton";
import { Link } from "./core/Link"; import { Link } from "./core/Link";
import { platform } from "@yaakapp-internal/platform"; import { platform } from "@yaakapp-internal/platform";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
interface Props { interface Props {
onDone: () => void; onDone: () => void;
@@ -20,7 +20,10 @@ export function GrpcProtoSelectionDialog(props: Props) {
return <GrpcProtoSelectionDialogWithRequest request={request} {...props} />; return <GrpcProtoSelectionDialogWithRequest request={request} {...props} />;
} }
function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: GrpcRequest }) { function GrpcProtoSelectionDialogWithRequest({
request,
onDone,
}: Props & { request: GrpcRequest }) {
const protoFilesKv = useGrpcProtoFiles(request.id); const protoFilesKv = useGrpcProtoFiles(request.id);
const protoFiles = protoFilesKv.value ?? []; const protoFiles = protoFilesKv.value ?? [];
const grpc = useGrpc(request, null, protoFiles); const grpc = useGrpc(request, null, protoFiles);
@@ -38,54 +41,8 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
} }
return ( return (
<VStack className="flex-col-reverse mb-3" space={3}> <>
{/* Buttons on top so they get focus first */} <VStack space={5} className="pb-4">
<HStack space={2} justifyContent="start" className="flex-row-reverse mt-3">
<Button
color="primary"
variant="border"
onClick={async () => {
const selected = await platform.dialog.open({
title: "Select Proto Files",
multiple: true,
filters: [{ name: "Proto Files", extensions: ["proto"] }],
});
if (selected == null) return;
const newFiles = selected.filter((p) => !protoFiles.includes(p));
await protoFilesKv.set([...protoFiles, ...newFiles]);
await grpc.reflect.refetch();
}}
>
Add Proto Files
</Button>
<Button
variant="border"
color="primary"
onClick={async () => {
const selected = await platform.dialog.open({
title: "Select Proto Directory",
directory: true,
});
if (selected == null) return;
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
await grpc.reflect.refetch();
}}
>
Add Import Folders
</Button>
<Button
isLoading={grpc.reflect.isFetching}
disabled={grpc.reflect.isFetching}
variant="border"
color="secondary"
onClick={() => grpc.reflect.refetch()}
>
Refresh Schema
</Button>
</HStack>
<VStack space={5}>
{reflectError && ( {reflectError && (
<Banner color="warning"> <Banner color="warning">
<h1 className="font-bold"> <h1 className="font-bold">
@@ -128,46 +85,47 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
</Banner> </Banner>
)} )}
{protoFiles.length > 0 && ( <PathList
<table className="w-full divide-y divide-surface-highlight"> items={protoFiles.map((path) => ({
<thead> path,
<tr> icon: path.endsWith(".proto") ? "file_code" : "folder_code",
<th className="text-text-subtlest" colSpan={3}> }))}
Added File Paths onRemove={async (path) => {
</th> await protoFilesKv.set(protoFiles.filter((p) => p !== path));
</tr> await grpc.reflect.refetch();
</thead> }}
<tbody className="divide-y divide-surface-highlight"> addLabel="Add proto files or an import folder"
{protoFiles.map((f, i) => { addItems={[
const parts = f.split("/"); {
// oxlint-disable-next-line no-array-index-key -- none label: "Proto files",
return ( icon: "file_code",
<tr key={f + i} className="group"> onSelect: async () => {
<td> const selected = await platform.dialog.open({
<Icon icon={f.endsWith(".proto") ? "file_code" : "folder_code"} /> title: "Select Proto Files",
</td> multiple: true,
<td className="pl-1 font-mono text-sm" title={f}> filters: [{ name: "Proto Files", extensions: ["proto"] }],
{parts.length > 3 && ".../"} });
{parts.slice(-3).join("/")} if (selected == null) return;
</td> const newFiles = selected.filter((p) => !protoFiles.includes(p));
<td className="w-0 py-0.5"> await protoFilesKv.set([...protoFiles, ...newFiles]);
<IconButton await grpc.reflect.refetch();
title="Remove file" },
variant="border" },
size="xs" {
icon="trash" label: "Import folder",
className="my-0.5 ml-auto opacity-50 transition-opacity group-hover:opacity-100" icon: "folder_code",
onClick={async () => { onSelect: async () => {
await protoFilesKv.set(protoFiles.filter((p) => p !== f)); const selected = await platform.dialog.open({
}} title: "Select Proto Directory",
/> directory: true,
</td> });
</tr> if (selected == null) return;
); await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
})} await grpc.reflect.refetch();
</tbody> },
</table> },
)} ]}
/>
{reflectionUnimplemented && protoFiles.length === 0 && ( {reflectionUnimplemented && protoFiles.length === 0 && (
<Banner> <Banner>
<InlineCode>{request.url}</InlineCode> doesn&apos;t implement{" "} <InlineCode>{request.url}</InlineCode> doesn&apos;t implement{" "}
@@ -178,6 +136,17 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
</Banner> </Banner>
)} )}
</VStack> </VStack>
</VStack> <DialogFooter
actions={[
{
label: "Refresh Schema",
isLoading: grpc.reflect.isFetching,
disabled: grpc.reflect.isFetching,
onClick: () => grpc.reflect.refetch(),
},
{ label: "Done", color: "primary", onClick: onDone },
]}
/>
</>
); );
} }
@@ -4,7 +4,7 @@ import {
patchModel, patchModel,
patchModelDebounced, patchModelDebounced,
} from "@yaakapp-internal/models"; } from "@yaakapp-internal/models";
import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui"; import { HStack, Icon, useContainerSize, VStack, CountBadge } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { useCallback, useMemo, useRef } from "react"; import { useCallback, useMemo, useRef } from "react";
@@ -15,7 +15,6 @@ import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey"; import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { resolvedModelName } from "../lib/resolvedModelName"; import { resolvedModelName } from "../lib/resolvedModelName";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput"; import { PlainInput } from "./core/PlainInput";
import { RadioDropdown } from "./core/RadioDropdown"; import { RadioDropdown } from "./core/RadioDropdown";
@@ -1,5 +1,13 @@
import type { GrpcEvent, GrpcRequest } from "@yaakapp-internal/models"; import type { GrpcEvent, GrpcRequest } from "@yaakapp-internal/models";
import { HStack, Icon, type IconProps, LoadingIcon, VStack } from "@yaakapp-internal/ui"; import {
HStack,
Icon,
type IconProps,
LoadingIcon,
VStack,
useStateWithDeps,
KeyValueRows,
} from "@yaakapp-internal/ui";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
@@ -9,13 +17,12 @@ import {
pinnedGrpcConnectionIdAtom, pinnedGrpcConnectionIdAtom,
useGrpcEvents, useGrpcEvents,
} from "../hooks/usePinnedGrpcConnection"; } from "../hooks/usePinnedGrpcConnection";
import { useStateWithDeps } from "../hooks/useStateWithDeps";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { Editor } from "./core/Editor/LazyEditor"; import { Editor } from "./core/Editor/LazyEditor";
import { EventDetailHeader, EventViewer } from "./core/EventViewer"; import { EventDetailHeader, EventViewer } from "./core/EventViewer";
import { EventViewerRow } from "./core/EventViewerRow"; import { EventViewerRow } from "./core/EventViewerRow";
import { HotkeyList } from "./core/HotkeyList"; import { HotkeyList } from "./core/HotkeyList";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow"; import { KeyValueRow } from "./core/KeyValueRow";
import { EmptyStateText } from "./EmptyStateText"; import { EmptyStateText } from "./EmptyStateText";
import { ErrorBoundary } from "./ErrorBoundary"; import { ErrorBoundary } from "./ErrorBoundary";
import { RecentGrpcConnectionsDropdown } from "./RecentGrpcConnectionsDropdown"; import { RecentGrpcConnectionsDropdown } from "./RecentGrpcConnectionsDropdown";
+1 -190
View File
@@ -1,190 +1 @@
import type { HttpRequestHeader } from "@yaakapp-internal/models"; export { HeadersEditor } from "@yaakapp-internal/ui";
import { HStack } 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 { CountBadge } from "./core/CountBadge";
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
},
),
};
const validateHttpHeader = (v: string) => {
if (v === "") {
return true;
}
// Template strings are not allowed so we replace them with a valid example string
const withoutTemplateStrings = v.replace(/\$\{\[\s*[^\]\s]+\s*]}/gi, "123");
return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null;
};
@@ -6,7 +6,7 @@ import type {
Workspace, Workspace,
} from "@yaakapp-internal/models"; } from "@yaakapp-internal/models";
import { patchModel } 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 { useCallback } from "react";
import { openFolderSettings } from "../commands/openFolderSettings"; import { openFolderSettings } from "../commands/openFolderSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings"; import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
@@ -21,7 +21,6 @@ import { IconButton } from "./core/IconButton";
import { Input, type InputProps } from "./core/Input"; import { Input, type InputProps } from "./core/Input";
import { Link } from "./core/Link"; import { Link } from "./core/Link";
import { RadioDropdown } from "./core/RadioDropdown"; import { RadioDropdown } from "./core/RadioDropdown";
import { SegmentedControl } from "./core/SegmentedControl";
import { DynamicForm } from "./DynamicForm"; import { DynamicForm } from "./DynamicForm";
import { EmptyStateText } from "./EmptyStateText"; import { EmptyStateText } from "./EmptyStateText";
+166 -197
View File
@@ -1,9 +1,16 @@
import type { HttpRequest } from "@yaakapp-internal/models"; import type { HttpRequest } from "@yaakapp-internal/models";
import { getModel, patchModel, patchModelDebounced } from "@yaakapp-internal/models"; import { getModel, patchModel, patchModelDebounced } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins"; 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 { 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 { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
import { importCurl, looksLikeCurl } from "../commands/importCurl"; import { importCurl, looksLikeCurl } from "../commands/importCurl";
import { allRequestUrlsAtom } from "../hooks/useAllRequests"; import { allRequestUrlsAtom } from "../hooks/useAllRequests";
@@ -15,10 +22,8 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor"; import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey"; import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest"; import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { languageFromContentType } from "../lib/contentType"; import { fireAndForget } from "../lib/fireAndForget";
import { generateId } from "../lib/generateId"; import { httpRequestToView } from "../lib/httpViews";
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
import { convertRequestBody } from "../lib/requestBodyConversion";
import { import {
BODY_TYPE_BINARY, BODY_TYPE_BINARY,
BODY_TYPE_FORM_MULTIPART, BODY_TYPE_FORM_MULTIPART,
@@ -30,30 +35,24 @@ import {
BODY_TYPE_XML, BODY_TYPE_XML,
getContentTypeFromHeaders, getContentTypeFromHeaders,
} from "../lib/model_util"; } from "../lib/model_util";
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring"; import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
import { convertRequestBody } from "../lib/requestBodyConversion";
import { resolvedModelName } from "../lib/resolvedModelName"; import { resolvedModelName } from "../lib/resolvedModelName";
import { showToast } from "../lib/toast"; import { showToast } from "../lib/toast";
import { BinaryFileEditor } from "./BinaryFileEditor"; import { BinaryFileEditor } from "./BinaryFileEditor";
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody"; import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
import { CountBadge } from "./core/CountBadge";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion"; import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import { getUrlCompletionConfig } from "./core/Editor/url/completion"; import { getUrlCompletionConfig } from "./core/Editor/url/completion";
import { Editor } from "./core/Editor/LazyEditor";
import { InlineCode } from "@yaakapp-internal/ui";
import { PlainInput } from "./core/PlainInput"; import { PlainInput } from "./core/PlainInput";
import type { TabItem, TabsRef } from "./core/Tabs/Tabs"; import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs"; import { setActiveTab } from "./core/Tabs/Tabs";
import { EmptyStateText } from "./EmptyStateText";
import { FormMultipartEditor } from "./FormMultipartEditor";
import { FormUrlencodedEditor } from "./FormUrlencodedEditor";
import { HeadersEditor } from "./HeadersEditor";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor"; import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { JsonBodyEditor } from "./JsonBodyEditor";
import { MarkdownEditor } from "./MarkdownEditor"; import { MarkdownEditor } from "./MarkdownEditor";
import { RequestMethodDropdown } from "./RequestMethodDropdown";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor"; import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar"; import { RequestMethodDropdown } from "./RequestMethodDropdown";
import { UrlParametersEditor } from "./UrlParameterEditor"; import { UrlParametersEditor } from "./UrlParameterEditor";
import { generateId } from "@yaakapp-internal/lib/generateId";
const GraphQLEditor = lazy(() => const GraphQLEditor = lazy(() =>
import("./graphql/GraphQLEditor").then((m) => ({ default: m.GraphQLEditor })), import("./graphql/GraphQLEditor").then((m) => ({ default: m.GraphQLEditor })),
@@ -167,9 +166,9 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const hasDescription = activeRequest.description.trim().length > 0; const hasDescription = activeRequest.description.trim().length > 0;
const tabs = useMemo<TabItem[]>( const bodyTab = useMemo<TabItem>(
() => [ () =>
{ ({
value: TAB_BODY, value: TAB_BODY,
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null, rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
options: { options: {
@@ -246,36 +245,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
} }
}, },
}, },
}, }) satisfies TabItem,
{ [activeRequest, contentType, handleContentTypeChange, numParams],
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,
],
); );
const { mutate: sendRequest } = useSendAnyHttpRequest(); const { mutate: sendRequest } = useSendAnyHttpRequest();
@@ -337,154 +308,152 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
[activeRequest], [activeRequest],
); );
return ( const handleJsonOrBodyTextChange = useCallback(
<div (text: string, bodyType: string | null) => {
style={style} // JSON writes at once, as its editor always has; other text debounces
className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1")} if (bodyType === BODY_TYPE_JSON) {
> return patchModel(activeRequest, { body: { ...activeRequest.body, text } });
{activeRequest && ( }
<> return handleBodyTextChange(text);
<UrlBar },
stateKey={`url.${activeRequest.id}`} [activeRequest, handleBodyTextChange],
key={forceUpdateKey + urlKey} );
url={activeRequest.url}
placeholder="https://example.com" const handleSendJsonCommentsChange = useCallback(
onPasteOverwrite={handlePaste} (sendJsonComments: boolean) => {
autocomplete={autocomplete} const newBody = { ...activeRequest.body };
onSend={handleSend} if (sendJsonComments) {
onCancel={cancelResponse} newBody.sendJsonComments = true;
onUrlChange={handleUrlChange} } else {
leftSlot={ delete newBody.sendJsonComments;
<div className="py-0.5"> }
<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" /> fireAndForget(patchModel(activeRequest, { body: newBody }));
</div> },
} [activeRequest],
forceUpdateKey={updateKey} );
isLoading={activeResponse != null && activeResponse.state !== "closed"}
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} sharedEditor
label="Request" )}
tabs={tabs} </ConfirmLargeRequestBody>
tabListClassName="mt-1 -mb-1.5" ),
storageKey={TABS_STORAGE_KEY} [activeRequest, contentType, forceUpdateKey, handleBodyChange, handleContentTypeChange],
activeTabKey={activeRequestId} );
>
<TabContent value={TAB_AUTH}> const renderUrlParameters = useCallback(
<HttpAuthenticationEditor model={activeRequest} /> (pairs: EditablePair[]) => (
</TabContent> <UrlParametersEditor
<TabContent value={TAB_HEADERS}> stateKey={`params.${activeRequest.id}`}
<HeadersEditor forceUpdateKey={forceUpdateKey + urlParametersKey}
inheritedHeaders={inheritedHeaders} pairs={pairs}
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`} onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
headers={activeRequest.headers} />
stateKey={`headers.${activeRequest.id}`} ),
onChange={(headers) => patchModelDebounced(activeRequest, { headers })} [activeRequest, forceUpdateKey, urlParametersKey],
/> );
</TabContent>
<TabContent value={TAB_PARAMS}> return (
<UrlParametersEditor <HttpRequestPaneView
stateKey={`params.${activeRequest.id}`} editable
forceUpdateKey={forceUpdateKey + urlParametersKey} style={style}
pairs={urlParameterPairs} className={className}
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })} fullHeight={fullHeight}
/> request={requestView}
</TabContent> tabsRef={tabsRef}
<TabContent value={TAB_SETTINGS}> tabsStorageKey={TABS_STORAGE_KEY}
<ModelSettingsEditor model={activeRequest} /> forceUpdateKey={forceUpdateKey}
</TabContent> urlBarKey={forceUpdateKey + urlKey}
<TabContent value={TAB_BODY}> urlForceUpdateKey={updateKey}
<ConfirmLargeRequestBody request={activeRequest}> urlAutocomplete={autocomplete}
{activeRequest.bodyType === BODY_TYPE_JSON ? ( onUrlChange={handleUrlChange}
<JsonBodyEditor onUrlPasteOverwrite={handlePaste}
forceUpdateKey={forceUpdateKey} onSend={handleSend}
heightMode={fullHeight ? "full" : "auto"} onCancel={cancelResponse}
request={activeRequest} isSending={activeResponse != null && activeResponse.state !== "closed"}
/> sendHotkeyAction="request.send"
) : activeRequest.bodyType === BODY_TYPE_XML ? ( focusHotkeyAction="url_bar.focus"
<Editor methodSlot={<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />}
forceUpdateKey={forceUpdateKey} bodyTab={bodyTab}
autocompleteFunctions renderBody={renderBody}
autocompleteVariables onBodyChange={handleBodyChange}
placeholder="..." onBodyTextChange={handleJsonOrBodyTextChange}
heightMode={fullHeight ? "full" : "auto"} onSendJsonCommentsChange={handleSendJsonCommentsChange}
defaultValue={`${activeRequest.body?.text ?? ""}`} jsonNoticeStorageKey={activeRequest.workspaceId}
language="xml" urlParameterPairs={urlParameterPairs}
onChange={handleBodyTextChange} renderUrlParameters={renderUrlParameters}
stateKey={`xml.${activeRequest.id}`} headersTabs={headersTab}
/> inheritedHeaders={inheritedHeaders}
) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? ( headersForceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
<Suspense> onHeadersChange={(headers) => patchModelDebounced(activeRequest, { headers })}
<GraphQLEditor authTabs={authTab}
forceUpdateKey={forceUpdateKey} authContent={<HttpAuthenticationEditor model={activeRequest} />}
baseRequest={activeRequest} extraTabs={extraTabs}
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>
); );
} }
+183 -356
View File
@@ -1,50 +1,36 @@
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models"; import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
import { Banner, HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui"; import {
import classNames from "classnames"; CountBadge,
import type { ComponentType, CSSProperties } from "react"; type ExtraPaneTab,
import { lazy, Suspense, useMemo } from "react"; type HostBodyViewerArgs,
HttpResponsePaneView,
Icon,
type ResponseTextViewerExtras,
type TextFilterApi,
Tooltip,
VStack,
} from "@yaakapp-internal/ui";
import type { CSSProperties } from "react";
import { useCallback, useMemo } from "react";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse"; import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse"; import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
import { useRequestBodyLoader, useResponseBodyLoader } from "../hooks/useHttpBodyLoaders";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents"; import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse"; import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText"; import { useRecentFilters } from "../hooks/useRecentFilters";
import { useResponseBodyUrl } from "../hooks/useResponseBodyUrl";
import { useResponseViewMode } from "../hooks/useResponseViewMode"; import { useResponseViewMode } from "../hooks/useResponseViewMode";
import { useSaveResponse } from "../hooks/useSaveResponse"; import { useSaveResponse } from "../hooks/useSaveResponse";
import { useTimelineViewMode } from "../hooks/useTimelineViewMode"; import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
import { getMimeTypeFromContentType } from "../lib/contentType"; import { httpResponseToView } from "../lib/httpViews";
import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util"; import { getCookieCounts } from "../lib/model_util";
import { ConfirmLargeResponse } from "./ConfirmLargeResponse"; import { CopyButton } from "./CopyButton";
import { ConfirmLargeResponseRequest } from "./ConfirmLargeResponseRequest";
import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge";
import { HotkeyList } from "./core/HotkeyList"; import { HotkeyList } from "./core/HotkeyList";
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag"; import { IconButton } from "./core/IconButton";
import { HttpStatusTag } from "./core/HttpStatusTag";
import { PillButton } from "./core/PillButton"; import { PillButton } from "./core/PillButton";
import { SizeTag } from "./core/SizeTag";
import type { TabItem } from "./core/Tabs/Tabs";
import { TabContent, Tabs } from "./core/Tabs/Tabs";
import { Tooltip } from "./core/Tooltip";
import { EmptyStateText } from "./EmptyStateText";
import { ErrorBoundary } from "./ErrorBoundary";
import { HttpResponseTimeline } from "./HttpResponseTimeline"; import { HttpResponseTimeline } from "./HttpResponseTimeline";
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown"; import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
import { RequestBodyViewer } from "./RequestBodyViewer";
import { ResponseCookies } from "./ResponseCookies";
import { ResponseHeaders } from "./ResponseHeaders";
import { AudioViewer } from "./responseViewers/AudioViewer";
import { CsvViewer } from "./responseViewers/CsvViewer";
import { EventStreamViewer } from "./responseViewers/EventStreamViewer"; import { EventStreamViewer } from "./responseViewers/EventStreamViewer";
import { HTMLOrTextViewer } from "./responseViewers/HTMLOrTextViewer"; import { RecentFiltersDropdown } from "./responseViewers/RecentFiltersDropdown";
import { ImageViewer } from "./responseViewers/ImageViewer";
import { MultipartViewer } from "./responseViewers/MultipartViewer";
import { SvgViewer } from "./responseViewers/SvgViewer";
import { VideoViewer } from "./responseViewers/VideoViewer";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
);
interface Props { interface Props {
style?: CSSProperties; style?: CSSProperties;
@@ -52,10 +38,6 @@ interface Props {
activeRequestId: string; activeRequestId: string;
} }
const TAB_BODY = "body";
const TAB_REQUEST = "request";
const TAB_HEADERS = "headers";
const TAB_COOKIES = "cookies";
const TAB_TIMELINE = "timeline"; const TAB_TIMELINE = "timeline";
export type TimelineViewMode = "timeline" | "text"; export type TimelineViewMode = "timeline" | "text";
@@ -65,12 +47,11 @@ interface RedirectDropWarning {
droppedHeaders: string[]; droppedHeaders: string[];
} }
/** The shared response pane, bound to the pinned response of the active request */
export function HttpResponsePane({ style, className, activeRequestId }: Props) { export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId); const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId); const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode(); const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
const responseEvents = useHttpResponseEvents(activeResponse); const responseEvents = useHttpResponseEvents(activeResponse);
const redirectDropWarning = useMemo( const redirectDropWarning = useMemo(
@@ -83,280 +64,181 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]); const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
const saveResponse = useSaveResponse(activeResponse ?? null); const saveResponse = useSaveResponse(activeResponse ?? null);
const copyResponse = useCopyHttpResponse(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[]>( const responseView = useMemo(
() => [ () => (activeResponse == null ? null : httpResponseToView(activeResponse, responseEvents.data)),
{ [activeResponse, responseEvents.data],
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 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 ( return (
<div <HttpResponsePaneView
style={style} style={style}
className={classNames( className={className}
className, response={responseView}
"x-theme-responsePane", body={body}
"max-h-full h-full", requestBody={requestBody}
"bg-surface rounded-md border border-border-subtle overflow-hidden", emptyState={
"relative",
)}
>
{activeResponse == null ? (
<HotkeyList hotkeys={["request.send", "model.create", "sidebar.focus", "url_bar.focus"]} /> <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"> viewMode={viewMode === "raw" ? "raw" : "pretty"}
<HStack onViewModeChange={setViewMode}
className={classNames( activeTabKey={activeRequestId}
"text-text-subtle w-full shrink-0", onSaveBody={saveResponse.mutate}
// Remove a bit of space because the tabs have lots too onCopyBody={copyResponse.mutate}
"-mb-1.5", onCancel={cancel.mutate}
)} cookieCounts={cookieCounts}
> extraTabs={extraTabs}
{activeResponse && ( textViewer={textViewer}
<div renderBodyViewer={renderBodyViewer}
className={classNames( renderCopyButton={renderCopyButton}
"grid grid-cols-[auto_minmax(4rem,1fr)_auto]", statusLineCenter={
"cursor-default select-none", shouldShowRedirectDropWarning ? (
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars", <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 && (
<HStack space={2} className="w-full shrink-0"> <span>
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />} Headers dropped:{" "}
<HttpStatusTag showReason response={activeResponse} /> <span className="font-mono">
<span>&bull;</span> {redirectDropWarning.droppedHeaders.join(", ")}
<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>
</span> </span>
</Tooltip> </span>
) : (
<span />
)} )}
<div className="justify-self-end shrink-0"> <span className="text-text-subtle">See Timeline for details.</span>
<RecentHttpResponsesDropdown </VStack>
responses={responses} }
activeResponse={activeResponse} >
onPinnedResponseId={setPinnedResponseId} <span className="inline-flex min-w-0">
/> <PillButton
</div> color="warning"
</div> className="font-sans text-sm shrink! max-w-full"
)} innerClassName="flex items-center"
</HStack> leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
>
<div className="overflow-hidden flex flex-col min-h-0"> <span className="truncate">{getRedirectWarningLabel(redirectDropWarning)}</span>
{activeResponse?.error && ( </PillButton>
<Banner color="danger" className="mx-3 mt-1 shrink-0"> </span>
{activeResponse.error} </Tooltip>
</Banner> ) : null
)} }
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */} statusLineRight={
<Tabs activeResponse != null && (
tabs={tabs} <RecentHttpResponsesDropdown
label="Response" responses={responses}
className="ml-3 mr-3 mb-3 min-h-0 flex-1" activeResponse={activeResponse}
tabListClassName="mt-0.5 -mb-1.5" onPinnedResponseId={setPinnedResponseId}
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>
); );
} }
/** 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( function getRedirectDropWarning(
events: HttpResponseEvent[] | undefined, events: HttpResponseEvent[] | undefined,
): RedirectDropWarning | null { ): RedirectDropWarning | null {
@@ -404,58 +286,3 @@ function getRedirectWarningLabel(warning: RedirectDropWarning): string {
} }
return "Dropped headers"; 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} />;
}
@@ -13,9 +13,8 @@ import { resolvedModelName } from "../lib/resolvedModelName";
import { Editor } from "./core/Editor/LazyEditor"; import { Editor } from "./core/Editor/LazyEditor";
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer"; import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
import { EventViewerRow } from "./core/EventViewerRow"; import { EventViewerRow } from "./core/EventViewerRow";
import { HttpStatusTagRaw } from "./core/HttpStatusTag"; import { Icon, type IconProps, HttpStatusTagRaw, KeyValueRows } from "@yaakapp-internal/ui";
import { Icon, type IconProps } from "@yaakapp-internal/ui"; import { KeyValueRow } from "./core/KeyValueRow";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import type { TimelineViewMode } from "./HttpResponsePane"; import type { TimelineViewMode } from "./HttpResponsePane";
interface Props { interface Props {
+316 -342
View File
@@ -1,33 +1,42 @@
import { import {
type Folder,
type ImportDestination, type ImportDestination,
type ImportPlan, type ImportPlan,
type ImportPlanItem, type ImportPlanItem,
type ImportSource, type ImportSource,
type Workspace, type Workspace,
} from "@yaakapp-internal/models"; } from "@yaakapp-internal/models";
import { Banner, HStack, Icon, type IconProps, InlineCode, VStack } from "@yaakapp-internal/ui"; import {
import { platform } from "@yaakapp-internal/platform"; Banner,
FormattedError,
HStack,
Icon,
VStack,
IconTooltip,
SegmentedControl,
} from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { formatDistanceToNowStrict } from "date-fns"; import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react"; import { errorMessage } from "../lib/errorMessage";
import { pluralize } from "../lib/pluralize";
import { CommercialUseBanner } from "./CommercialUseBanner"; import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button"; import {
import { Checkbox } from "./core/Checkbox"; ImportSourceList,
type ImportSourceDetection,
type ImportSourcePath,
InlineButton,
toSourcePath,
} from "./ImportSourceList";
import type { CheckboxTreeNode } from "./core/CheckboxTree"; import type { CheckboxTreeNode } from "./core/CheckboxTree";
import { CheckboxTree } from "./core/CheckboxTree"; import { CheckboxTree } from "./core/CheckboxTree";
import { IconTooltip } from "./core/IconTooltip"; import { Chip } from "./core/Chip";
import { PlainInput } from "./core/PlainInput"; import { DialogFooter } from "./core/Dialog";
import { Select } from "./core/Select"; import { Select } from "./core/Select";
import { SegmentedControl } from "./core/SegmentedControl"; import { pluralize } from "@yaakapp-internal/lib/pluralize";
interface Props { interface Props {
currentWorkspace: Workspace | null; currentWorkspace: Workspace | null;
workspaces: Workspace[]; workspaces: Workspace[];
selectedFolder: Folder | null; planSources: (sources: ImportSourcePath[], destination: ImportDestination) => Promise<ImportPlan>;
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>; detectSource: (source: ImportSourcePath) => Promise<string>;
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
listSources: (workspaceId: string) => Promise<ImportSource[]>; listSources: (workspaceId: string) => Promise<ImportSource[]>;
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>; findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
commit: (plan: ImportPlan) => Promise<void>; commit: (plan: ImportPlan) => Promise<void>;
@@ -35,25 +44,6 @@ interface Props {
onError: (err: unknown) => void; onError: (err: unknown) => void;
} }
/**
* An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a
* bare host like `example.com/openapi.json` still works (the backend defaults it to https).
*/
function isFilePath(value: string): boolean {
return (
value.startsWith("/") ||
value.startsWith("./") ||
value.startsWith("../") ||
value.startsWith("~/") ||
value.startsWith("\\\\") ||
/^[a-zA-Z]:[\\/]/.test(value)
);
}
function fileName(path: string): string {
return path.split(/[/\\]/).at(-1) || path;
}
/** /**
* Loads the current workspace's linked sources before rendering the dialog, so the inner * Loads the current workspace's linked sources before rendering the dialog, so the inner
* component can construct its initial state (prefilled path, destination) in one pass instead of * component can construct its initial state (prefilled path, destination) in one pass instead of
@@ -82,30 +72,19 @@ export function ImportDataDialog(props: Props) {
return <LoadedImportDataDialog {...props} initialSources={initialSources} />; return <LoadedImportDataDialog {...props} initialSources={initialSources} />;
} }
function latestSource(sources: ImportSource[]): ImportSource | null {
return sources.reduce<ImportSource | null>(
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
null,
);
}
function LoadedImportDataDialog({ function LoadedImportDataDialog({
currentWorkspace, currentWorkspace,
workspaces, workspaces,
selectedFolder, planSources,
planFile, detectSource,
planUrl,
listSources,
findSourcesForOrigin, findSourcesForOrigin,
commit, commit,
cancel, cancel,
onError, onError,
initialSources, initialSources,
}: Props & { initialSources: ImportSource[] }) { }: Props & { initialSources: ImportSource[] }) {
// A workspace with a linked source is probably being re-imported, so start from that source
const prefill = latestSource(initialSources);
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [sourceError, setSourceError] = useState<unknown>(null);
const [plan, setPlan] = useState<ImportPlan | null>(null); const [plan, setPlan] = useState<ImportPlan | null>(null);
const [items, setItems] = useState<ImportPlanItem[]>([]); const [items, setItems] = useState<ImportPlanItem[]>([]);
// null means no explicit choice yet, so the default below applies // null means no explicit choice yet, so the default below applies
@@ -113,65 +92,82 @@ function LoadedImportDataDialog({
null, null,
); );
const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null); const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null);
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null); const [originSources, setOriginSources] = useState<ImportSource[]>(initialSources);
const [linkedSources, setLinkedSources] = useState<ImportSource[]>( const initialPaths = useMemo(
prefill != null ? initialSources : [], () => [...new Set(initialSources.map((source) => source.origin))].map(toSourcePath),
[initialSources],
); );
const [originSources, setOriginSources] = useState<ImportSource[]>( const [sources, setSources] = useState<ImportSourcePath[]>(initialPaths);
prefill != null ? [prefill] : [], const knownPaths = useRef(new Set(initialPaths.map((source) => source.path)));
const [detections, setDetections] = useState<Record<string, ImportSourceDetection>>({});
const detect = useCallback(
(added: ImportSourcePath[]) => {
setDetections((prev) => ({
...prev,
...Object.fromEntries(added.map((s) => [s.path, { status: "loading" as const }])),
}));
for (const source of added) {
const settle = (detection: ImportSourceDetection) =>
setDetections((prev) =>
source.path in prev ? { ...prev, [source.path]: detection } : prev,
);
detectSource(source).then(
(importer) => settle({ status: "ok", importer }),
(err) => settle({ status: "error", message: errorMessage(err) }),
);
}
},
[detectSource],
); );
// A file path or a URL. Both inputs write here, so there is only ever one thing to import const allDetected = sources.every((source) => detections[source.path]?.status === "ok");
const [source, setSource] = useState<string | null>(prefill?.origin ?? null); useEffect(() => {
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0); detect(initialPaths);
const [isHovering, setIsHovering] = useState<boolean>(false); }, [detect, initialPaths]);
const ref = useRef<HTMLDivElement>(null);
const trimmedSource = source?.trim() ?? "";
const filePath = isFilePath(trimmedSource) ? trimmedSource : null;
const selectSource = (value: string) => { const addSources = useCallback(
setSource(value); (added: ImportSourcePath[]) => {
// Remount the input so it shows the path of the newly-picked file setSourceError(null);
setForceUpdateKey((k) => k + 1); const fresh = added.filter(({ path }) => {
if (knownPaths.current.has(path)) return false;
knownPaths.current.add(path);
return true;
});
if (fresh.length === 0) return;
setSources((existing) => [...existing, ...fresh]);
detect(fresh);
},
[detect],
);
const removeSource = (path: string) => {
setSourceError(null);
knownPaths.current.delete(path);
setSources((current) => current.filter((source) => source.path !== path));
setDetections(({ [path]: _removed, ...rest }) => rest);
}; };
// Accept a file dropped anywhere on the dialog, the way SelectFile does for its button
useEffect(() => { useEffect(() => {
return platform.window.onDragDrop((event) => { let cancelled = false;
if (event.type === "over") { if (sources.length === 0) {
const p = event.position;
const r = ref.current?.getBoundingClientRect();
if (r == null) return;
setIsHovering(p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom);
} else if (event.type === "drop" && isHovering) {
const p = event.paths[0];
if (p) selectSource(p);
setIsHovering(false);
} else {
setIsHovering(false);
}
});
}, [isHovering, setSource]);
useEffect(() => {
if (trimmedSource === "") {
setOriginSources([]); setOriginSources([]);
return; return;
} }
let cancelled = false; Promise.all(
const timeout = setTimeout(() => { sources.map(({ path, kind }) =>
findSourcesForOrigin(filePath != null ? { filePath } : { url: trimmedSource }) findSourcesForOrigin(kind === "url" ? { url: path } : { filePath: path }),
.then((sources) => { ),
if (!cancelled) setOriginSources(sources); )
}) .then((found) => {
.catch(() => setOriginSources([])); if (!cancelled) setOriginSources(found.flat());
}, 300); })
.catch(() => {
if (!cancelled) setOriginSources([]);
});
return () => { return () => {
cancelled = true; cancelled = true;
clearTimeout(timeout);
}; };
}, [trimmedSource, filePath, findSourcesForOrigin]); }, [sources, findSourcesForOrigin]);
// The one workspace this file is linked to, if there is exactly one. // The one workspace these sources are linked to, if there is exactly one.
const linkedWorkspace = useMemo(() => { const linkedWorkspace = useMemo(() => {
const ids = [...new Set(originSources.map((s) => s.workspaceId))]; const ids = [...new Set(originSources.map((s) => s.workspaceId))];
if (ids.length !== 1) return null; if (ids.length !== 1) return null;
@@ -193,32 +189,6 @@ function LoadedImportDataDialog({
? otherWorkspaceId ? otherWorkspaceId
: null; : null;
useEffect(() => {
if (destinationWorkspaceId == null) {
setLinkedSources([]);
return;
}
let cancelled = false;
listSources(destinationWorkspaceId)
.then((sources) => {
if (!cancelled) setLinkedSources(sources);
})
.catch(() => setLinkedSources([]));
return () => {
cancelled = true;
};
}, [destinationWorkspaceId, listSources]);
const handleSelectFile = async () => {
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
if (selected == null) return;
selectSource(selected);
};
// The selected folder belongs to the workspace being viewed, so it is only offerable when that
// is also the destination.
const canTargetSelectedFolder = selectedFolder != null && destinationKind === "current";
const destination = (): ImportDestination => { const destination = (): ImportDestination => {
if (destinationWorkspaceId == null) { if (destinationWorkspaceId == null) {
return { type: "new_workspace" }; return { type: "new_workspace" };
@@ -226,21 +196,18 @@ function LoadedImportDataDialog({
return { return {
type: "existing_workspace", type: "existing_workspace",
workspaceId: destinationWorkspaceId, workspaceId: destinationWorkspaceId,
folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined,
}; };
}; };
const handlePreview = async () => { const handlePreview = async () => {
setIsLoading(true); setIsLoading(true);
setSourceError(null);
try { try {
const nextPlan = const nextPlan = await planSources(sources, destination());
filePath != null
? await planFile(filePath, destination())
: await planUrl(trimmedSource, destination());
setPlan(nextPlan); setPlan(nextPlan);
setItems(nextPlan.items); setItems(nextPlan.items);
} catch (err) { } catch (err) {
onError(err); setSourceError(err);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -258,8 +225,6 @@ function LoadedImportDataDialog({
} }
}; };
const itemTree = useMemo(() => buildItemTree(items), [items]);
// A folder row's checkbox carries everything beneath it, deletions included — the row labels // A folder row's checkbox carries everything beneath it, deletions included — the row labels
// say which of those are destructive. Checking anything also brings back the folders it needs // say which of those are destructive. Checking anything also brings back the folders it needs
// to live in. // to live in.
@@ -313,196 +278,215 @@ function LoadedImportDataDialog({
if (plan.destination.type === "new_workspace") { if (plan.destination.type === "new_workspace") {
const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== ""); const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== "");
if (names.length === 0) return ["New workspace", "Untitled"]; if (names.length === 0) return ["New workspace", "Untitled"];
return [pluralize("New workspace", names.length), names.join(", ")]; return [
names.length === 1 ? "New workspace" : `${names.length} new workspaces`,
names.join(", "),
];
} }
const { workspaceId, folderId } = plan.destination; const { workspaceId } = plan.destination;
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace"; const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
return [ return ["Destination", name];
"Destination",
folderId != null && folderId === selectedFolder?.id
? `${name} / ${selectedFolder.name}`
: name,
];
})(); })();
// The destination workspace roots the tree. It is not a plan item — commit always applies // The destination workspace roots the tree. It is not a plan item — commit always applies
// it — so its checkbox only aggregates the subtree. // it — so its checkbox only aggregates the subtree.
const workspaceRoot: CheckboxTreeNode<TreeRow> = (() => { const workspaceRoots: CheckboxTreeNode<TreeRow>[] = (() => {
const planned = plan.resources.workspaces[0];
const planDestination = plan.destination; const planDestination = plan.destination;
const existing = const existing =
planDestination.type === "existing_workspace" planDestination.type === "existing_workspace"
? workspaces.find((w) => w.id === planDestination.workspaceId) ? workspaces.find((w) => w.id === planDestination.workspaceId)
: null; : null;
return { const destinations = existing ? [existing] : plan.resources.workspaces;
key: existing?.id ?? planned?.id ?? "workspace", if (destinations.length === 1) {
const workspace = destinations[0]!;
return [
{
key: workspace.id,
data: {
kind: "destination",
label: workspace.name,
isNew: planDestination.type === "new_workspace",
},
children: buildItemTree(items),
},
];
}
const workspaceById = new Map(
[
...plan.resources.environments,
...plan.resources.folders,
...plan.resources.httpRequests,
...plan.resources.grpcRequests,
...plan.resources.websocketRequests,
].map((resource) => [resource.id, resource.workspaceId]),
);
return destinations.map((workspace) => ({
key: workspace.id,
data: { data: {
kind: "destination", kind: "destination",
label: existing?.name ?? planned?.name ?? "New workspace", label: workspace.name,
isNew: planDestination.type === "new_workspace", isNew: planDestination.type === "new_workspace",
}, },
children: itemTree, children: buildItemTree(
}; items.filter((item) => workspaceById.get(item.modelId) === workspace.id),
),
}));
})(); })();
return ( return (
<VStack space={4} className="pb-4"> <>
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle"> <VStack space={4} className="pb-4">
<PreviewRow label="Detected format" value={plan.importer} /> <div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
<PreviewRow label={destinationLabel} value={destinationValue} /> <PreviewRow label="Detected format" value={plan.importer} />
</div> <PreviewRow label={destinationLabel} value={destinationValue} />
</div>
{plan.warnings.map((warning) => ( {plan.warnings
<Banner .filter(
key={`${warning.title}:${warning.detail}`} (warning, index, warnings) =>
color={warning.level === "warning" ? "warning" : "info"} warnings.findIndex(
className="flex items-start gap-2.5" (other) =>
> other.title === warning.title &&
<Icon other.detail === warning.detail &&
icon={warning.level === "warning" ? "alert_triangle" : "info"} other.level === warning.level,
size="sm" ) === index,
className="mt-0.5" )
/> .map((warning) => (
<div className="min-w-0"> <Banner
<div className="text-sm font-medium">{warning.title}</div> key={`${warning.level}:${warning.title}:${warning.detail}`}
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div> color={warning.level === "warning" ? "warning" : "info"}
className="flex items-start gap-2.5"
>
<Icon
icon={warning.level === "warning" ? "alert_triangle" : "info"}
size="sm"
className="mt-0.5"
/>
<div className="min-w-0">
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
</div>
</Banner>
))}
{workspaceRoots.map((root) => (
<div key={root.key} className="rounded-lg border border-border-subtle px-3 py-2">
<CheckboxTree
node={root}
checked={nodeCheckedStatus}
onCheck={toggleNode}
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
isCollapsedByDefault={(n) =>
(n.data.kind === "destination" && workspaceRoots.length > 1) ||
(n.data.kind === "item" && n.data.item.action === "ignored")
}
isRelevant={(n) =>
n.data.kind === "destination" ||
(n.data.kind === "item" && n.data.item.action !== "unchanged")
}
renderRow={(n) => (
<ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />
)}
/>
</div> </div>
</Banner> ))}
))} </VStack>
<DialogFooter
<div className="rounded-lg border border-border-subtle px-3 py-2 overflow-y-auto max-h-[40vh]"> leftSlot={
<CheckboxTree footerNote !== "" ? (
node={workspaceRoot} <div className="text-xs text-text-subtle">{footerNote}</div>
checked={nodeCheckedStatus} ) : undefined
onCheck={toggleNode} }
isCheckboxDisabled={(n) => disabledIds.has(n.key)} actions={[
isCollapsedByDefault={(n) => n.data.kind === "item" && n.data.item.action === "ignored"} {
isRelevant={(n) => label: "Back",
n.data.kind === "destination" || disabled: isLoading,
(n.data.kind === "item" && n.data.item.action !== "unchanged") onClick: () => {
} setPlan(null);
renderRow={(n) => <ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />} setItems([]);
/> },
</div> },
{
<HStack space={2} alignItems="center" className="mt-3"> label: isLoading
{footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>} ? "Importing"
<Button : changeCount > 0
className="ml-auto" ? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
color="secondary" : "Done",
variant="border" color: "primary",
disabled={isLoading} isLoading,
onClick={() => { onClick: handleCommit,
setPlan(null); },
setItems([]); ]}
}} />
> </>
Back
</Button>
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
{isLoading
? "Importing"
: changeCount > 0
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
: "Done"}
</Button>
</HStack>
</VStack>
); );
} }
const lastImported = const destinationPicker = (
originSources.find((s) => s.workspaceId === destinationWorkspaceId) ?? <HStack
linkedSources.reduce<ImportSource | null>( space={2}
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest), alignItems="center"
null, className={classNames(workspaces.length === 0 && "hidden")}
); >
<Select
return ( name="import-destination-kind"
<VStack ref={ref} space={4} className="pb-4"> label="Import into"
<CommercialUseBanner source="data-import" title="Importing work data?" /> labelPosition="left"
<button
type="button"
onClick={handleSelectFile}
className={classNames(
"w-full rounded-lg border border-dashed px-4 py-6",
"flex flex-col items-center gap-1 text-center",
isHovering
? "border-notice bg-surface-highlight"
: "border-border hover:border-text-subtle",
)}
>
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
{/* Fixed height so the region doesn't resize between the empty and selected states */}
<div className="h-6 w-full flex items-center justify-center">
{filePath == null ? (
<div className="text-text">
<strong className="font-semibold">Choose a file</strong> or drag it here
</div>
) : (
<div className="text-text font-mono text-xs max-w-full truncate" title={filePath}>
{fileName(filePath)}
</div>
)}
</div>
<div className="text-xs text-text-subtlest">
Supports OpenAPI, Swagger, Postman, Insomnia, and curl
</div>
</button>
<PlainInput
label="Or enter a file path or URL"
size="sm" size="sm"
placeholder="https://example.com/openapi.json" value={destinationKind}
defaultValue={source ?? ""} onChange={setDestinationChoice}
forceUpdateKey={String(forceUpdateKey)} options={[
onChange={setSource} { value: "new", label: "New Workspace" },
...(currentWorkspace != null
? [{ value: "current" as const, label: "Current Workspace" }]
: []),
{ value: "other", label: "Other Workspace" },
]}
/> />
{destinationKind === "other" && (
<VStack space={2}>
<Select <Select
name="import-destination-kind" name="import-destination-workspace"
label="Import location" label="Workspace"
hideLabel
size="sm" size="sm"
value={destinationKind} value={otherWorkspaceId ?? ""}
onChange={setDestinationChoice} onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
filterable
options={[ options={[
{ value: "new", label: "New Workspace" }, { value: "", label: "Select a workspace" },
...(currentWorkspace != null ...workspaces
? [{ value: "current" as const, label: "Current Workspace" }] .filter((w) => w.id !== currentWorkspace?.id)
: []), .map((w) => ({ value: w.id, label: w.name })),
{ value: "other", label: "Other Workspace" },
]} ]}
/> />
{destinationKind === "other" && ( )}
<Select </HStack>
name="import-destination-workspace" );
label="Workspace"
hideLabel return (
size="sm" <>
value={otherWorkspaceId ?? ""} <VStack space={4} className="h-full pb-4">
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)} <CommercialUseBanner source="data-import" title="Importing work data?" />
filterable
options={[ <ImportSourceList
{ value: "", label: "Select a workspace" }, sources={sources}
...workspaces detections={detections}
.filter((w) => w.id !== currentWorkspace?.id) disabled={isLoading}
.map((w) => ({ value: w.id, label: w.name })), onAdd={addSources}
]} onRemove={removeSource}
/> onError={setSourceError}
/>
{sourceError != null && (
<Banner color="warning">
<FormattedError>{errorMessage(sourceError)}</FormattedError>
</Banner>
)} )}
{lastImported != null && destinationWorkspaceId != null ? (
{linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId && (
<div className="text-xs text-text-subtle"> <div className="text-xs text-text-subtle">
Last imported from {lastImported.originLabel} ·{" "} {sources.length === 1 ? "This source was" : "These sources were"} last imported into{" "}
{formatDistanceToNowStrict(`${lastImported.lastImportedAt}Z`, { addSuffix: true })} <InlineButton
</div>
) : linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId ? (
<div className="text-xs text-text-subtle">
This file was last imported into{" "}
<button
type="button"
className="underline hocus:text-text"
onClick={() => { onClick={() => {
if (linkedWorkspace.id === currentWorkspace?.id) { if (linkedWorkspace.id === currentWorkspace?.id) {
setDestinationChoice("current"); setDestinationChoice("current");
@@ -513,36 +497,28 @@ function LoadedImportDataDialog({
}} }}
> >
{linkedWorkspace.name} {linkedWorkspace.name}
</button> </InlineButton>
</div> </div>
) : null}
{canTargetSelectedFolder && (
<Checkbox
checked={targetSelectedFolder}
title={`Place root resources in selected folder “${selectedFolder.name}”`}
onChange={setTargetSelectedFolder}
/>
)} )}
</VStack> </VStack>
<DialogFooter
<HStack space={2} justifyContent="end"> leftSlot={destinationPicker}
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}> actions={[
Cancel { label: "Cancel", disabled: isLoading, onClick: cancel },
</Button> {
<Button label: isLoading ? "Analyzing" : "Preview Import",
color="primary" color: "primary",
disabled={ disabled:
trimmedSource === "" || sources.length === 0 ||
isLoading || !allDetected ||
(destinationKind === "other" && otherWorkspaceId == null) isLoading ||
} (destinationKind === "other" && otherWorkspaceId == null),
isLoading={isLoading} isLoading,
onClick={handlePreview} onClick: handlePreview,
> },
{isLoading ? "Analyzing" : "Preview Import"} ]}
</Button> />
</HStack> </>
</VStack>
); );
} }
@@ -556,10 +532,12 @@ function ImportTreeRow({
if (row.kind !== "item") { if (row.kind !== "item") {
return ( return (
<> <>
<Icon color="secondary" icon={row.kind === "destination" ? "house" : row.icon} /> {row.kind === "destination" && (
<Icon icon="box" size="sm" className="shrink-0 text-text-subtle" />
)}
<div className="truncate flex-1">{row.label}</div> <div className="truncate flex-1">{row.label}</div>
{row.kind === "destination" && row.isNew && ( {row.kind === "destination" && row.isNew && (
<ActionChip label="new" help="Created by this import" className="text-success" /> <ActionChip label="new" help="Created by this import" color="success" />
)} )}
</> </>
); );
@@ -569,11 +547,6 @@ function ImportTreeRow({
const label = actionLabel(item); const label = actionLabel(item);
return ( return (
<> <>
{item.model === "folder" || item.model === "environment" ? (
<Icon color="secondary" icon={item.model === "folder" ? "folder" : "variable"} />
) : (
<span aria-hidden className="w-4" />
)}
<div className="truncate flex-1">{item.name}</div> <div className="truncate flex-1">{item.name}</div>
{item.action === "conflict" ? ( {item.action === "conflict" ? (
<div className="shrink-0"> <div className="shrink-0">
@@ -593,17 +566,7 @@ function ImportTreeRow({
</div> </div>
) : ( ) : (
label != null && ( label != null && (
<ActionChip <ActionChip label={label} help={actionHelp(item)} color={actionColor(item)} />
label={label}
help={actionHelp(item)}
className={classNames(
item.action === "create" && "text-success",
item.action === "update" && "text-info",
item.action === "delete" && "text-danger",
item.action === "keep_local" && item.selected && "text-warning",
item.action === "ignored" && "text-text-subtlest",
)}
/>
) )
)} )}
</> </>
@@ -613,26 +576,37 @@ function ImportTreeRow({
function ActionChip({ function ActionChip({
label, label,
help, help,
className, color,
}: { }: {
label: string; label: string;
help: string | null; help: string | null;
className?: string; color: ComponentProps<typeof Chip>["color"];
}) { }) {
return ( return (
<InlineCode <Chip color={color}>
className={classNames(
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
"inline-flex items-center justify-center gap-1.5",
className,
)}
>
{label} {label}
{help != null && <IconTooltip content={help} iconSize="xs" />} {help != null && <IconTooltip content={help} iconSize="xs" />}
</InlineCode> </Chip>
); );
} }
function actionColor(item: ImportPlanItem): ComponentProps<typeof Chip>["color"] {
switch (item.action) {
case "create":
return "success";
case "update":
return "info";
case "delete":
return "danger";
case "keep_local":
return item.selected ? "warning" : "default";
case "unchanged":
case "conflict":
case "ignored":
return "default";
}
}
function actionLabel(item: ImportPlanItem): string | null { function actionLabel(item: ImportPlanItem): string | null {
switch (item.action) { switch (item.action) {
case "create": case "create":
@@ -701,7 +675,7 @@ function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): I
*/ */
type TreeRow = type TreeRow =
| { kind: "destination"; label: string; isNew: boolean } | { kind: "destination"; label: string; isNew: boolean }
| { kind: "group"; label: string; icon: IconProps["icon"] } | { kind: "group"; label: string }
| { kind: "item"; item: ImportPlanItem }; | { kind: "item"; item: ImportPlanItem };
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] { function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
@@ -743,7 +717,7 @@ function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
return [ return [
{ {
key: "group:environments", key: "group:environments",
data: { kind: "group", label: "Variables", icon: "variable" }, data: { kind: "group", label: "Variables" },
children: environments.map((e) => toNode(e, new Set())), children: environments.map((e) => toNode(e, new Set())),
}, },
...nodes, ...nodes,
@@ -0,0 +1,271 @@
import { platform } from "@yaakapp-internal/platform";
import { Icon, LoadingIcon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from "react";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { PathList, splitPath } from "./core/PathList";
import { PlainInput } from "./core/PlainInput";
export interface ImportSourcePath {
path: string;
kind?: "file" | "folder" | "url";
}
export type ImportSourceDetection =
| { status: "loading" }
| { status: "ok"; importer: string }
| { status: "error"; message: string };
interface Props {
sources: ImportSourcePath[];
detections: Record<string, ImportSourceDetection>;
disabled?: boolean;
onAdd: (sources: ImportSourcePath[]) => void;
onRemove: (path: string) => void;
onError: (err: unknown) => void;
}
/** A bare hostname is a URL; local paths need an absolute or explicit relative prefix. */
export function isImportFilePath(value: string): boolean {
return (
value.startsWith("/") ||
value.startsWith("./") ||
value.startsWith("../") ||
value.startsWith("~/") ||
value.startsWith("\\\\") ||
/^[a-zA-Z]:[\\/]/.test(value)
);
}
/** A typed or prefilled path is a URL unless it looks like a local path; kind is left for the host */
export function toSourcePath(path: string): ImportSourcePath {
return { path, kind: isImportFilePath(path) ? undefined : "url" };
}
export function ImportSourceList({
sources,
detections,
disabled,
onAdd,
onRemove,
onError,
}: Props) {
const ref = useRef<HTMLDivElement>(null);
const [isHovering, setIsHovering] = useState(false);
const [pathInput, setPathInput] = useState("");
const [showPathInput, setShowPathInput] = useState(false);
const pathInputRef = useRef<{ focus: () => void }>(null);
const trimmedPath = pathInput.trim();
const openPathInput = () => {
setShowPathInput(true);
// The add-row menu hands focus back to its trigger as it closes, on a timer, so focus after it
setTimeout(() => pathInputRef.current?.focus(), 50);
};
const closePathInput = () => {
setPathInput("");
setShowPathInput(false);
};
const addPath = () => {
if (!trimmedPath || disabled) return;
onAdd([toSourcePath(trimmedPath)]);
closePathInput();
};
useEffect(() => {
if (disabled) return;
return platform.window.onDragDrop((event) => {
if (event.type === "leave") {
setIsHovering(false);
return;
}
const rect = ref.current?.getBoundingClientRect();
const { x, y } = event.position;
const isOver =
rect != null && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
if (event.type === "drop") {
if (isOver) onAdd(event.paths.map((path) => ({ path })));
setIsHovering(false);
} else {
setIsHovering(isOver);
}
});
}, [disabled, onAdd]);
const pick = async (directory: boolean) => {
try {
const selected = await platform.dialog.open({
title: directory ? "Select Import Folder" : "Select Import Files",
multiple: true,
directory,
});
if (selected == null) return;
onAdd(selected.map((path) => ({ path, kind: directory ? "folder" : "file" })));
} catch (err) {
onError(err);
}
};
const handlePathKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
addPath();
} else if (event.key === "Escape") {
event.stopPropagation();
closePathInput();
}
};
const pathInputEl = (
<PlainInput
ref={pathInputRef}
label="URL or file path"
hideLabel
size="sm"
autoFocus
disabled={disabled}
placeholder="Paste a URL or a file path"
onChange={setPathInput}
onKeyDownCapture={handlePathKeyDown}
rightSlot={
<div className="flex items-center gap-1 my-1 mr-1">
{trimmedPath !== "" && (
<Button size="xs" variant="border" onClick={addPath}>
Add
</Button>
)}
<IconButton size="xs" icon="x" title="Cancel" onClick={closePathInput} />
</div>
}
/>
);
if (sources.length === 0) {
return (
<div className="flex-1 flex flex-col gap-2">
<div
ref={ref}
className={classNames(
"flex-1 rounded-lg border min-h-28 flex flex-col items-center justify-center gap-2 px-4 py-5 text-center text-text-subtle",
isHovering ? "border-notice bg-surface-highlight" : "border-dashed border-border",
)}
>
<Icon icon="folder_input" className="text-text-subtlest w-10! h-10! mb-1" />
{isHovering ? (
<span>Drop to add</span>
) : (
<span>
Choose{" "}
<InlineButton disabled={disabled} onClick={() => pick(false)}>
files
</InlineButton>
, a{" "}
<InlineButton disabled={disabled} onClick={() => pick(true)}>
folder
</InlineButton>
, or a{" "}
<InlineButton disabled={disabled} onClick={openPathInput}>
URL
</InlineButton>
, or drag them here
</span>
)}
<span className="text-xs text-text-subtlest">
Supports OpenAPI, Swagger, Postman, Insomnia, Bruno, curl, and Yaak exports
</span>
</div>
{showPathInput && pathInputEl}
</div>
);
}
return (
<PathList
ref={ref}
hovering={isHovering}
disabled={disabled}
onRemove={onRemove}
addLabel="Add files, a folder, or a URL"
addHint="or drop them here"
addItems={[
{ label: "Files", icon: "file", onSelect: () => pick(false) },
{ label: "Folder", icon: "folder", onSelect: () => pick(true) },
{ label: "URL or file path", icon: "globe", onSelect: openPathInput },
]}
items={sources.map((source) => {
const detection = detections[source.path];
return {
path: source.path,
icon: sourceIcon(source),
failed: detection?.status === "error",
subtitle: <DetectionSubtitle path={source.path} detection={detection} />,
};
})}
>
{showPathInput && pathInputEl}
</PathList>
);
}
function DetectionSubtitle({
path,
detection,
}: {
path: string;
detection: ImportSourceDetection | undefined;
}) {
const { parent } = splitPath(path);
if (detection?.status === "loading") {
return (
<>
<LoadingIcon size="xs" />
Detecting format
</>
);
}
if (detection?.status === "error") return detection.message;
return (
<>
{detection?.status === "ok" && <span>{detection.importer}</span>}
{detection?.status === "ok" && parent !== "" && <span>·</span>}
{parent !== "" && <span className="truncate font-mono">{parent}</span>}
</>
);
}
function sourceIcon({ kind }: ImportSourcePath) {
switch (kind) {
case "url":
return "globe";
case "folder":
return "folder";
case "file":
return "file";
case undefined:
return "import";
}
}
export function InlineButton({
children,
disabled,
onClick,
}: {
children: ReactNode;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
className="underline decoration-current/40 underline-offset-3 hocus:decoration-current disabled:opacity-disabled"
onClick={onClick}
>
{children}
</button>
);
}
@@ -1,122 +0,0 @@
import { linter } from "@codemirror/lint";
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { Banner, Icon } 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";
import { IconTooltip } from "./core/IconTooltip";
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,15 +1,20 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize"; import { formatSize } from "@yaakapp-internal/lib/formatSize";
import { Banner, Button, HStack, LoadingIcon } from "@yaakapp-internal/ui"; import {
Banner,
Button,
HStack,
LoadingIcon,
AudioViewer,
ImageViewer,
SvgViewer,
VideoViewer,
} from "@yaakapp-internal/ui";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { lazy, Suspense, useEffect, useMemo, useState } from "react"; import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import type { SniffedValue } from "./core/Editor/sniffValue"; import type { SniffedValue } from "./core/Editor/sniffValue";
import { showDialog } from "../lib/dialog"; import { showDialog } from "../lib/dialog";
import { decodeValue, largeValueActions } from "../lib/largeValue"; import { decodeValue, largeValueActions } from "../lib/largeValue";
import { Dropdown } from "./core/Dropdown"; import { Dropdown } from "./core/Dropdown";
import { AudioViewer } from "./responseViewers/AudioViewer";
import { ImageViewer } from "./responseViewers/ImageViewer";
import { SvgViewer } from "./responseViewers/SvgViewer";
import { VideoViewer } from "./responseViewers/VideoViewer";
const PdfViewer = lazy(() => const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })), import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
@@ -1,8 +1,8 @@
import { SegmentedControl } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import type { EditorProps } from "./core/Editor/Editor"; import type { EditorProps } from "./core/Editor/Editor";
import { Editor } from "./core/Editor/LazyEditor"; import { Editor } from "./core/Editor/LazyEditor";
import { SegmentedControl } from "./core/SegmentedControl";
import { Markdown } from "./Markdown"; import { Markdown } from "./Markdown";
type ViewMode = "edit" | "preview"; type ViewMode = "edit" | "preview";
@@ -3,12 +3,12 @@ import { patchModel, workspacesAtom } from "@yaakapp-internal/models";
import { InlineCode, VStack } from "@yaakapp-internal/ui"; import { InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useState } from "react"; import { useState } from "react";
import { pluralizeCount } from "../lib/pluralize";
import { resolvedModelName } from "../lib/resolvedModelName"; import { resolvedModelName } from "../lib/resolvedModelName";
import { router } from "../lib/router"; import { router } from "../lib/router";
import { showToast } from "../lib/toast"; import { showToast } from "../lib/toast";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { Select } from "./core/Select"; import { Select } from "./core/Select";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
interface Props { interface Props {
activeWorkspaceId: string; activeWorkspaceId: string;
+212
View File
@@ -0,0 +1,212 @@
import type { Color } from "@yaakapp-internal/plugins";
import { platform } from "@yaakapp-internal/platform";
import { settingsAtom } from "@yaakapp-internal/models";
import { HeaderSize, Heading, Icon, type IconProps, LoadingIcon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { showDialog } from "../lib/dialog";
import { importData } from "../lib/importData";
import {
createExampleWorkspace,
createFreshWorkspace,
type OnboardingChoice,
recordOnboardingChoice,
} from "../lib/onboarding";
import { showErrorToast } from "../lib/toast";
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
import { Button } from "./core/Button";
/** Shown instead of a workspace when there are none. */
export function Onboarding() {
const settings = useAtomValue(settingsAtom);
const { capabilities } = platform;
const canImport = capabilities.plugins;
const showAlreadyUsing = capabilities.localFiles || capabilities.git || canImport;
const [busy, setBusy] = useState<OnboardingChoice | null>(null);
const choose = (choice: OnboardingChoice, run: () => Promise<void> | void) => async () => {
recordOnboardingChoice(choice);
setBusy(choice);
try {
await run();
} catch (err) {
showErrorToast({
id: "onboarding-failed",
title: "Something went wrong",
message: String(err),
});
} finally {
setBusy(null);
}
};
return (
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
<HeaderSize
data-tauri-drag-region
size="lg"
className="x-theme-appHeader bg-surface"
osType={platform.osType()}
hideWindowControls={settings.hideWindowControls}
useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale}
/>
<div className="overflow-auto px-6 py-10 grid">
<div className="m-auto w-full max-w-lg flex flex-col gap-7">
<div className="flex flex-col gap-1.5">
<Heading>How would you like to get started?</Heading>
<p className="text-text-subtle">
{canImport
? "Bring over existing work, try a real API, or start fresh."
: "Try a real API, or start fresh."}
</p>
</div>
<div className="flex flex-col gap-2">
{canImport && (
<StartOption
color="primary"
icon="folder_input"
title="Migrate from another tool"
description="Postman, Insomnia, Bruno, OpenAPI, or curl"
busy={busy === "import"}
disabled={busy != null}
onClick={choose("import", () => importData.mutateAsync())}
/>
)}
<StartOption
color="info"
icon="flask"
title="Try Yaak with a real API"
description="Ready-made requests you can send right away"
busy={busy === "example"}
disabled={busy != null}
onClick={choose("example", createExampleWorkspace)}
/>
<StartOption
color="success"
icon="plus"
title="Start fresh"
description="An empty workspace for your first request"
busy={busy === "fresh"}
disabled={busy != null}
onClick={choose("fresh", createFreshWorkspace)}
/>
</div>
{showAlreadyUsing && (
<div className="pt-5 border-t border-dashed border-border-subtle flex flex-col items-start gap-2.5">
<span className="text-sm text-text-subtle">Already using Yaak?</span>
<div className="flex flex-wrap gap-2">
{capabilities.localFiles && (
<Button
size="xs"
variant="border"
color="secondary"
leftSlot={<Icon icon="folder_open" size="sm" />}
disabled={busy != null}
onClick={choose("open_folder", async () => {
const dir = await platform.dialog.open({
title: "Select Workspace Directory",
directory: true,
multiple: false,
});
if (dir == null) return;
await openWorkspaceFromSyncDir.mutateAsync(dir);
})}
>
Open folder
</Button>
)}
{capabilities.git && (
<Button
size="xs"
variant="border"
color="secondary"
leftSlot={<Icon icon="git_branch" size="sm" />}
disabled={busy != null}
onClick={choose("clone_git", () => {
showDialog({
id: "clone-git-repository",
size: "md",
title: "Clone Git Repository",
render: ({ hide }) => <CloneGitRepositoryDialog hide={hide} />,
});
})}
>
Clone repository
</Button>
)}
{canImport && (
<Button
size="xs"
variant="border"
color="secondary"
leftSlot={<Icon icon="import" size="sm" />}
disabled={busy != null}
onClick={choose("import_yaak", () => importData.mutateAsync())}
>
Import
</Button>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}
function StartOption({
color,
icon,
title,
description,
onClick,
busy,
disabled,
}: {
color: Color;
icon: IconProps["icon"];
title: string;
description: string;
onClick: () => void;
busy: boolean;
disabled: boolean;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={classNames(
"group w-full text-left rounded-lg px-3 py-2.5 border border-border-subtle",
"grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3.5",
"enabled:hocus:bg-surface-highlight/50 enabled:hocus:border-border",
"outline-border-focus focus-visible:outline-2",
disabled && !busy && "opacity-disabled",
)}
>
{/* The banner theme tints this tile from the theme's own color for each option */}
<div
className={classNames(
`x-theme-banner--${color}`,
"size-10 rounded-md bg-surface-highlight grid place-items-center",
)}
>
{busy ? <LoadingIcon size="sm" /> : <Icon icon={icon} color={color} size="md" />}
</div>
<div className="min-w-0">
<div className="font-semibold text-text">{title}</div>
<div className="text-sm text-text-subtle">{description}</div>
</div>
<Icon
icon="chevron_right"
className="text-text-subtlest group-enabled:group-hover:text-text-subtle group-enabled:group-focus-visible:text-text-subtle"
/>
</button>
);
}
@@ -1,6 +1,6 @@
import type { GrpcConnection } from "@yaakapp-internal/models"; import type { GrpcConnection } from "@yaakapp-internal/models";
import { deleteModel } from "@yaakapp-internal/models"; import { deleteModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui"; import { HStack, Icon, formatMillis } from "@yaakapp-internal/ui";
import { import {
differenceInHours, differenceInHours,
differenceInMinutes, differenceInMinutes,
@@ -9,10 +9,9 @@ import {
isYesterday, isYesterday,
} from "date-fns"; } from "date-fns";
import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections"; import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections";
import { pluralizeCount } from "../lib/pluralize";
import { Dropdown, type DropdownItem } from "./core/Dropdown"; import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
interface Props { interface Props {
connections: GrpcConnection[]; connections: GrpcConnection[];
@@ -1,6 +1,6 @@
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import { deleteModel } from "@yaakapp-internal/models"; import { deleteModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui"; import { HStack, Icon, formatMillis, HttpStatusTag, SizeTag } from "@yaakapp-internal/ui";
import { import {
differenceInHours, differenceInHours,
differenceInMinutes, differenceInMinutes,
@@ -9,13 +9,8 @@ import {
isYesterday, isYesterday,
} from "date-fns"; } from "date-fns";
import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses"; import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
import { useKeyValue } from "../hooks/useKeyValue";
import { DismissibleBanner } from "./core/DismissibleBanner";
import { Dropdown, type DropdownItem } from "./core/Dropdown"; import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { SizeTag } from "./core/SizeTag";
interface Props { interface Props {
responses: HttpResponse[]; responses: HttpResponse[];
@@ -30,12 +25,6 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
onPinnedResponseId, onPinnedResponseId,
}: Props) { }: Props) {
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId); 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 latestResponseId = responses[0]?.id ?? "n/a";
const responseHistoryItems: DropdownItem[] = []; const responseHistoryItems: DropdownItem[] = [];
let lastHistoryGroup: string | null = null; let lastHistoryGroup: string | null = null;
@@ -126,20 +115,6 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
hidden: latestResponseId === activeResponse.id, hidden: latestResponseId === activeResponse.id,
disabled: responses.length === 0, 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", type: "separator",
label: "Recent", label: "Recent",
@@ -1,6 +1,6 @@
import type { WebsocketConnection } from "@yaakapp-internal/models"; import type { WebsocketConnection } from "@yaakapp-internal/models";
import { deleteModel, getModel } from "@yaakapp-internal/models"; import { deleteModel, getModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui"; import { HStack, Icon, formatMillis } from "@yaakapp-internal/ui";
import { import {
differenceInHours, differenceInHours,
differenceInMinutes, differenceInMinutes,
@@ -9,10 +9,9 @@ import {
isYesterday, isYesterday,
} from "date-fns"; } from "date-fns";
import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections"; import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections";
import { pluralizeCount } from "../lib/pluralize";
import { Dropdown, type DropdownItem } from "./core/Dropdown"; import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
interface Props { interface Props {
connections: WebsocketConnection[]; connections: WebsocketConnection[];
@@ -7,6 +7,7 @@ import { getRecentRequests } from "../hooks/useRecentRequests";
import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces"; import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces";
import { fireAndForget } from "../lib/fireAndForget"; import { fireAndForget } from "../lib/fireAndForget";
import { router } from "../lib/router"; import { router } from "../lib/router";
import { Onboarding } from "./Onboarding";
export function RedirectToLatestWorkspace() { export function RedirectToLatestWorkspace() {
const workspaces = useAtomValue(workspacesAtom); const workspaces = useAtomValue(workspacesAtom);
@@ -14,10 +15,6 @@ export function RedirectToLatestWorkspace() {
useEffect(() => { useEffect(() => {
if (workspaces.length === 0 || recentWorkspaces == null) { if (workspaces.length === 0 || recentWorkspaces == null) {
console.log("No workspaces found to redirect to. Skipping.", {
workspaces,
recentWorkspaces,
});
return; return;
} }
@@ -34,11 +31,21 @@ export function RedirectToLatestWorkspace() {
request_id: requestId, request_id: requestId,
}; };
// Whatever created the first workspace may have already navigated into it
const currentId = router.history.location.pathname.match(/^\/workspaces\/([^/]+)/)?.[1];
if (workspaces.some((w) => w.id === currentId)) return;
console.log("Redirecting to workspace", params, search); console.log("Redirecting to workspace", params, search);
await router.navigate({ to: "/workspaces/$workspaceId", params, search }); await router.navigate({ to: "/workspaces/$workspaceId", params, search });
})(), })(),
); );
}, [recentWorkspaces, workspaces, workspaces.length]); }, [recentWorkspaces, workspaces, workspaces.length]);
// The global models are loaded before the router mounts, so an empty list is a real
// first launch (or the last workspace was just deleted), not a store still loading
if (workspaces.length === 0) {
return <Onboarding />;
}
return null; return null;
} }
@@ -1,102 +0,0 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { lazy, Suspense } from "react";
import { useHttpRequestBody } from "../hooks/useHttpRequestBody";
import { getMimeTypeFromContentType, languageFromContentType } from "../lib/contentType";
import { LoadingIcon } from "@yaakapp-internal/ui";
import { EmptyStateText } from "./EmptyStateText";
import { AudioViewer } from "./responseViewers/AudioViewer";
import { CsvViewer } from "./responseViewers/CsvViewer";
import { ImageViewer } from "./responseViewers/ImageViewer";
import { MultipartViewer } from "./responseViewers/MultipartViewer";
import { SvgViewer } from "./responseViewers/SvgViewer";
import { TextViewer } from "./responseViewers/TextViewer";
import { VideoViewer } from "./responseViewers/VideoViewer";
import { WebPageViewer } from "./responseViewers/WebPageViewer";
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 type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models"; import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import classNames from "classnames"; import { RequestMethodDropdown as BaseRequestMethodDropdown } from "@yaakapp-internal/ui";
import { memo, useCallback, useMemo } from "react"; import { useAtomValue } from "jotai";
import { showPrompt } from "../lib/prompt"; import { memo, useCallback } from "react";
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";
type Props = { type Props = {
request: HttpRequest; request: HttpRequest;
className?: string; className?: string;
}; };
const radioItems: RadioDropdownItem<string>[] = [ /** The shared method menu, writing to the request and coloured by the user's setting */
"GET",
"PUT",
"POST",
"PATCH",
"DELETE",
"OPTIONS",
"QUERY",
"HEAD",
].map((m) => ({
value: m,
label: <HttpMethodTagRaw method={m} />,
}));
export const RequestMethodDropdown = memo(function RequestMethodDropdown({ export const RequestMethodDropdown = memo(function RequestMethodDropdown({
request, request,
className, className,
}: Props) { }: Props) {
const settings = useAtomValue(settingsAtom);
const handleChange = useCallback( const handleChange = useCallback(
async (method: string) => { async (method: string) => {
await patchModel(request, { method }); await patchModel(request, { method });
@@ -40,39 +22,12 @@ export const RequestMethodDropdown = memo(function RequestMethodDropdown({
[request], [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 ( return (
<RadioDropdown <BaseRequestMethodDropdown
value={request.method} method={request.method}
items={radioItems} colored={settings.coloredMethods}
itemsAfter={itemsAfter}
onChange={handleChange} onChange={handleChange}
> className={className}
<Button size="xs" className={classNames(className, "text-text-subtle hover:text")}> />
<HttpMethodTag request={request} noAlias />
</Button>
</RadioDropdown>
); );
}); });
@@ -1,110 +0,0 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { format, formatDistanceToNowStrict } from "date-fns";
import { useMemo } from "react";
import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from "./core/DetailsBanner";
import { IconButton } from "./core/IconButton";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { platform } from "@yaakapp-internal/platform";
interface Props {
response: HttpResponse;
}
export function ResponseHeaders({ response }: Props) {
const responseHeaders = useMemo(
() =>
[...response.headers].sort((a, b) =>
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
),
[response.headers],
);
const requestHeaders = useMemo(
() =>
[...response.requestHeaders].sort((a, b) =>
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
),
[response.requestHeaders],
);
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>
<DetailsBanner
storageKey={`${response.requestId}.request_headers`}
summary={
<h2 className="flex items-center">
Request Headers <CountBadge showZero count={requestHeaders.length} />
</h2>
}
>
{requestHeaders.length === 0 ? (
<NoHeaders />
) : (
<KeyValueRows>
{requestHeaders.map((h, i) => (
// oxlint-disable-next-line react/no-array-index-key
<KeyValueRow labelColor="primary" key={i} label={h.name}>
{h.value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</DetailsBanner>
<DetailsBanner
defaultOpen
storageKey={`${response.requestId}.response_headers`}
summary={
<h2 className="flex items-center">
Response Headers <CountBadge showZero count={responseHeaders.length} />
</h2>
}
>
{responseHeaders.length === 0 ? (
<NoHeaders />
) : (
<KeyValueRows>
{responseHeaders.map((h, i) => (
// oxlint-disable-next-line react/no-array-index-key
<KeyValueRow labelColor="info" key={i} label={h.name}>
{h.value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</DetailsBanner>
</div>
);
}
function NoHeaders() {
return <span className="text-text-subtlest text-sm italic">No Headers</span>;
}
+2 -1
View File
@@ -1,7 +1,8 @@
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow"; import { KeyValueRow } from "./core/KeyValueRow";
import { platform } from "@yaakapp-internal/platform"; import { platform } from "@yaakapp-internal/platform";
import { KeyValueRows } from "@yaakapp-internal/ui";
interface Props { interface Props {
response: HttpResponse; response: HttpResponse;
+1 -3
View File
@@ -1,5 +1,5 @@
import { platform } from "@yaakapp-internal/platform"; import { platform } from "@yaakapp-internal/platform";
import { HStack } from "@yaakapp-internal/ui"; import { HStack, IconTooltip, Label } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import mime from "mime"; import mime from "mime";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
@@ -7,8 +7,6 @@ import { useEffect, useRef, useState } from "react";
import type { ButtonProps } from "./core/Button"; import type { ButtonProps } from "./core/Button";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip";
import { Label } from "./core/Label";
type Props = Omit<ButtonProps, "type"> & { type Props = Omit<ButtonProps, "type"> & {
onChange: (value: { filePath: string | null; contentType: string | null }) => void; onChange: (value: { filePath: string | null; contentType: string | null }) => void;
+67 -100
View File
@@ -1,14 +1,20 @@
import { platform } from "@yaakapp-internal/platform"; import { platform } from "@yaakapp-internal/platform";
import { useLicense } from "@yaakapp-internal/license"; import { useLicense } from "@yaakapp-internal/license";
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models"; import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
import { HeaderSize, HStack, Icon } from "@yaakapp-internal/ui"; import {
import classNames from "classnames"; 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 { useAtomValue } from "jotai";
import { useKeyPressEvent } from "react-use"; import { useKeyPressEvent } from "react-use";
import { appInfo } from "../../lib/appInfo"; import { appInfo } from "../../lib/appInfo";
import { capitalize } from "../../lib/capitalize"; import { capitalize } from "../../lib/capitalize";
import { CountBadge } from "../core/CountBadge"; import { SettingsAgents } from "./SettingsAgents";
import { TabContent, type TabItem, Tabs } from "../core/Tabs/Tabs";
import { SettingsCertificates } from "./SettingsCertificates"; import { SettingsCertificates } from "./SettingsCertificates";
import { SettingsGeneral } from "./SettingsGeneral"; import { SettingsGeneral } from "./SettingsGeneral";
import { SettingsHotkeys } from "./SettingsHotkeys"; import { SettingsHotkeys } from "./SettingsHotkeys";
@@ -24,20 +30,22 @@ interface Props {
hide?: () => void; hide?: () => void;
} }
const TAB_GENERAL = "general"; const TAB_GENERAL = SETTINGS_TAB_GENERAL;
const TAB_INTERFACE = "interface"; const TAB_INTERFACE = SETTINGS_TAB_INTERFACE;
const TAB_THEME = "theme"; const TAB_THEME = SETTINGS_TAB_THEME;
const TAB_SHORTCUTS = "shortcuts"; const TAB_SHORTCUTS = "shortcuts";
const TAB_PROXY = "proxy"; const TAB_PROXY = "proxy";
const TAB_CERTIFICATES = "certificates"; const TAB_CERTIFICATES = "certificates";
const TAB_PLUGINS = "plugins"; const TAB_PLUGINS = "plugins";
const TAB_LICENSE = "license"; const TAB_AGENTS = "agents";
const TAB_LICENSE = SETTINGS_TAB_LICENSE;
const tabs = [ const tabs = [
TAB_GENERAL, TAB_GENERAL,
TAB_THEME, TAB_THEME,
TAB_INTERFACE, TAB_INTERFACE,
TAB_SHORTCUTS, TAB_SHORTCUTS,
TAB_PLUGINS, TAB_PLUGINS,
TAB_AGENTS,
TAB_CERTIFICATES, TAB_CERTIFICATES,
TAB_PROXY, TAB_PROXY,
TAB_LICENSE, TAB_LICENSE,
@@ -58,98 +66,57 @@ export default function Settings({ tab, hide }: Props) {
if (hide == null) await platform.window.close(); 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 ( return (
<div className={classNames("grid grid-rows-[auto_minmax(0,1fr)] h-full")}> <SettingsDialog
{hide ? ( defaultTab={mainTab}
<span /> titlebar={
) : ( hide
<HeaderSize ? null
data-tauri-drag-region : {
ignoreControlsSpacing osType: platform.osType(),
onlyXWindowControl hideWindowControls: settings.hideWindowControls,
size="md" useNativeTitlebar: settings.useNativeTitlebar,
className="x-theme-appHeader bg-surface text-text-subtle flex items-center justify-center border-b border-border-subtle text-sm font-semibold" interfaceScale: settings.interfaceScale,
osType={platform.osType()} }
hideWindowControls={settings.hideWindowControls} }
useNativeTitlebar={settings.useNativeTitlebar} tabs={tabs.map((value) => ({ value, label: capitalize(value), ...tabDefs[value] }))}
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,
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_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_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>
); );
} }
@@ -0,0 +1,131 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { platform } from "@yaakapp-internal/platform";
import type { AgentSetupStatus, AppMetaData } from "@yaakapp-internal/rpc-schema";
import { Banner, Icon, InlineCode, SegmentedControl, SettingsPage } from "@yaakapp-internal/ui";
import { useEffect } from "react";
import { useKeyValue } from "../../hooks/useKeyValue";
import { usePluginInfos } from "../../hooks/usePluginInfo";
import {
AGENT_CLIENTS,
type AgentClient,
cliSetup,
skillInstallCommand,
} from "../../lib/agentSetup";
import { appInfo } from "../../lib/appInfo";
import { rpc } from "../../lib/rpc";
import { Link } from "../core/Link";
import { SettingCommand, SettingSnippet, SettingsList, SettingsSection } from "../core/SettingRow";
import { DeprecatedPluginBanner } from "./SettingsPlugins";
const CLI_INSTALL_COMMAND = "npm install -g @yaakapp/cli";
const MCP_SERVER_PLUGIN = "@yaak/mcp-server";
export function SettingsAgents() {
const queryClient = useQueryClient();
const { value: storedClient, set: setClient } = useKeyValue<AgentClient>({
key: "agents_setup_client",
fallback: "claude_code",
});
const client = AGENT_CLIENTS.find((c) => c.value === storedClient)?.value ?? "claude_code";
const clientLabel = AGENT_CLIENTS.find((c) => c.value === client)?.label ?? "your agent";
const cliVersion = useQuery({
queryKey: ["cli_version"],
queryFn: async () => (await rpc<AppMetaData>("cmd_metadata")).cliVersion,
initialData: appInfo.cliVersion,
});
const setupStatus = useQuery({
queryKey: ["agent_setup_status", client],
queryFn: () => rpc<AgentSetupStatus>("cmd_agent_setup_status", { agent: client }),
});
const mcpServerPlugin = usePluginInfos().find(({ info }) => info.name === MCP_SERVER_PLUGIN);
useEffect(() => {
return platform.window.onFocusChanged((focused) => {
if (!focused) return;
void queryClient.invalidateQueries({ queryKey: ["cli_version"] });
void queryClient.invalidateQueries({ queryKey: ["agent_setup_status"] });
});
}, [queryClient]);
const cliInstalled = cliVersion.data != null;
const cli = cliSetup(client);
return (
<SettingsPage
title="Agents"
description="Connect AI coding agents like Claude Code and Cursor to Yaak."
>
<SettingsList>
<SettingsSection title={null}>
<div className="pb-4 border-b border-border-subtle">
<SegmentedControl
name="agents_setup_client"
label="Agent"
hideLabel
value={client}
onChange={setClient}
options={AGENT_CLIENTS}
/>
</div>
{cliInstalled ? (
<div className="flex items-center gap-2 py-3 text-sm text-text-subtle border-b border-border-subtle">
<Icon icon="check" size="sm" color="success" />
<span>
Yaak CLI <span className="text-text">{cliVersion.data}</span> is installed.
</span>
</div>
) : (
<div className="py-4 border-b border-border-subtle">
<Banner color="info">
<div className="flex items-center gap-2 text text-text">
<Icon icon="square_terminal" color="info" />
Install the Yaak CLI first
</div>
<p className="mt-1 text-sm text-text-subtle">
Agents launch it to reach Yaak, even with the app closed.
</p>
<SettingSnippet className="mt-3" language="shell" snippet={CLI_INSTALL_COMMAND} />
<div className="mt-3 mb-1 flex items-center gap-4 text-sm">
<Link href="https://yaak.app/docs/cli">Docs</Link>
<span className="text-text-subtlest">
Checks again when you come back to Yaak.
</span>
</div>
</Banner>
</div>
)}
{mcpServerPlugin != null && (
<div className="py-4 border-b border-border-subtle">
<DeprecatedPluginBanner
plugin={mcpServerPlugin.plugin}
name={mcpServerPlugin.info.displayName}
>
The two steps below replace it. Uninstall it once your agent is connected.
</DeprecatedPluginBanner>
</div>
)}
<SettingCommand
title="Add the MCP server"
description={cli.where}
snippet={cli.snippet}
language={cli.language}
disabled={!cliInstalled}
done={setupStatus.data?.mcpServer ? "Added" : null}
/>
<SettingCommand
title="Install the skill"
description={
<>
Lets {clientLabel} run the <InlineCode>yaak</InlineCode> command directly.
</>
}
snippet={skillInstallCommand(client)}
language="shell"
disabled={!cliInstalled}
done={setupStatus.data?.skill ? "Installed" : null}
/>
</SettingsSection>
</SettingsList>
</SettingsPage>
);
}
@@ -1,6 +1,6 @@
import type { ClientCertificate } from "@yaakapp-internal/models"; import type { ClientCertificate } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } 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 { useAtomValue } from "jotai";
import { useRef } from "react"; import { useRef } from "react";
import { showConfirmDelete } from "../../lib/confirm"; import { showConfirmDelete } from "../../lib/confirm";
@@ -218,24 +218,19 @@ export function SettingsCertificates() {
}; };
return ( return (
<VStack space={3}> <SettingsPage
<div className="mb-3"> title="Client Certificates"
<HStack justifyContent="between" alignItems="start"> description="Add and manage TLS certificates on a per domain basis"
<div> actions={
<Heading>Client Certificates</Heading> <Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
<p className="text-text-subtle"> Add Certificate
Add and manage TLS certificates on a per domain basis </Button>
</p> }
</div> banner={
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}> <CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
Add Certificate }
</Button> >
</HStack> {certificates.length > 0 ? (
</div>
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
{certificates.length > 0 && (
<VStack space={3}> <VStack space={3}>
{certificates.map((cert, index) => ( {certificates.map((cert, index) => (
<CertificateEditor <CertificateEditor
@@ -248,7 +243,7 @@ export function SettingsCertificates() {
/> />
))} ))}
</VStack> </VStack>
)} ) : null}
</VStack> </SettingsPage>
); );
} }
@@ -1,32 +1,17 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models"; import { settingsAtom } from "@yaakapp-internal/models";
import { Heading, VStack } from "@yaakapp-internal/ui"; import { SettingsGeneral as SharedSettingsGeneral } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates"; import { useSettingsView } from "../../hooks/useSettingsView";
import { appInfo } from "../../lib/appInfo";
import { revealInFinderText } from "../../lib/reveal";
import { CargoFeature } from "../CargoFeature";
import { CommercialUseBanner } from "../CommercialUseBanner"; import { CommercialUseBanner } from "../CommercialUseBanner";
import { DismissibleBanner } from "../core/DismissibleBanner"; 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"; const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
export function SettingsGeneral() { export function SettingsGeneral() {
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const checkForUpdates = useCheckForUpdates(); const bound = useSettingsView();
if (settings == null) { if (settings == null || bound == null) {
return null; return null;
} }
@@ -34,147 +19,24 @@ export function SettingsGeneral() {
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT; settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
return ( return (
<VStack space={1.5} className="mb-4"> <SharedSettingsGeneral
<div> view={bound.view}
<Heading>General</Heading> callbacks={bound.callbacks}
<p className="text-text-subtle"> banner={<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />}
Configure general settings for update behavior and more. extraSections={
</p> showWorkspaceSettingsMovedBanner && (
</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 && (
<DismissibleBanner <DismissibleBanner
id="workspace-settings-moved-2026-06-30" id="workspace-settings-moved-2026-06-30"
color="info" color="info"
className="w-full p-4 max-w-xl mr-auto" className="w-full p-4 max-w-xl mr-auto"
> >
<p> <p>
Workspace specific settings have moved to{" "} Workspace specific settings have moved to <b>Workspace Settings</b>, accessible from
<b>Workspace Settings</b>, accessible from the workspace switcher the workspace switcher menu.
menu.
</p> </p>
</DismissibleBanner> </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 { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { import {
Heading,
HStack, HStack,
Icon, Icon,
SettingsPage,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
@@ -94,59 +94,58 @@ export function SettingsHotkeys() {
} }
return ( return (
<VStack space={3} className="mb-4"> <SettingsPage
<div className="mb-3"> title="Keyboard Shortcuts"
<Heading>Keyboard Shortcuts</Heading> description="Click the menu button to add, remove, or reset keyboard shortcuts."
<p className="text-text-subtle"> >
Click the menu button to add, remove, or reset keyboard shortcuts. <VStack space={3}>
</p> <PlainInput
</div> label="Filter"
<PlainInput placeholder="Filter shortcuts..."
label="Filter" defaultValue={filter}
placeholder="Filter shortcuts..." onChange={setFilter}
defaultValue={filter} hideLabel
onChange={setFilter} containerClassName="max-w-xs"
hideLabel />
containerClassName="max-w-xs" <Table>
/> <TableHead>
<Table> <TableRow>
<TableHead> <TableHeaderCell>Scope</TableHeaderCell>
<TableRow> <TableHeaderCell>Action</TableHeaderCell>
<TableHeaderCell>Scope</TableHeaderCell> <TableHeaderCell>Shortcut</TableHeaderCell>
<TableHeaderCell>Action</TableHeaderCell> <TableHeaderCell></TableHeaderCell>
<TableHeaderCell>Shortcut</TableHeaderCell> </TableRow>
<TableHeaderCell></TableHeaderCell> </TableHead>
</TableRow> {/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
</TableHead> <TableBody key={filter}>
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */} {filteredActions.map((action) => (
<TableBody key={filter}> <HotkeyRow
{filteredActions.map((action) => ( key={action}
<HotkeyRow action={action}
key={action} currentKeys={hotkeys[action] ?? []}
action={action} defaultKeys={defaultHotkeys[action]}
currentKeys={hotkeys[action] ?? []} onSave={async (keys) => {
defaultKeys={defaultHotkeys[action]} const newHotkeys = { ...settings.hotkeys };
onSave={async (keys) => { if (arraysEqual(keys, defaultHotkeys[action])) {
const newHotkeys = { ...settings.hotkeys }; // Remove from settings if it matches default (use default)
if (arraysEqual(keys, defaultHotkeys[action])) { delete newHotkeys[action];
// Remove from settings if it matches default (use default) } 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]; delete newHotkeys[action];
} else { await patchModel(settings, { hotkeys: newHotkeys });
// Store the keys (including empty array to disable) }}
newHotkeys[action] = keys; />
} ))}
await patchModel(settings, { hotkeys: newHotkeys }); </TableBody>
}} </Table>
onReset={async () => { </VStack>
const newHotkeys = { ...settings.hotkeys }; </SettingsPage>
delete newHotkeys[action];
await patchModel(settings, { hotkeys: newHotkeys });
}}
/>
))}
</TableBody>
</Table>
</VStack>
); );
} }
@@ -1,59 +1,30 @@
import { platform } from "@yaakapp-internal/platform";
import { useFonts } from "@yaakapp-internal/fonts";
import { useLicense } from "@yaakapp-internal/license"; 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 { 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 { useAtomValue } from "jotai";
import { useState } from "react";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace"; import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { useSettingsView } from "../../hooks/useSettingsView";
import { showConfirm } from "../../lib/confirm"; import { showConfirm } from "../../lib/confirm";
import { pricingUrl } from "../../lib/pricingUrl"; import { pricingUrl } from "../../lib/pricingUrl";
import { rpc } from "../../lib/rpc";
import { CargoFeature } from "../CargoFeature"; import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox";
import { Link } from "../core/Link"; import { Link } from "../core/Link";
import { import { SettingRowBoolean, SettingRowSelect, SettingsSection } from "../core/SettingRow";
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" },
];
export function SettingsInterface() { export function SettingsInterface() {
const workspace = useAtomValue(activeWorkspaceAtom); const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom); 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 null;
} }
return ( return (
<VStack space={1.5} className="mb-4"> <SharedSettingsInterface
<div className="mb-3"> view={bound.view}
<Heading>Interface</Heading> callbacks={bound.callbacks}
<p className="text-text-subtle">Tweak settings related to the user interface.</p> leadingSections={
</div>
<SettingsList className="space-y-8">
<SettingsSection title="Workspaces"> <SettingsSection title="Workspaces">
<SettingRowSelect <SettingRowSelect
title="Open workspace behavior" title="Open workspace behavior"
@@ -78,151 +49,13 @@ export function SettingsInterface() {
]} ]}
/> />
</SettingsSection> </SettingsSection>
}
<SettingsSection title="Fonts"> trailingSections={
<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>
<CargoFeature feature="license"> <CargoFeature feature="license">
<LicenseSettings settings={settings} /> <LicenseSettings settings={settings} />
</CargoFeature> </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 { SettingsLicense as SharedSettingsLicense } from "@yaakapp-internal/ui";
import { Banner, HStack, Icon, VStack } from "@yaakapp-internal/ui"; import { useSettingsView } from "../../hooks/useSettingsView";
import { differenceInDays } from "date-fns";
import { formatDate } from "date-fns/format";
import { useState } from "react";
import { useToggle } from "../../hooks/useToggle";
import { pluralizeCount } from "../../lib/pluralize";
import { pricingUrl } from "../../lib/pricingUrl";
import { CargoFeature } from "../CargoFeature"; 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";
export function SettingsLicense() { export function SettingsLicense() {
return ( return (
@@ -22,166 +11,7 @@ export function SettingsLicense() {
} }
function SettingsLicenseCmp() { function SettingsLicenseCmp() {
const { check, activate, deactivate } = useLicense(); const bound = useSettingsView();
const [key, setKey] = useState<string>(""); if (bound == null) return null;
const [activateFormVisible, toggleActivateFormVisible] = useToggle(false); return <SharedSettingsLicense view={bound.view} callbacks={bound.callbacks} />;
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>
);
} }
@@ -2,13 +2,9 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import type { Plugin } from "@yaakapp-internal/models"; import type { Plugin } from "@yaakapp-internal/models";
import { patchModel, pluginsAtom } from "@yaakapp-internal/models"; import { patchModel, pluginsAtom } from "@yaakapp-internal/models";
import type { PluginVersion } from "@yaakapp-internal/plugins"; import type { PluginVersion } from "@yaakapp-internal/plugins";
import { checkPluginUpdates, installPlugin, searchPlugins } from "@yaakapp-internal/plugins";
import { import {
checkPluginUpdates, Banner,
installPlugin,
searchPlugins,
uninstallPlugin,
} from "@yaakapp-internal/plugins";
import {
HStack, HStack,
Icon, Icon,
InlineCode, InlineCode,
@@ -20,18 +16,17 @@ import {
TableHeaderCell, TableHeaderCell,
TableRow, TableRow,
useDebouncedValue, useDebouncedValue,
CountBadge,
} from "@yaakapp-internal/ui"; } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useState } from "react"; import { type ReactNode, useState } from "react";
import { useInstallPlugin } from "../../hooks/useInstallPlugin"; import { useInstallPlugin } from "../../hooks/useInstallPlugin";
import { usePluginInfo } from "../../hooks/usePluginInfo"; import { usePluginInfo, usePluginInfos } from "../../hooks/usePluginInfo";
import { usePluginsKey, useRefreshPlugins } from "../../hooks/usePlugins"; import { usePluginsKey, useRefreshPlugins } from "../../hooks/usePlugins";
import { showConfirmDelete } from "../../lib/confirm"; import { usePromptUninstallPlugin } from "../../hooks/useUninstallPlugin";
import { minPromiseMillis } from "../../lib/minPromiseMillis";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox"; import { Checkbox } from "../core/Checkbox";
import { CountBadge } from "../core/CountBadge";
import { IconButton } from "../core/IconButton"; import { IconButton } from "../core/IconButton";
import { Link } from "../core/Link"; import { Link } from "../core/Link";
import { PlainInput } from "../core/PlainInput"; import { PlainInput } from "../core/PlainInput";
@@ -205,7 +200,7 @@ function PluginTableRow({
mutationKey: ["install_plugin", name], mutationKey: ["install_plugin", name],
mutationFn: (name: string) => installPlugin(name, null), mutationFn: (name: string) => installPlugin(name, null),
}); });
const uninstall = usePromptUninstall(plugin?.id ?? null, displayName); const uninstall = usePromptUninstallPlugin(plugin?.id ?? null, displayName);
const refreshPlugins = useRefreshPlugins(); const refreshPlugins = useRefreshPlugins();
return ( return (
@@ -296,7 +291,10 @@ function PluginSearch() {
const debouncedQuery = useDebouncedValue(query); const debouncedQuery = useDebouncedValue(query);
const results = useQuery({ const results = useQuery({
queryKey: ["plugins", debouncedQuery], queryKey: ["plugins", debouncedQuery],
queryFn: () => searchPlugins(query), queryFn: async () => {
const { plugins } = await searchPlugins(query);
return { plugins: plugins.filter((p) => p.deprecated == null) };
},
}); });
return ( return (
@@ -349,22 +347,77 @@ function InstalledPlugins({ plugins, className }: { plugins: Plugin[]; className
</EmptyStateText> </EmptyStateText>
</div> </div>
) : ( ) : (
<Table scrollable className={className}> <div className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)]")}>
<TableHead> <DeprecatedPluginBanners />
<TableRow> <Table scrollable>
<TableHeaderCell className="w-0" /> <TableHead>
<TableHeaderCell>Display Name</TableHeaderCell> <TableRow>
<TableHeaderCell>Name</TableHeaderCell> <TableHeaderCell className="w-0" />
<TableHeaderCell>Version</TableHeaderCell> <TableHeaderCell>Display Name</TableHeaderCell>
<TableHeaderCell /> <TableHeaderCell>Name</TableHeaderCell>
</TableRow> <TableHeaderCell>Version</TableHeaderCell>
</TableHead> <TableHeaderCell />
<tbody className="divide-y divide-surface-highlight"> </TableRow>
{plugins.map((p) => ( </TableHead>
<PluginTableRowForInstalledPlugin key={p.id} plugin={p} /> <tbody className="divide-y divide-surface-highlight">
))} {plugins.map((p) => (
</tbody> <PluginTableRowForInstalledPlugin key={p.id} plugin={p} />
</Table> ))}
</tbody>
</Table>
</div>
);
}
function DeprecatedPluginBanners() {
const deprecated = usePluginInfos().filter(
({ plugin, info }) => plugin.source !== "bundled" && info.deprecated != null,
);
return (
<div className={classNames("flex flex-col gap-2", deprecated.length > 0 && "pb-3")}>
{deprecated.map(({ plugin, info }) => (
<DeprecatedPluginBanner key={plugin.id} plugin={plugin} name={info.displayName}>
{withInlineCode(info.deprecated ?? "")}
</DeprecatedPluginBanner>
))}
</div>
);
}
/** Backtick spans in a package.json string, set as code. */
function withInlineCode(text: string): ReactNode {
return text
.split("`")
.map((part, i) => (i % 2 === 1 ? <InlineCode key={i}>{part}</InlineCode> : part));
}
export function DeprecatedPluginBanner({
children,
name,
plugin,
}: {
children: ReactNode;
name: string;
plugin: Plugin;
}) {
const uninstall = usePromptUninstallPlugin(plugin.id, name);
return (
<Banner color="info" className="flex items-center gap-4">
<div className="min-w-0 flex-1">
<div className="text text-text">The {name} plugin is deprecated.</div>
<p className="mt-1 text-sm text-text-subtle">{children}</p>
</div>
<Button
size="xs"
variant="border"
title="Uninstall plugin"
className="shrink-0"
isLoading={uninstall?.isPending}
onClick={() => uninstall?.mutate()}
>
Uninstall
</Button>
</Banner>
); );
} }
@@ -393,31 +446,6 @@ function BundledPlugins({ plugins }: { plugins: Plugin[] }) {
); );
} }
function usePromptUninstall(pluginId: string | null, name: string) {
const mut = useMutation({
mutationKey: ["uninstall_plugin", pluginId],
mutationFn: async () => {
if (pluginId == null) return;
const confirmed = await showConfirmDelete({
id: `uninstall-plugin-${pluginId}`,
title: "Uninstall Plugin",
confirmText: "Uninstall",
description: (
<>
Permanently uninstall <InlineCode>{name}</InlineCode>?
</>
),
});
if (confirmed) {
await minPromiseMillis(uninstallPlugin(pluginId), 700);
}
},
});
return pluginId == null ? null : mut;
}
function usePluginUpdates() { function usePluginUpdates() {
return useQuery({ return useQuery({
queryKey: ["plugin_updates", usePluginsKey()], queryKey: ["plugin_updates", usePluginsKey()],
@@ -1,6 +1,6 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models"; import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import type { ProxySetting } 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 { useAtomValue } from "jotai";
import { CommercialUseBanner } from "../CommercialUseBanner"; import { CommercialUseBanner } from "../CommercialUseBanner";
import { import {
@@ -26,15 +26,11 @@ export function SettingsProxy() {
}; };
return ( return (
<VStack space={1.5} className="mb-4"> <SettingsPage
<div className="mb-3"> title="Proxy"
<Heading>Proxy</Heading> description="Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging traffic, or routing through specific infrastructure."
<p className="text-text-subtle"> banner={<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />}
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?" />
<SettingsList className="space-y-8"> <SettingsList className="space-y-8">
<SettingsSection title="Proxy"> <SettingsSection title="Proxy">
<SettingRowSelect <SettingRowSelect
@@ -160,7 +156,7 @@ export function SettingsProxy() {
</> </>
)} )}
</SettingsList> </SettingsList>
</VStack> </SettingsPage>
); );
} }
@@ -1,173 +1,15 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models"; import { SettingsTheme as SharedSettingsTheme } from "@yaakapp-internal/ui";
import { Heading, HStack, Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { lazy, Suspense } from "react";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace"; import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { useResolvedAppearance } from "../../hooks/useResolvedAppearance"; import { useSettingsView } from "../../hooks/useSettingsView";
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",
];
export function SettingsTheme() { export function SettingsTheme() {
const workspace = useAtomValue(activeWorkspaceAtom); const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom); const bound = useSettingsView();
const appearance = useResolvedAppearance();
const activeTheme = useResolvedTheme();
if (settings == null || workspace == null || activeTheme.data == null) { if (workspace == null || bound == null) {
return null; return null;
} }
const lightThemes: SelectProps<string>["options"] = activeTheme.data.themes return <SharedSettingsTheme view={bound.view} callbacks={bound.callbacks} />;
.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>
);
} }
@@ -82,10 +82,9 @@ export function SettingsDropdown() {
}, },
{ {
label: "Install CLI", label: "Install CLI",
hidden: appInfo.cliVersion != null, hidden: appInfo.cliVersion != null || !platform.capabilities.agents,
leftSlot: <Icon icon="square_terminal" />, leftSlot: <Icon icon="square_terminal" />,
rightSlot: <Icon icon="external_link" color="secondary" />, onSelect: () => openSettings.mutate("agents"),
onSelect: () => platform.openUrl("https://yaak.app/docs/cli"),
}, },
{ {
label: "Feedback", label: "Feedback",
+83 -43
View File
@@ -63,13 +63,10 @@ import { isSidebarFocused } from "../lib/scopes";
import { navigateToRequestOrFolderOrWorkspace } from "../lib/setWorkspaceSearchParams"; import { navigateToRequestOrFolderOrWorkspace } from "../lib/setWorkspaceSearchParams";
import type { ContextMenuProps, DropdownItem } from "./core/Dropdown"; import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
import { ContextMenu, Dropdown } from "./core/Dropdown"; import { ContextMenu, Dropdown } from "./core/Dropdown";
import type { FieldDef } from "./core/Editor/filter/extension"; import type { Node as QueryNode, Schema } from "@yaakapp-internal/query";
import { filter } from "./core/Editor/filter/extension"; import { formatField, parseQuery, QueryMatcher } from "@yaakapp-internal/query";
import type { Ast } from "./core/Editor/filter/query"; import { queryFilter } from "@yaakapp-internal/ui/editor";
import { evaluate, parseQuery } from "./core/Editor/filter/query";
import { formatFieldFilter } from "./core/Editor/filter/format";
import { HttpMethodTag } from "./core/HttpMethodTag"; import { HttpMethodTag } from "./core/HttpMethodTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import { import {
Icon, Icon,
LoadingIcon, LoadingIcon,
@@ -77,6 +74,7 @@ import {
isSelectedFamily, isSelectedFamily,
selectedIdsFamily, selectedIdsFamily,
InlineCode, InlineCode,
HttpStatusTag,
} from "@yaakapp-internal/ui"; } from "@yaakapp-internal/ui";
import type { TreeNode, TreeHandle, TreeProps, TreeItemProps } from "@yaakapp-internal/ui"; import type { TreeNode, TreeHandle, TreeProps, TreeItemProps } from "@yaakapp-internal/ui";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
@@ -112,7 +110,7 @@ function Sidebar({ className }: { className?: string }) {
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id; const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`; const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
const filterText = useAtomValue(sidebarFilterAtom); const filterText = useAtomValue(sidebarFilterAtom);
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? []; const [tree, fieldValues, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
const wrapperRef = useRef<HTMLElement>(null); const wrapperRef = useRef<HTMLElement>(null);
const treeRef = useRef<TreeHandle>(null); const treeRef = useRef<TreeHandle>(null);
@@ -553,18 +551,18 @@ function Sidebar({ className }: { className?: string }) {
const filterCompartmentMountExtRef = useRef<Extension | null>(null); const filterCompartmentMountExtRef = useRef<Extension | null>(null);
if (filterCompartmentMountExtRef.current == null) { if (filterCompartmentMountExtRef.current == null) {
filterCompartmentMountExtRef.current = filterLanguageCompartmentRef.current.of( filterCompartmentMountExtRef.current = filterLanguageCompartmentRef.current.of(
filter({ fields: allFields ?? [] }), queryFilter({ schema: sidebarFilterSchema, values: fieldValues }),
); );
} }
useEffect(() => { useEffect(() => {
const view = filterRef.current; const view = filterRef.current;
if (!view) return; if (!view) return;
const ext = filter({ fields: allFields ?? [] }); const ext = queryFilter({ schema: sidebarFilterSchema, values: fieldValues });
view.dispatch({ view.dispatch({
effects: filterLanguageCompartmentRef.current.reconfigure(ext), effects: filterLanguageCompartmentRef.current.reconfigure(ext),
}); });
}, [allFields]); }, [fieldValues]);
if (tree == null || hidden) { if (tree == null || hidden) {
return null; return null;
@@ -852,16 +850,44 @@ function setSidebarFilterText(text: string) {
jotaiStore.set(sidebarFilterAtom, { text, key: `${Math.random()}` }); 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 == null) return null;
if (ast.type === "Term" || ast.type === "Phrase") { if (ast.type === "Term" || ast.type === "Phrase") {
const value = ast.value.trim(); const value = ast.text.trim();
return value.length > 0 ? value : null; return value.length > 0 ? value : null;
} }
if (ast.type === "Field") { 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; return value.length > 0 ? value : null;
} }
@@ -883,7 +909,7 @@ const sidebarSuggestionFieldOrder = [
]; ];
const sidebarTreeAtom = atom< const sidebarTreeAtom = atom<
[TreeNode<SidebarModel>, FieldDef[], SidebarFilterSuggestion[]] | null [TreeNode<SidebarModel>, Record<string, string[]>, SidebarFilterSuggestion[]] | null
>((get) => { >((get) => {
const allModels = get(memoAllPotentialChildrenAtom); const allModels = get(memoAllPotentialChildrenAtom);
const activeWorkspace = get(activeWorkspaceAtom); const activeWorkspace = get(activeWorkspaceAtom);
@@ -904,15 +930,18 @@ const sidebarTreeAtom = atom<
return null; return null;
} }
const queryAst = parseQuery(filter.text); const suggestionValue = getSidebarSuggestionValue(parseQuery(filter.text), filter.text);
const suggestionValue = getSidebarSuggestionValue(queryAst); 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 allFields: Record<string, Set<string>> = {};
const suggestionFields = new 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] ?? []; const childItems = childrenMap[node.item.id] ?? [];
let matchesSelf = true;
const fields = getItemFields(node); const fields = getItemFields(node);
const model = node.item.model; const model = node.item.model;
const isLeafNode = !(model === "folder" || model === "workspace"); const isLeafNode = !(model === "folder" || model === "workspace");
@@ -930,38 +959,44 @@ const sidebarTreeAtom = atom<
} }
} }
if (queryAst != null) { if (isLeafNode && !matcher.isEmpty) {
matchesSelf = isLeafNode && evaluate(queryAst, { text: getItemText(node.item), fields }); leaves.push(node);
cells.push(getItemText(node.item));
for (const column of sidebarFilterColumns) cells.push(fields[column] ?? null);
} }
let matchesChild = false;
// Recurse to children // Recurse to children
node.children = !isLeafNode ? [] : undefined; node.children = !isLeafNode ? [] : undefined;
if (node.children != null) { if (node.children != null) {
childItems.sort((a, b) => { childItems.sort((a, b) => {
if (a.sortPriority === b.sortPriority) { if (a.sortPriority !== b.sortPriority) {
return a.updatedAt > b.updatedAt ? 1 : -1; return a.sortPriority - b.sortPriority;
} }
return a.sortPriority - b.sortPriority; // Keep equal-priority items in a stable order when they are edited.
if (a.createdAt !== b.createdAt) {
return a.createdAt > b.createdAt ? 1 : -1;
}
return a.id === b.id ? 0 : a.id > b.id ? 1 : -1;
}); });
for (const item of childItems) { for (const item of childItems) {
const childNode = { item, parent: node, depth }; const childNode = { item, parent: node, depth };
const childMatches = build(childNode, depth + 1); build(childNode, depth + 1);
if (childMatches) {
matchesChild = true;
}
node.children.push(childNode); node.children.push(childNode);
} }
} }
};
// hide node IFF nothing in its subtree matches // Hide a node IFF nothing in its subtree matches; with no query, nothing is hidden
const anyMatch = matchesSelf || matchesChild; let matched: Set<TreeNode<SidebarModel>> | null = null;
node.hidden = !anyMatch; const markHidden = (node: TreeNode<SidebarModel>): boolean => {
let visible = matched == null || matched.has(node);
return anyMatch; for (const child of node.children ?? []) {
if (markHidden(child)) visible = true;
}
node.hidden = !visible;
return visible;
}; };
const root: TreeNode<SidebarModel> = { const root: TreeNode<SidebarModel> = {
@@ -971,15 +1006,20 @@ const sidebarTreeAtom = atom<
depth: 0, depth: 0,
}; };
// Build tree and mark visibility in one pass
build(root, 1); 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)) { for (const [name, values] of Object.entries(allFields)) {
fields.push({ if (name === "url" || name === "name") continue;
name, fieldValues[name] = Array.from(values);
values: Array.from(values).filter((v) => v.length < 20),
});
} }
const suggestions = Array.from(suggestionFields) const suggestions = Array.from(suggestionFields)
.sort((a, b) => { .sort((a, b) => {
@@ -990,9 +1030,9 @@ const sidebarTreeAtom = atom<
}) })
.map((field) => ({ .map((field) => ({
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) => { const sidebarGitStatusByModelIdAtom = atom<Record<string, GitStatus>>((get) => {
@@ -1,11 +1,11 @@
import type { Workspace } from "@yaakapp-internal/models"; import type { Workspace } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } from "@yaakapp-internal/models"; import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui"; import { Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useState } from "react"; import { useState } from "react";
import { switchWorkspace } from "../commands/switchWorkspace"; import { switchWorkspace } from "../commands/switchWorkspace";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox"; import { Checkbox } from "./core/Checkbox";
import { DialogFooter } from "./core/Dialog";
interface Props { interface Props {
hide: () => void; hide: () => void;
@@ -16,45 +16,33 @@ export function SwitchWorkspaceDialog({ hide, workspace }: Props) {
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const [remember, setRemember] = useState<boolean>(false); const [remember, setRemember] = useState<boolean>(false);
const open = async (inNewWindow: boolean) => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: inNewWindow });
}
};
return ( return (
<VStack space={3}> <VStack space={3}>
<p> <p>
Where would you like to open <InlineCode>{workspace.name}</InlineCode>? Where would you like to open <InlineCode>{workspace.name}</InlineCode>?
</p> </p>
<HStack space={2} justifyContent="start" className="flex-row-reverse">
<Button
className="focus"
color="primary"
onClick={async () => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: false });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: false });
}
}}
>
This Window
</Button>
<Button
className="focus"
color="secondary"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: true });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: true });
}
}}
>
New Window
</Button>
</HStack>
{settings && ( {settings && (
<HStack justifyContent="end"> <Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
</HStack>
)} )}
<DialogFooter
inline
actions={[
{
label: "New Window",
rightSlot: <Icon icon="external_link" />,
onClick: () => open(true),
},
{ label: "This Window", color: "primary", autoFocus: true, onClick: () => open(false) },
]}
/>
</VStack> </VStack>
); );
} }
@@ -0,0 +1,152 @@
// @vitest-environment happy-dom
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import type { Workspace } from "@yaakapp-internal/models";
import type { TemplateFunction } from "@yaakapp-internal/plugins";
import type { Tokens } from "@yaakapp-internal/templates";
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeAll, beforeEach, expect, test, vi } from "vite-plus/test";
import { TemplateFunctionDialog } from "./TemplateFunctionDialog";
interface FormProps {
data: Record<string, string | boolean>;
onChange: (values: Record<string, string | boolean>) => void;
}
const mocks = vi.hoisted(() => ({
parse: vi.fn<(text: string) => Tokens>(),
print: vi.fn<(tokens: Tokens) => Promise<string>>(),
serialize: vi.fn<(tokens: Tokens) => { data: string }>(),
preview: vi.fn<({ template }: { template: string }) => { data: string }>(),
form: null as FormProps | null,
config: {
name: "timestamp.format",
args: [
{ type: "text", name: "date" },
{ type: "text", name: "format" },
],
} as TemplateFunction,
}));
vi.mock("@yaakapp-internal/templates", () => ({ parseTemplate: mocks.parse }));
vi.mock("@yaakapp-internal/ui", () => ({
HStack: ({ children }: { children: ReactNode }) => <div>{children}</div>,
InlineCode: "code",
LoadingIcon: () => null,
useDebouncedValue: (value: string) => value,
}));
vi.mock("../hooks/useActiveWorkspace", () => ({ activeWorkspaceAtom: null }));
vi.mock("../hooks/useRenderTemplate", () => ({ useRenderTemplate: mocks.preview }));
vi.mock("../hooks/useTemplateFunctionConfig", () => ({
useTemplateFunctionConfig: () => ({ data: mocks.config }),
}));
vi.mock("../hooks/useTemplateTokensToString", () => ({
templateTokensToString: mocks.print,
useTemplateTokensToString: mocks.serialize,
}));
vi.mock("../lib/dialog", () => ({ showDialog: vi.fn() }));
vi.mock("../lib/encryption", () => ({ convertTemplateToInsecure: vi.fn() }));
vi.mock("../lib/jotai", () => ({ jotaiStore: { get: vi.fn() } }));
vi.mock("../lib/setupOrConfigureEncryption", () => ({ setupOrConfigureEncryption: vi.fn() }));
vi.mock("./core/Dialog", () => ({ DialogFooter: () => <button type="submit">Save</button> }));
vi.mock("./core/IconButton", () => ({ IconButton: () => <button type="button" /> }));
vi.mock("./core/PlainInput", () => ({ PlainInput: () => null }));
vi.mock("./DynamicForm", () => ({
DYNAMIC_FORM_NULL_ARG: "__NULL__",
DynamicForm: (props: FormProps) => {
mocks.form = props;
return <input aria-label="Timestamp" readOnly value={String(props.data.date ?? "")} />;
},
}));
beforeAll(async () => {
const base = pathToFileURL(
resolve(import.meta.dirname, "../../../crates/yaak-templates/pkg") + "/",
);
const bindings = await import(new URL("yaak_templates_bg.js", base).href);
const { instance } = await WebAssembly.instantiate(
await readFile(new URL("yaak_templates_bg.wasm", base)),
{ "./yaak_templates_bg.js": bindings },
);
bindings.__wbg_set_wasm(instance.exports);
(instance.exports.__wbindgen_start as () => void)();
mocks.parse.mockImplementation(bindings.parse_template);
});
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
mocks.form = null;
// The transport returns an opaque string. Carry the AST as JSON so the assertions can
// inspect what Preview and Save receive, without duplicating the backend's printer.
mocks.serialize.mockImplementation((tokens) => ({ data: JSON.stringify(tokens) }));
mocks.preview.mockImplementation(({ template }) => ({ data: template }));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
vi.unstubAllGlobals();
});
function functionValue(tokens: Tokens) {
const first = tokens.tokens[0];
if (first?.type !== "tag" || first.val.type !== "fn") throw new Error("Expected function");
return first.val;
}
test.each([
["timestamp.offset(expression='-1h')", "${[ timestamp.offset(expression='-1h') ]}"],
["date_var", "${[ date_var ]}"],
["dates.previous", "${[ dates.previous ]}"],
["'${[ date_var ]}'", "${[ date_var ]}"],
])("Preview and Save preserve %s until that input changes", async (expression, display) => {
const initialTokens = mocks.parse(`\${[ timestamp.format(date=${expression}, format='HH:mm') ]}`);
const originalDate = functionValue(initialTokens).args[0];
const saved = vi.fn<(value: string) => void>();
mocks.print.mockResolvedValue(display);
await act(async () =>
root.render(
<TemplateFunctionDialog
templateFunction={mocks.config}
initialTokens={initialTokens}
model={{ id: "wk_test" } as Workspace}
onChange={saved}
hide={() => {}}
/>,
),
);
expect(host.querySelector<HTMLInputElement>("input")?.value).toBe(display);
async function submit() {
const form = host.querySelector("form");
if (form == null) throw new Error("Dialog did not initialize");
await act(async () => {
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
const value = saved.mock.calls.at(-1)?.[0];
expect(value).toBe(mocks.preview.mock.calls.at(-1)?.[0].template);
return functionValue(JSON.parse(value ?? "null") as Tokens).args;
}
expect((await submit())[0]).toEqual(originalDate);
await act(async () => mocks.form?.onChange({ ...mocks.form.data, format: "yyyy" }));
expect(await submit()).toEqual([
originalDate,
{ name: "format", value: { type: "str", text: "yyyy" } },
]);
const edited = "${[ timestamp.offset(expression='-2h') ]}";
await act(async () => mocks.form?.onChange({ ...mocks.form.data, date: edited }));
expect((await submit())[0]).toEqual({ name: "date", value: { type: "str", text: edited } });
await act(async () => mocks.form?.onChange({ ...mocks.form.data, date: "" }));
expect((await submit())[0]).toEqual({ name: "date", value: { type: "str", text: "" } });
});
@@ -11,7 +11,7 @@ import type { FnArg, Tokens } from "@yaakapp-internal/templates";
import { parseTemplate } from "@yaakapp-internal/templates"; import { parseTemplate } from "@yaakapp-internal/templates";
import { HStack, InlineCode, LoadingIcon, useDebouncedValue } from "@yaakapp-internal/ui"; import { HStack, InlineCode, LoadingIcon, useDebouncedValue } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useId, useMemo, useState } from "react";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace"; import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { useRenderTemplate } from "../hooks/useRenderTemplate"; import { useRenderTemplate } from "../hooks/useRenderTemplate";
import { useTemplateFunctionConfig } from "../hooks/useTemplateFunctionConfig"; import { useTemplateFunctionConfig } from "../hooks/useTemplateFunctionConfig";
@@ -24,8 +24,11 @@ import { showDialog } from "../lib/dialog";
import { convertTemplateToInsecure } from "../lib/encryption"; import { convertTemplateToInsecure } from "../lib/encryption";
import { jotaiStore } from "../lib/jotai"; import { jotaiStore } from "../lib/jotai";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption"; import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import { Button } from "./core/Button"; import {
import { collectArgumentValues } from "./core/Editor/twig/util"; collectEditableArgumentValues,
preserveUnchangedArgumentExpressions,
} from "../lib/templateFunctionArguments";
import { DialogFooter } from "./core/Dialog";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput"; import { PlainInput } from "./core/PlainInput";
import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from "./DynamicForm"; import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from "./DynamicForm";
@@ -48,7 +51,11 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
} }
(async () => { (async () => {
const initial = collectArgumentValues(initialTokens, templateFunction); const initial = await collectEditableArgumentValues(
initialTokens,
templateFunction,
templateTokensToString,
);
// HACK: Replace the secure() function's encrypted `value` arg with the decrypted version so // HACK: Replace the secure() function's encrypted `value` arg with the decrypted version so
// we can display it in the editor input. // we can display it in the editor input.
@@ -74,6 +81,7 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
<InitializedTemplateFunctionDialog <InitializedTemplateFunctionDialog
{...props} {...props}
templateFunction={templateFunction} templateFunction={templateFunction}
initialTokens={initialTokens}
initialArgValues={initialArgValues} initialArgValues={initialArgValues}
/> />
); );
@@ -82,15 +90,17 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
function InitializedTemplateFunctionDialog({ function InitializedTemplateFunctionDialog({
templateFunction: { name, previewType: ogPreviewType }, templateFunction: { name, previewType: ogPreviewType },
initialArgValues, initialArgValues,
initialTokens,
hide, hide,
onChange, onChange,
model, model,
}: Omit<Props, "initialTokens"> & { }: Props & {
initialArgValues: Record<string, string | boolean>; initialArgValues: Record<string, string | boolean>;
}) { }) {
const previewType = ogPreviewType == null ? "live" : ogPreviewType; const previewType = ogPreviewType == null ? "live" : ogPreviewType;
const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false); const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false);
const [argValues, setArgValues] = useState<Record<string, string | boolean>>(initialArgValues); const [argValues, setArgValues] = useState<Record<string, string | boolean>>(initialArgValues);
const formId = useId();
const tokens: Tokens = useMemo(() => { const tokens: Tokens = useMemo(() => {
const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({ const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({
@@ -110,12 +120,17 @@ function InitializedTemplateFunctionDialog({
val: { val: {
type: "fn", type: "fn",
name, name,
args: argTokens, args: preserveUnchangedArgumentExpressions(
argTokens,
initialTokens,
initialArgValues,
argValues,
),
}, },
}, },
], ],
}; };
}, [argValues, name]); }, [argValues, initialArgValues, initialTokens, name]);
const tagText = useTemplateTokensToString(tokens); const tagText = useTemplateTokensToString(tokens);
const templateFunction = useTemplateFunctionConfig(name, argValues, model); const templateFunction = useTemplateFunctionConfig(name, argValues, model);
@@ -160,13 +175,14 @@ function InitializedTemplateFunctionDialog({
return ( return (
<form <form
className="grid grid-rows-[minmax(0,1fr)_auto_auto] h-full max-h-[90vh]" id={formId}
className="grid grid-rows-[minmax(0,1fr)_auto] h-full"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
handleDone(); handleDone();
}} }}
> >
<div className="overflow-y-auto h-full px-6"> <div className="overflow-y-auto h-full">
{name === "secure" ? ( {name === "secure" ? (
<PlainInput <PlainInput
required required
@@ -188,75 +204,71 @@ function InitializedTemplateFunctionDialog({
/> />
)} )}
</div> </div>
<div className="px-6 border-t border-t-border pt-3 pb-6 bg-surface-highlight w-full flex flex-col gap-4"> {previewType !== "none" ? (
{previewType !== "none" ? ( <div className="w-full grid grid-cols-1 grid-rows-[auto_auto] border-t border-t-border pt-3">
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto]"> <HStack space={0.5}>
<HStack space={0.5}> <HStack className="text-sm text-text-subtle" space={1.5}>
<HStack className="text-sm text-text-subtle" space={1.5}> Rendered Preview
Rendered Preview {rendered.isLoading && <LoadingIcon size="xs" />}
{rendered.isLoading && <LoadingIcon size="xs" />} </HStack>
</HStack> <IconButton
size="xs"
iconSize="sm"
icon={showSecretsInPreview ? "lock" : "lock_open"}
title={showSecretsInPreview ? "Show preview" : "Hide preview"}
onClick={toggleShowSecretsInPreview}
className={classNames(
"ml-auto text-text-subtlest",
!dataContainsSecrets && "invisible",
)}
/>
</HStack>
<div className="relative w-full max-h-40">
<InlineCode
className={classNames(
"block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
tooLarge && "italic text-danger",
)}
>
{rendered.error || tagText.error ? (
<em className="text-danger">
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
</em>
) : dataContainsSecrets && !showSecretsInPreview ? (
<span className="italic text-text-subtle">
------ sensitive values hidden ------
</span>
) : tooLarge ? (
"too large to preview"
) : (
rendered.data || <>&nbsp;</>
)}
</InlineCode>
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
<IconButton <IconButton
size="xs" size="xs"
iconSize="sm" icon="refresh"
icon={showSecretsInPreview ? "lock" : "lock_open"} className="text-text-subtle"
title={showSecretsInPreview ? "Show preview" : "Hide preview"} title="Refresh preview"
onClick={toggleShowSecretsInPreview} spin={rendered.isPending}
className={classNames( onClick={() => {
"ml-auto text-text-subtlest", setRenderKey(new Date().toISOString());
!dataContainsSecrets && "invisible", }}
)}
/> />
</HStack>
<div className="relative w-full max-h-40">
<InlineCode
className={classNames(
"block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
tooLarge && "italic text-danger",
)}
>
{rendered.error || tagText.error ? (
<em className="text-danger">
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
</em>
) : dataContainsSecrets && !showSecretsInPreview ? (
<span className="italic text-text-subtle">
------ sensitive values hidden ------
</span>
) : tooLarge ? (
"too large to preview"
) : (
rendered.data || <>&nbsp;</>
)}
</InlineCode>
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
<IconButton
size="xs"
icon="refresh"
className="text-text-subtle"
title="Refresh preview"
spin={rendered.isPending}
onClick={() => {
setRenderKey(new Date().toISOString());
}}
/>
</div>
</div> </div>
</div> </div>
) : (
<span />
)}
<div className="flex justify-stretch w-full grow gap-2 *:flex-1">
{templateFunction.data.name === "secure" && (
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
Reveal Encryption Key
</Button>
)}
<Button type="submit" color="primary">
Save
</Button>
</div> </div>
</div> ) : (
<span />
)}
<DialogFooter
actions={[
...(templateFunction.data.name === "secure"
? [{ label: "Reveal Encryption Key", onClick: setupOrConfigureEncryption }]
: []),
{ label: "Save", color: "primary", form: formId },
]}
/>
</form> </form>
); );
} }
@@ -272,7 +284,6 @@ TemplateFunctionDialog.show = (
id: `template-function-${Math.random()}`, // Allow multiple at once id: `template-function-${Math.random()}`, // Allow multiple at once
size: "md", size: "md",
className: "h-240", className: "h-240",
noPadding: true,
title: <InlineCode>{fn.name}(…)</InlineCode>, title: <InlineCode>{fn.name}(…)</InlineCode>,
description: fn.description, description: fn.description,
render: ({ hide }) => { render: ({ hide }) => {
+4 -41
View File
@@ -1,43 +1,6 @@
import { useAtomValue } from "jotai"; import { Toasts as BaseToasts } from "@yaakapp-internal/ui";
import { AnimatePresence } from "motion/react"; import { showErrorDialog } from "./ErrorBoundary";
import type { ReactNode } from "react";
import { hideToast, toastsAtom } from "../lib/toast";
import { Toast, type ToastProps } from "./core/Toast";
import { ErrorBoundary } from "./ErrorBoundary";
import { Portal } from "@yaakapp-internal/ui";
export type ToastInstance = { export type { ToastInstance } from "@yaakapp-internal/ui";
id: string;
uniqueKey: string;
message: ReactNode;
timeout: 3000 | 5000 | 8000 | (number & {}) | null;
onClose?: ToastProps["onClose"];
} & Omit<ToastProps, "onClose" | "open" | "children" | "timeout">;
export const Toasts = () => { export const Toasts = () => <BaseToasts onShowError={showErrorDialog} />;
const toasts = useAtomValue(toastsAtom);
return (
<Portal name="toasts">
<div className="absolute right-0 bottom-0 z-50">
<AnimatePresence>
{toasts.map((toast: ToastInstance) => {
const { message, uniqueKey, ...props } = toast;
return (
<ErrorBoundary key={uniqueKey} name={`Toast ${uniqueKey}`}>
<Toast
open
{...props}
// We call onClose inside actions.hide instead of passing to toast so that
// it gets called from external close calls as well
onClose={() => hideToast(toast)}
>
{message}
</Toast>
</ErrorBoundary>
);
})}
</AnimatePresence>
</div>
</Portal>
);
};
+6 -105
View File
@@ -1,113 +1,14 @@
import type { HttpRequest } from "@yaakapp-internal/models"; import { UrlBar as BaseUrlBar, type UrlBarProps } from "@yaakapp-internal/ui";
import type { IconProps } from "@yaakapp-internal/ui"; import { memo } from "react";
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";
type Props = Pick<HttpRequest, "url"> & { type Props = Omit<UrlBarProps, "sendHotkeyAction" | "focusHotkeyAction" | "readOnly"> & {
className?: string;
placeholder: string;
onSend: () => void; onSend: () => void;
onUrlChange: (url: string) => void; onUrlChange: (url: string) => void;
onPaste?: (v: string) => void;
onPasteOverwrite?: InputProps["onPasteOverwrite"];
onCancel: () => void; onCancel: () => void;
submitIcon?: IconProps["icon"] | null;
isLoading: boolean; isLoading: boolean;
forceUpdateKey: string;
rightSlot?: ReactNode;
leftSlot?: ReactNode;
autocomplete?: InputProps["autocomplete"];
stateKey: InputProps["stateKey"];
}; };
export const UrlBar = memo(function UrlBar({ /** The shared URL bar, sending and focusing on the client's hotkeys */
forceUpdateKey, export const UrlBar = memo(function UrlBar(props: Props) {
onUrlChange, return <BaseUrlBar {...props} sendHotkeyAction="request.send" focusHotkeyAction="url_bar.focus" />;
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>
);
}); });
@@ -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 { useCallback, useRef } from "react";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor"; import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import type { EditablePair, PairEditorHandle, PairEditorProps } from "./core/PairEditor";
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
type Props = { type Props = {
forceUpdateKey: string; forceUpdateKey: string;
@@ -11,6 +14,7 @@ type Props = {
onChange: PairEditorProps["onChange"]; onChange: PairEditorProps["onChange"];
}; };
/** The shared parameters editor, wired to the request editor's focus events */
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) { export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
const pairEditorRef = useRef<PairEditorHandle>(null); const pairEditorRef = useRef<PairEditorHandle>(null);
const handleInitPairEditorRef = useCallback((ref: PairEditorHandle) => { const handleInitPairEditorRef = useCallback((ref: PairEditorHandle) => {
@@ -33,22 +37,12 @@ export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey
); );
return ( return (
<VStack className="h-full"> <BaseUrlParametersEditor
<PairOrBulkEditor setRef={handleInitPairEditorRef}
setRef={handleInitPairEditorRef} forceUpdateKey={forceUpdateKey + urlParametersKey}
allowMultilineValues onChange={onChange}
forceUpdateKey={forceUpdateKey + urlParametersKey} pairs={pairs}
nameAutocompleteFunctions stateKey={stateKey}
nameAutocompleteVariables />
namePlaceholder="param_name"
onChange={onChange}
pairs={pairs}
preferenceName="url_parameters"
stateKey={stateKey}
valueAutocompleteFunctions
valueAutocompleteVariables
valuePlaceholder="Value"
/>
</VStack>
); );
} }
@@ -22,11 +22,9 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection"; import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor"; import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey"; import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { languageFromContentType } from "../lib/contentType";
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders"; import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring"; import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
import { resolvedModelName } from "../lib/resolvedModelName"; import { resolvedModelName } from "../lib/resolvedModelName";
import { CountBadge } from "./core/CountBadge";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion"; import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import { getUrlCompletionConfig } from "./core/Editor/url/completion"; import { getUrlCompletionConfig } from "./core/Editor/url/completion";
import { Editor } from "./core/Editor/LazyEditor"; import { Editor } from "./core/Editor/LazyEditor";
@@ -40,6 +38,7 @@ import { MarkdownEditor } from "./MarkdownEditor";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor"; import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar"; import { UrlBar } from "./UrlBar";
import { UrlParametersEditor } from "./UrlParameterEditor"; import { UrlParametersEditor } from "./UrlParameterEditor";
import { languageFromContentType, CountBadge } from "@yaakapp-internal/ui";
interface Props { interface Props {
style: CSSProperties; style: CSSProperties;
@@ -1,6 +1,14 @@
import type { WebsocketEvent, WebsocketRequest } from "@yaakapp-internal/models"; import type { WebsocketEvent, WebsocketRequest } from "@yaakapp-internal/models";
import { HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui"; import {
import { hexy } from "hexy"; HStack,
hexdump,
HexdumpViewer,
Icon,
LoadingIcon,
VStack,
useStateWithDeps,
languageFromContentType,
} from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useFormatText } from "../hooks/useFormatText"; import { useFormatText } from "../hooks/useFormatText";
@@ -10,10 +18,9 @@ import {
setPinnedWebsocketConnectionId, setPinnedWebsocketConnectionId,
useWebsocketEvents, useWebsocketEvents,
} from "../hooks/usePinnedWebsocketConnection"; } from "../hooks/usePinnedWebsocketConnection";
import { useStateWithDeps } from "../hooks/useStateWithDeps";
import { languageFromContentType } from "../lib/contentType";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { Editor } from "./core/Editor/LazyEditor"; import { Editor } from "./core/Editor/LazyEditor";
import { renderEditor } from "./core/Editor/renderEditor";
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer"; import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
import { EventViewerRow } from "./core/EventViewerRow"; import { EventViewerRow } from "./core/EventViewerRow";
import { HotkeyList } from "./core/HotkeyList"; import { HotkeyList } from "./core/HotkeyList";
@@ -117,10 +124,10 @@ function WebsocketEventRow({
messageType === "error" messageType === "error"
? "alert_triangle" ? "alert_triangle"
: messageType === "close" || messageType === "open" : messageType === "close" || messageType === "open"
? "info" ? "info"
: isServer : isServer
? "arrow_big_down_dash" ? "arrow_big_down_dash"
: "arrow_big_up_dash"; : "arrow_big_up_dash";
const content = const content =
messageType === "close" ? ( messageType === "close" ? (
@@ -167,7 +174,7 @@ function WebsocketEventDetail({
}) { }) {
const message = useMemo(() => { const message = useMemo(() => {
if (hexDump) { if (hexDump) {
return event.message ? hexy(event.message) : ""; return event.message ? hexdump(event.message) : "";
} }
return event.message ? new TextDecoder("utf-8").decode(Uint8Array.from(event.message)) : ""; return event.message ? new TextDecoder("utf-8").decode(Uint8Array.from(event.message)) : "";
}, [event.message, hexDump]); }, [event.message, hexDump]);
@@ -227,6 +234,8 @@ function WebsocketEventDetail({
</VStack> </VStack>
) : event.message.length === 0 ? ( ) : event.message.length === 0 ? (
<EmptyStateText>No Content</EmptyStateText> <EmptyStateText>No Content</EmptyStateText>
) : hexDump ? (
<HexdumpViewer data={event.message} renderEditor={renderEditor} />
) : ( ) : (
<Editor <Editor
language={language} language={language}
+5 -17
View File
@@ -1,10 +1,10 @@
import { platform } from "@yaakapp-internal/platform"; import { platform } from "@yaakapp-internal/platform";
import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models"; 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 classNames from "classnames";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import * as m from "motion/react-m"; import * as m from "motion/react-m";
import { useMemo, useState } from "react"; import { useState } from "react";
import { import {
getActiveCookieJar, getActiveCookieJar,
useEnsureActiveCookieJar, useEnsureActiveCookieJar,
@@ -40,6 +40,7 @@ import { ErrorBoundary } from "./ErrorBoundary";
import { FolderLayout } from "./FolderLayout"; import { FolderLayout } from "./FolderLayout";
import { GrpcConnectionLayout } from "./GrpcConnectionLayout"; import { GrpcConnectionLayout } from "./GrpcConnectionLayout";
import { HttpRequestLayout } from "./HttpRequestLayout"; import { HttpRequestLayout } from "./HttpRequestLayout";
import { RedirectToLatestWorkspace } from "./RedirectToLatestWorkspace";
import Sidebar from "./Sidebar"; import Sidebar from "./Sidebar";
import { SidebarActions } from "./SidebarActions"; import { SidebarActions } from "./SidebarActions";
import { WebsocketRequestLayout } from "./WebsocketRequestLayout"; import { WebsocketRequestLayout } from "./WebsocketRequestLayout";
@@ -60,15 +61,8 @@ export function Workspace() {
const activeEnvironment = useAtomValue(activeEnvironmentAtom); const activeEnvironment = useAtomValue(activeEnvironmentAtom);
const [floating, setFloating] = useState(false); 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]);
// We're loading still
if (workspaces.length === 0) { if (workspaces.length === 0) {
return null; return <RedirectToLatestWorkspace />;
} }
const header = ( const header = (
@@ -81,13 +75,7 @@ export function Workspace() {
useNativeTitlebar={settings.useNativeTitlebar} useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale} interfaceScale={settings.interfaceScale}
> >
<div className="absolute inset-0 pointer-events-none"> <HeaderTint color={activeEnvironment?.color ?? null} />
<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>
<WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} /> <WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} />
</HeaderSize> </HeaderSize>
); );
@@ -5,20 +5,17 @@ import {
setWorkspaceKey, setWorkspaceKey,
} from "@yaakapp-internal/crypto"; } from "@yaakapp-internal/crypto";
import type { WorkspaceMeta } from "@yaakapp-internal/models"; import type { WorkspaceMeta } from "@yaakapp-internal/models";
import { Banner, HStack, VStack } from "@yaakapp-internal/ui"; import { Banner, HStack, VStack, useStateWithDeps, IconTooltip, Label } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace"; import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation"; import { createFastMutation } from "../hooks/useFastMutation";
import { useStateWithDeps } from "../hooks/useStateWithDeps";
import { showConfirm } from "../lib/confirm"; import { showConfirm } from "../lib/confirm";
import { CopyIconButton } from "./CopyIconButton"; import { CopyIconButton } from "./CopyIconButton";
import type { ButtonProps } from "./core/Button"; import type { ButtonProps } from "./core/Button";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip";
import { Label } from "./core/Label";
import { PlainInput } from "./core/PlainInput"; import { PlainInput } from "./core/PlainInput";
import { SettingRow } from "./core/SettingRow"; import { SettingRow } from "./core/SettingRow";
import { EncryptionHelp } from "./EncryptionHelp"; import { EncryptionHelp } from "./EncryptionHelp";
@@ -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,15 +1,22 @@
import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models"; import type { WorkspaceMeta } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui"; import {
mergeModelsInStore,
patchModel,
workspaceMetasAtom,
workspacesAtom,
} from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode, CountBadge } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useEffect, useState } from "react";
import { useAuthTab } from "../hooks/useAuthTab"; import { useAuthTab } from "../hooks/useAuthTab";
import { useHeadersTab } from "../hooks/useHeadersTab"; import { useHeadersTab } from "../hooks/useHeadersTab";
import { useInheritedHeaders } from "../hooks/useInheritedHeaders"; import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm"; import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { showDialog } from "../lib/dialog"; import { showDialog } from "../lib/dialog";
import { router } from "../lib/router"; import { router } from "../lib/router";
import { rpc } from "../lib/rpc";
import { CopyIconButton } from "./CopyIconButton"; import { CopyIconButton } from "./CopyIconButton";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge";
import { PlainInput } from "./core/PlainInput"; import { PlainInput } from "./core/PlainInput";
import { SettingsList, SettingsSection } from "./core/SettingRow"; import { SettingsList, SettingsSection } from "./core/SettingRow";
import { TabContent, Tabs } from "./core/Tabs/Tabs"; import { TabContent, Tabs } from "./core/Tabs/Tabs";
@@ -48,6 +55,10 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
const authTab = useAuthTab(TAB_AUTH, workspace ?? null); const authTab = useAuthTab(TAB_AUTH, workspace ?? null);
const headersTab = useHeadersTab(TAB_HEADERS, workspace ?? null); const headersTab = useHeadersTab(TAB_HEADERS, workspace ?? null);
const inheritedHeaders = useInheritedHeaders(workspace ?? null); const inheritedHeaders = useInheritedHeaders(workspace ?? null);
const workspaceMetaError = useEnsureWorkspaceMeta(
workspaceId,
workspace != null && workspaceMeta == null,
);
if (workspace == null) { if (workspace == null) {
return ( return (
@@ -57,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 ( return (
<Tabs <Tabs
defaultValue={tab ?? DEFAULT_TAB} defaultValue={tab ?? DEFAULT_TAB}
@@ -106,15 +110,26 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
<TabContent value={TAB_SETTINGS} className="overflow-y-auto h-full px-4"> <TabContent value={TAB_SETTINGS} className="overflow-y-auto h-full px-4">
<SettingsList className="space-y-8 pb-3"> <SettingsList className="space-y-8 pb-3">
<SettingsSection title={null}> <SettingsSection title={null}>
<SyncToFilesystemSetting {workspaceMeta != null ? (
layout="settings" <>
value={{ filePath: workspaceMeta.settingSyncDir }} <SyncToFilesystemSetting
onCreateNewWorkspace={hide} layout="settings"
onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })} value={{ filePath: workspaceMeta.settingSyncDir }}
/> onCreateNewWorkspace={hide}
<div className="mt-4"> onChange={({ filePath }) =>
<WorkspaceEncryptionSetting layout="settings" size="xs" /> patchModel(workspaceMeta, { settingSyncDir: filePath })
</div> }
/>
<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> </SettingsSection>
<ModelSettingsEditor model={workspace} showSectionTitles /> <ModelSettingsEditor model={workspace} showSectionTitles />
</SettingsList> </SettingsList>
@@ -178,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) => { WorkspaceSettingsDialog.show = (workspaceId: string, tab?: WorkspaceSettingsTab) => {
showDialog({ showDialog({
id: "workspace-settings", id: "workspace-settings",
@@ -1,36 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { parseBulkPairLine } from "./BulkPairEditor";
describe("parseBulkPairLine", () => {
test("parses colon-space pairs as name and value", () => {
expect(parseBulkPairLine("foo: bar")).toMatchObject({
enabled: true,
name: "foo",
value: "bar",
});
});
test("preserves colon-without-space lines as a name with an empty value", () => {
expect(parseBulkPairLine("foo:bar")).toMatchObject({
enabled: true,
name: "foo:bar",
value: "",
});
});
test("preserves malformed lines instead of dropping their contents", () => {
expect(parseBulkPairLine("not a pair")).toMatchObject({
enabled: true,
name: "not a pair",
value: "",
});
});
test("unescapes newlines in parsed values", () => {
expect(parseBulkPairLine("foo: bar\\nbaz")).toMatchObject({
enabled: true,
name: "foo",
value: "bar\nbaz",
});
});
});
@@ -1,63 +0,0 @@
import { useCallback, useMemo } from "react";
import { generateId } from "../../lib/generateId";
import { Editor } from "./Editor/LazyEditor";
import type { Pair, PairEditorProps, PairWithId } from "./PairEditor";
type Props = PairEditorProps;
export function BulkPairEditor({
pairs,
onChange,
namePlaceholder,
valuePlaceholder,
forceUpdateKey,
forcedEnvironmentId,
stateKey,
}: Props) {
const pairsText = useMemo(() => {
return pairs
.filter((p) => !(p.name.trim() === "" && p.value.trim() === ""))
.map(formatBulkPairLine)
.join("\n");
}, [pairs]);
const handleChange = useCallback(
(text: string) => {
const pairs = text
.split("\n")
.filter((l: string) => l.trim())
.map(parseBulkPairLine);
onChange(pairs);
},
[onChange],
);
return (
<Editor
autocompleteFunctions
autocompleteVariables
stateKey={`bulk_pair.${stateKey}`}
forcedEnvironmentId={forcedEnvironmentId}
forceUpdateKey={forceUpdateKey}
placeholder={`${namePlaceholder ?? "name"}: ${valuePlaceholder ?? "value"}`}
defaultValue={pairsText}
language="pairs"
onChange={handleChange}
/>
);
}
export function formatBulkPairLine(pair: Pair) {
const value = pair.value.replaceAll("\n", "\\n");
return `${pair.name}: ${value}`;
}
export function parseBulkPairLine(line: string): PairWithId {
const [, name, value] = line.match(/^([^:]+):\s+(.*)$/) ?? [];
return {
enabled: true,
name: (name ?? line).trim(),
value: (value ?? "").replaceAll("\\n", "\n").trim(),
id: generateId(),
};
}
+2 -73
View File
@@ -1,73 +1,2 @@
import { HStack, Icon } from "@yaakapp-internal/ui"; export type { CheckboxProps } from "@yaakapp-internal/ui";
import classNames from "classnames"; export { Checkbox } from "@yaakapp-internal/ui";
import type { ReactNode } from "react";
import { IconTooltip } from "./IconTooltip";
export interface CheckboxProps {
checked: boolean | "indeterminate";
title: ReactNode;
onChange: (checked: boolean) => void;
className?: string;
disabled?: boolean;
inputWrapperClassName?: string;
hideLabel?: boolean;
fullWidth?: boolean;
help?: ReactNode;
size?: "sm" | "md";
}
export function Checkbox({
checked,
onChange,
className,
inputWrapperClassName,
disabled,
title,
hideLabel,
fullWidth,
help,
size = "sm",
}: CheckboxProps) {
return (
<HStack
as="label"
alignItems="center"
space={2}
className={classNames(className, "text-text mr-auto")}
>
<div className={classNames(inputWrapperClassName, "x-theme-input", "relative flex mr-0.5")}>
<input
aria-hidden
className={classNames(
"appearance-none shrink-0 border border-border",
size === "sm" && "w-4 h-4",
size === "md" && "w-5 h-5",
"rounded-sm outline-hidden ring-0",
!disabled && "hocus:border-border-focus hocus:bg-focus/[5%]",
disabled && "border-dotted",
)}
type="checkbox"
disabled={disabled}
onChange={() => {
onChange(checked === "indeterminate" ? true : !checked);
}}
/>
<div className="absolute inset-0 flex items-center justify-center">
<Icon
size={size}
className={classNames(disabled && "opacity-disabled")}
icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"}
/>
</div>
</div>
{!hideLabel && (
<div
className={classNames("text-sm", fullWidth && "w-full", disabled && "opacity-disabled")}
>
{title}
</div>
)}
{help && <IconTooltip content={help} />}
</HStack>
);
}
+32
View File
@@ -0,0 +1,32 @@
import type { Color } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import type { ReactNode } from "react";
interface Props {
color?: Color | "default";
className?: string;
children: ReactNode;
}
/** A small tinted label, like a Gmail or Trello label */
export function Chip({ color = "default", className, children }: Props) {
return (
<span
className={classNames(
className,
"text-xs font-medium rounded-md px-2 py-0.5 bg-current/10 border border-current/10 shrink-0 whitespace-nowrap",
"inline-flex items-center gap-1",
color === "default" && "text-text-subtle",
color === "primary" && "text-primary",
color === "secondary" && "text-secondary",
color === "info" && "text-info",
color === "success" && "text-success",
color === "notice" && "text-notice",
color === "warning" && "text-warning",
color === "danger" && "text-danger",
)}
>
{children}
</span>
);
}
+17 -12
View File
@@ -1,9 +1,8 @@
import type { Color } from "@yaakapp-internal/plugins"; import type { Color } from "@yaakapp-internal/plugins";
import { HStack } from "@yaakapp-internal/ui";
import type { FormEvent } from "react"; import type { FormEvent } from "react";
import { useState } from "react"; import { useId, useState } from "react";
import { CopyIconButton } from "../CopyIconButton"; import { CopyIconButton } from "../CopyIconButton";
import { Button } from "./Button"; import { DialogFooter } from "./Dialog";
import { PlainInput } from "./PlainInput"; import { PlainInput } from "./PlainInput";
export interface ConfirmProps { export interface ConfirmProps {
@@ -22,6 +21,7 @@ export function Confirm({
color = "primary", color = "primary",
}: ConfirmProps) { }: ConfirmProps) {
const [confirm, setConfirm] = useState<string>(""); const [confirm, setConfirm] = useState<string>("");
const formId = useId();
const handleHide = () => { const handleHide = () => {
onResult(false); onResult(false);
onHide(); onHide();
@@ -38,7 +38,7 @@ export function Confirm({
}; };
return ( return (
<form className="flex flex-col" onSubmit={handleSuccess}> <form id={formId} className="flex flex-col" onSubmit={handleSuccess}>
{requireTyping && ( {requireTyping && (
<PlainInput <PlainInput
autoFocus autoFocus
@@ -61,14 +61,19 @@ export function Confirm({
} }
/> />
)} )}
<HStack space={2} justifyContent="start" className="mt-2 mb-4 flex-row-reverse"> <DialogFooter
<Button type="submit" color={color} disabled={!didConfirm}> inline
{confirmText ?? "Confirm"} actions={[
</Button> { label: "Cancel", onClick: handleHide },
<Button onClick={handleHide} variant="border"> {
Cancel label: confirmText ?? "Confirm",
</Button> color,
</HStack> form: formId,
disabled: !didConfirm,
autoFocus: !requireTyping,
},
]}
/>
</form> </form>
); );
} }
@@ -1,60 +1,27 @@
import classNames from "classnames"; import {
import { atom, useAtom } from "jotai"; DetailsBanner as BaseDetailsBanner,
import type { HTMLAttributes, ReactNode } from "react"; type DetailsBannerProps,
import { useMemo } from "react"; type StoredValue,
} from "@yaakapp-internal/ui";
import { atom, useAtom, type WritableAtom } from "jotai";
import { type SetStateAction, useMemo } from "react";
import { atomWithKVStorage } from "../../lib/atoms/atomWithKVStorage"; import { atomWithKVStorage } from "../../lib/atoms/atomWithKVStorage";
import type { BannerProps } from "@yaakapp-internal/ui";
import { Banner } from "@yaakapp-internal/ui";
interface Props extends HTMLAttributes<HTMLDetailsElement> { export function DetailsBanner(props: Omit<DetailsBannerProps, "useStoredValue">) {
summary: ReactNode; return <BaseDetailsBanner {...props} useStoredValue={useDetailsBannerStoredValue} />;
color?: BannerProps["color"];
defaultOpen?: boolean;
storageKey?: string;
} }
export function DetailsBanner({ export function useDetailsBannerStoredValue<T extends StoredValue>(
className, key: string | string[] | null,
color, fallback: T,
summary, ) {
children, // oxlint-disable-next-line react-hooks/exhaustive-deps -- We only want to recompute the atom when the key changes
defaultOpen, const valueAtom = useMemo<WritableAtom<T, [SetStateAction<T>], void>>(
storageKey,
...extraProps
}: Props) {
// oxlint-disable-next-line react-hooks/exhaustive-deps -- We only want to recompute the atom when storageKey changes
const openAtom = useMemo(
() => () =>
storageKey key != null
? atomWithKVStorage<boolean>(["details_banner", storageKey], defaultOpen ?? false) ? atomWithKVStorage<T>(["details_banner", ...[key].flat()], fallback)
: atom(defaultOpen ?? false), : atom(fallback),
[storageKey], [key],
);
const [isOpen, setIsOpen] = useAtom(openAtom);
const handleToggle = (e: React.SyntheticEvent<HTMLDetailsElement>) => {
if (storageKey) {
setIsOpen(e.currentTarget.open);
}
};
return (
<Banner color={color} className={className}>
<details className="group list-none" open={isOpen} onToggle={handleToggle} {...extraProps}>
<summary className="cursor-default! select-none! list-none flex items-center gap-3 focus:outline-hidden opacity-70">
<div
className={classNames(
"transition-transform",
"group-open:rotate-90",
"w-0 h-0 border-t-[0.3em] border-b-[0.3em] border-l-[0.5em] border-r-0",
"border-t-transparent border-b-transparent border-l-text-subtle",
)}
/>
{summary}
</summary>
<div className="mt-1.5 pb-2">{children}</div>
</details>
</Banner>
); );
return useAtom(valueAtom);
} }
+2 -131
View File
@@ -1,131 +1,2 @@
import type { DialogSize } from "@yaakapp-internal/plugins"; export type { DialogFooterAction, DialogProps } from "@yaakapp-internal/ui";
import { Heading, Overlay } from "@yaakapp-internal/ui"; export { Dialog, DialogFooter } from "@yaakapp-internal/ui";
import classNames from "classnames";
import * as m from "motion/react-m";
import type { ReactNode } from "react";
import { useMemo } from "react";
import { IconButton } from "./IconButton";
export interface DialogProps {
children: ReactNode;
open: boolean;
onClose?: () => void;
/** Block dismissal from the backdrop, Escape key, and built-in close button. */
disableClose?: boolean;
title?: ReactNode;
description?: ReactNode;
className?: string;
size?: DialogSize;
/** Hide the built-in close button without changing backdrop or Escape behavior. */
hideX?: boolean;
noPadding?: boolean;
noScroll?: boolean;
vAlign?: "top" | "center";
}
export function Dialog({
children,
className,
size = "full",
open,
onClose,
disableClose,
title,
description,
hideX,
noPadding,
noScroll,
vAlign = "center",
}: DialogProps) {
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
const descriptionId = useMemo(
() => (description ? Math.random().toString(36).slice(2) : undefined),
[description],
);
return (
<Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
<div
role="dialog"
className={classNames(
"py-4 x-theme-dialog absolute inset-0 pointer-events-none",
"h-full flex flex-col items-center justify-center",
vAlign === "top" && "justify-start",
vAlign === "center" && "justify-center",
)}
aria-labelledby={titleId}
aria-describedby={descriptionId}
tabIndex={-1}
onKeyDown={(e) => {
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
// dialogs and can be intercepted by children if needed.
if (e.key === "Escape") {
if (!disableClose) onClose?.();
e.stopPropagation();
e.preventDefault();
}
}}
>
<m.div
initial={{ top: 5, scale: 0.97 }}
animate={{ top: 0, scale: 1 }}
className={classNames(
className,
"grid grid-rows-[auto_auto_minmax(0,1fr)]",
"grid-cols-1", // must be here for inline code blocks to correctly break words
"relative bg-surface pointer-events-auto",
"rounded-lg",
"border border-border-subtle shadow-lg shadow-[rgba(0,0,0,0.1)]",
"min-h-40",
"max-w-[calc(100vw-5rem)] max-h-[calc(100vh-5rem)]",
size === "sm" && "w-120",
size === "md" && "w-200",
size === "lg" && "w-280",
size === "full" && "w-screen h-screen",
size === "dynamic" && "min-w-80 max-w-[100vw]",
)}
>
{title ? (
<Heading className="px-6 mt-4 mb-2" level={1} id={titleId}>
{title}
</Heading>
) : (
<span />
)}
{description ? (
<div className="min-h-0 px-6 text-text-subtle mb-3" id={descriptionId}>
{description}
</div>
) : (
<span />
)}
<div
className={classNames(
"h-full w-full grid grid-cols-[minmax(0,1fr)] grid-rows-1",
!noPadding && "px-6 py-2",
!noScroll && "overflow-y-auto overflow-x-hidden",
)}
>
{children}
</div>
{/*Put close at the end so that it's the last thing to be tabbed to*/}
{!disableClose && !hideX && (
<div className="ml-auto absolute right-1 top-1">
<IconButton
className="opacity-70 hover:opacity-100"
onClick={onClose}
title="Close dialog (Esc)"
aria-label="Close"
size="sm"
icon="x"
/>
</div>
)}
</m.div>
</div>
</Overlay>
);
}
File diff suppressed because it is too large. Load diff
@@ -5,7 +5,8 @@ import { EditorView } from "@codemirror/view";
import classNames from "classnames"; import classNames from "classnames";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import "./DiffViewer.css"; import "./DiffViewer.css";
import { readonlyExtensions, syntaxHighlightStyle } from "./extensions"; import { syntaxHighlightStyle } from "@yaakapp-internal/ui/editor";
import { readonlyExtensions } from "./extensions";
interface Props { interface Props {
/** Original/previous version (left side) */ /** Original/previous version (left side) */
@@ -1,147 +1,53 @@
import { startCompletion } from "@codemirror/autocomplete"; import { startCompletion } from "@codemirror/autocomplete";
import { defaultKeymap, historyField, indentWithTab } from "@codemirror/commands"; import type { Extension } from "@codemirror/state";
import { foldState, forceParsing } from "@codemirror/language"; import type { EditorView } from "@codemirror/view";
import type { EditorStateConfig, Extension } from "@codemirror/state";
import { Compartment, EditorState } from "@codemirror/state";
import { EditorView, keymap, placeholder as placeholderExt, tooltips } from "@codemirror/view";
import { emacs } from "@replit/codemirror-emacs";
import { vim } from "@replit/codemirror-vim";
import { vscodeKeymap } from "@replit/codemirror-vscode-keymap";
import { debounce } from "@yaakapp-internal/lib";
import type { EditorKeymap } from "@yaakapp-internal/models";
import { settingsAtom } from "@yaakapp-internal/models"; import { settingsAtom } from "@yaakapp-internal/models";
import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins"; import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins";
import { HStack } from "@yaakapp-internal/ui"; import { CodeEditor, type CodeEditorProps } from "@yaakapp-internal/ui/editor";
import classNames from "classnames";
import type { GraphQLSchema } from "graphql"; import type { GraphQLSchema } from "graphql";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import type { ReactNode, RefObject } from "react"; import { useCallback, useMemo, useRef } from "react";
import {
Children,
cloneElement,
isValidElement,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from "react";
import { activeEnvironmentAtom } from "../../../hooks/useActiveEnvironment"; import { activeEnvironmentAtom } from "../../../hooks/useActiveEnvironment";
import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables"; import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables";
import { useEnvironmentVariables } from "../../../hooks/useEnvironmentVariables"; import { useEnvironmentVariables } from "../../../hooks/useEnvironmentVariables";
import { eventMatchesHotkey } from "../../../hooks/useHotKey"; import { eventMatchesHotkey } from "../../../hooks/useHotKey";
import { useRequestEditor } from "../../../hooks/useRequestEditor"; import { useRequestEditor } from "../../../hooks/useRequestEditor";
import { useTemplateFunctionCompletionOptions } from "../../../hooks/useTemplateFunctions"; import { useTemplateFunctionCompletionOptions } from "../../../hooks/useTemplateFunctions";
import { docFingerprint } from "../../../lib/docFingerprint";
import { editEnvironment } from "../../../lib/editEnvironment"; import { editEnvironment } from "../../../lib/editEnvironment";
import { tryFormatJson, tryFormatXml } from "../../../lib/formatters"; import { tryFormatJson, tryFormatXml } from "../../../lib/formatters";
import { jotaiStore } from "../../../lib/jotai"; import { jotaiStore } from "../../../lib/jotai";
import { withEncryptionEnabled } from "../../../lib/setupOrConfigureEncryption"; import { withEncryptionEnabled } from "../../../lib/setupOrConfigureEncryption";
import { TemplateFunctionDialog } from "../../TemplateFunctionDialog"; import { TemplateFunctionDialog } from "../../TemplateFunctionDialog";
import { IconButton } from "../IconButton"; import { getLanguageExtension } from "./extensions";
import "./Editor.css";
import {
baseExtensions,
editableExtensions,
getLanguageExtension,
multiLineExtensions,
readonlyExtensions,
} from "./extensions";
import type { GenericCompletionConfig } from "./genericCompletion"; import type { GenericCompletionConfig } from "./genericCompletion";
import { singleLineExtensions } from "./singleLine"; import { largeValues, mediaValues } from "./largeValues";
// VSCode's Tab actions mess with the single-line editor tab actions, so remove it. export type EditorProps = Omit<CodeEditorProps, "language" | "extensions" | "keymap"> & {
const vsCodeWithoutTab = vscodeKeymap.filter((k) => k.key !== "Tab");
const keymapExtensions: Record<EditorKeymap, Extension> = {
vim: vim(),
emacs: emacs(),
vscode: keymap.of(vsCodeWithoutTab),
default: [],
};
export interface EditorProps {
actions?: ReactNode;
autoFocus?: boolean;
autoSelect?: boolean;
autocomplete?: GenericCompletionConfig; autocomplete?: GenericCompletionConfig;
autocompleteFunctions?: boolean; autocompleteFunctions?: boolean;
autocompleteVariables?: boolean | ((v: WrappedEnvironmentVariable) => boolean); autocompleteVariables?: boolean | ((v: WrappedEnvironmentVariable) => boolean);
className?: string;
defaultValue?: string | null;
disableTabIndent?: boolean;
disabled?: boolean;
extraExtensions?: Extension[] | Extension; extraExtensions?: Extension[] | Extension;
forcedEnvironmentId?: string; forcedEnvironmentId?: string;
forceUpdateKey?: string | number;
format?: (v: string) => Promise<string>;
heightMode?: "auto" | "full";
hideGutter?: boolean;
id?: string;
language?: EditorLanguage | "pairs" | "url" | "timeline" | null;
lintExtension?: Extension;
graphQLSchema?: GraphQLSchema | null; graphQLSchema?: GraphQLSchema | null;
onBlur?: () => void; language?: EditorLanguage | "pairs" | "url" | "timeline" | null;
onChange?: (value: string) => void; };
onFocus?: () => void;
onKeyDown?: (e: KeyboardEvent) => void;
onPaste?: (value: string) => void;
onPasteOverwrite?: (e: ClipboardEvent, value: string) => void;
placeholder?: string;
readOnly?: boolean;
singleLine?: boolean;
containerOnly?: boolean;
stateKey: string | null;
tooltipContainer?: HTMLElement;
type?: "text" | "password";
wrapLines?: boolean;
setRef?: (view: EditorView | null) => void;
}
const stateFields = { history: historyField, folds: foldState };
const emptyVariables: WrappedEnvironmentVariable[] = []; const emptyVariables: WrappedEnvironmentVariable[] = [];
const emptyExtension: Extension = [];
export function Editor(props: EditorProps) { export function Editor({
return <EditorInner key={props.stateKey} {...props} />;
}
function EditorInner({
actions,
autoFocus,
autoSelect,
autocomplete, autocomplete,
autocompleteFunctions, autocompleteFunctions,
autocompleteVariables, autocompleteVariables,
className,
defaultValue,
disableTabIndent,
disabled,
extraExtensions, extraExtensions,
forcedEnvironmentId, forcedEnvironmentId,
forceUpdateKey,
format, format,
heightMode,
hideGutter,
graphQLSchema, graphQLSchema,
hideGutter,
language, language,
lintExtension, lintExtension,
onBlur,
onChange,
onFocus,
onKeyDown,
onPaste,
onPasteOverwrite,
placeholder,
readOnly, readOnly,
singleLine,
containerOnly,
stateKey,
type,
wrapLines, wrapLines,
setRef, ...props
}: EditorProps) { }: EditorProps) {
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
@@ -158,20 +64,10 @@ function EditorInner({
wrapLines = settings.editorSoftWrap; wrapLines = settings.editorSoftWrap;
} }
if (disabled) { if (props.disabled) {
readOnly = true; readOnly = true;
} }
if (
singleLine ||
language == null ||
language === "text" ||
language === "url" ||
language === "pairs"
) {
disableTabIndent = true;
}
if (format == null && !readOnly) { if (format == null && !readOnly) {
format = format =
language === "json" language === "json"
@@ -181,130 +77,22 @@ function EditorInner({
: undefined; : undefined;
} }
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null); // Template dialogs are opened against the editor the tag was clicked in
const viewRef = useRef<EditorView | null>(null);
// Use ref so we can update the handler without re-initializing the editor const { setRef } = props;
const handleChange = useRef<EditorProps["onChange"]>(onChange); const handleSetRef = useCallback(
useEffect(() => { (view: EditorView | null) => {
handleChange.current = onChange; viewRef.current = view;
}, [onChange]); setRef?.(view);
// Use ref so we can update the handler without re-initializing the editor
const handlePaste = useRef<EditorProps["onPaste"]>(onPaste);
useEffect(() => {
handlePaste.current = onPaste;
}, [onPaste]);
// Use ref so we can update the handler without re-initializing the editor
const handlePasteOverwrite = useRef<EditorProps["onPasteOverwrite"]>(onPasteOverwrite);
useEffect(() => {
handlePasteOverwrite.current = onPasteOverwrite;
}, [onPasteOverwrite]);
// Use ref so we can update the handler without re-initializing the editor
const handleFocus = useRef<EditorProps["onFocus"]>(onFocus);
useEffect(() => {
handleFocus.current = onFocus;
}, [onFocus]);
// Use ref so we can update the handler without re-initializing the editor
const handleBlur = useRef<EditorProps["onBlur"]>(onBlur);
useEffect(() => {
handleBlur.current = onBlur;
}, [onBlur]);
// Use ref so we can update the handler without re-initializing the editor
const handleKeyDown = useRef<EditorProps["onKeyDown"]>(onKeyDown);
useEffect(() => {
handleKeyDown.current = onKeyDown;
}, [onKeyDown]);
// Update placeholder
const placeholderCompartment = useRef(new Compartment());
useEffect(
function configurePlaceholder() {
if (cm.current === null) return;
const ext = placeholderExt(placeholderElFromText(placeholder));
const effects = placeholderCompartment.current.reconfigure(ext);
cm.current?.view.dispatch({ effects });
}, },
[placeholder], [setRef],
);
// Update vim
const keymapCompartment = useRef(new Compartment());
useEffect(
function configureKeymap() {
if (cm.current === null) return;
const current = keymapCompartment.current.get(cm.current.view.state) ?? [];
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
if (settings.editorKeymap === "default" && current === keymapExtensions.default) return; // Nothing to do
if (settings.editorKeymap === "vim" && current === keymapExtensions.vim) return; // Nothing to do
if (settings.editorKeymap === "vscode" && current === keymapExtensions.vscode) return; // Nothing to do
if (settings.editorKeymap === "emacs" && current === keymapExtensions.emacs) return; // Nothing to do
const ext = keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default;
const effects = keymapCompartment.current.reconfigure(ext);
cm.current.view.dispatch({ effects });
},
[settings.editorKeymap],
);
// Update wrap lines
const wrapLinesCompartment = useRef(new Compartment());
useEffect(
function configureWrapLines() {
if (cm.current === null) return;
const current = wrapLinesCompartment.current.get(cm.current.view.state) ?? emptyExtension;
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
if (wrapLines && current !== emptyExtension) return; // Nothing to do
if (!wrapLines && current === emptyExtension) return; // Nothing to do
const ext = wrapLines ? EditorView.lineWrapping : emptyExtension;
const effects = wrapLinesCompartment.current.reconfigure(ext);
cm.current?.view.dispatch({ effects });
},
[wrapLines],
);
// Update tab indent
const tabIndentCompartment = useRef(new Compartment());
useEffect(
function configureTabIndent() {
if (cm.current === null) return;
const current = tabIndentCompartment.current.get(cm.current.view.state) ?? emptyExtension;
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
if (disableTabIndent && current !== emptyExtension) return; // Nothing to do
if (!disableTabIndent && current === emptyExtension) return; // Nothing to do
const ext = !disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension;
const effects = tabIndentCompartment.current.reconfigure(ext);
cm.current?.view.dispatch({ effects });
},
[disableTabIndent],
);
// Update read-only
const readOnlyCompartment = useRef(new Compartment());
useEffect(
function configureReadOnly() {
if (cm.current === null) return;
const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension;
const next = readOnly ? readonlyExtensions : editableExtensions;
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
if (current === next) return;
const effects = readOnlyCompartment.current.reconfigure(next);
cm.current?.view.dispatch({ effects });
},
[readOnly],
); );
const onClickFunction = useCallback( const onClickFunction = useCallback(
async (fn: TemplateFunction, tagValue: string, startPos: number) => { async (fn: TemplateFunction, tagValue: string, startPos: number) => {
const show = () => { const show = () => {
if (cm.current === null) return; if (viewRef.current === null) return;
TemplateFunctionDialog.show(fn, tagValue, startPos, cm.current.view); TemplateFunctionDialog.show(fn, tagValue, startPos, viewRef.current);
}; };
if (fn.name === "secure") { if (fn.name === "secure") {
@@ -344,427 +132,64 @@ function EditorInner({
!!autocompleteFunctions, !!autocompleteFunctions,
); );
// Update the language extension when the language changes const languageExtension = useMemo(
// oxlint-disable-next-line react-hooks/exhaustive-deps -- intentionally limited deps () =>
useEffect(() => { getLanguageExtension({
if (cm.current === null) return; useTemplating,
const { view, languageCompartment } = cm.current; language,
const ext = getLanguageExtension({ lintExtension,
useTemplating, hideGutter,
environmentVariables,
autocomplete,
completionOptions,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
graphQLSchema: graphQLSchema ?? null,
}),
[
language, language,
lintExtension, lintExtension,
hideGutter,
environmentVariables,
autocomplete, autocomplete,
completionOptions, environmentVariables,
onClickVariable, onClickVariable,
onClickMissingVariable, onClickMissingVariable,
onClickPathParameter, onClickPathParameter,
graphQLSchema: graphQLSchema ?? null, completionOptions,
}); useTemplating,
view.dispatch({ effects: languageCompartment.reconfigure(ext) }); graphQLSchema,
}, [ hideGutter,
language, ],
lintExtension,
autocomplete,
environmentVariables,
onClickFunction,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
completionOptions,
useTemplating,
graphQLSchema,
hideGutter,
]);
// Initialize the editor when ref mounts
// oxlint-disable-next-line react-hooks/exhaustive-deps -- only reinitialize when necessary
const initEditorRef = useCallback(
function initEditorRef(container: HTMLDivElement | null) {
if (container === null) {
flushCachedEditorState(stateKey);
cm.current?.view.destroy();
cm.current = null;
return;
}
try {
const languageCompartment = new Compartment();
const langExt = getLanguageExtension({
useTemplating,
language,
lintExtension,
completionOptions,
autocomplete,
environmentVariables,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
graphQLSchema: graphQLSchema ?? null,
});
const extensions = [
languageCompartment.of(langExt),
placeholderCompartment.current.of(placeholderExt(placeholderElFromText(placeholder))),
wrapLinesCompartment.current.of(wrapLines ? EditorView.lineWrapping : emptyExtension),
tabIndentCompartment.current.of(
!disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension,
),
keymapCompartment.current.of(
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
),
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : editableExtensions),
...getExtensions({
container,
singleLine,
hideGutter,
stateKey,
onChange: handleChange,
onPaste: handlePaste,
onPasteOverwrite: handlePasteOverwrite,
onFocus: handleFocus,
onBlur: handleBlur,
onKeyDown: handleKeyDown,
}),
...(Array.isArray(extraExtensions)
? extraExtensions
: extraExtensions
? [extraExtensions]
: []),
];
const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey, !!readOnly);
const doc = `${defaultValue ?? ""}`;
const config: EditorStateConfig = { extensions, doc };
const state = cachedJsonState
? EditorState.fromJSON(cachedJsonState, config, stateFields)
: EditorState.create(config);
const view = new EditorView({ state, parent: container });
// For large documents, the parser may parse the max number of lines and fail to add
// things like fold markers because of it.
// This forces it to parse more but keeps the timeout to the default of 100 ms.
forceParsing(view, 9e6, 100);
cm.current = { view, languageCompartment };
if (autoFocus) {
view.focus();
}
if (autoSelect) {
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
}
setRef?.(view);
} catch (e) {
console.log("Failed to initialize Codemirror", e);
}
},
[forceUpdateKey],
); );
// For read-only mode, update content when `defaultValue` changes
useEffect(
function updateReadOnlyEditor() {
if (readOnly && cm.current?.view != null) {
updateContents(cm.current.view, defaultValue || "");
}
},
[defaultValue, readOnly],
);
// Force input to update when receiving change and not in focus
useLayoutEffect(
function updateNonFocusedEditor() {
const notFocused = !cm.current?.view.hasFocus;
if (notFocused && cm.current != null) {
updateContents(cm.current.view, defaultValue || "");
}
},
[defaultValue],
);
// Add bg classes to actions, so they appear over the text
const decoratedActions = useMemo(() => {
const results = [];
const actionClassName = classNames(
"bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:opacity-100! shadow",
);
if (format) {
results.push(
<IconButton
showConfirm
key="format"
size="sm"
title="Reformat contents"
icon="magic_wand"
variant="border"
className={classNames(actionClassName)}
onClick={async () => {
if (cm.current === null) return;
const { doc } = cm.current.view.state;
const formatted = await format(doc.toString());
// Update editor and blur because the cursor will reset anyway
cm.current.view.dispatch({
changes: { from: 0, to: doc.length, insert: formatted },
});
cm.current.view.contentDOM.blur();
// Fire change event
onChange?.(formatted);
}}
/>,
);
}
results.push(
Children.map(actions, (existingChild) => {
if (!isValidElement<{ className?: string }>(existingChild)) return null;
const existingProps = existingChild.props;
return cloneElement(existingChild, {
...existingProps,
className: classNames(existingProps.className, actionClassName),
});
}),
);
return results;
}, [actions, format, onChange]);
const cmContainer = (
<div
ref={initEditorRef}
className={classNames(
className,
"cm-wrapper text-base",
disabled && "opacity-disabled",
type === "password" && "cm-obscure-text",
heightMode === "auto" ? "cm-auto-height" : "cm-full-height",
singleLine ? "cm-singleline" : "cm-multiline",
readOnly && "cm-readonly",
)}
/>
);
if (singleLine || containerOnly) {
return cmContainer;
}
return ( return (
<div className="group relative h-full w-full x-theme-editor bg-surface"> <CodeEditor
{cmContainer} {...props}
{decoratedActions && ( editableExtensions={mediaValues}
<HStack extensions={extraExtensions}
space={1} format={format}
justifyContent="end" hideGutter={hideGutter}
className={classNames( keyDownHandler={startCompletionOnHotkey}
"absolute bottom-2 left-0 right-0", keymap={settings.editorKeymap}
"pointer-events-none", // No pointer events, so we don't block the editor language={codeEditorLanguage(language)}
)} languageExtension={languageExtension}
> lintExtension={lintExtension}
{decoratedActions} readOnly={readOnly}
</HStack> readOnlyExtensions={largeValues}
)} setRef={handleSetRef}
</div> wrapLines={wrapLines}
/>
); );
} }
function getExtensions({ /** The client's own languages bring their extension along, so CodeEditor needn't know them */
stateKey, function codeEditorLanguage(language: EditorProps["language"]): CodeEditorProps["language"] {
container, return language === "url" || language === "pairs" || language === "timeline" ? null : language;
singleLine,
hideGutter,
onChange,
onPaste,
onPasteOverwrite,
onFocus,
onBlur,
onKeyDown,
}: Pick<EditorProps, "singleLine" | "hideGutter"> & {
stateKey: EditorProps["stateKey"];
container: HTMLDivElement | null;
onChange: RefObject<EditorProps["onChange"]>;
onPaste: RefObject<EditorProps["onPaste"]>;
onPasteOverwrite: RefObject<EditorProps["onPasteOverwrite"]>;
onFocus: RefObject<EditorProps["onFocus"]>;
onBlur: RefObject<EditorProps["onBlur"]>;
onKeyDown: RefObject<EditorProps["onKeyDown"]>;
}) {
// TODO: Ensure tooltips render inside the dialog if we are in one.
const parent =
container?.closest<HTMLDivElement>('[role="dialog"]') ??
document.querySelector<HTMLDivElement>("#cm-portal") ??
undefined;
return [
...baseExtensions, // Must be first
EditorView.contentAttributes.of({
autocapitalize: "off",
autocorrect: "off",
// Keeps macOS Writing Tools from offering to write code for us
writingsuggestions: "false",
}),
EditorView.domEventHandlers({
focus: () => {
onFocus.current?.();
},
blur: () => {
onBlur.current?.();
},
keydown: (e, view) => {
// Check if the hotkey matches the editor.autocomplete action
if (eventMatchesHotkey(e, "editor.autocomplete")) {
e.preventDefault();
startCompletion(view);
return true;
}
onKeyDown.current?.(e);
},
paste: (e, v) => {
const textData = e.clipboardData?.getData("text/plain") ?? "";
onPaste.current?.(textData);
if (v.state.selection.main.from === 0 && v.state.selection.main.to === v.state.doc.length) {
onPasteOverwrite.current?.(e, textData);
}
},
}),
tooltips({ parent }),
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap),
...(singleLine ? [singleLineExtensions()] : []),
...(!singleLine ? multiLineExtensions({ hideGutter }) : []),
// ------------------------ //
// Things that must be last //
// ------------------------ //
EditorView.updateListener.of((update) => {
if (update.startState === update.state) return;
if (onChange && update.docChanged) {
onChange.current?.(update.state.doc.toString());
}
saveCachedEditorStateDebounced(stateKey, update.state);
}),
];
} }
const placeholderElFromText = (text: string | undefined) => { function startCompletionOnHotkey(e: KeyboardEvent, view: EditorView): boolean {
const el = document.createElement("div"); if (!eventMatchesHotkey(e, "editor.autocomplete")) return false;
// Default to <SPACE> because codemirror needs it for sizing. I'm not sure why, but probably something e.preventDefault();
// to do with how Yaak "hacks" it with CSS for single line input. startCompletion(view);
el.innerHTML = text ? text.replaceAll("\n", "<br/>") : " "; return true;
return el;
};
// Caching the state is too expensive to do on every update (every keystroke and cursor move),
// so debounce it per state key and flush when the editor unmounts.
//
// The cost scales with the document, and every update pays it in full:
// - `state.toJSON` flattens the whole rope into a string, ~0.13 ms per 200 KB
// - the fingerprint is an exact md5 for editable documents, ~0.3 ms per 200 KB. `docFingerprint`
// only samples read-only ones, so editing a large body still hashes all of it
// - `sessionStorage.setItem` is synchronous and blocks the main thread
// Typing in a 200 KB body costs ~0.4 ms per keystroke before the storage write, and a 1 MB one
// ~2.3 ms. Read-only documents skip most of the hashing but still serialize, which is the larger
// half once documents get into the megabytes.
const SAVE_STATE_DEBOUNCE_MS = 500;
const stateSavers = new Map<string, ReturnType<typeof debounce>>();
function saveCachedEditorStateDebounced(stateKey: string | null, state: EditorState) {
if (!stateKey) return;
let saver = stateSavers.get(stateKey);
if (saver == null) {
saver = debounce(
(s: EditorState) => saveCachedEditorState(stateKey, s),
SAVE_STATE_DEBOUNCE_MS,
);
stateSavers.set(stateKey, saver);
}
saver(state);
}
// NOTE: Only called when an editor unmounts, so the saver is dropped rather than left in the map
// for every state key the session has ever shown. A pending saver holds the last EditorState,
// which holds the whole document.
function flushCachedEditorState(stateKey: string | null) {
if (!stateKey) return;
const saver = stateSavers.get(stateKey);
if (saver == null) return;
saver.flush();
stateSavers.delete(stateKey);
}
function saveCachedEditorState(stateKey: string | null, state: EditorState | null) {
if (!stateKey || state == null) return;
const stateObj = state.toJSON(stateFields);
// Save state in sessionStorage by removing doc and saving a fingerprint of it instead.
// This will be checked on restore and put back in if it matches.
// Editable documents get the exact hash, so a collision can never restore undo history
// belonging to other content
stateObj.docHash = docFingerprint(stateObj.doc, { exact: !state.readOnly });
stateObj.doc = undefined;
try {
sessionStorage.setItem(computeFullStateKey(stateKey), JSON.stringify(stateObj));
} catch (err) {
console.log("Failed to save to editor state", stateKey, err);
}
}
function getCachedEditorState(doc: string, stateKey: string | null, readOnly: boolean) {
if (stateKey == null) return;
try {
const stateStr = sessionStorage.getItem(computeFullStateKey(stateKey));
if (stateStr == null) return null;
const { docHash, ...state } = JSON.parse(stateStr);
// Ensure the doc matches the one that was used to save the state
if (docHash !== docFingerprint(doc, { exact: !readOnly })) {
return null;
}
state.doc = doc;
return state;
} catch (err) {
console.log("Failed to restore editor storage", stateKey, err);
}
return null;
}
function computeFullStateKey(stateKey: string): string {
return `editor.${stateKey}`;
}
function updateContents(view: EditorView, text: string) {
// Replace codemirror contents
const currentDoc = view.state.doc.toString();
if (currentDoc === text) {
return;
}
if (text.startsWith(currentDoc)) {
// If we're just appending, append only the changes. This preserves
// things like scroll position.
view.dispatch({
changes: view.state.changes({
from: currentDoc.length,
insert: text.slice(currentDoc.length),
}),
});
} else {
// If we're replacing everything, reset the entire content
view.dispatch({
changes: view.state.changes({
from: 0,
to: currentDoc.length,
insert: text,
}),
});
}
} }
@@ -1,141 +1,30 @@
import { import { linter } from "@codemirror/lint";
autocompletion,
closeBrackets,
closeBracketsKeymap,
completionKeymap,
} from "@codemirror/autocomplete";
import { history, historyKeymap } from "@codemirror/commands";
import { go } from "@codemirror/lang-go";
import { java } from "@codemirror/lang-java";
import { javascript } from "@codemirror/lang-javascript";
import { markdown } from "@codemirror/lang-markdown";
import { php } from "@codemirror/lang-php";
import { python } from "@codemirror/lang-python";
import { xml } from "@codemirror/lang-xml";
import {
bracketMatching,
codeFolding,
foldGutter,
foldKeymap,
HighlightStyle,
indentOnInput,
LanguageSupport,
StreamLanguage,
syntaxHighlighting,
} from "@codemirror/language";
import { c, csharp, kotlin, objectiveC } from "@codemirror/legacy-modes/mode/clike";
import { clojure } from "@codemirror/legacy-modes/mode/clojure";
import { http } from "@codemirror/legacy-modes/mode/http";
import { oCaml } from "@codemirror/legacy-modes/mode/mllike";
import { powerShell } from "@codemirror/legacy-modes/mode/powershell";
import { r } from "@codemirror/legacy-modes/mode/r";
import { ruby } from "@codemirror/legacy-modes/mode/ruby";
import { shell } from "@codemirror/legacy-modes/mode/shell";
import { swift } from "@codemirror/legacy-modes/mode/swift";
import { linter, lintGutter, lintKeymap } from "@codemirror/lint";
import { search, searchKeymap } from "@codemirror/search";
import type { Extension } from "@codemirror/state"; import type { Extension } from "@codemirror/state";
import { EditorState } from "@codemirror/state";
import { import {
crosshairCursor, closeBracketsExtensions,
drawSelection, getLanguageExtension as getBaseLanguageExtension,
dropCursor, type LanguageSupports,
EditorView, readonlyExtensions as baseReadonlyExtensions,
highlightActiveLineGutter, syntaxExtensions,
highlightSpecialChars, } from "@yaakapp-internal/ui/editor";
keymap,
lineNumbers,
rectangularSelection,
} from "@codemirror/view";
import { tags as t } from "@lezer/highlight";
import { jsonc, jsoncLanguage } from "@shopify/lang-jsonc";
import { graphql } from "cm6-graphql"; import { graphql } from "cm6-graphql";
import type { GraphQLSchema } from "graphql"; import type { GraphQLSchema } from "graphql";
import { activeRequestIdAtom } from "../../../hooks/useActiveRequestId"; import { activeRequestIdAtom } from "../../../hooks/useActiveRequestId";
import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables"; import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables";
import { jotaiStore } from "../../../lib/jotai"; import { jotaiStore } from "../../../lib/jotai";
import { renderMarkdown } from "../../../lib/markdown"; import { renderMarkdown } from "../../../lib/markdown";
import { pluralizeCount } from "../../../lib/pluralize";
import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms"; import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms";
import type { EditorProps } from "./Editor"; import type { EditorProps } from "./Editor";
import { jsonParseLinter } from "./json-lint"; import { jsonParseLinter } from "./json-lint";
import { largeValues, mediaValues } from "./largeValues"; import { largeValues } from "./largeValues";
import { pairs } from "./pairs/extension"; import { pairs } from "./pairs/extension";
import { searchMatchCount } from "./searchMatchCount";
import { text } from "./text/extension";
import { timeline } from "./timeline/extension"; import { timeline } from "./timeline/extension";
import type { TwigCompletionOption } from "./twig/completion"; import type { TwigCompletionOption } from "./twig/completion";
import { twig } from "./twig/extension"; import { twig } from "./twig/extension";
import { pathParametersPlugin } from "./twig/pathParameters"; import { pathParametersPlugin } from "./twig/pathParameters";
import { url } from "./url/extension"; import { url } from "./url/extension";
export const syntaxHighlightStyle = HighlightStyle.define([ const languages: LanguageSupports = { ...syntaxExtensions, url, pairs, timeline };
{
tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment],
color: "var(--textSubtlest)",
},
{
tag: [t.emphasis],
textDecoration: "underline",
},
{
tag: [t.angleBracket, t.paren, t.bracket, t.squareBracket, t.brace, t.separator, t.punctuation],
color: "var(--textSubtle)",
},
{
tag: [t.link, t.name, t.tagName, t.angleBracket, t.docString, t.number],
color: "var(--info)",
},
{ tag: [t.variableName], color: "var(--success)" },
{ tag: [t.bool], color: "var(--warning)" },
{ tag: [t.attributeName, t.propertyName], color: "var(--primary)" },
{ tag: [t.attributeValue], color: "var(--warning)" },
{ tag: [t.string], color: "var(--notice)" },
{ tag: [t.atom, t.meta, t.operator, t.bool, t.null, t.keyword], color: "var(--danger)" },
]);
const syntaxTheme = EditorView.theme({}, { dark: true });
const closeBracketsExtensions: Extension = [closeBrackets(), keymap.of([...closeBracketsKeymap])];
const legacyLang = (mode: Parameters<typeof StreamLanguage.define>[0]) => {
return () => new LanguageSupport(StreamLanguage.define(mode));
};
const syntaxExtensions: Record<
NonNullable<EditorProps["language"]>,
null | (() => LanguageSupport)
> = {
graphql: null,
json: jsonc,
javascript: javascript,
// HTML as XML because HTML is oddly slow
html: xml,
xml: xml,
url: url,
pairs: pairs,
text: text,
timeline: timeline,
markdown: markdown,
c: legacyLang(c),
clojure: legacyLang(clojure),
csharp: legacyLang(csharp),
go: go,
http: legacyLang(http),
java: java,
kotlin: legacyLang(kotlin),
objective_c: legacyLang(objectiveC),
ocaml: legacyLang(oCaml),
php: php,
powershell: legacyLang(powerShell),
python: python,
r: legacyLang(r),
ruby: legacyLang(ruby),
shell: legacyLang(shell),
swift: legacyLang(swift),
};
const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ["json", "javascript", "graphql"];
export function getLanguageExtension({ export function getLanguageExtension({
useTemplating, useTemplating,
@@ -157,18 +46,7 @@ export function getLanguageExtension({
onClickPathParameter: (name: string) => void; onClickPathParameter: (name: string) => void;
completionOptions: TwigCompletionOption[]; completionOptions: TwigCompletionOption[];
graphQLSchema: GraphQLSchema | null; graphQLSchema: GraphQLSchema | null;
} & Pick<EditorProps, "language" | "autocomplete" | "hideGutter" | "lintExtension">) { } & Pick<EditorProps, "language" | "autocomplete" | "hideGutter" | "lintExtension">): Extension {
const extraExtensions: Extension[] = [];
if (language === "url") {
extraExtensions.push(pathParametersPlugin(onClickPathParameter));
}
// Only close brackets on languages that need it
if (language && closeBracketsFor.includes(language)) {
extraExtensions.push(closeBracketsExtensions);
}
// GraphQL is a special exception // GraphQL is a special exception
if (language === "graphql") { if (language === "graphql") {
return [ return [
@@ -189,155 +67,31 @@ export function getLanguageExtension({
})); }));
}, },
}), }),
extraExtensions, [closeBracketsExtensions],
]; ];
} }
if (language === "json") { return getBaseLanguageExtension({
extraExtensions.push(lintExtension ?? linter(jsonParseLinter())); language,
extraExtensions.push( hideGutter,
jsoncLanguage.data.of({ languages,
commentTokens: { line: "//", block: { open: "/*", close: "*/" } }, lintExtension: language === "json" ? (lintExtension ?? linter(jsonParseLinter())) : undefined,
}), extraExtensions: language === "url" ? [pathParametersPlugin(onClickPathParameter)] : [],
); wrap: useTemplating
if (!hideGutter) { ? (base, extraExtensions) =>
extraExtensions.push(lintGutter()); twig({
} base,
} environmentVariables,
completionOptions,
const maybeBase = language ? syntaxExtensions[language] : null; autocomplete,
const base = typeof maybeBase === "function" ? maybeBase() : null; onClickVariable,
if (base == null) { onClickMissingVariable,
return []; onClickPathParameter,
} extraExtensions,
})
if (!useTemplating) { : undefined,
return [base, extraExtensions];
}
return twig({
base,
environmentVariables,
completionOptions,
autocomplete,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
extraExtensions,
}); });
} }
// Filter out autocomplete start triggers from completionKeymap since we handle it via configurable hotkeys. // Nobody is editing this, so every rule applies
// Keep navigation keys (ArrowUp/Down, Enter, Escape, etc.) but remove startCompletion bindings. export const readonlyExtensions = [...baseReadonlyExtensions, largeValues];
const filteredCompletionKeymap = completionKeymap.filter((binding) => {
const key = binding.key?.toLowerCase() ?? "";
const mac = (binding as { mac?: string }).mac?.toLowerCase() ?? "";
// Filter out Ctrl-Space and Mac-specific autocomplete triggers (Alt-`, Alt-i)
const isStartTrigger = key.includes("space") || mac.includes("alt-") || mac.includes("`");
return !isStartTrigger;
});
export const baseExtensions = [
highlightSpecialChars(),
history(),
dropCursor(),
drawSelection(),
autocompletion({
tooltipClass: () => "x-theme-menu",
closeOnBlur: true, // Set to `false` for debugging in devtools without closing it
defaultKeymap: false, // We handle the trigger via configurable hotkeys
compareCompletions: (a, b) => {
// Don't sort completions at all, only on boost
return (a.boost ?? 0) - (b.boost ?? 0);
},
}),
syntaxHighlighting(syntaxHighlightStyle),
syntaxTheme,
keymap.of([...historyKeymap, ...filteredCompletionKeymap]),
];
export const readonlyExtensions = [
EditorState.readOnly.of(true),
EditorView.contentAttributes.of({ tabindex: "-1" }),
// Nobody is editing this, so every rule applies
largeValues,
];
/**
* The counterpart for a document being edited.
*
* Only encoded media collapses here: it is pasted rather than typed, and replaced rather than
* edited, so a tag stands in for it without hiding anything anyone meant to read. The rules that
* hide text by length alone stay out. See {@link mediaValues}.
*/
export const editableExtensions = [mediaValues];
export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [
search({ top: true }),
searchMatchCount(),
hideGutter
? []
: [
lineNumbers(),
foldGutter({
markerDOM: (open) => {
const el = document.createElement("div");
el.classList.add("fold-gutter-icon");
el.tabIndex = -1;
if (open) {
el.setAttribute("data-open", "");
}
return el;
},
}),
],
codeFolding({
placeholderDOM(_view, onclick, prepared) {
const el = document.createElement("span");
el.onclick = onclick;
el.className = "cm-foldPlaceholder";
el.innerText = prepared || "…";
el.title = "unfold";
el.ariaLabel = "folded code";
return el;
},
/**
* Show the number of items when code folded. NOTE: this doesn't get called when restoring
* a previous serialized editor state, which is a bummer
*/
preparePlaceholder(state, range) {
let count: number | undefined;
let startToken = "{";
let endToken = "}";
const prevLine = state.doc.lineAt(range.from).text;
const isArray = prevLine.lastIndexOf("[") > prevLine.lastIndexOf("{");
if (isArray) {
startToken = "[";
endToken = "]";
}
const internal = state.sliceDoc(range.from, range.to);
const toParse = startToken + internal + endToken;
try {
const parsed = JSON.parse(toParse);
count = Object.keys(parsed).length;
} catch {
/* empty */
}
if (count !== undefined) {
const label = isArray ? "item" : "key";
return pluralizeCount(label, count);
}
},
}),
indentOnInput(),
rectangularSelection(),
crosshairCursor(),
bracketMatching(),
highlightActiveLineGutter(),
keymap.of([...searchKeymap, ...foldKeymap, ...lintKeymap]),
];
@@ -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
})
Loaded 100 of 958 files, more files were not shown because too many files have changed in this diff. Show more