From bed5797d44378ba78094483114faf0587d40478b Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 10 Nov 2026 09:37:20 +0530 Subject: [PATCH] feat(api-client): unsaved-changes dot and Cmd+S save in place Tabs opened from (or saved into) a collection now remember their request. A dot replaces the close button while the tab differs from what was saved; Cmd+S overwrites that request in place (DB and folder collections), or opens Save for drafts and requests deleted since. Failed saves keep the dot so the next Cmd+S retries. - Folder defaults (headers/auth/scripts) are merged at send, cURL copy and codegen time via withFolderDefaults instead of being baked into the tab on load, so saving never writes them back into the request. - Opening a request that is already open focuses its tab. - Save-as now keeps pre-request/test scripts, timeout and cookie-jar setting. Examples and comments are not written by Cmd+S (tabs may hold quota-stripped copies); stripped form-data file bytes are restored from the stored request. - Duplicated tabs are unlinked drafts; URL edits no longer rename a saved request's tab. - saveRequest resolves with success; patchFolder generalised to patchItem, adding updateRequest. - i18n: shortcuts.saveRequest, tabBar.unsavedChanges in all 27 locales. Co-Authored-By: Claude Opus 5.5 --- apps/desktop-ui/messages/af.json | 6 +- apps/desktop-ui/messages/ar.json | 6 +- apps/desktop-ui/messages/ca.json | 6 +- apps/desktop-ui/messages/cs.json | 6 +- apps/desktop-ui/messages/da.json | 6 +- apps/desktop-ui/messages/de.json | 6 +- apps/desktop-ui/messages/el.json | 6 +- apps/desktop-ui/messages/en.json | 6 +- apps/desktop-ui/messages/es.json | 6 +- apps/desktop-ui/messages/fa.json | 6 +- apps/desktop-ui/messages/fr.json | 6 +- apps/desktop-ui/messages/id.json | 6 +- apps/desktop-ui/messages/it.json | 6 +- apps/desktop-ui/messages/ja.json | 6 +- apps/desktop-ui/messages/ko.json | 6 +- apps/desktop-ui/messages/ms.json | 6 +- apps/desktop-ui/messages/nb.json | 6 +- apps/desktop-ui/messages/nl.json | 6 +- apps/desktop-ui/messages/pl.json | 6 +- apps/desktop-ui/messages/pt-BR.json | 6 +- apps/desktop-ui/messages/pt.json | 6 +- apps/desktop-ui/messages/ru.json | 6 +- apps/desktop-ui/messages/sv.json | 6 +- apps/desktop-ui/messages/tr.json | 6 +- apps/desktop-ui/messages/uk.json | 6 +- apps/desktop-ui/messages/vi.json | 6 +- apps/desktop-ui/messages/zh.json | 6 +- .../src/components/api-client/api-client.tsx | 280 ++++++++++-------- .../collections/save-request-dialog.tsx | 6 +- .../api-client/collections/use-collections.ts | 62 ++-- .../context/collections-context.tsx | 8 +- .../api-client/context/tabs-context.tsx | 9 + .../api-client/help-shortcuts-dialog.tsx | 3 +- .../src/components/api-client/tab-bar.tsx | 19 +- .../src/components/api-client/types.ts | 6 +- .../__tests__/saved-request.test.ts | 70 +++++ .../src/lib/api-client/saved-request.ts | 82 +++++ apps/desktop-ui/src/lib/folder-inheritance.ts | 24 +- 38 files changed, 519 insertions(+), 212 deletions(-) create mode 100644 apps/desktop-ui/src/lib/api-client/__tests__/saved-request.test.ts create mode 100644 apps/desktop-ui/src/lib/api-client/saved-request.ts diff --git a/apps/desktop-ui/messages/af.json b/apps/desktop-ui/messages/af.json index a4493ea4b..065556a96 100644 --- a/apps/desktop-ui/messages/af.json +++ b/apps/desktop-ui/messages/af.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "Nuwe oortjie" + "addTabAria": "Nuwe oortjie", + "unsavedChanges": "Ongestoorde veranderinge" }, "collectionsSidebar": { "title": "API-hulpbronne", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "Stoor versoek" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/ar.json b/apps/desktop-ui/messages/ar.json index 1997a23dd..c7263e9eb 100644 --- a/apps/desktop-ui/messages/ar.json +++ b/apps/desktop-ui/messages/ar.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "تبويب جديد" + "addTabAria": "تبويب جديد", + "unsavedChanges": "تغييرات غير محفوظة" }, "collectionsSidebar": { "title": "موارد API", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "حفظ الطلب" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/ca.json b/apps/desktop-ui/messages/ca.json index 930023815..23da37904 100644 --- a/apps/desktop-ui/messages/ca.json +++ b/apps/desktop-ui/messages/ca.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "Pestanya nova" + "addTabAria": "Pestanya nova", + "unsavedChanges": "Canvis sense desar" }, "collectionsSidebar": { "title": "Recursos API", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "Desa la sol·licitud" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/cs.json b/apps/desktop-ui/messages/cs.json index 95df044e7..dcc03ac79 100644 --- a/apps/desktop-ui/messages/cs.json +++ b/apps/desktop-ui/messages/cs.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "Nová karta" + "addTabAria": "Nová karta", + "unsavedChanges": "Neuložené změny" }, "collectionsSidebar": { "title": "API zdroje", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "Uložit požadavek" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/da.json b/apps/desktop-ui/messages/da.json index ac8a16234..eb63575ce 100644 --- a/apps/desktop-ui/messages/da.json +++ b/apps/desktop-ui/messages/da.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "Ny fane" + "addTabAria": "Ny fane", + "unsavedChanges": "Ikke-gemte ændringer" }, "collectionsSidebar": { "title": "API-ressourcer", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "Gem anmodning" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/de.json b/apps/desktop-ui/messages/de.json index 4e0d8ba07..ebe1d6872 100644 --- a/apps/desktop-ui/messages/de.json +++ b/apps/desktop-ui/messages/de.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "Neue Registerkarte" + "addTabAria": "Neue Registerkarte", + "unsavedChanges": "Ungespeicherte Änderungen" }, "collectionsSidebar": { "title": "API-Ressourcen", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "Anfrage speichern" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/el.json b/apps/desktop-ui/messages/el.json index 40822aba8..927fdfffa 100644 --- a/apps/desktop-ui/messages/el.json +++ b/apps/desktop-ui/messages/el.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "Νέα καρτέλα" + "addTabAria": "Νέα καρτέλα", + "unsavedChanges": "Μη αποθηκευμένες αλλαγές" }, "collectionsSidebar": { "title": "Πόροι API", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "Αποθήκευση αιτήματος" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index 825361d08..8622dae3b 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "New tab" + "addTabAria": "New tab", + "unsavedChanges": "Unsaved changes" }, "collectionsSidebar": { "title": "API resources", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "Save request" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/es.json b/apps/desktop-ui/messages/es.json index a6d90b26d..5fb081f74 100644 --- a/apps/desktop-ui/messages/es.json +++ b/apps/desktop-ui/messages/es.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "Nueva pestaña" + "addTabAria": "Nueva pestaña", + "unsavedChanges": "Cambios sin guardar" }, "collectionsSidebar": { "title": "Recursos API", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "Guardar solicitud" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/fa.json b/apps/desktop-ui/messages/fa.json index 821294733..e147886a6 100644 --- a/apps/desktop-ui/messages/fa.json +++ b/apps/desktop-ui/messages/fa.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "برگه جدید" + "addTabAria": "برگه جدید", + "unsavedChanges": "تغییرات ذخیره‌نشده" }, "collectionsSidebar": { "title": "منابع API", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "ذخیره درخواست" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/fr.json b/apps/desktop-ui/messages/fr.json index aac382b48..f8f312f20 100644 --- a/apps/desktop-ui/messages/fr.json +++ b/apps/desktop-ui/messages/fr.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "Nouvel onglet" + "addTabAria": "Nouvel onglet", + "unsavedChanges": "Modifications non enregistrées" }, "collectionsSidebar": { "title": "Ressources API", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "Enregistrer la requête" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/id.json b/apps/desktop-ui/messages/id.json index a0c372ce9..3c0cff7db 100644 --- a/apps/desktop-ui/messages/id.json +++ b/apps/desktop-ui/messages/id.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "New tab" + "addTabAria": "New tab", + "unsavedChanges": "Perubahan belum disimpan" }, "collectionsSidebar": { "title": "API resources", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "Simpan permintaan" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/it.json b/apps/desktop-ui/messages/it.json index 6d9a01d9b..2c14425e8 100644 --- a/apps/desktop-ui/messages/it.json +++ b/apps/desktop-ui/messages/it.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "Nuova scheda" + "addTabAria": "Nuova scheda", + "unsavedChanges": "Modifiche non salvate" }, "collectionsSidebar": { "title": "Risorse API", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "Salva richiesta" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/ja.json b/apps/desktop-ui/messages/ja.json index bb8b2d194..9117b607a 100644 --- a/apps/desktop-ui/messages/ja.json +++ b/apps/desktop-ui/messages/ja.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "新しいタブ" + "addTabAria": "新しいタブ", + "unsavedChanges": "未保存の変更" }, "collectionsSidebar": { "title": "APIリソース", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "リクエストを保存" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/ko.json b/apps/desktop-ui/messages/ko.json index e85f005e1..3335ac94a 100644 --- a/apps/desktop-ui/messages/ko.json +++ b/apps/desktop-ui/messages/ko.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "새 탭" + "addTabAria": "새 탭", + "unsavedChanges": "저장되지 않은 변경 사항" }, "collectionsSidebar": { "title": "API 리소스", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "요청 저장" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/ms.json b/apps/desktop-ui/messages/ms.json index c36963d1b..91085eccb 100644 --- a/apps/desktop-ui/messages/ms.json +++ b/apps/desktop-ui/messages/ms.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "Tab baharu" + "addTabAria": "Tab baharu", + "unsavedChanges": "Perubahan belum disimpan" }, "collectionsSidebar": { "title": "Sumber API", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "Simpan permintaan" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/nb.json b/apps/desktop-ui/messages/nb.json index bbd0516d9..2126088f4 100644 --- a/apps/desktop-ui/messages/nb.json +++ b/apps/desktop-ui/messages/nb.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "Ny fane" + "addTabAria": "Ny fane", + "unsavedChanges": "Ulagrede endringer" }, "collectionsSidebar": { "title": "API-ressurser", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "Lagre forespørsel" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/nl.json b/apps/desktop-ui/messages/nl.json index 1cc759df5..7c06b0144 100644 --- a/apps/desktop-ui/messages/nl.json +++ b/apps/desktop-ui/messages/nl.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "Nieuw tabblad" + "addTabAria": "Nieuw tabblad", + "unsavedChanges": "Niet-opgeslagen wijzigingen" }, "collectionsSidebar": { "title": "API-bronnen", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "Verzoek opslaan" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/pl.json b/apps/desktop-ui/messages/pl.json index 70479fbfb..0a8616a51 100644 --- a/apps/desktop-ui/messages/pl.json +++ b/apps/desktop-ui/messages/pl.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "New tab" + "addTabAria": "New tab", + "unsavedChanges": "Niezapisane zmiany" }, "collectionsSidebar": { "title": "API resources", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "Zapisz żądanie" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/pt-BR.json b/apps/desktop-ui/messages/pt-BR.json index 581d36049..108172e85 100644 --- a/apps/desktop-ui/messages/pt-BR.json +++ b/apps/desktop-ui/messages/pt-BR.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "Nova aba" + "addTabAria": "Nova aba", + "unsavedChanges": "Alterações não salvas" }, "collectionsSidebar": { "title": "Recursos API", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "Salvar requisição" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/pt.json b/apps/desktop-ui/messages/pt.json index 6a5e9f25f..a92ebec20 100644 --- a/apps/desktop-ui/messages/pt.json +++ b/apps/desktop-ui/messages/pt.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "Novo separador" + "addTabAria": "Novo separador", + "unsavedChanges": "Alterações não guardadas" }, "collectionsSidebar": { "title": "Recursos API", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "Guardar pedido" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/ru.json b/apps/desktop-ui/messages/ru.json index 55758bbf8..341c94062 100644 --- a/apps/desktop-ui/messages/ru.json +++ b/apps/desktop-ui/messages/ru.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "Новая вкладка" + "addTabAria": "Новая вкладка", + "unsavedChanges": "Несохранённые изменения" }, "collectionsSidebar": { "title": "API-ресурсы", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "Сохранить запрос" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/sv.json b/apps/desktop-ui/messages/sv.json index 67e05e7c5..f75c556cb 100644 --- a/apps/desktop-ui/messages/sv.json +++ b/apps/desktop-ui/messages/sv.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "Ny flik" + "addTabAria": "Ny flik", + "unsavedChanges": "Osparade ändringar" }, "collectionsSidebar": { "title": "API-resurser", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "Spara begäran" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/tr.json b/apps/desktop-ui/messages/tr.json index 92c8555d2..37a32152e 100644 --- a/apps/desktop-ui/messages/tr.json +++ b/apps/desktop-ui/messages/tr.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "Yeni sekme" + "addTabAria": "Yeni sekme", + "unsavedChanges": "Kaydedilmemiş değişiklikler" }, "collectionsSidebar": { "title": "API kaynakları", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "İsteği kaydet" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/uk.json b/apps/desktop-ui/messages/uk.json index 8961f3240..7c030816b 100644 --- a/apps/desktop-ui/messages/uk.json +++ b/apps/desktop-ui/messages/uk.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "Нова вкладка" + "addTabAria": "Нова вкладка", + "unsavedChanges": "Незбережені зміни" }, "collectionsSidebar": { "title": "API ресурси", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "Зберегти запит" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/vi.json b/apps/desktop-ui/messages/vi.json index bd74a2d48..1a5122c5a 100644 --- a/apps/desktop-ui/messages/vi.json +++ b/apps/desktop-ui/messages/vi.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "Tab mới" + "addTabAria": "Tab mới", + "unsavedChanges": "Thay đổi chưa lưu" }, "collectionsSidebar": { "title": "Tài nguyên API", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "Lưu yêu cầu" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/messages/zh.json b/apps/desktop-ui/messages/zh.json index 0e2e67f40..90e7bcf68 100644 --- a/apps/desktop-ui/messages/zh.json +++ b/apps/desktop-ui/messages/zh.json @@ -1924,7 +1924,8 @@ "downloadFullBody": "Download full body" }, "tabBar": { - "addTabAria": "新标签" + "addTabAria": "新标签", + "unsavedChanges": "未保存的更改" }, "collectionsSidebar": { "title": "API 资源", @@ -2021,7 +2022,8 @@ "title": "Keyboard shortcuts", "newTab": "New tab", "closeTab": "Close tab", - "sendRequest": "Send request" + "sendRequest": "Send request", + "saveRequest": "保存请求" }, "toolbar": { "moreActions": "More actions", diff --git a/apps/desktop-ui/src/components/api-client/api-client.tsx b/apps/desktop-ui/src/components/api-client/api-client.tsx index b7b0d3a69..d353c1522 100644 --- a/apps/desktop-ui/src/components/api-client/api-client.tsx +++ b/apps/desktop-ui/src/components/api-client/api-client.tsx @@ -57,7 +57,8 @@ import { cookieHeaderForUrl, storeCookiesFromResponse } from "@/lib/cookie-jar" import { resolveResponsePath } from "@/lib/response-path" import { resolveSecretVariables } from "@/lib/secret-variables" import { useCipherKey } from "@/lib/use-cipher-key" -import { applyFolderInheritance, findRequestAncestors } from "@/lib/folder-inheritance" +import { withFolderDefaults, findRequestAncestors } from "@/lib/folder-inheritance" +import { requestFromTab, requestFingerprint, savePatch } from "@/lib/api-client/saved-request" import { useJsonFormatter } from "./workers/use-json-formatter" import { useScriptsRunner } from "./workers/use-scripts-runner" import { useJsonBodyValidation } from "./use-json-validation" @@ -112,7 +113,7 @@ function ApiClientInner() { const t = useTranslations("ApiClient") const tNav = useTranslations("Navigation") const { tabs, activeTabId, activeTab } = useTabs() - const { addTab, appendTab, closeTab, duplicateTab, renameTab, reorderTabs, setActiveTabId, updateActiveTab } = useTabsActions() + const { addTab, appendTab, closeTab, duplicateTab, renameTab, reorderTabs, setActiveTabId, updateActiveTab, updateTab } = useTabsActions() const abortControllerRef = React.useRef(null) @@ -122,7 +123,7 @@ function ApiClientInner() { const { format: formatJson } = useJsonFormatter() const { run: runScript } = useScriptsRunner() const { collections } = useCollectionsState() - const { saveRequest, addFolder, createCollection } = useCollectionsActions() + const { saveRequest, updateRequest, addFolder, createCollection } = useCollectionsActions() const { history } = useHistoryState() const { addHistoryItem } = useHistoryActions() const { environments, activeEnvId, activeEnvironmentVariables } = useEnvironmentsState() @@ -292,7 +293,8 @@ function ApiClientInner() { }, [updateActiveTab]) const handleUrlChange = React.useCallback((url: string) => { - updateActiveTab({ url, name: url || API_CLIENT_DEFAULT_TAB_NAME }) + // Drafts are named after their URL; a tab linked to a saved request keeps its name. + updateActiveTab((tab) => tab.source ? { url } : { url, name: url || API_CLIENT_DEFAULT_TAB_NAME }) }, [updateActiveTab]) const handleImportCurl = (curl: string, target: ImportCurlTarget = "new-tab") => { @@ -328,49 +330,72 @@ function ApiClientInner() { // stay referentially stable across keystrokes. const activeTabRef = React.useRef(activeTab) React.useEffect(() => { activeTabRef.current = activeTab }, [activeTab]) + const tabsRef = React.useRef(tabs) + React.useEffect(() => { tabsRef.current = tabs }, [tabs]) + const collectionsRef = React.useRef(collections) + React.useEffect(() => { collectionsRef.current = collections }, [collections]) const handleCopyCurl = React.useCallback(() => { - void copyToClipboard(generateCode(activeTabRef.current, "curl"), { + void copyToClipboard(generateCode(withFolderDefaults(activeTabRef.current, collectionsRef.current), "curl"), { successMessage: t("toasts.curlCopied"), errorMessage: t("toasts.copyFailed"), }) }, [copyToClipboard, t]) - const handleSaveRequest = (parentId: string, name: string) => { - const requestToSave: CollectionRequest = { - id: crypto.randomUUID(), - name, - method: activeTab.method, - url: activeTab.url, - params: activeTab.params, - headers: activeTab.headers, - body: activeTab.body, - auth: activeTab.auth, + const handleSaveRequest = async (parentId: string, name: string) => { + const tab = { ...activeTabRef.current, name } + const requestToSave: CollectionRequest = { ...requestFromTab(tab), id: crypto.randomUUID() } + updateTab(tab.id, { name }) + // Link the tab only once persisted, so a failed save stays a draft. + if (await saveRequest(parentId, requestToSave)) { + updateTab(tab.id, { source: { requestId: requestToSave.id }, savedFingerprint: requestFingerprint(tab) }) } - saveRequest(parentId, requestToSave) - updateActiveTab({ name }) } - const handleLoadRequest = React.useCallback((request: CollectionRequest) => { - // Locate the request inside any collection so we can fold ancestor folders' - // defaults (headers / auth / scripts) into the materialised tab. A request - // edited in a tab is a snapshot — later folder-default edits don't propagate. - let effective = request - for (const col of collections) { - const ancestors = findRequestAncestors(col.items, request.id) - if (ancestors !== null) { - effective = applyFolderInheritance(request, ancestors) + /** ⌘S: overwrite the linked request in place, or ask where to save a draft. */ + const handleSaveShortcut = React.useCallback(async () => { + const tab = activeTabRef.current + if (tab.kind && tab.kind !== "rest") return + const requestId = tab.source?.requestId + let stored: CollectionRequest | undefined + if (requestId) { + for (const col of collectionsRef.current) { + const ancestors = findRequestAncestors(col.items, requestId) + if (ancestors === null) continue + const siblings = ancestors.length ? ancestors[ancestors.length - 1].items : col.items + stored = siblings.find((i): i is CollectionRequest => i.id === requestId) break } } + if (!requestId || !stored) { + // Never linked, or the request was deleted since: save as new. + if (requestId) updateTab(tab.id, { source: undefined, savedFingerprint: undefined }) + setSaveOpen(true) + return + } + // Keep dirty on failure (the hook toasts the reason) so the next ⌘S retries. + if (await updateRequest(requestId, savePatch(tab, stored))) { + updateTab(tab.id, { savedFingerprint: requestFingerprint(tab) }) + } + }, [updateRequest, updateTab]) + + const handleLoadRequest = React.useCallback((request: CollectionRequest) => { + const open = tabsRef.current.find((t) => t.source?.requestId === request.id) + if (open) { + setActiveTabId(open.id) + return + } + // The tab holds the request exactly as saved; folder defaults are applied + // at send time (withFolderDefaults) so ⌘S never writes them back. const newTab: ApiRequestState = { ...createNewTab(), - ...effective, + ...request, id: crypto.randomUUID(), // New ID for the tab instance response: null, isLoading: false, + source: { requestId: request.id }, } - appendTab(newTab) - }, [collections, appendTab]) + appendTab({ ...newTab, savedFingerprint: requestFingerprint(newTab) }) + }, [appendTab, setActiveTabId]) const handleCancel = React.useCallback(() => { abortControllerRef.current?.abort() @@ -379,7 +404,8 @@ function ApiClientInner() { }, [updateActiveTab]) const sendImpl = React.useCallback(async () => { - if (!activeTab.url?.trim()) return + const sendTab = withFolderDefaults(activeTab, collections) + if (!sendTab.url?.trim()) return abortControllerRef.current?.abort() const controller = new AbortController() @@ -400,7 +426,7 @@ function ApiClientInner() { const scriptEnvUnsets = new Set() // Previous response on the same tab — what `{{response.body.token}}` chains against. - const previousResponse = activeTab.response + const previousResponse = sendTab.response // Cross-tool secrets ({{vault.*}} / {{env.*}}) — resolved per send below, // never cached in component state. @@ -423,28 +449,28 @@ function ApiClientInner() { try { // Working request state. Pre-request script may rewrite url/method/headers/body. - let workMethod: string = activeTab.method - let workUrl: string = activeTab.url + let workMethod: string = sendTab.method + let workUrl: string = sendTab.url let workHeaders: Record = {} - activeTab.headers.forEach((h) => { + sendTab.headers.forEach((h) => { if (h.active && h.key) workHeaders[h.key] = h.value }) // ── Pre-request script ──────────────────────────────────────── - if (activeTab.preRequestScript && activeTab.preRequestScript.trim()) { + if (sendTab.preRequestScript && sendTab.preRequestScript.trim()) { const preCtx: ScriptContext = { request: { url: workUrl, method: workMethod, headers: workHeaders, - body: activeTab.body.type === "json" || activeTab.body.type === "text" - ? activeTab.body.content + body: sendTab.body.type === "json" || sendTab.body.type === "text" + ? sendTab.body.content : undefined, }, environment: { ...activeEnvironmentVariables }, variables: { ...sessionVarsRef.current }, } - const r = await runScript(activeTab.preRequestScript, preCtx) + const r = await runScript(sendTab.preRequestScript, preCtx) scriptTests.push(...r.tests) scriptLogs.push(...r.logs) if (!r.ok && r.error) scriptErrors.push(`pre-request: ${r.error}`) @@ -474,10 +500,10 @@ function ApiClientInner() { workUrl, JSON.stringify(workHeaders), JSON.stringify({ - params: activeTab.params, - headers: activeTab.headers, - auth: activeTab.auth, - body: { ...activeTab.body, formData: activeTab.body.formData?.map((f) => ({ ...f, fileContentBase64: undefined })) }, + params: sendTab.params, + headers: sendTab.headers, + auth: sendTab.auth, + body: { ...sendTab.body, formData: sendTab.body.formData?.map((f) => ({ ...f, fileContentBase64: undefined })) }, }), ], cipherKey, @@ -488,7 +514,7 @@ function ApiClientInner() { // Construct URL with params const urlObj = buildRequestUrl(finalUrl) - activeTab.params.forEach((p) => { + sendTab.params.forEach((p) => { if (p.active && p.key) { urlObj.searchParams.append(substituteAll(p.key), substituteAll(p.value)) } @@ -502,25 +528,25 @@ function ApiClientInner() { // Add Auth — credentials are trimmed: copy-pasted tokens routinely carry leading/trailing // whitespace which most servers reject as malformed. - if (activeTab.auth.type === "bearer" && activeTab.auth.token) { - headersObj["Authorization"] = `Bearer ${substituteAll(activeTab.auth.token).trim()}` - } else if (activeTab.auth.type === "basic" && activeTab.auth.username && activeTab.auth.password) { + if (sendTab.auth.type === "bearer" && sendTab.auth.token) { + headersObj["Authorization"] = `Bearer ${substituteAll(sendTab.auth.token).trim()}` + } else if (sendTab.auth.type === "basic" && sendTab.auth.username && sendTab.auth.password) { const credentials = encodeBasicCredentials( - substituteAll(activeTab.auth.username).trim(), - substituteAll(activeTab.auth.password), + substituteAll(sendTab.auth.username).trim(), + substituteAll(sendTab.auth.password), ) headersObj["Authorization"] = `Basic ${credentials}` - } else if (activeTab.auth.type === "api-key" && activeTab.auth.apiKeyKey && activeTab.auth.apiKeyValue) { - const key = substituteAll(activeTab.auth.apiKeyKey).trim() - const val = substituteAll(activeTab.auth.apiKeyValue).trim() - if (activeTab.auth.apiKeyLocation === "query") { + } else if (sendTab.auth.type === "api-key" && sendTab.auth.apiKeyKey && sendTab.auth.apiKeyValue) { + const key = substituteAll(sendTab.auth.apiKeyKey).trim() + const val = substituteAll(sendTab.auth.apiKeyValue).trim() + if (sendTab.auth.apiKeyLocation === "query") { urlObj.searchParams.append(key, val) } else { headersObj[key] = val } - } else if (activeTab.auth.type === "jwt-bearer" && activeTab.auth.jwtBearer) { + } else if (sendTab.auth.type === "jwt-bearer" && sendTab.auth.jwtBearer) { try { - const cfg = activeTab.auth.jwtBearer + const cfg = sendTab.auth.jwtBearer let extra: Record | undefined if (cfg.extraClaimsJson && cfg.extraClaimsJson.trim()) { extra = JSON.parse(cfg.extraClaimsJson) as Record @@ -543,24 +569,27 @@ function ApiClientInner() { updateActiveTab({ isLoading: false }) return } - } else if (activeTab.auth.type === "aws-sigv4" && activeTab.auth.awsSigV4) { + } else if (sendTab.auth.type === "aws-sigv4" && sendTab.auth.awsSigV4) { // Defer signing until after the body is built — it has to hash the payload. // We mark the intent here and apply at the end of body assembly. - } else if (activeTab.auth.type === "hawk" && activeTab.auth.hawk) { + } else if (sendTab.auth.type === "hawk" && sendTab.auth.hawk) { // Same — deferred to post-body assembly. - } else if (activeTab.auth.type === "digest" && activeTab.auth.digest) { + } else if (sendTab.auth.type === "digest" && sendTab.auth.digest) { // Digest with qop=auth-int also needs body. Deferred. - } else if (activeTab.auth.type === "oauth2" && activeTab.auth.oauth2) { + } else if (sendTab.auth.type === "oauth2" && sendTab.auth.oauth2) { // Refresh / re-fetch as needed. The fresh config is persisted back // into tab state so subsequent sends reuse the same access token. try { - const fresh = await ensureFreshToken(activeTab.auth.oauth2) + const fresh = await ensureFreshToken(sendTab.auth.oauth2) + // Write refreshed tokens back only when the auth is the tab's own, + // not inherited from a folder (that would copy it into the request). if ( - fresh.accessToken !== activeTab.auth.oauth2.accessToken || - fresh.refreshToken !== activeTab.auth.oauth2.refreshToken || - fresh.expiresAt !== activeTab.auth.oauth2.expiresAt + sendTab.auth === activeTab.auth && + fresh.accessToken !== sendTab.auth.oauth2.accessToken || + fresh.refreshToken !== sendTab.auth.oauth2.refreshToken || + fresh.expiresAt !== sendTab.auth.oauth2.expiresAt ) { - updateActiveTab({ auth: { ...activeTab.auth, oauth2: fresh } }) + updateActiveTab({ auth: { ...sendTab.auth, oauth2: fresh } }) } if (fresh.accessToken) { const tokenType = fresh.tokenType || "Bearer" @@ -576,7 +605,7 @@ function ApiClientInner() { // Cookie jar: prepend stored cookies to the Cookie header for this URL. // User-provided Cookie header takes precedence by appearing later in the merge // (browsers/curl typically take the merged value as-sent — undici will combine). - if (activeTab.useCookieJar !== false) { + if (sendTab.useCookieJar !== false) { const jarHeader = cookieHeaderForUrl(urlObj.toString()) if (jarHeader) { const existingKey = Object.keys(headersObj).find((k) => k.toLowerCase() === "cookie") @@ -597,10 +626,10 @@ function ApiClientInner() { delete headersMap[existingKey] } } - if (workMethod !== "GET" && workMethod !== "HEAD" && activeTab.body.type !== "none") { - if (activeTab.body.type === "json") { + if (workMethod !== "GET" && workMethod !== "HEAD" && sendTab.body.type !== "none") { + if (sendTab.body.type === "json") { try { - const substitutedBody = substituteAll(activeTab.body.content) + const substitutedBody = substituteAll(sendTab.body.content) // Validate JSON JSON.parse(substitutedBody) bodyContent = substitutedBody @@ -608,7 +637,7 @@ function ApiClientInner() { headersObj["Content-Type"] = "application/json" } catch (e) { const parseErr = (e as Error).message - const hasUnresolvedVar = activeTab.body.content.includes("{{") + const hasUnresolvedVar = sendTab.body.content.includes("{{") toast.error( hasUnresolvedVar ? `Invalid JSON after env substitution: ${parseErr}` @@ -617,9 +646,9 @@ function ApiClientInner() { updateActiveTab({ isLoading: false }) return } - } else if (activeTab.body.type === "x-www-form-urlencoded") { + } else if (sendTab.body.type === "x-www-form-urlencoded") { const params = new URLSearchParams() - ;(activeTab.body.urlEncoded ?? []).forEach((item) => { + ;(sendTab.body.urlEncoded ?? []).forEach((item) => { if (item.active && item.key) { params.append(substituteAll(item.key), substituteAll(item.value)) } @@ -629,8 +658,8 @@ function ApiClientInner() { if (!Object.keys(headersObj).some((key) => key.toLowerCase() === "content-type")) { headersObj["Content-Type"] = "application/x-www-form-urlencoded" } - } else if (activeTab.body.type === "form-data") { - const entries = (activeTab.body.formData ?? []) + } else if (sendTab.body.type === "form-data") { + const entries = (sendTab.body.formData ?? []) .filter((item) => item.active && item.key) .map((item) => { if (item.valueType === "file") { @@ -655,11 +684,11 @@ function ApiClientInner() { entries, } deleteContentTypeHeader(headersObj) - } else if (activeTab.body.type === "graphql") { + } else if (sendTab.body.type === "graphql") { // GraphQL is serialised as { query, variables } JSON and sent as application/json. - const query = substituteAll(activeTab.body.content) + const query = substituteAll(sendTab.body.content) let variables: unknown = undefined - const rawVars = (activeTab.body.graphqlVariables ?? "").trim() + const rawVars = (sendTab.body.graphqlVariables ?? "").trim() if (rawVars) { try { variables = JSON.parse(substituteAll(rawVars)) @@ -673,7 +702,7 @@ function ApiClientInner() { bodyPayload = bodyContent headersObj["Content-Type"] = "application/json" } else { - bodyContent = substituteAll(activeTab.body.content) + bodyContent = substituteAll(sendTab.body.content) bodyPayload = bodyContent if (!headersObj["Content-Type"]) { headersObj["Content-Type"] = "text/plain" @@ -688,29 +717,29 @@ function ApiClientInner() { ? bodyPayload : (bodyContent ?? "") try { - if (activeTab.auth.type === "aws-sigv4" && activeTab.auth.awsSigV4) { + if (sendTab.auth.type === "aws-sigv4" && sendTab.auth.awsSigV4) { await signAwsSigV4({ method: workMethod, url: urlObj, headers: headersObj, body: bodyForSigning, - cfg: activeTab.auth.awsSigV4, + cfg: sendTab.auth.awsSigV4, }) - } else if (activeTab.auth.type === "hawk" && activeTab.auth.hawk) { + } else if (sendTab.auth.type === "hawk" && sendTab.auth.hawk) { await signHawk({ method: workMethod, url: urlObj, headers: headersObj, body: bodyForSigning, - cfg: activeTab.auth.hawk, + cfg: sendTab.auth.hawk, }) - } else if (activeTab.auth.type === "digest" && activeTab.auth.digest) { + } else if (sendTab.auth.type === "digest" && sendTab.auth.digest) { await signDigest({ method: workMethod, url: urlObj, headers: headersObj, body: bodyForSigning, - cfg: activeTab.auth.digest, + cfg: sendTab.auth.digest, }) } } catch (signErr) { @@ -720,7 +749,7 @@ function ApiClientInner() { } // ── SPNEGO / Kerberos branch ───────────────────────────────── - if (activeTab.auth.type === "spnego" && activeTab.auth.spnego) { + if (sendTab.auth.type === "spnego" && sendTab.auth.spnego) { const spnegoRes = await apiFetch("/api/proxy-spnego", { method: "POST", credentials: "include", @@ -731,7 +760,7 @@ function ApiClientInner() { method: workMethod, headers: headersObj, body: bodyContent ?? undefined, - token: activeTab.auth.spnego.token, + token: sendTab.auth.spnego.token, }), }) const data = await spnegoRes.json() @@ -754,7 +783,7 @@ function ApiClientInner() { // NTLM does its own 3-step handshake on a single keepalive socket; // route the request through the NTLM proxy and skip the streaming // branch (NTLM responses are bounded). - if (activeTab.auth.type === "ntlm" && activeTab.auth.ntlm) { + if (sendTab.auth.type === "ntlm" && sendTab.auth.ntlm) { const ntlmRes = await apiFetch("/api/proxy-ntlm", { method: "POST", credentials: "include", @@ -766,10 +795,10 @@ function ApiClientInner() { headers: headersObj, body: bodyContent ?? undefined, ntlm: { - username: substituteAll(activeTab.auth.ntlm.username), - password: substituteAll(activeTab.auth.ntlm.password), - domain: activeTab.auth.ntlm.domain ? substituteAll(activeTab.auth.ntlm.domain) : undefined, - workstation: activeTab.auth.ntlm.workstation ? substituteAll(activeTab.auth.ntlm.workstation) : undefined, + username: substituteAll(sendTab.auth.ntlm.username), + password: substituteAll(sendTab.auth.ntlm.password), + domain: sendTab.auth.ntlm.domain ? substituteAll(sendTab.auth.ntlm.domain) : undefined, + workstation: sendTab.auth.ntlm.workstation ? substituteAll(sendTab.auth.ntlm.workstation) : undefined, }, }), }) @@ -788,14 +817,14 @@ function ApiClientInner() { }) addHistoryItem({ method: workMethod as RequestMethod, - url: activeTab.url, - params: activeTab.params, - headers: activeTab.headers, - body: activeTab.body, - auth: activeTab.auth, - preRequestScript: activeTab.preRequestScript, - testScript: activeTab.testScript, - }, historyName(activeTab.name, workMethod, activeTab.url), ntlmData.status) + url: sendTab.url, + params: sendTab.params, + headers: sendTab.headers, + body: sendTab.body, + auth: sendTab.auth, + preRequestScript: sendTab.preRequestScript, + testScript: sendTab.testScript, + }, historyName(sendTab.name, workMethod, sendTab.url), ntlmData.status) return } @@ -817,7 +846,7 @@ function ApiClientInner() { method: beforeApplied.req.method, headers: headersObj, body: beforeApplied.req.body ?? bodyPayload ?? bodyContent, - timeoutMs: activeTab.timeoutMs, + timeoutMs: sendTab.timeoutMs, }, controller.signal) const proxyData = { ...rawEnvelope, @@ -864,7 +893,7 @@ function ApiClientInner() { // Cookie jar: persist Set-Cookie headers from the response. if ( - activeTab.useCookieJar !== false && + sendTab.useCookieJar !== false && Array.isArray(proxyData.setCookies) && proxyData.setCookies.length > 0 ) { @@ -888,7 +917,7 @@ function ApiClientInner() { } // ── Test script (runs against the live response) ────────────── - if (activeTab.testScript && activeTab.testScript.trim()) { + if (sendTab.testScript && sendTab.testScript.trim()) { const postCtx: ScriptContext = { request: { url: urlObj.toString(), @@ -906,7 +935,7 @@ function ApiClientInner() { environment: { ...activeEnvironmentVariables, ...scriptEnvOverlay }, variables: { ...sessionVarsRef.current }, } - const r = await runScript(activeTab.testScript, postCtx) + const r = await runScript(sendTab.testScript, postCtx) scriptTests.push(...r.tests) scriptLogs.push(...r.logs) if (!r.ok && r.error) scriptErrors.push(`test: ${r.error}`) @@ -967,9 +996,9 @@ function ApiClientInner() { recordExchange({ request: { method: workMethod, - url: activeTab.url, - headers: Object.fromEntries(activeTab.headers.filter((h) => h.active && h.key).map((h) => [h.key, h.value])), - body: typeof activeTab.body?.content === "string" ? activeTab.body.content : undefined, + url: sendTab.url, + headers: Object.fromEntries(sendTab.headers.filter((h) => h.active && h.key).map((h) => [h.key, h.value])), + body: typeof sendTab.body?.content === "string" ? sendTab.body.content : undefined, }, response: { status: proxyData.status, @@ -980,7 +1009,7 @@ function ApiClientInner() { }) // IndexedDB cache: keep full response body across reloads (localStorage strips it). - void putCachedResponse(activeTab.id, { + void putCachedResponse(sendTab.id, { status: proxyData.status, statusText: proxyData.statusText, headers: proxyData.headers, @@ -995,14 +1024,14 @@ function ApiClientInner() { addHistoryItem({ method: workMethod as RequestMethod, - url: activeTab.url, - params: activeTab.params, - headers: activeTab.headers, - body: activeTab.body, - auth: activeTab.auth, - preRequestScript: activeTab.preRequestScript, - testScript: activeTab.testScript, - }, historyName(activeTab.name, workMethod, activeTab.url), proxyData.status) + url: sendTab.url, + params: sendTab.params, + headers: sendTab.headers, + body: sendTab.body, + auth: sendTab.auth, + preRequestScript: sendTab.preRequestScript, + testScript: sendTab.testScript, + }, historyName(sendTab.name, workMethod, sendTab.url), proxyData.status) } catch (error) { if ((error as Error).name === "AbortError") return @@ -1022,16 +1051,17 @@ function ApiClientInner() { }) addHistoryItem({ - method: activeTab.method, - url: activeTab.url, - params: activeTab.params, - headers: activeTab.headers, - body: activeTab.body, - auth: activeTab.auth, - }, historyName(activeTab.name, activeTab.method, activeTab.url), 0) + method: sendTab.method, + url: sendTab.url, + params: sendTab.params, + headers: sendTab.headers, + body: sendTab.body, + auth: sendTab.auth, + }, historyName(sendTab.name, sendTab.method, sendTab.url), 0) } }, [ activeTab, + collections, updateActiveTab, isMobile, formatJson, @@ -1090,11 +1120,14 @@ function ApiClientInner() { } else if (mod && e.key === "Enter") { e.preventDefault() handleSend() + } else if (mod && !e.shiftKey && !e.altKey && (e.key === "s" || e.key === "S")) { + e.preventDefault() + void handleSaveShortcut() } } window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) - }, [addTab, closeTab, handleSend, activeTabId]) + }, [addTab, closeTab, handleSend, handleSaveShortcut, activeTabId]) // Desktop request column — reused whether it sits full-width (no response yet) // or inside the resizable split (once a response exists / is loading). @@ -1258,6 +1291,9 @@ function ApiClientInner() { onCreateFolder={addFolder} onCreateCollection={createCollection} defaultName={activeTab.name !== API_CLIENT_DEFAULT_TAB_NAME ? activeTab.name : ""} + open={saveOpen} + onOpenChange={setSaveOpen} + showTrigger /> ))} diff --git a/apps/desktop-ui/src/components/api-client/types.ts b/apps/desktop-ui/src/components/api-client/types.ts index 4d038f99e..7bc6af033 100644 --- a/apps/desktop-ui/src/components/api-client/types.ts +++ b/apps/desktop-ui/src/components/api-client/types.ts @@ -256,6 +256,10 @@ export interface ApiRequestState { examples?: SavedExample[] /** Threaded discussion on this request. Persisted with the collection. */ comments?: RequestComment[] + /** The collection request this tab was opened from or saved as — ⌘S overwrites it. */ + source?: { requestId: string } + /** `requestFingerprint` as last saved/loaded; a different live value = unsaved changes. */ + savedFingerprint?: string } export interface SavedExampleRequest { @@ -292,7 +296,7 @@ export interface RequestComment { text: string } -export interface CollectionRequest extends Omit { +export interface CollectionRequest extends Omit { id: string name: string examples?: SavedExample[] diff --git a/apps/desktop-ui/src/lib/api-client/__tests__/saved-request.test.ts b/apps/desktop-ui/src/lib/api-client/__tests__/saved-request.test.ts new file mode 100644 index 000000000..e57cee2b9 --- /dev/null +++ b/apps/desktop-ui/src/lib/api-client/__tests__/saved-request.test.ts @@ -0,0 +1,70 @@ +import { createNewTab } from "@/components/api-client/context/tabs-context" +import type { ApiRequestState, Collection, CollectionRequest } from "@/components/api-client/types" +import { isTabDirty, requestFingerprint, savePatch } from "../saved-request" +import { withFolderDefaults } from "../../folder-inheritance" + +const linked = (patch: Partial = {}): ApiRequestState => { + const tab = { ...createNewTab(), url: "https://api.test/users", source: { requestId: "r1" }, ...patch } + return { ...tab, savedFingerprint: requestFingerprint(tab) } +} + +describe("isTabDirty", () => { + it("is false for drafts and freshly loaded/saved tabs", () => { + expect(isTabDirty(createNewTab())).toBe(false) + expect(isTabDirty(linked())).toBe(false) + }) + + it("flips on a request edit and back when the edit is undone", () => { + const tab = linked() + const edited = { ...tab, url: "https://api.test/other" } + expect(isTabDirty(edited)).toBe(true) + expect(isTabDirty({ ...edited, url: tab.url })).toBe(false) + }) + + it("ignores runtime state, refreshed OAuth tokens and stripped file bytes", () => { + const tab = linked({ + auth: { type: "oauth2", oauth2: { accessToken: "a" } as never }, + body: { type: "form-data", content: "", formData: [{ id: "f", key: "file", value: "", active: true, valueType: "file", fileName: "a.bin", fileContentBase64: "AAAA" }] }, + }) + expect(isTabDirty({ ...tab, isLoading: true, response: { status: 200 } as never })).toBe(false) + expect(isTabDirty({ ...tab, auth: { type: "oauth2", oauth2: { accessToken: "b", expiresAt: 1 } as never } })).toBe(false) + expect(isTabDirty({ ...tab, body: { ...tab.body, formData: [{ ...tab.body.formData![0], fileContentBase64: "" }] } })).toBe(false) + }) +}) + +describe("savePatch", () => { + it("restores file bytes the tab lost from the stored request, never blanks them", () => { + const file = { id: "f", key: "file", value: "", active: true, valueType: "file" as const, fileName: "a.bin" } + const tab = linked({ body: { type: "form-data", content: "", formData: [{ ...file, fileContentBase64: "" }] } }) + const stored = { id: "r1", body: { type: "form-data", content: "", formData: [{ ...file, fileContentBase64: "AAAA" }] } } as CollectionRequest + expect(savePatch(tab, stored).body.formData![0].fileContentBase64).toBe("AAAA") + }) + + it("does not write examples or comments", () => { + const patch = savePatch(linked({ examples: [], comments: [] }), undefined) + expect("examples" in patch).toBe(false) + expect("comments" in patch).toBe(false) + }) +}) + +describe("withFolderDefaults", () => { + const req: CollectionRequest = { ...createNewTab(), id: "r1", headers: [], preRequestScript: "own()" } + const collections: Collection[] = [{ + id: "c1", + name: "C", + items: [{ id: "fo", name: "F", type: "folder", items: [req], defaultHeaders: [{ id: "h", key: "X-Team", value: "a", active: true }], preRequestScript: "folder()" }], + } as Collection] + + it("merges the source request's folder defaults without touching the tab", () => { + const tab: ApiRequestState = { ...createNewTab(), headers: [], preRequestScript: "own()", source: { requestId: "r1" } } + const sent = withFolderDefaults(tab, collections) + expect(sent.headers.map((h) => h.key)).toEqual(["X-Team"]) + expect(sent.preRequestScript).toBe("folder()\n\nown()") + expect(tab.headers).toEqual([]) + }) + + it("returns drafts unchanged", () => { + const tab = createNewTab() + expect(withFolderDefaults(tab, collections)).toBe(tab) + }) +}) diff --git a/apps/desktop-ui/src/lib/api-client/saved-request.ts b/apps/desktop-ui/src/lib/api-client/saved-request.ts new file mode 100644 index 000000000..db0612aa0 --- /dev/null +++ b/apps/desktop-ui/src/lib/api-client/saved-request.ts @@ -0,0 +1,82 @@ +import type { ApiRequestState, CollectionRequest } from "@/components/api-client/types" + +/** + * The tab fields ⌘S writes back to a collection request. + * + * Deliberately excluded: `examples` and `comments` (tabs can hold + * quota-stripped copies of example bodies — writing them back would destroy + * the stored ones; both keep their stored values because the update patch is + * shallow), plus runtime-only state (response, loading, script results). + */ +export function requestFromTab(tab: ApiRequestState): Omit { + return { + name: tab.name, + method: tab.method, + url: tab.url, + params: tab.params, + headers: tab.headers, + body: tab.body, + auth: tab.auth, + preRequestScript: tab.preRequestScript, + testScript: tab.testScript, + timeoutMs: tab.timeoutMs, + useCookieJar: tab.useCookieJar, + } +} + +const cache = new WeakMap() + +/** + * Compact identity of what ⌘S would save. Cached per tab object, so only the + * tab being edited is re-serialised on a keystroke. + * + * Ignores values that change without the user editing the request: OAuth2 + * tokens refreshed on send, and file bytes (which the quota fallback may strip + * from localStorage) — the file's name stands in for them. + */ +export function requestFingerprint(tab: ApiRequestState): string { + const hit = cache.get(tab) + if (hit !== undefined) return hit + const r = requestFromTab(tab) + const oauth2 = r.auth.oauth2 + const fp = JSON.stringify({ + ...r, + auth: oauth2 + ? { ...r.auth, oauth2: { ...oauth2, accessToken: undefined, refreshToken: undefined, tokenType: undefined, expiresAt: undefined } } + : r.auth, + body: { + ...r.body, + formData: r.body.formData?.map((f) => ({ ...f, fileContentBase64: undefined })), + }, + }) + cache.set(tab, fp) + return fp +} + +/** True when a tab linked to a saved request has edits ⌘S hasn't written yet. */ +export function isTabDirty(tab: ApiRequestState): boolean { + return tab.savedFingerprint !== undefined && requestFingerprint(tab) !== tab.savedFingerprint +} + +/** + * Patch for overwriting `stored` with `tab`. File bytes the tab no longer + * holds (quota fallback) are taken from the stored request, never blanked. + */ +export function savePatch(tab: ApiRequestState, stored: CollectionRequest | undefined): Omit { + const r = requestFromTab(tab) + const storedFiles = new Map( + (stored?.body.formData ?? []).map((f) => [f.id, f.fileContentBase64] as const), + ) + if (!r.body.formData?.some((f) => f.valueType === "file" && !f.fileContentBase64 && storedFiles.get(f.id))) return r + return { + ...r, + body: { + ...r.body, + formData: r.body.formData.map((f) => + f.valueType === "file" && !f.fileContentBase64 && storedFiles.get(f.id) + ? { ...f, fileContentBase64: storedFiles.get(f.id) } + : f, + ), + }, + } +} diff --git a/apps/desktop-ui/src/lib/folder-inheritance.ts b/apps/desktop-ui/src/lib/folder-inheritance.ts index 14736e18f..23f8eb59b 100644 --- a/apps/desktop-ui/src/lib/folder-inheritance.ts +++ b/apps/desktop-ui/src/lib/folder-inheritance.ts @@ -10,6 +10,7 @@ */ import type { + Collection, CollectionFolder, CollectionRequest, KeyValueItem, @@ -56,10 +57,10 @@ function concatScripts(parts: Array): string { return cleaned.join("\n\n") } -export function applyFolderInheritance( - request: CollectionRequest, +export function applyFolderInheritance>( + request: R, ancestors: CollectionFolder[], -): CollectionRequest { +): R { if (ancestors.length === 0) return request // Nearest-ancestor-first auth: request wins; if request is `none`, look outward. @@ -88,3 +89,20 @@ export function applyFolderInheritance( testScript: concatScripts(testChain), } } + +/** + * A tab opened from a collection holds the request exactly as saved; its + * folders' defaults are merged in here, at send/codegen time, so ⌘S never + * writes inherited headers or scripts back into the request. + */ +export function withFolderDefaults & { source?: { requestId: string } }>( + tab: T, + collections: Collection[], +): T { + if (!tab.source) return tab + for (const col of collections) { + const ancestors = findRequestAncestors(col.items, tab.source.requestId) + if (ancestors !== null) return applyFolderInheritance(tab, ancestors) + } + return tab +}