diff --git a/.gitignore b/.gitignore index b174a5e0..f6860576 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,8 @@ yarn.lock /out/ apps/*/.next/ apps/*/out/ +# Monaco AMD bundle copied from node_modules by apps/desktop-ui/scripts/copy-monaco.mjs +apps/desktop-ui/public/monaco/ apps/*/.agent # production /build @@ -99,4 +101,6 @@ docs/superpowers/ # graft's local graph cache — regenerable, not committed (run `graft build`). graft/ # Local Linux build output (published to GitHub releases, never committed) -dist-linux/ \ No newline at end of file +dist-linux/# Regenerated by `next dev` (agentRules); not project instructions +apps/desktop-ui/AGENTS.md +apps/desktop-ui/CLAUDE.md diff --git a/apps/desktop-ui/messages/af.json b/apps/desktop-ui/messages/af.json index b5c44dfe..5226d011 100644 --- a/apps/desktop-ui/messages/af.json +++ b/apps/desktop-ui/messages/af.json @@ -2351,6 +2351,8 @@ "collectionsSidebar": { "title": "API-hulpbronne", "newCollection": "Nuwe versameling", + "openFolderCollection": "Maak vouer oop as versameling", + "folderCollection": "Vouerversameling", "tabCollections": "Versamelings", "tabHistory": "Geskiedenis", "noCollectionsTitle": "Geen versamelings", @@ -2392,6 +2394,7 @@ "placeholderNewEnv": "Nuwe omgewing", "variablesHeading": "{name}-veranderlikes", "addVariable": "Voeg veranderlike by", + "exportPostman": "Voer uit (Postman-omgewing)", "columnVariable": "Veranderlike", "columnValue": "Waarde", "placeholderKey": "Sleutel", diff --git a/apps/desktop-ui/messages/ar.json b/apps/desktop-ui/messages/ar.json index 9a8aadc5..41831c2a 100644 --- a/apps/desktop-ui/messages/ar.json +++ b/apps/desktop-ui/messages/ar.json @@ -2340,6 +2340,8 @@ "collectionsSidebar": { "title": "موارد API", "newCollection": "مجموعة جديدة", + "openFolderCollection": "فتح مجلد كمجموعة", + "folderCollection": "مجموعة مجلد", "tabCollections": "المجموعات", "tabHistory": "السجل", "noCollectionsTitle": "لا مجموعات", @@ -2381,6 +2383,7 @@ "placeholderNewEnv": "بيئة جديدة", "variablesHeading": "متغيرات {name}", "addVariable": "إضافة متغير", + "exportPostman": "تصدير (بيئة Postman)", "columnVariable": "متغير", "columnValue": "قيمة", "placeholderKey": "مفتاح", diff --git a/apps/desktop-ui/messages/ca.json b/apps/desktop-ui/messages/ca.json index edd94bda..16670042 100644 --- a/apps/desktop-ui/messages/ca.json +++ b/apps/desktop-ui/messages/ca.json @@ -2340,6 +2340,8 @@ "collectionsSidebar": { "title": "Recursos API", "newCollection": "Col·lecció nova", + "openFolderCollection": "Obre una carpeta com a col·lecció", + "folderCollection": "Col·lecció de carpeta", "tabCollections": "Col·leccions", "tabHistory": "Historial", "noCollectionsTitle": "Sense col·leccions", @@ -2381,6 +2383,7 @@ "placeholderNewEnv": "Entorn nou", "variablesHeading": "Variables de {name}", "addVariable": "Afegeix variable", + "exportPostman": "Exporta (entorn de Postman)", "columnVariable": "Variable", "columnValue": "Valor", "placeholderKey": "Clau", diff --git a/apps/desktop-ui/messages/cs.json b/apps/desktop-ui/messages/cs.json index 1cecc88f..e748a831 100644 --- a/apps/desktop-ui/messages/cs.json +++ b/apps/desktop-ui/messages/cs.json @@ -2340,6 +2340,8 @@ "collectionsSidebar": { "title": "API zdroje", "newCollection": "Nová kolekce", + "openFolderCollection": "Otevřít složku jako kolekci", + "folderCollection": "Kolekce ze složky", "tabCollections": "Kolekce", "tabHistory": "Historie", "noCollectionsTitle": "Žádné kolekce", @@ -2381,6 +2383,7 @@ "placeholderNewEnv": "Nové prostředí", "variablesHeading": "Proměnné: {name}", "addVariable": "Přidat proměnnou", + "exportPostman": "Exportovat (prostředí Postman)", "columnVariable": "Proměnná", "columnValue": "Hodnota", "placeholderKey": "Klíč", diff --git a/apps/desktop-ui/messages/da.json b/apps/desktop-ui/messages/da.json index c3b78cb8..edffc75c 100644 --- a/apps/desktop-ui/messages/da.json +++ b/apps/desktop-ui/messages/da.json @@ -2340,6 +2340,8 @@ "collectionsSidebar": { "title": "API-ressourcer", "newCollection": "Ny kollektion", + "openFolderCollection": "Åbn mappe som samling", + "folderCollection": "Mappesamling", "tabCollections": "Samlinger", "tabHistory": "Historie", "noCollectionsTitle": "Ingen samlinger", @@ -2381,6 +2383,7 @@ "placeholderNewEnv": "Nyt miljø", "variablesHeading": "{name} variabler", "addVariable": "Tilføj variabel", + "exportPostman": "Eksportér (Postman-miljø)", "columnVariable": "Variabel", "columnValue": "Værdi", "placeholderKey": "Nøgle", diff --git a/apps/desktop-ui/messages/de.json b/apps/desktop-ui/messages/de.json index 33813e79..259b7035 100644 --- a/apps/desktop-ui/messages/de.json +++ b/apps/desktop-ui/messages/de.json @@ -2340,6 +2340,8 @@ "collectionsSidebar": { "title": "API-Ressourcen", "newCollection": "Neue Sammlung", + "openFolderCollection": "Ordner als Sammlung öffnen", + "folderCollection": "Ordner-Sammlung", "tabCollections": "Sammlungen", "tabHistory": "Verlauf", "noCollectionsTitle": "Keine Sammlungen", @@ -2381,6 +2383,7 @@ "placeholderNewEnv": "Neue Umgebung", "variablesHeading": "Variablen: {name}", "addVariable": "Variable hinzufügen", + "exportPostman": "Exportieren (Postman-Umgebung)", "columnVariable": "Variable", "columnValue": "Wert", "placeholderKey": "Schlüssel", diff --git a/apps/desktop-ui/messages/el.json b/apps/desktop-ui/messages/el.json index 98a744c5..9da3820b 100644 --- a/apps/desktop-ui/messages/el.json +++ b/apps/desktop-ui/messages/el.json @@ -2340,6 +2340,8 @@ "collectionsSidebar": { "title": "Πόροι API", "newCollection": "Νέα συλλογή", + "openFolderCollection": "Άνοιγμα φακέλου ως συλλογή", + "folderCollection": "Συλλογή φακέλου", "tabCollections": "Συλλογές", "tabHistory": "Ιστορία", "noCollectionsTitle": "Χωρίς συλλογές", @@ -2381,6 +2383,7 @@ "placeholderNewEnv": "Νέο περιβάλλον", "variablesHeading": "{name} μεταβλητές", "addVariable": "Προσθήκη μεταβλητής", + "exportPostman": "Εξαγωγή (περιβάλλον Postman)", "columnVariable": "Μεταβλητός", "columnValue": "Αξία", "placeholderKey": "Κλειδί", diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index 24f9b8cd..59bb38e2 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -2352,6 +2352,8 @@ "collectionsSidebar": { "title": "API resources", "newCollection": "New collection", + "openFolderCollection": "Open folder as collection", + "folderCollection": "Folder collection", "tabCollections": "Collections", "tabHistory": "History", "noCollectionsTitle": "No collections", @@ -2393,6 +2395,7 @@ "placeholderNewEnv": "New environment", "variablesHeading": "{name} variables", "addVariable": "Add variable", + "exportPostman": "Export (Postman environment)", "columnVariable": "Variable", "columnValue": "Value", "placeholderKey": "Key", diff --git a/apps/desktop-ui/messages/es.json b/apps/desktop-ui/messages/es.json index ddd9d36a..6d552b01 100644 --- a/apps/desktop-ui/messages/es.json +++ b/apps/desktop-ui/messages/es.json @@ -2351,6 +2351,8 @@ "collectionsSidebar": { "title": "Recursos API", "newCollection": "Nueva colección", + "openFolderCollection": "Abrir carpeta como colección", + "folderCollection": "Colección de carpeta", "tabCollections": "Colecciones", "tabHistory": "Historial", "noCollectionsTitle": "Sin colecciones", @@ -2392,6 +2394,7 @@ "placeholderNewEnv": "Nuevo entorno", "variablesHeading": "Variables de {name}", "addVariable": "Añadir variable", + "exportPostman": "Exportar (entorno de Postman)", "columnVariable": "Variable", "columnValue": "Valor", "placeholderKey": "Clave", diff --git a/apps/desktop-ui/messages/fa.json b/apps/desktop-ui/messages/fa.json index 96fa10f4..bcbc10d0 100644 --- a/apps/desktop-ui/messages/fa.json +++ b/apps/desktop-ui/messages/fa.json @@ -2351,6 +2351,8 @@ "collectionsSidebar": { "title": "منابع API", "newCollection": "مجموعه جدید", + "openFolderCollection": "باز کردن پوشه به عنوان مجموعه", + "folderCollection": "مجموعه پوشه‌ای", "tabCollections": "مجموعه ها", "tabHistory": "تاریخچه", "noCollectionsTitle": "بدون مجموعه", @@ -2392,6 +2394,7 @@ "placeholderNewEnv": "محیط جدید", "variablesHeading": "{name} متغیرها", "addVariable": "متغیر اضافه کنید", + "exportPostman": "خروجی (محیط Postman)", "columnVariable": "متغیر", "columnValue": "ارزش", "placeholderKey": "کلید", diff --git a/apps/desktop-ui/messages/fr.json b/apps/desktop-ui/messages/fr.json index e6ed772a..1a681f24 100644 --- a/apps/desktop-ui/messages/fr.json +++ b/apps/desktop-ui/messages/fr.json @@ -2351,6 +2351,8 @@ "collectionsSidebar": { "title": "Ressources API", "newCollection": "Nouvelle collection", + "openFolderCollection": "Ouvrir un dossier comme collection", + "folderCollection": "Collection de dossier", "tabCollections": "Collections", "tabHistory": "Historique", "noCollectionsTitle": "Aucune collection", @@ -2392,6 +2394,7 @@ "placeholderNewEnv": "Nouvel environnement", "variablesHeading": "Variables {name}", "addVariable": "Ajouter une variable", + "exportPostman": "Exporter (environnement Postman)", "columnVariable": "Variable", "columnValue": "Valeur", "placeholderKey": "Clé", diff --git a/apps/desktop-ui/messages/id.json b/apps/desktop-ui/messages/id.json index f57d3a7e..da056a98 100644 --- a/apps/desktop-ui/messages/id.json +++ b/apps/desktop-ui/messages/id.json @@ -2340,6 +2340,8 @@ "collectionsSidebar": { "title": "API resources", "newCollection": "New collection", + "openFolderCollection": "Buka folder sebagai koleksi", + "folderCollection": "Koleksi folder", "tabCollections": "Collections", "tabHistory": "History", "noCollectionsTitle": "No collections", @@ -2381,6 +2383,7 @@ "placeholderNewEnv": "New environment", "variablesHeading": "{name} variables", "addVariable": "Add variable", + "exportPostman": "Ekspor (lingkungan Postman)", "columnVariable": "Variable", "columnValue": "Value", "placeholderKey": "Key", diff --git a/apps/desktop-ui/messages/it.json b/apps/desktop-ui/messages/it.json index 8c2dbcdc..26411714 100644 --- a/apps/desktop-ui/messages/it.json +++ b/apps/desktop-ui/messages/it.json @@ -2351,6 +2351,8 @@ "collectionsSidebar": { "title": "Risorse API", "newCollection": "Nuova collezione", + "openFolderCollection": "Apri cartella come raccolta", + "folderCollection": "Raccolta da cartella", "tabCollections": "Collezioni", "tabHistory": "Storia", "noCollectionsTitle": "Nessuna raccolta", @@ -2392,6 +2394,7 @@ "placeholderNewEnv": "Nuovo ambiente", "variablesHeading": "{name} variabili", "addVariable": "Aggiungi variabile", + "exportPostman": "Esporta (ambiente Postman)", "columnVariable": "Variabile", "columnValue": "Valore", "placeholderKey": "Chiave", diff --git a/apps/desktop-ui/messages/ja.json b/apps/desktop-ui/messages/ja.json index 30f0a028..391af32c 100644 --- a/apps/desktop-ui/messages/ja.json +++ b/apps/desktop-ui/messages/ja.json @@ -2340,6 +2340,8 @@ "collectionsSidebar": { "title": "APIリソース", "newCollection": "新しいコレクション", + "openFolderCollection": "フォルダーをコレクションとして開く", + "folderCollection": "フォルダーコレクション", "tabCollections": "コレクション", "tabHistory": "履歴", "noCollectionsTitle": "コレクションなし", @@ -2381,6 +2383,7 @@ "placeholderNewEnv": "新しい環境", "variablesHeading": "{name}の変数", "addVariable": "変数を追加", + "exportPostman": "エクスポート(Postman 環境)", "columnVariable": "変数", "columnValue": "値", "placeholderKey": "キー", diff --git a/apps/desktop-ui/messages/ko.json b/apps/desktop-ui/messages/ko.json index 07064145..6573a880 100644 --- a/apps/desktop-ui/messages/ko.json +++ b/apps/desktop-ui/messages/ko.json @@ -2340,6 +2340,8 @@ "collectionsSidebar": { "title": "API 리소스", "newCollection": "새 컬렉션", + "openFolderCollection": "폴더를 컬렉션으로 열기", + "folderCollection": "폴더 컬렉션", "tabCollections": "컬렉션", "tabHistory": "기록", "noCollectionsTitle": "컬렉션 없음", @@ -2381,6 +2383,7 @@ "placeholderNewEnv": "새 환경", "variablesHeading": "{name} 변수", "addVariable": "변수 추가", + "exportPostman": "내보내기 (Postman 환경)", "columnVariable": "변수", "columnValue": "값", "placeholderKey": "키", diff --git a/apps/desktop-ui/messages/ms.json b/apps/desktop-ui/messages/ms.json index 5e3138d3..889cf36f 100644 --- a/apps/desktop-ui/messages/ms.json +++ b/apps/desktop-ui/messages/ms.json @@ -2351,6 +2351,8 @@ "collectionsSidebar": { "title": "Sumber API", "newCollection": "Koleksi baharu", + "openFolderCollection": "Buka folder sebagai koleksi", + "folderCollection": "Koleksi folder", "tabCollections": "Koleksi", "tabHistory": "Sejarah", "noCollectionsTitle": "Tiada koleksi", @@ -2392,6 +2394,7 @@ "placeholderNewEnv": "Persekitaran baharu", "variablesHeading": "Pembolehubah {name}", "addVariable": "Tambah pembolehubah", + "exportPostman": "Eksport (persekitaran Postman)", "columnVariable": "Pembolehubah", "columnValue": "Nilai", "placeholderKey": "Kunci", diff --git a/apps/desktop-ui/messages/nb.json b/apps/desktop-ui/messages/nb.json index 244a3502..afd3b8a5 100644 --- a/apps/desktop-ui/messages/nb.json +++ b/apps/desktop-ui/messages/nb.json @@ -2340,6 +2340,8 @@ "collectionsSidebar": { "title": "API-ressurser", "newCollection": "Ny samling", + "openFolderCollection": "Åpne mappe som samling", + "folderCollection": "Mappesamling", "tabCollections": "Samlinger", "tabHistory": "Historikk", "noCollectionsTitle": "Ingen samlinger", @@ -2381,6 +2383,7 @@ "placeholderNewEnv": "Nytt miljø", "variablesHeading": "{name}-variabler", "addVariable": "Legg til variabel", + "exportPostman": "Eksporter (Postman-miljø)", "columnVariable": "Variabel", "columnValue": "Verdi", "placeholderKey": "Nøkkel", diff --git a/apps/desktop-ui/messages/nl.json b/apps/desktop-ui/messages/nl.json index 3e94ac0e..d45d8ee7 100644 --- a/apps/desktop-ui/messages/nl.json +++ b/apps/desktop-ui/messages/nl.json @@ -2340,6 +2340,8 @@ "collectionsSidebar": { "title": "API-bronnen", "newCollection": "Nieuwe collectie", + "openFolderCollection": "Map openen als collectie", + "folderCollection": "Mapcollectie", "tabCollections": "Collecties", "tabHistory": "Geschiedenis", "noCollectionsTitle": "Geen collecties", @@ -2381,6 +2383,7 @@ "placeholderNewEnv": "Nieuwe omgeving", "variablesHeading": "{name} variabelen", "addVariable": "Variabele toevoegen", + "exportPostman": "Exporteren (Postman-omgeving)", "columnVariable": "Variabele", "columnValue": "Waarde", "placeholderKey": "Sleutel", diff --git a/apps/desktop-ui/messages/pl.json b/apps/desktop-ui/messages/pl.json index 70acbfd6..314ab030 100644 --- a/apps/desktop-ui/messages/pl.json +++ b/apps/desktop-ui/messages/pl.json @@ -2340,6 +2340,8 @@ "collectionsSidebar": { "title": "API resources", "newCollection": "New collection", + "openFolderCollection": "Otwórz folder jako kolekcję", + "folderCollection": "Kolekcja z folderu", "tabCollections": "Collections", "tabHistory": "History", "noCollectionsTitle": "No collections", @@ -2381,6 +2383,7 @@ "placeholderNewEnv": "New environment", "variablesHeading": "{name} variables", "addVariable": "Add variable", + "exportPostman": "Eksportuj (środowisko Postman)", "columnVariable": "Variable", "columnValue": "Value", "placeholderKey": "Key", diff --git a/apps/desktop-ui/messages/pt-BR.json b/apps/desktop-ui/messages/pt-BR.json index 2ad26082..b43499e2 100644 --- a/apps/desktop-ui/messages/pt-BR.json +++ b/apps/desktop-ui/messages/pt-BR.json @@ -2340,6 +2340,8 @@ "collectionsSidebar": { "title": "Recursos API", "newCollection": "Nova coleção", + "openFolderCollection": "Abrir pasta como coleção", + "folderCollection": "Coleção de pasta", "tabCollections": "Coleções", "tabHistory": "Histórico", "noCollectionsTitle": "Sem coleções", @@ -2381,6 +2383,7 @@ "placeholderNewEnv": "Novo ambiente", "variablesHeading": "Variáveis de {name}", "addVariable": "Adicionar variável", + "exportPostman": "Exportar (ambiente Postman)", "columnVariable": "Variável", "columnValue": "Valor", "placeholderKey": "Chave", diff --git a/apps/desktop-ui/messages/pt.json b/apps/desktop-ui/messages/pt.json index 83839c88..4719463c 100644 --- a/apps/desktop-ui/messages/pt.json +++ b/apps/desktop-ui/messages/pt.json @@ -2340,6 +2340,8 @@ "collectionsSidebar": { "title": "Recursos API", "newCollection": "Nova coleção", + "openFolderCollection": "Abrir pasta como coleção", + "folderCollection": "Coleção de pasta", "tabCollections": "Coleções", "tabHistory": "Histórico", "noCollectionsTitle": "Sem coleções", @@ -2381,6 +2383,7 @@ "placeholderNewEnv": "Novo ambiente", "variablesHeading": "Variáveis de {name}", "addVariable": "Adicionar variável", + "exportPostman": "Exportar (ambiente Postman)", "columnVariable": "Variável", "columnValue": "Valor", "placeholderKey": "Chave", diff --git a/apps/desktop-ui/messages/ru.json b/apps/desktop-ui/messages/ru.json index 342ec3d2..faf1dfee 100644 --- a/apps/desktop-ui/messages/ru.json +++ b/apps/desktop-ui/messages/ru.json @@ -2340,6 +2340,8 @@ "collectionsSidebar": { "title": "API-ресурсы", "newCollection": "Новая коллекция", + "openFolderCollection": "Открыть папку как коллекцию", + "folderCollection": "Коллекция из папки", "tabCollections": "Коллекции", "tabHistory": "История", "noCollectionsTitle": "Нет коллекций", @@ -2381,6 +2383,7 @@ "placeholderNewEnv": "Новое окружение", "variablesHeading": "Переменные {name}", "addVariable": "Добавить переменную", + "exportPostman": "Экспорт (окружение Postman)", "columnVariable": "Переменная", "columnValue": "Значение", "placeholderKey": "Ключ", diff --git a/apps/desktop-ui/messages/sv.json b/apps/desktop-ui/messages/sv.json index 2c6845e6..ba7cb0ec 100644 --- a/apps/desktop-ui/messages/sv.json +++ b/apps/desktop-ui/messages/sv.json @@ -2351,6 +2351,8 @@ "collectionsSidebar": { "title": "API-resurser", "newCollection": "Ny samling", + "openFolderCollection": "Öppna mapp som samling", + "folderCollection": "Mappsamling", "tabCollections": "Samlingar", "tabHistory": "Historik", "noCollectionsTitle": "Inga samlingar", @@ -2392,6 +2394,7 @@ "placeholderNewEnv": "Ny miljö", "variablesHeading": "{name}-variabler", "addVariable": "Lägg till variabel", + "exportPostman": "Exportera (Postman-miljö)", "columnVariable": "Variabel", "columnValue": "Värde", "placeholderKey": "Nyckel", diff --git a/apps/desktop-ui/messages/tr.json b/apps/desktop-ui/messages/tr.json index 7d9d6106..91ce80ee 100644 --- a/apps/desktop-ui/messages/tr.json +++ b/apps/desktop-ui/messages/tr.json @@ -2351,6 +2351,8 @@ "collectionsSidebar": { "title": "API kaynakları", "newCollection": "Yeni koleksiyon", + "openFolderCollection": "Klasörü koleksiyon olarak aç", + "folderCollection": "Klasör koleksiyonu", "tabCollections": "Koleksiyonlar", "tabHistory": "Geçmiş", "noCollectionsTitle": "Koleksiyon yok", @@ -2392,6 +2394,7 @@ "placeholderNewEnv": "Yeni ortam", "variablesHeading": "{name} değişkenleri", "addVariable": "Değişken ekle", + "exportPostman": "Dışa aktar (Postman ortamı)", "columnVariable": "Değişken", "columnValue": "Değer", "placeholderKey": "Anahtar", diff --git a/apps/desktop-ui/messages/uk.json b/apps/desktop-ui/messages/uk.json index d9b4fcf7..b820fef0 100644 --- a/apps/desktop-ui/messages/uk.json +++ b/apps/desktop-ui/messages/uk.json @@ -2351,6 +2351,8 @@ "collectionsSidebar": { "title": "API ресурси", "newCollection": "Нова колекція", + "openFolderCollection": "Відкрити папку як колекцію", + "folderCollection": "Колекція з папки", "tabCollections": "Колекції", "tabHistory": "Історія", "noCollectionsTitle": "Немає колекцій", @@ -2392,6 +2394,7 @@ "placeholderNewEnv": "Нове середовище", "variablesHeading": "Змінні {name}", "addVariable": "Додати змінну", + "exportPostman": "Експорт (середовище Postman)", "columnVariable": "Змінна", "columnValue": "Значення", "placeholderKey": "Ключ", diff --git a/apps/desktop-ui/messages/vi.json b/apps/desktop-ui/messages/vi.json index bca53f10..047503e4 100644 --- a/apps/desktop-ui/messages/vi.json +++ b/apps/desktop-ui/messages/vi.json @@ -2340,6 +2340,8 @@ "collectionsSidebar": { "title": "Tài nguyên API", "newCollection": "Bộ sưu tập mới", + "openFolderCollection": "Mở thư mục dưới dạng bộ sưu tập", + "folderCollection": "Bộ sưu tập thư mục", "tabCollections": "Bộ sưu tập", "tabHistory": "Lịch sử", "noCollectionsTitle": "Chưa có bộ sưu tập", @@ -2381,6 +2383,7 @@ "placeholderNewEnv": "Môi trường mới", "variablesHeading": "Biến {name}", "addVariable": "Thêm biến", + "exportPostman": "Xuất (môi trường Postman)", "columnVariable": "Biến", "columnValue": "Giá trị", "placeholderKey": "Khóa", diff --git a/apps/desktop-ui/messages/zh.json b/apps/desktop-ui/messages/zh.json index 2a5e15b1..73852b8e 100644 --- a/apps/desktop-ui/messages/zh.json +++ b/apps/desktop-ui/messages/zh.json @@ -2340,6 +2340,8 @@ "collectionsSidebar": { "title": "API 资源", "newCollection": "新建集合", + "openFolderCollection": "将文件夹作为集合打开", + "folderCollection": "文件夹集合", "tabCollections": "集合", "tabHistory": "历史", "noCollectionsTitle": "暂无集合", @@ -2381,6 +2383,7 @@ "placeholderNewEnv": "新环境", "variablesHeading": "{name} 变量", "addVariable": "添加变量", + "exportPostman": "导出(Postman 环境)", "columnVariable": "变量", "columnValue": "值", "placeholderKey": "键", diff --git a/apps/desktop-ui/package.json b/apps/desktop-ui/package.json index 2f02c1d5..c21c5602 100644 --- a/apps/desktop-ui/package.json +++ b/apps/desktop-ui/package.json @@ -6,12 +6,15 @@ "node": ">=20.19.0" }, "scripts": { + "predev": "node scripts/copy-monaco.mjs", "dev": "next dev", + "prebuild": "node scripts/copy-monaco.mjs", "build": "next build", "lint": "eslint .", "test": "jest", "test:watch": "jest --watch", "build:tauri": "node scripts/build-tauri.mjs", + "predev:tauri": "node scripts/copy-monaco.mjs", "dev:tauri": "NEXT_PUBLIC_TAURI=1 next dev" }, "dependencies": { @@ -53,6 +56,8 @@ "@tanstack/react-virtual": "^3.14.10", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-deep-link": "^2.4.9", + "@tauri-apps/plugin-dialog": "^2.4.0", + "@tauri-apps/plugin-fs": "^2.4.0", "@tauri-apps/plugin-opener": "^2.5.4", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", @@ -91,7 +96,7 @@ "lucide-react": "^0.552.0", "marked": "^18.0.10", "mime-types": "^3.0.2", - "monaco-editor": "^0.53.0", + "monaco-editor": "^0.55.1", "mongodb": "^7.5.0", "mysql2": "^3.23.4", "netmask": "^2.1.1", diff --git a/apps/desktop-ui/scripts/build-tauri.mjs b/apps/desktop-ui/scripts/build-tauri.mjs index 29b3534e..b3824afc 100644 --- a/apps/desktop-ui/scripts/build-tauri.mjs +++ b/apps/desktop-ui/scripts/build-tauri.mjs @@ -10,7 +10,6 @@ * Output: apps/desktop-ui/out/ */ import { execSync, spawnSync } from "node:child_process"; -import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -26,26 +25,11 @@ if (major < 20 || (major === 20 && minor < 19)) { process.exit(1); } -// Activation is mandatory: a desktop build without real Firebase credentials -// produces an app nobody can activate. Hard-fail rather than ship it. -// Next loads .env files itself; this check just guards CI/misconfigured shells. -const hasFirebaseKey = - !!process.env.NEXT_PUBLIC_FIREBASE_API_KEY || - [".env", ".env.local", ".env.production"].some((f) => { - try { - return fs.readFileSync(path.join(uiRoot, f), "utf8").includes("NEXT_PUBLIC_FIREBASE_API_KEY="); - } catch { - return false; - } - }); -if (!hasFirebaseKey) { - console.error( - "build-tauri: NEXT_PUBLIC_FIREBASE_API_KEY is not set (env or .env file). Desktop " + - "activation requires real Firebase credentials baked into the bundle — export the " + - "NEXT_PUBLIC_FIREBASE_* vars before building." - ); - process.exit(1); -} +// Monaco must be served from the bundle, not jsdelivr (offline app). +const copyMonaco = spawnSync(process.execPath, [path.join(uiRoot, "scripts", "copy-monaco.mjs")], { + stdio: "inherit", +}); +if (copyMonaco.status !== 0) process.exit(copyMonaco.status ?? 1); const env = { ...process.env, diff --git a/apps/desktop-ui/scripts/copy-monaco.mjs b/apps/desktop-ui/scripts/copy-monaco.mjs new file mode 100644 index 00000000..3f044fa3 --- /dev/null +++ b/apps/desktop-ui/scripts/copy-monaco.mjs @@ -0,0 +1,22 @@ +#!/usr/bin/env node +/** + * Copy Monaco's prebuilt AMD bundle into public/ so the editor loads from the + * app itself instead of jsdelivr (the @monaco-editor/loader default). The + * desktop app is offline-first; a CDN fetch per cold launch broke every editor + * without network. Output is gitignored — runs from the pre* lifecycle hooks. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const uiRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const src = path.join(uiRoot, "node_modules", "monaco-editor", "min", "vs"); +const dest = path.join(uiRoot, "public", "monaco", "vs"); + +if (!fs.existsSync(src)) { + console.error(`copy-monaco: ${src} missing — run pnpm install first`); + process.exit(1); +} +fs.rmSync(dest, { recursive: true, force: true }); +fs.cpSync(src, dest, { recursive: true }); +console.log("copy-monaco: public/monaco/vs refreshed"); diff --git a/apps/desktop-ui/src/components/api-client/__tests__/tabs-context.test.tsx b/apps/desktop-ui/src/components/api-client/__tests__/tabs-context.test.tsx index bde4ceba..1d3dfe80 100644 --- a/apps/desktop-ui/src/components/api-client/__tests__/tabs-context.test.tsx +++ b/apps/desktop-ui/src/components/api-client/__tests__/tabs-context.test.tsx @@ -83,3 +83,34 @@ describe("guard functions are exported", () => { expect(typeof useTabsActions).toBe("function") }) }) + +describe("slimTabsForStorage", () => { + const { createNewTab, slimTabsForStorage } = require("../context/tabs-context") + const fat = () => { + const t = createNewTab() + t.response = { status: 200, statusText: "OK", headers: {}, body: "x".repeat(1000), time: 1, size: 1 } + t.isLoading = true + t.graphqlSchema = { types: [] } + t.examples = [{ id: "e", name: "ex", capturedAt: 1, request: { method: "GET", url: "u", headers: [], body: t.body }, response: { status: 200, statusText: "OK", headers: {}, body: "big" } }] + t.body.formData = [{ id: "f", key: "file", value: "", enabled: true, valueType: "file", fileName: "a.bin", fileContentBase64: "AAAA" }] + return t + } + + it("always drops runtime + refetchable state, keeps examples and file bytes", () => { + const [s] = slimTabsForStorage([fat()]) + expect(s.response).toBeUndefined() + expect(s.isLoading).toBeUndefined() + expect(s.websocket).toBeUndefined() + expect(s.graphqlSchema).toBeUndefined() + expect(s.examples[0].response.body).toBe("big") + expect(s.body.formData[0].fileContentBase64).toBe("AAAA") + }) + + it("aggressive mode also drops example bodies and file bytes but keeps metadata", () => { + const [s] = slimTabsForStorage([fat()], true) + expect(s.examples[0].name).toBe("ex") + expect(s.examples[0].response.body).toBe("") + expect(s.body.formData[0].fileName).toBe("a.bin") + expect(s.body.formData[0].fileContentBase64).toBe("") + }) +}) 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 943e9519..3a8b2e72 100644 --- a/apps/desktop-ui/src/components/api-client/api-client.tsx +++ b/apps/desktop-ui/src/components/api-client/api-client.tsx @@ -1,6 +1,6 @@ "use client" -import { apiFetch } from "@/lib/desktop/api-fetch"; +import { apiFetch, sendProxyRequest } from "@/lib/desktop/api-fetch"; import * as React from "react" import { Card } from "@/components/ui/card" import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable" @@ -9,13 +9,8 @@ import { RequestTabs } from "./request-tabs" import { ResponsePanel } from "./response-panel" import { TabBar } from "./tab-bar" import { ImportCurlDialog, type ImportCurlTarget } from "./import-curl-dialog" -import { ImportDialog } from "./import-dialog" -import { CookieJarDialog } from "./cookie-jar-dialog" -import { WebSocketPanel } from "./websocket-panel" -import { GrpcPanel } from "./grpc-panel" import { SaveExampleDialog } from "./save-example-dialog" import { loadPlugins, instantiatePlugin, applyBeforeSend, applyAfterResponse, type PluginInstance } from "@/lib/plugins/plugin-runtime" -import { MetricsDialog } from "./metrics-dialog" import { recordMetric, recordLog } from "@/lib/observability/metrics" import { OfflineIndicator } from "./offline-indicator" import { putCachedResponse, getCachedResponse } from "@/lib/cache/response-cache" @@ -40,6 +35,7 @@ import { API_CLIENT_ERROR_STATUS_TEXT, ScriptTestResult, ScriptLog, + type ApiResponse, } from "./types" import type { ScriptContext } from "./workers/scripts-runner.worker" import { useTranslations } from "next-intl" @@ -78,6 +74,13 @@ const CodeGenerator = dynamic( () => import("./code-generator").then(m => ({ default: m.CodeGenerator })), { ssr: false, loading: () => null } ) +// Heavy or rarely-used surfaces stay out of the route's critical chunk: +// gRPC pulls protobufjs, the importer pulls js-yaml + every importer. +const GrpcPanel = dynamic(() => import("./grpc-panel").then(m => ({ default: m.GrpcPanel })), { ssr: false, loading: () => null }) +const WebSocketPanel = dynamic(() => import("./websocket-panel").then(m => ({ default: m.WebSocketPanel })), { ssr: false, loading: () => null }) +const ImportDialog = dynamic(() => import("./import-dialog").then(m => ({ default: m.ImportDialog })), { ssr: false, loading: () => null }) +const CookieJarDialog = dynamic(() => import("./cookie-jar-dialog").then(m => ({ default: m.CookieJarDialog })), { ssr: false, loading: () => null }) +const MetricsDialog = dynamic(() => import("./metrics-dialog").then(m => ({ default: m.MetricsDialog })), { ssr: false, loading: () => null }) /** * Tag history entries with the HTTP method when falling back to URL, so the list @@ -201,11 +204,11 @@ function ApiClientInner() { toast.success(`Saved example "${name}"`) } - const handleDeleteExample = (id: string) => { - updateActiveTab({ examples: (activeTab.examples ?? []).filter((e) => e.id !== id) }) - } + const handleDeleteExample = React.useCallback((id: string) => { + updateActiveTab((tab) => ({ examples: (tab.examples ?? []).filter((e) => e.id !== id) })) + }, [updateActiveTab]) - const handleLoadExample = (example: SavedExample) => { + const handleLoadExample = React.useCallback((example: SavedExample) => { updateActiveTab({ response: { status: example.response.status, @@ -219,7 +222,25 @@ function ApiClientInner() { isLoading: false, }) toast.success(`Loaded example "${example.name}"`) - } + }, [updateActiveTab]) + + // One stable setter per RequestTabs field. Inline `(v) => updateActiveTab({...})` + // props defeated RequestTabs' memo and re-rendered the whole editor per keystroke. + const setField = React.useMemo(() => { + const make = (key: K) => + (value: ApiRequestState[K]) => updateActiveTab({ [key]: value } as Partial) + return { + params: make("params"), + headers: make("headers"), + body: make("body"), + auth: make("auth"), + preRequestScript: make("preRequestScript"), + testScript: make("testScript"), + graphqlSchema: make("graphqlSchema"), + comments: make("comments"), + } + }, [updateActiveTab]) + const openSaveExample = React.useCallback(() => setSaveExampleOpen(true), []) // Scroll position memory for mobile panel toggle const scrollMemory = React.useRef<{ request: number; response: number }>({ request: 0, response: 0 }) @@ -302,12 +323,16 @@ function ApiClientInner() { } const { copyToClipboard } = useCopyToClipboard() + // Latest-value ref so callbacks handed to memoized children (RequestPanel) + // stay referentially stable across keystrokes. + const activeTabRef = React.useRef(activeTab) + React.useEffect(() => { activeTabRef.current = activeTab }, [activeTab]) const handleCopyCurl = React.useCallback(() => { - void copyToClipboard(generateCode(activeTab, "curl"), { + void copyToClipboard(generateCode(activeTabRef.current, "curl"), { successMessage: t("toasts.curlCopied"), errorMessage: t("toasts.copyFailed"), }) - }, [activeTab, copyToClipboard, t]) + }, [copyToClipboard, t]) const handleSaveRequest = (parentId: string, name: string) => { const requestToSave: CollectionRequest = { @@ -324,7 +349,7 @@ function ApiClientInner() { updateActiveTab({ name }) } - const handleLoadRequest = (request: CollectionRequest) => { + 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. @@ -344,15 +369,15 @@ function ApiClientInner() { isLoading: false, } appendTab(newTab) - } + }, [collections, appendTab]) - const handleCancel = () => { + const handleCancel = React.useCallback(() => { abortControllerRef.current?.abort() abortControllerRef.current = null updateActiveTab({ isLoading: false }) - } + }, [updateActiveTab]) - const handleSend = React.useCallback(async () => { + const sendImpl = React.useCallback(async () => { if (!activeTab.url?.trim()) return abortControllerRef.current?.abort() @@ -441,8 +466,19 @@ function ApiClientInner() { // Resolve cross-tool secrets referenced anywhere in the request // (scan the raw tab JSON so params/headers/auth/body are all covered). // Throws a user-facing message when the vault is locked → outer catch. + // Only the editable request parts — stringifying the whole tab dragged + // the previous response body and every saved example through the scan. secretVars = await resolveSecretVariables( - [workUrl, JSON.stringify(workHeaders), JSON.stringify(activeTab)], + [ + 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 })) }, + }), + ], cipherKey, ) @@ -774,24 +810,23 @@ function ApiClientInner() { Object.assign(headersObj, beforeApplied.req.headers) const finalUrlFromPlugins = beforeApplied.req.url - // Send via Proxy - const res = await apiFetch("/api/proxy", { - method: "POST", - credentials: "include", - signal: controller.signal, - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - url: finalUrlFromPlugins, - method: beforeApplied.req.method, - headers: headersObj, - body: beforeApplied.req.body ?? bodyPayload ?? bodyContent, - timeoutMs: activeTab.timeoutMs, - }), - }) - - const proxyData = await res.json() + // Send via Proxy (envelope returned directly — no fake Response round-trip) + const rawEnvelope = await sendProxyRequest({ + url: finalUrlFromPlugins, + method: beforeApplied.req.method, + headers: headersObj, + body: beforeApplied.req.body ?? bodyPayload ?? bodyContent, + timeoutMs: activeTab.timeoutMs, + }, controller.signal) + const proxyData = { + ...rawEnvelope, + statusText: rawEnvelope.statusText ?? "", + headers: rawEnvelope.headers ?? {}, + body: rawEnvelope.body ?? "", + time: rawEnvelope.time ?? 0, + size: rawEnvelope.size ?? 0, + redirectChain: rawEnvelope.redirectChain as ApiResponse["redirectChain"], + } // Plugins: onAfterResponse hook gets the live response. applyAfterResponse(pluginInstancesRef.current, { @@ -862,10 +897,10 @@ function ApiClientInner() { }, response: { status: proxyData.status, - statusText: proxyData.statusText, - headers: proxyData.headers, - body: proxyData.body, - time: proxyData.time, + statusText: proxyData.statusText ?? "", + headers: proxyData.headers ?? {}, + body: proxyData.body ?? "", + time: proxyData.time ?? 0, }, environment: { ...activeEnvironmentVariables, ...scriptEnvOverlay }, variables: { ...sessionVarsRef.current }, @@ -911,15 +946,15 @@ function ApiClientInner() { updateActiveTab({ response: { status: proxyData.status, - statusText: proxyData.statusText, - headers: proxyData.headers, - body: formattedBody, + statusText: proxyData.statusText ?? "", + headers: proxyData.headers ?? {}, + body: formattedBody ?? "", isBase64: proxyData.isBase64, - time: proxyData.time, - size: proxyData.size, + time: proxyData.time ?? 0, + size: proxyData.size ?? 0, error: proxyData.error, setCookies: proxyData.setCookies, - redirectChain: proxyData.redirectChain, + redirectChain: proxyData.redirectChain as ApiResponse["redirectChain"], }, scriptResults: (scriptTests.length || scriptLogs.length || scriptErrors.length) ? { tests: scriptTests, logs: scriptLogs, errors: scriptErrors } @@ -1008,8 +1043,13 @@ function ApiClientInner() { cipherKey, t, ]) + // `sendImpl` changes identity on every keystroke (it closes over activeTab); + // the stable wrapper keeps RequestPanel memoized and the keydown listener put. + const sendRef = React.useRef(sendImpl) + React.useEffect(() => { sendRef.current = sendImpl }, [sendImpl]) + const handleSend = React.useCallback(() => sendRef.current(), []) - const handleCurlPaste = (curl: string) => { + const handleCurlPaste = React.useCallback((curl: string) => { try { const parsed = parseCurlCommand(curl) // No URL extracted → don't claim success and don't wipe the current URL. @@ -1029,7 +1069,7 @@ function ApiClientInner() { console.error(error) toast.error(t("toasts.curlParseFailed")) } - } + }, [updateActiveTab, replaceUrlWithEnvBaseUrl, t]) // Keyboard shortcuts. // Cmd/Ctrl+T and Cmd/Ctrl+W are hard-reserved by the browser (`preventDefault` @@ -1053,7 +1093,6 @@ function ApiClientInner() { } window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) - // eslint-disable-next-line react-hooks/exhaustive-deps }, [addTab, closeTab, handleSend, activeTabId]) // Desktop request column — reused whether it sits full-width (no response yet) @@ -1077,25 +1116,25 @@ function ApiClientInner() {
updateActiveTab({ params })} + setParams={setField.params} headers={activeTab.headers} - setHeaders={(headers) => updateActiveTab({ headers })} + setHeaders={setField.headers} body={activeTab.body} - setBody={(body) => updateActiveTab({ body })} + setBody={setField.body} auth={activeTab.auth} - setAuth={(auth) => updateActiveTab({ auth })} + setAuth={setField.auth} preRequestScript={activeTab.preRequestScript} - setPreRequestScript={(s) => updateActiveTab({ preRequestScript: s })} + setPreRequestScript={setField.preRequestScript} testScript={activeTab.testScript} - setTestScript={(s) => updateActiveTab({ testScript: s })} + setTestScript={setField.testScript} graphqlUrl={activeTab.url} graphqlSchema={activeTab.graphqlSchema} - setGraphqlSchema={(s) => updateActiveTab({ graphqlSchema: s })} + setGraphqlSchema={setField.graphqlSchema} examples={activeTab.examples} onDeleteExample={handleDeleteExample} onLoadExample={handleLoadExample} comments={activeTab.comments} - setComments={(next) => updateActiveTab({ comments: next })} + setComments={setField.comments} />
@@ -1378,25 +1417,25 @@ function ApiClientInner() { /> updateActiveTab({ params })} + setParams={setField.params} headers={activeTab.headers} - setHeaders={(headers) => updateActiveTab({ headers })} + setHeaders={setField.headers} body={activeTab.body} - setBody={(body) => updateActiveTab({ body })} + setBody={setField.body} auth={activeTab.auth} - setAuth={(auth) => updateActiveTab({ auth })} + setAuth={setField.auth} preRequestScript={activeTab.preRequestScript} - setPreRequestScript={(s) => updateActiveTab({ preRequestScript: s })} + setPreRequestScript={setField.preRequestScript} testScript={activeTab.testScript} - setTestScript={(s) => updateActiveTab({ testScript: s })} + setTestScript={setField.testScript} graphqlUrl={activeTab.url} graphqlSchema={activeTab.graphqlSchema} - setGraphqlSchema={(s) => updateActiveTab({ graphqlSchema: s })} + setGraphqlSchema={setField.graphqlSchema} examples={activeTab.examples} onDeleteExample={handleDeleteExample} onLoadExample={handleLoadExample} comments={activeTab.comments} - setComments={(next) => updateActiveTab({ comments: next })} + setComments={setField.comments} /> @@ -1406,29 +1445,28 @@ function ApiClientInner() { onScroll={(e) => { scrollMemory.current.response = (e.target as HTMLDivElement).scrollTop }} >
- setSaveExampleOpen(true) : undefined} /> +
- ) : !activeTab.response && !activeTab.isLoading ? ( - /* Desktop: no response yet — request panel gets the full width */ -
- {desktopRequestContent} -
) : ( - /* Desktop: side-by-side resizable panels once a response exists / is loading */ + /* Desktop: the request panel is always mounted in the same group; the + response panel joins the split once a response exists / is loading. + Swapping the whole layout on Send remounted every Monaco editor. */ - + {desktopRequestContent} - - - - -
- setSaveExampleOpen(true) : undefined} /> -
-
+ {(activeTab.response || activeTab.isLoading) && ( + <> + + +
+ +
+
+ + )}
)} diff --git a/apps/desktop-ui/src/components/api-client/collections/collections-sidebar.tsx b/apps/desktop-ui/src/components/api-client/collections/collections-sidebar.tsx index d4164ce7..f94fddb2 100644 --- a/apps/desktop-ui/src/components/api-client/collections/collections-sidebar.tsx +++ b/apps/desktop-ui/src/components/api-client/collections/collections-sidebar.tsx @@ -6,15 +6,17 @@ import { Checkbox } from "@/components/ui/checkbox" import { ScrollArea } from "@/components/ui/scroll-area" import { Collection, CollectionRequest } from "../types" import { CollectionItem } from "./collection-item" -import { FolderPlus, Trash2, Pencil, MoreHorizontal, Search, X, FileDown, Play, Server, Link2, Globe } from "lucide-react" +import { FolderPlus, FolderGit2, Trash2, Pencil, MoreHorizontal, Search, X, FileDown, Play, Server, Link2, Globe } from "lucide-react" import { buildShareUrl } from "@/lib/share-link" import { apiFetch } from "@/lib/desktop/api-fetch" import { toast } from "sonner" -import { downloadCollectionAsPostman } from "@/lib/export/postman" -import { downloadCollectionAsOpenApi } from "@/lib/export/openapi" -import { downloadCollectionAsHar } from "@/lib/export/har" -import { downloadCollectionAsInsomnia } from "@/lib/export/insomnia" -import { CollectionRunnerDialog } from "../collection-runner-dialog" +import dynamic from "next/dynamic" +import type { HistoryRequest } from "../types" +// Runner (+ csv/junit libs) only loads when a run is opened. +const CollectionRunnerDialog = dynamic( + () => import("../collection-runner-dialog").then((m) => ({ default: m.CollectionRunnerDialog })), + { ssr: false, loading: () => null }, +) import { useWorkspacesContext } from "../context/workspaces-context" import { WorkspacesDialog } from "../workspaces-dialog" import { Briefcase } from "lucide-react" @@ -65,7 +67,11 @@ export function CollectionsSidebar({ onLoadRequest: loadRequest }: CollectionsSi [loadRequest, panel], ) const { collections, isLoading } = useCollectionsState() - const { addFolder: onAddFolder, deleteItem: onDelete, toggleFolder: onToggle, createCollection: onCreateCollection, renameCollection: onRenameCollection, renameFolder: onRenameFolder, patchFolder, deleteMultipleCollections: onDeleteMultiple } = useCollectionsActions() + const { addFolder: onAddFolder, deleteItem: onDelete, toggleFolder: onToggle, createCollection: onCreateCollection, renameCollection: onRenameCollection, renameFolder: onRenameFolder, patchFolder, deleteMultipleCollections: onDeleteMultiple, openFolderCollection } = useCollectionsActions() + + // isDesktop() is false during SSR — gate behind a mounted flag to avoid hydration mismatch. + const [desktop, setDesktop] = React.useState(false) + React.useEffect(() => { setDesktop(isDesktop()) }, []) const { history, isLoading: isHistoryLoading } = useHistoryState() const { clearHistory: onClearHistory, deleteHistoryItem: onDeleteHistoryItem } = useHistoryActions() const t = useTranslations("ApiClient.collectionsSidebar") @@ -195,6 +201,65 @@ export function CollectionsSidebar({ onLoadRequest: loadRequest }: CollectionsSi setSelectedCollections(new Set()) } + // Stable row renderer so VirtualHistoryList's memo holds while the request + // editor re-renders on every keystroke. + const renderHistoryRow = React.useCallback((item: HistoryRequest, style: React.CSSProperties) => ( +
+
onLoadRequest(item)} + > +
+
+ + {item.method} + + + {item.name ? getApiClientRequestDisplayName(item.name, tRoot) : item.url} + +
+ +
+
+ {item.url} +
+ {new Date(item.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} + {item.status && ( + = 200 && item.status < 300 ? "text-green-500" + : item.status >= 300 && item.status < 400 ? "text-amber-500" + : "text-destructive" + )}> + {item.status} + + )} +
+
+
+
+ ), [onLoadRequest, onDeleteHistoryItem, tRoot]) + return ( // Header (icon, title, collapse) is owned by ToolSidebarLayout in // components/api-client/api-client.tsx — this renders the body only. @@ -220,6 +285,17 @@ export function CollectionsSidebar({ onLoadRequest: loadRequest }: CollectionsSi > + {desktop && ( + + )} @@ -273,9 +349,17 @@ export function CollectionsSidebar({ onLoadRequest: loadRequest }: CollectionsSi

{t("noCollectionsHint")}

- +
+ + {desktop && ( + + )} +
) : ( collectionsForActiveWs.map((collection) => ( @@ -292,6 +376,11 @@ export function CollectionsSidebar({ onLoadRequest: loadRequest }: CollectionsSi {collection.name} + {collection.source && ( + + + + )}
-
-
- {item.url} -
- {new Date(item.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} - {item.status && ( - = 200 && item.status < 300 ? "text-green-500" - : item.status >= 300 && item.status < 400 ? "text-amber-500" - : "text-destructive" - )}> - {item.status} - - )} -
-
- - - )} + renderRow={renderHistoryRow} /> )} diff --git a/apps/desktop-ui/src/components/api-client/collections/use-collections.ts b/apps/desktop-ui/src/components/api-client/collections/use-collections.ts index 4a7d64ae..bf2edc67 100644 --- a/apps/desktop-ui/src/components/api-client/collections/use-collections.ts +++ b/apps/desktop-ui/src/components/api-client/collections/use-collections.ts @@ -6,6 +6,10 @@ import { toast } from "sonner" import useAuth from "@/utils/useAuth" import { apiFetch } from "@/lib/desktop/api-fetch" import { broadcastApiClientUpdate, useApiClientSyncListener } from "@/lib/api-client-sync" +import { isDesktop } from "@/lib/desktop/is-desktop" +import { countLiteralAuthSecrets, diffFiles, newManifest, parseCollection, serializeCollection, MANIFEST_FILE } from "@/lib/api-client/file-store" +import { moveCollectionSecretsToVault } from "@/lib/api-client/move-to-vault" +import { useCipherKey } from "@/lib/use-cipher-key" const STORAGE_KEY = "api-client-collections" @@ -13,11 +17,154 @@ function sortCollections(cols: Collection[]) { return [...cols].sort((a, b) => a.name.localeCompare(b.name)) } +/** Carry UI-only open/closed state (stripped from disk) across reloads, by folder id. */ +function applyOpenState(prev: Collection | undefined, next: Collection): Collection { + if (!prev) return next + const openIds = new Set() + const collect = (items: Collection["items"]) => { + for (const item of items) { + if ("type" in item && item.type === "folder") { + if (item.isOpen) openIds.add(item.id) + collect(item.items) + } + } + } + collect(prev.items) + const apply = (items: Collection["items"]): Collection["items"] => + items.map((item) => + "type" in item && item.type === "folder" + ? { ...item, isOpen: openIds.has(item.id), items: apply(item.items) } + : item + ) + return { ...next, items: apply(next.items) } +} + export function useCollections() { const { user, loading } = useAuth() const [collections, setCollections] = React.useState([]) + const [fileCollections, setFileCollections] = React.useState([]) const [isLoading, setIsLoading] = React.useState(true) const migrationRanRef = React.useRef(false) + /** Paths already toasted about, so a failing folder doesn't re-toast on every window focus. */ + const failedFilePathsRef = React.useRef(new Set()) + /** Collections already warned about literal credentials — once per session is enough. */ + const secretWarnedRef = React.useRef(new Set()) + const cipherKey = useCipherKey() + // Latest file collections for toast-action callbacks (they outlive the closing render). + const fileCollectionsRef = React.useRef(fileCollections) + fileCollectionsRef.current = fileCollections + + // DB collections + folder-backed collections, one list for consumers. + const allCollections = React.useMemo( + () => sortCollections([...collections, ...fileCollections]), + [collections, fileCollections] + ) + + /** (Re-)read every registered folder collection from disk. Desktop only. */ + const reloadFileCollections = React.useCallback(async () => { + if (!isDesktop()) return + const { readCollectionFiles, allowCollectionDir, loadCollectionRegistry } = await import( + "@/lib/desktop/collection-files" + ) + const paths = await loadCollectionRegistry() + if (paths.length === 0) { + setFileCollections((cur) => (cur.length === 0 ? cur : [])) + return + } + const loaded: Collection[] = [] + for (const path of paths) { + try { + await allowCollectionDir(path) + const { collection, warnings } = parseCollection(await readCollectionFiles(path)) + warnings.forEach((w) => console.warn(`[file-collection] ${path}: ${w}`)) + loaded.push({ ...collection, source: { kind: "file", path } }) + failedFilePathsRef.current.delete(path) + } catch (e) { + console.error(`Failed to read folder collection at ${path}`, e) + if (!failedFilePathsRef.current.has(path)) { + failedFilePathsRef.current.add(path) + toast.error(`Couldn't read folder collection: ${path}`) + } + } + } + setFileCollections((cur) => { + const next = loaded.map((col) => applyOpenState(cur.find((c) => c.id === col.id), col)) + return JSON.stringify(next) === JSON.stringify(cur) ? cur : next + }) + }, []) + + // Load folder collections on mount and when the window regains focus + // (catches git pulls / external editor changes — no file watcher in v1). + React.useEffect(() => { + if (!isDesktop()) return + void reloadFileCollections() + const onFocus = () => void reloadFileCollections() + window.addEventListener("focus", onFocus) + return () => window.removeEventListener("focus", onFocus) + }, [reloadFileCollections]) + + /** + * Persist a mutated folder collection: swap state optimistically, then write + * only the changed files (deterministic serializer ⇒ no-op edits write nothing). + * On write failure the previous state is restored — never silently drop edits. + */ + const mutateFileCollection = async ( + target: Collection, + nextItems: Collection["items"], + patch?: Partial + ): Promise => { + const next = { ...target, ...patch, items: nextItems } + setFileCollections((cur) => cur.map((c) => (c.id === target.id ? next : c))) + // Literal credentials are redacted by the serializer (never written to git-able + // files) — tell the user once so a "missing" token isn't a mystery, and offer + // to move them into the encrypted vault (rewrites fields to {{vault.*}} tokens). + if (!secretWarnedRef.current.has(target.id) && countLiteralAuthSecrets(next) > 0) { + secretWarnedRef.current.add(target.id) + toast.warning( + "Credentials typed directly are not saved into collection files — keep them in the encrypted vault as {{vault.NAME}} tokens", + { + duration: 10000, + action: { + label: "Move to vault", + onClick: () => void moveFileCollectionSecrets(target.id), + }, + } + ) + } + try { + const { writes, deletes } = diffFiles(serializeCollection(target), serializeCollection(next)) + if (writes.length > 0 || deletes.length > 0) { + const { writeCollectionFiles } = await import("@/lib/desktop/collection-files") + await writeCollectionFiles(target.source!.path, writes, deletes) + } + return true + } catch (e) { + setFileCollections((cur) => cur.map((c) => (c.id === target.id ? target : c))) + console.error("Error writing folder collection", e) + toast.error(`Failed to write collection files: ${e instanceof Error ? e.message : String(e)}`) + return false + } + } + + /** Toast action: move every literal credential in a folder collection into the vault. */ + const moveFileCollectionSecrets = async (collectionId: string) => { + const target = fileCollectionsRef.current.find((c) => c.id === collectionId) + if (!target) return + try { + const { collection: moved, moved: count } = await moveCollectionSecretsToVault(target, cipherKey) + if (count === 0) return + if (await mutateFileCollection(target, moved.items)) { + toast.success( + count === 1 + ? "1 credential moved to the vault" + : `${count} credentials moved to the vault` + ) + } + } catch (e) { + console.error("Error moving credentials to vault", e) + toast.error(e instanceof Error ? e.message : "Failed to move credentials to the vault") + } + } React.useEffect(() => { if (!user) migrationRanRef.current = false @@ -145,8 +292,6 @@ export function useCollections() { ) const addFolder = async (parentId: string, name: string) => { - if (!user) return - const newFolder: CollectionFolder = { id: crypto.randomUUID(), name, @@ -155,12 +300,22 @@ export function useCollections() { isOpen: true, } - const targetCollection = collections.find(c => + const targetCollection = allCollections.find(c => c.id === parentId || findItemInCollection(c.items, parentId) ) if (!targetCollection) return + if (targetCollection.source) { + const nextItems = targetCollection.id === parentId + ? [...targetCollection.items, newFolder] + : addItemToParent(targetCollection.items, parentId, newFolder) + const ok = await mutateFileCollection(targetCollection, nextItems) + return ok ? newFolder.id : undefined + } + + if (!user) return + // Optimistic update const prev = collections const optimisticItems = targetCollection.id === parentId @@ -187,13 +342,28 @@ export function useCollections() { } const deleteItem = async (itemId: string) => { - if (!user) return - // Find collection containing item - const targetCollection = collections.find(c => + const targetCollection = allCollections.find(c => c.items.some(i => i.id === itemId) || findItemInCollection(c.items, itemId) ) + if (targetCollection?.source) { + await mutateFileCollection(targetCollection, deleteFromItems(targetCollection.items, itemId)) + return + } + + // Deleting a folder collection = forget it, never touch the user's files. + const fileCol = fileCollections.find((c) => c.id === itemId) + if (fileCol?.source) { + const { loadCollectionRegistry, saveCollectionRegistry } = await import("@/lib/desktop/collection-files") + await saveCollectionRegistry((await loadCollectionRegistry()).filter((p) => p !== fileCol.source!.path)) + setFileCollections((cur) => cur.filter((c) => c.id !== itemId)) + toast.success("Folder collection removed from list (files kept)") + return + } + + if (!user) return + // If not found in items, maybe it IS the collection? if (!targetCollection) { const colToDelete = collections.find(c => c.id === itemId) @@ -236,14 +406,22 @@ export function useCollections() { } const saveRequest = async (parentId: string, request: CollectionRequest) => { - if (!user) return - - const targetCollection = collections.find(c => + const targetCollection = allCollections.find(c => c.id === parentId || findItemInCollection(c.items, parentId) ) if (!targetCollection) return + if (targetCollection.source) { + const nextItems = targetCollection.id === parentId + ? [...targetCollection.items, request] + : addItemToParent(targetCollection.items, parentId, request) + if (await mutateFileCollection(targetCollection, nextItems)) toast.success("Request saved") + return + } + + if (!user) return + // Optimistic update const prev = collections const optimisticItems = targetCollection.id === parentId @@ -270,11 +448,19 @@ export function useCollections() { } const toggleFolder = async (folderId: string) => { - if (!user) return - - const targetCollection = collections.find(c => findItemInCollection(c.items, folderId)) + const targetCollection = allCollections.find(c => findItemInCollection(c.items, folderId)) if (!targetCollection) return + if (targetCollection.source) { + // isOpen is UI-only and stripped from disk — pure state change. + setFileCollections((cur) => + cur.map((c) => (c.id === targetCollection.id ? { ...c, items: toggleInItems(c.items, folderId) } : c)) + ) + return + } + + if (!user) return + // Optimistic update (toggle is purely local UX — we still persist to avoid drift) const prev = collections const optimisticItems = toggleInItems(targetCollection.items, folderId) @@ -306,10 +492,8 @@ export function useCollections() { * Applies optimistically against state, reconciles from the delta result. */ const patchFolder = async (folderId: string, patch: Partial) => { - if (!user) return - const targetCollection = collections.find((c) => findItemInCollection(c.items, folderId)) + const targetCollection = allCollections.find((c) => findItemInCollection(c.items, folderId)) if (!targetCollection) return - const prev = collections const patchInItems = (items: (CollectionFolder | CollectionRequest)[]): (CollectionFolder | CollectionRequest)[] => items.map((item) => { if ("type" in item && item.type === "folder") { @@ -318,6 +502,16 @@ export function useCollections() { } return item }) + + if (targetCollection.source) { + if (await mutateFileCollection(targetCollection, patchInItems(targetCollection.items))) { + toast.success("Folder defaults saved") + } + return + } + + if (!user) return + const prev = collections setCollections((cur) => sortCollections(cur.map((c) => c.id === targetCollection.id ? { ...c, items: patchInItems(c.items) } : c @@ -338,11 +532,16 @@ export function useCollections() { } const renameFolder = async (folderId: string, name: string) => { - if (!user) return - - const targetCollection = collections.find(c => findItemInCollection(c.items, folderId)) + const targetCollection = allCollections.find(c => findItemInCollection(c.items, folderId)) if (!targetCollection) return + if (targetCollection.source) { + await mutateFileCollection(targetCollection, renameFolderInItems(targetCollection.items, folderId, name)) + return + } + + if (!user) return + // Optimistic update const prev = collections const optimisticItems = renameFolderInItems(targetCollection.items, folderId, name) @@ -498,6 +697,14 @@ export function useCollections() { } const renameCollection = async (collectionId: string, name: string) => { + const fileCol = fileCollections.find((c) => c.id === collectionId) + if (fileCol) { + if (await mutateFileCollection(fileCol, fileCol.items, { name })) { + toast.success("Collection renamed") + } + return + } + if (!user) return const prev = collections // Optimistic update @@ -521,9 +728,26 @@ export function useCollections() { } const deleteMultipleCollections = async (ids: string[]) => { - if (!user) return if (ids.length === 0) return + // Folder collections: forget only, never delete the user's files. + const fileIds = new Set(fileCollections.filter((c) => ids.includes(c.id)).map((c) => c.id)) + if (fileIds.size > 0) { + const removedPaths = new Set( + fileCollections.filter((c) => fileIds.has(c.id)).map((c) => c.source!.path) + ) + const { loadCollectionRegistry, saveCollectionRegistry } = await import("@/lib/desktop/collection-files") + await saveCollectionRegistry((await loadCollectionRegistry()).filter((p) => !removedPaths.has(p))) + setFileCollections((cur) => cur.filter((c) => !fileIds.has(c.id))) + ids = ids.filter((id) => !fileIds.has(id)) + if (ids.length === 0) { + toast.success("Folder collections removed from list (files kept)") + return + } + } + + if (!user) return + try { // Use Promise.allSettled to handle partial failures gracefully const deleteResults = await Promise.allSettled( @@ -581,8 +805,52 @@ export function useCollections() { } } + /** + * Pick a folder and open it as a file-backed collection. A folder with a + * `collection.yaml` is opened as-is; a folder without one is initialized + * (name = folder basename). Desktop only. + */ + const openFolderCollection = async (): Promise => { + if (!isDesktop()) return null + try { + const { pickFolder, readCollectionFiles, writeCollectionFiles, loadCollectionRegistry, saveCollectionRegistry } = + await import("@/lib/desktop/collection-files") + const path = await pickFolder() + if (!path) return null + + const registry = await loadCollectionRegistry() + if (registry.includes(path)) { + toast.info("This folder collection is already open") + return fileCollections.find((c) => c.source?.path === path) ?? null + } + + let col: Collection + const entries = await readCollectionFiles(path) + if (entries.some((e) => e.relPath === MANIFEST_FILE)) { + const { collection, warnings } = parseCollection(entries) + warnings.forEach((w) => console.warn(`[file-collection] ${path}: ${w}`)) + col = { ...collection, source: { kind: "file", path } } + } else { + const name = path.split(/[/\\]/).filter(Boolean).pop() || "Collection" + const manifest = newManifest(name) + await writeCollectionFiles(path, [{ relPath: MANIFEST_FILE, content: manifest.content }], []) + col = { id: manifest.id, name, items: [], source: { kind: "file", path } } + } + + await saveCollectionRegistry([...registry, path]) + setFileCollections((cur) => [...cur.filter((c) => c.id !== col.id), col]) + toast.success(`Opened folder collection "${col.name}"`) + return col + } catch (e) { + console.error("Error opening folder collection", e) + toast.error(`Couldn't open folder as collection: ${e instanceof Error ? e.message : String(e)}`) + return null + } + } + return { - collections, + collections: allCollections, + openFolderCollection, addFolder, deleteItem, saveRequest, diff --git a/apps/desktop-ui/src/components/api-client/context/collections-context.tsx b/apps/desktop-ui/src/components/api-client/context/collections-context.tsx index 5003f54b..1522a203 100644 --- a/apps/desktop-ui/src/components/api-client/context/collections-context.tsx +++ b/apps/desktop-ui/src/components/api-client/context/collections-context.tsx @@ -22,6 +22,8 @@ type CollectionsActions = { patchFolder: (folderId: string, patch: Partial) => Promise deleteMultipleCollections: (ids: string[]) => Promise importCollection: (incoming: Collection) => Promise + /** Desktop only: open (or initialize) a folder of YAML files as a git-friendly collection. */ + openFolderCollection: () => Promise } // ── Contexts ─────────────────────────────────────────────────────────────── @@ -45,6 +47,7 @@ export function CollectionsProvider({ children }: { children: React.ReactNode }) patchFolder, deleteMultipleCollections, importCollection, + openFolderCollection, } = useCollections() const state = React.useMemo( @@ -69,9 +72,10 @@ export function CollectionsProvider({ children }: { children: React.ReactNode }) patchFolder, deleteMultipleCollections, importCollection, + openFolderCollection, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [addFolder, deleteItem, saveRequest, toggleFolder, createCollection, renameCollection, renameFolder, patchFolder, deleteMultipleCollections, importCollection] + [addFolder, deleteItem, saveRequest, toggleFolder, createCollection, renameCollection, renameFolder, patchFolder, deleteMultipleCollections, importCollection, openFolderCollection] ) return ( diff --git a/apps/desktop-ui/src/components/api-client/context/tabs-context.tsx b/apps/desktop-ui/src/components/api-client/context/tabs-context.tsx index e1ecbbc9..4ccba53a 100644 --- a/apps/desktop-ui/src/components/api-client/context/tabs-context.tsx +++ b/apps/desktop-ui/src/components/api-client/context/tabs-context.tsx @@ -6,6 +6,40 @@ import { ApiRequestState, API_CLIENT_DEFAULT_TAB_NAME } from "../types" const TABS_STORAGE_KEY = "api-client-tabs" const ACTIVE_TAB_STORAGE_KEY = "api-client-active-tab" +/** + * Shape a tab for localStorage. Always drops runtime state (response, loading, + * websocket transcript) and the re-fetchable GraphQL schema. With `aggressive` + * it also drops saved-example bodies and uploaded file bytes — used only as a + * fallback when the full copy overflows the ~5 MB quota, so one big example + * can't wipe every open tab. + */ +export function slimTabsForStorage(tabs: ApiRequestState[], aggressive = false): Partial[] { + return tabs.map((t) => { + const { response: _r, isLoading: _l, websocket: _ws, graphqlSchema: _gs, ...rest } = t + if (!aggressive) return rest + return { + ...rest, + examples: rest.examples?.map((e) => ({ ...e, response: { ...e.response, body: "" } })), + body: { + ...rest.body, + formData: rest.body.formData?.map((f) => + f.fileContentBase64 ? { ...f, fileContentBase64: "" } : f), + }, + } + }) +} + +function persistTabs(tabs: ApiRequestState[]) { + for (const aggressive of [false, true]) { + try { + localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(slimTabsForStorage(tabs, aggressive))) + return + } catch (e) { + console.warn(`api-client tabs: localStorage write failed (aggressive=${aggressive})`, e) + } + } +} + export const createNewTab = (kind: "rest" | "websocket" | "grpc" = "rest"): ApiRequestState => ({ id: crypto.randomUUID(), name: kind === "websocket" ? "New WebSocket" : kind === "grpc" ? "New gRPC" : API_CLIENT_DEFAULT_TAB_NAME, @@ -101,42 +135,18 @@ export function TabsProvider({ children }: { children: React.ReactNode }) { activeTabIdRef.current = activeTabId }) - // Save tabs to localStorage — strip `response`/`isLoading` (responses can be MBs and - // would blow the per-origin localStorage quota). Writes are debounced to 500ms so rapid - // edits (e.g. typing in the URL input) don't saturate the storage layer. + // Save tabs to localStorage. Writes are debounced to 500ms so rapid edits + // (e.g. typing in the URL input) don't saturate the storage layer. React.useEffect(() => { if (!isInitialized) return - const timeoutId = setTimeout(() => { - const slim = tabsRef.current.map((t) => { - // Drop response (big) + isLoading + websocket runtime (transcript can be huge) - // before persisting. WS config (status/draft/protocols) is rebuilt fresh on reload. - const { response: _r, isLoading: _l, websocket: _ws, ...rest } = t - return rest - }) - try { - localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(slim)) - } catch (e) { - console.warn("api-client tabs: localStorage write failed, dropping persisted state", e) - try { localStorage.removeItem(TABS_STORAGE_KEY) } catch { /* noop */ } - } - }, 500) + const timeoutId = setTimeout(() => persistTabs(tabsRef.current), 500) return () => clearTimeout(timeoutId) }, [tabs, isInitialized]) // Flush the latest tab state immediately on unmount so mid-debounce edits // are not lost when the user navigates away. React.useEffect(() => { - return () => { - const slim = tabsRef.current.map((t) => { - // Drop response (big) + isLoading + websocket runtime (transcript can be huge) - // before persisting. WS config (status/draft/protocols) is rebuilt fresh on reload. - const { response: _r, isLoading: _l, websocket: _ws, ...rest } = t - return rest - }) - try { - localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(slim)) - } catch { /* noop */ } - } + return () => { persistTabs(tabsRef.current) } }, []) // Save active tab id to localStorage diff --git a/apps/desktop-ui/src/components/api-client/environment-manager.tsx b/apps/desktop-ui/src/components/api-client/environment-manager.tsx index c85b15b6..448b35c5 100644 --- a/apps/desktop-ui/src/components/api-client/environment-manager.tsx +++ b/apps/desktop-ui/src/components/api-client/environment-manager.tsx @@ -10,8 +10,9 @@ import { DialogTitle, } from "@/components/ui/dialog" import { ScrollArea } from "@/components/ui/scroll-area" -import { IconPlus, IconTrash, IconEdit } from "@tabler/icons-react" +import { IconPlus, IconTrash, IconEdit, IconDownload } from "@tabler/icons-react" import { EnvironmentVariable } from "./use-environments" +import { downloadEnvironmentAsPostman } from "@/lib/export/postman-env" import { useTranslations } from "next-intl" import { useEnvironmentsState, useEnvironmentsActions } from "./context/environments-context" @@ -140,6 +141,18 @@ export function EnvironmentManager({ open, onOpenChange }: EnvironmentManagerPro > + - {open && entries.map(([k, v]) => ( + {open && entries.slice(0, MAX_TREE_CHILDREN).map(([k, v]) => ( ))} + {open && entries.length > MAX_TREE_CHILDREN && ( + + )} ) } +// Nothing is virtualised here; a 10k-element array would otherwise mount ~100k +// DOM nodes when the Tree tab opens. Caps keep the views instant on big bodies. +const MAX_TREE_CHILDREN = 200 +const MAX_TABLE_ROWS = 500 + function Leaf({ name, text, tone, depth }: { name: string | number; text: string; tone: string; depth: number }) { return (
@@ -95,7 +103,7 @@ export function JsonTableView({ rows }: { rows: unknown }) { - {rows.map((r, i) => ( + {rows.slice(0, MAX_TABLE_ROWS).map((r, i) => ( {cols.map((c) => { const v = (r && typeof r === "object" ? (r as Record)[c] : undefined) @@ -109,6 +117,11 @@ export function JsonTableView({ rows }: { rows: unknown }) { ))} + {rows.length > MAX_TABLE_ROWS && ( +
+ Showing {MAX_TABLE_ROWS} of {rows.length} rows +
+ )}
) } diff --git a/apps/desktop-ui/src/components/api-client/request-tabs.tsx b/apps/desktop-ui/src/components/api-client/request-tabs.tsx index d12e2e4f..b1ece110 100644 --- a/apps/desktop-ui/src/components/api-client/request-tabs.tsx +++ b/apps/desktop-ui/src/components/api-client/request-tabs.tsx @@ -69,7 +69,7 @@ interface RequestTabsProps { setComments?: (next: import("./types").RequestComment[]) => void } -export function RequestTabs({ +function RequestTabsImpl({ params, setParams, headers, @@ -105,9 +105,9 @@ export function RequestTabs({ ], }), [body]) - const updateBody = (updates: Partial) => { + const updateBody = React.useCallback((updates: Partial) => { setBody({ ...normalizedBody, ...updates }) - } + }, [normalizedBody, setBody]) const addFormDataItem = () => { updateBody({ @@ -157,10 +157,10 @@ export function RequestTabs({ const handleBodyContentChange = React.useCallback( (v: string) => updateBody({ content: v }), - // updateBody closes over normalizedBody via its own capture; listing - // updateBody as the dep is sufficient and avoids re-creating the - // callback on every normalizedBody identity change. - // eslint-disable-next-line react-hooks/exhaustive-deps + [updateBody] + ) + const handleGraphqlVariablesChange = React.useCallback( + (v: string) => updateBody({ graphqlVariables: v }), [updateBody] ) @@ -413,7 +413,9 @@ export function RequestTabs({ - + {/* Editor tabs stay mounted (forceMount) so switching sub-tabs doesn't + re-create a Monaco instance each time; `hidden` via data-state. */} +