diff --git a/lib/app.dart b/lib/app.dart index 8b058e4bbe..c44b015d56 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -80,8 +80,9 @@ class _AppState extends ConsumerState with WindowListener { FilledButton( child: const Text('Save'), onPressed: () async { + ref.read(autoSaveNotifierProvider.notifier).cancelPending(); await ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .saveData(); Navigator.of(context).pop(); await windowManager.setPreventClose(false); @@ -113,7 +114,8 @@ class DashApp extends ConsumerWidget { ref.watch(settingsProvider.select((value) => value.isDark)); final workspaceFolderPath = ref .watch(settingsProvider.select((value) => value.workspaceFolderPath)); - final showWorkspaceSelector = kIsDesktop && (workspaceFolderPath == null); + final showWorkspaceSelector = kIsDesktop && + (workspaceFolderPath == null || workspaceFolderPath.isEmpty); final userOnboarded = ref.watch(userOnboardedProvider); return Portal( child: MaterialApp( @@ -123,11 +125,8 @@ class DashApp extends ConsumerWidget { themeMode: isDarkMode ? ThemeMode.dark : ThemeMode.light, home: showWorkspaceSelector ? WorkspaceSelector( - onContinue: (val) async { - await initHiveBoxes(kIsDesktop, val); - ref - .read(settingsProvider.notifier) - .update(workspaceFolderPath: val); + onContinue: (path) async { + await activateWorkspace(ref, path); }, onCancel: () async { try { diff --git a/lib/consts.dart b/lib/consts.dart index 81f9c8f21a..97e9a319a7 100644 --- a/lib/consts.dart +++ b/lib/consts.dart @@ -72,6 +72,38 @@ final kIconRemoveLight = Icon( const kCodePreviewLinesLimit = 500; +const kAutoSaveDebounceDuration = Duration(seconds: 1); + +const kDefaultMobileWorkspaceSubpath = 'apidash/workspace'; + +const kMaxSavedWorkspaces = 10; + +const kDefaultCollectionName = 'Collection 1'; +const kWorkspaceCollectionsDir = 'collections'; +const kWorkspaceCollectionsIndexFile = 'collection_index.json'; +const kWorkspaceRequestFile = 'request.json'; +const kWorkspaceResponseFile = 'response.json'; +const kWorkspaceResponseBodyFilePrefix = 'response_body'; +const kWorkspaceResponseBodyFileKey = 'bodyFile'; +const kWorkspaceRequestIndexFile = 'request_index.json'; +const kWorkspaceCollectionsIndexKey = 'collections'; +const kWorkspaceCollectionIdKey = 'id'; +const kWorkspaceCollectionNameKey = 'name'; +const kWorkspaceEnvironmentsDir = 'environments'; +const kWorkspaceEnvironmentIndexFile = 'environmentIndex.json'; +const kWorkspaceHistoryDir = 'history'; +const kWorkspaceHistoryIndexFile = 'history_index.json'; +const kJsonFileExtension = '.json'; + +const kWorkspaceRequestsKey = 'requests'; +const kWorkspaceEnvironmentIdsKey = 'environmentIds'; +const kWorkspaceHistoryMetasKey = 'historyMeta'; +const kWorkspaceActiveEnvironmentIdKey = 'activeEnvironmentId'; +const kWorkspaceSaveResponsesKey = 'saveResponses'; +const kWorkspaceHistoryRetentionPeriodKey = 'historyRetentionPeriod'; + +const kGlobalEnvironmentName = 'Global Variables'; + enum HistoryRetentionPeriod { oneWeek("1 Week", Icons.calendar_view_week_rounded), oneMonth("1 Month", Icons.calendar_view_month_rounded), @@ -431,6 +463,15 @@ const kRaiseIssue = const kHintTextUrlCard = "Enter API endpoint like https://$kDefaultUri/"; const kLabelPlusNew = "+ New"; +const kLabelNewCollection = "New collection"; +const kLabelRenameCollection = "Rename collection"; +const kLabelDeleteCollection = "Delete collection"; +const kLabelCreateCollection = "Create collection"; +const kMsgNoCollections = "No collections yet"; +const kLabelCollectionName = "Collection name"; +const kMsgCollectionNameInUse = "This collection name already exists"; +const kMsgCollectionNameInvalidChars = + r'Name cannot contain / \ : * ? " < > |'; const kLabelMoreOptions = "More Options"; const kLabelSend = "Send"; const kLabelSending = "Sending.."; @@ -497,6 +538,9 @@ const kMsgNoContent = "No content"; const kMsgUnknowContentType = "Unknown Response Content-Type"; // Workspace Selector const kMsgSelectWorkspace = "Create your workspace"; +const kLabelSelectWorkspace = "Select workspace"; +const kLabelOpenWorkspaceMenu = "Open workspace…"; +const kMsgWorkspaceOpenFailed = 'Could not open that workspace folder.'; // History Page const kTitleClearHistory = 'Clear History'; const kMsgClearHistory = @@ -528,6 +572,9 @@ const kLabelDefaultLLM = "Default Large Language Model (LLM)"; const kLabelSaveResponses = "Save Responses"; const kLabelSaveResponsesSubtitle = "Save disk space by not storing API responses"; +const kLabelSaveMediaResponsesAsFiles = "Save Media Responses as Files"; +const kLabelSaveMediaResponsesAsFilesSubtitle = + "Store image, audio, video & binary responses as standalone files "; const kLabelShowSaveAlert = "Show Save Alert on App Close"; const kLabelShowSaveAlertSubtitle = "Show a confirmation dialog to save workspace when the user closes the app"; diff --git a/lib/dashbot/pages/dashbot_home_page.dart b/lib/dashbot/pages/dashbot_home_page.dart index 565a84b345..ff97cc5573 100644 --- a/lib/dashbot/pages/dashbot_home_page.dart +++ b/lib/dashbot/pages/dashbot_home_page.dart @@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:apidash_design_system/apidash_design_system.dart'; -import 'package:apidash/providers/collection_providers.dart'; +import 'package:apidash/providers/active_collection_providers.dart'; import 'package:apidash/screens/common_widgets/agentic_ui_features/ai_ui_designer/generate_ui_dialog.dart'; import 'package:apidash/screens/common_widgets/agentic_ui_features/tool_generation/generate_tool_dialog.dart'; import '../constants.dart'; diff --git a/lib/dashbot/providers/chat_viewmodel.dart b/lib/dashbot/providers/chat_viewmodel.dart index 0ef9cc7cbb..178d7d4943 100644 --- a/lib/dashbot/providers/chat_viewmodel.dart +++ b/lib/dashbot/providers/chat_viewmodel.dart @@ -339,7 +339,7 @@ class ChatViewmodel extends StateNotifier { } Future _applyOpenApi(ChatAction action) async { - final collection = _ref.read(collectionStateNotifierProvider.notifier); + final collection = _ref.read(activeCollectionProvider.notifier); final payload = action.value is Map ? (action.value as Map) : {}; @@ -452,7 +452,7 @@ class ChatViewmodel extends StateNotifier { if (requestId == null) return; final collectionNotifier = _ref.read( - collectionStateNotifierProvider.notifier, + activeCollectionProvider.notifier, ); final testCode = action.value is String ? action.value as String : ''; final currentPostScript = _currentRequest?.postRequestScript ?? ''; @@ -813,7 +813,7 @@ class ChatViewmodel extends StateNotifier { if (action.field == 'apply_to_selected') { if (requestId == null) return; _ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .update( method: httpRequestModel.method, url: withEnvUrl, @@ -839,7 +839,7 @@ class ChatViewmodel extends StateNotifier { } else if (action.field == 'apply_to_new') { final model = httpRequestModel.copyWith(url: withEnvUrl); _ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .addRequestModel(model, name: 'Imported cURL'); _appendSystem( 'Created a new request from the cURL.', diff --git a/lib/dashbot/providers/service_providers.dart b/lib/dashbot/providers/service_providers.dart index 21c96732aa..b3b7d2e806 100644 --- a/lib/dashbot/providers/service_providers.dart +++ b/lib/dashbot/providers/service_providers.dart @@ -16,7 +16,7 @@ final requestApplyServiceProvider = Provider((ref) { }); final autoFixServiceProvider = Provider((ref) { - final collection = ref.read(collectionStateNotifierProvider.notifier); + final collection = ref.read(activeCollectionProvider.notifier); final urlEnv = ref.read(urlEnvServiceProvider); return AutoFixService( requestApply: ref.read(requestApplyServiceProvider), diff --git a/lib/dashbot/widgets/dashbot_task_buttons.dart b/lib/dashbot/widgets/dashbot_task_buttons.dart index 4ec2f6603f..d8364a44e5 100644 --- a/lib/dashbot/widgets/dashbot_task_buttons.dart +++ b/lib/dashbot/widgets/dashbot_task_buttons.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:apidash/providers/collection_providers.dart'; +import 'package:apidash/providers/active_collection_providers.dart'; import 'package:apidash/screens/common_widgets/agentic_ui_features/ai_ui_designer/generate_ui_dialog.dart'; import 'package:apidash/screens/common_widgets/agentic_ui_features/tool_generation/generate_tool_dialog.dart'; import '../constants.dart'; diff --git a/lib/importer/import_dialog.dart b/lib/importer/import_dialog.dart index a8d543668c..d9ce20c41a 100644 --- a/lib/importer/import_dialog.dart +++ b/lib/importer/import_dialog.dart @@ -36,7 +36,7 @@ void importToCollectionPane( } else { for (var model in importedRequestModels.reversed) { ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .addRequestModel( model.$2, name: model.$1, diff --git a/lib/main.dart b/lib/main.dart index 2b80ff6517..da12d9e2b5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -50,15 +50,15 @@ Future initApp( try { debugPrint("initializeUsingPath: $initializeUsingPath"); debugPrint("workspaceFolderPath: ${settingsModel?.workspaceFolderPath}"); - final openBoxesStatus = await initHiveBoxes( + final workspaceReady = await initWorkspaceStorage( initializeUsingPath, settingsModel?.workspaceFolderPath, ); - debugPrint("openBoxesStatus: $openBoxesStatus"); - if (openBoxesStatus) { + debugPrint("workspaceReady: $workspaceReady"); + if (workspaceReady) { await autoClearHistory(settingsModel: settingsModel); } - return openBoxesStatus; + return workspaceReady; } catch (e) { debugPrint("initApp failed due to $e"); return false; diff --git a/lib/models/collection_model.dart b/lib/models/collection_model.dart new file mode 100644 index 0000000000..b0b142fa3c --- /dev/null +++ b/lib/models/collection_model.dart @@ -0,0 +1,55 @@ +import 'package:apidash/consts.dart'; + +import 'request_summary_model.dart'; + +class CollectionModel { + const CollectionModel({ + required this.id, + required this.name, + this.requests = const [], + }); + + final String id; + final String name; + final List requests; + + List get requestIds => requests.map((r) => r.id).toList(); + + CollectionModel copyWith({ + String? name, + List? requests, + }) { + return CollectionModel( + id: id, + name: name ?? this.name, + requests: requests ?? this.requests, + ); + } + + factory CollectionModel.fromJson(Map json) { + final requestsJson = json[kWorkspaceRequestsKey]; + final requests = []; + if (requestsJson is List) { + for (final item in requestsJson) { + if (item is Map) { + requests.add( + RequestSummary.fromJson(Map.from(item)), + ); + } + } + } + return CollectionModel( + id: json[kWorkspaceCollectionIdKey] as String? ?? '', + name: json[kWorkspaceCollectionNameKey] as String? ?? '', + requests: requests, + ); + } + + Map toJson() { + return { + kWorkspaceCollectionIdKey: id, + kWorkspaceCollectionNameKey: name, + kWorkspaceRequestsKey: requests.map((r) => r.toJson()).toList(), + }; + } +} diff --git a/lib/models/models.dart b/lib/models/models.dart index 5d17479a02..aebb53c03c 100644 --- a/lib/models/models.dart +++ b/lib/models/models.dart @@ -1,3 +1,6 @@ +export 'collection_model.dart'; +export 'request_summary_model.dart'; +export 'saved_workspace_entry.dart'; export 'history_meta_model.dart'; export 'history_request_model.dart'; export 'request_model.dart'; diff --git a/lib/models/request_summary_model.dart b/lib/models/request_summary_model.dart new file mode 100644 index 0000000000..ad05fe9430 --- /dev/null +++ b/lib/models/request_summary_model.dart @@ -0,0 +1,54 @@ +import 'package:apidash_core/apidash_core.dart'; + +import 'request_model.dart'; + +class RequestSummary { + const RequestSummary({ + required this.id, + this.name = '', + this.apiType = APIType.rest, + this.method, + this.url = '', + }); + + final String id; + final String name; + final APIType apiType; + final HTTPVerb? method; + final String url; + + factory RequestSummary.fromRequestModel(RequestModel model) { + final httpUrl = model.httpRequestModel?.url.trim(); + final url = (httpUrl != null && httpUrl.isNotEmpty) + ? httpUrl + : model.aiRequestModel?.url.trim() ?? ''; + return RequestSummary( + id: model.id, + name: model.name, + apiType: model.apiType, + method: model.httpRequestModel?.method, + url: url, + ); + } + + factory RequestSummary.fromJson(Map json) { + final methodName = json['method'] as String?; + return RequestSummary( + id: json['id'] as String, + name: json['name'] as String? ?? '', + apiType: APIType.values.byName(json['apiType'] as String? ?? 'rest'), + method: methodName == null + ? null + : HTTPVerb.values.byName(methodName), + url: json['url'] as String? ?? '', + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'apiType': apiType.name, + if (method != null) 'method': method!.name, + 'url': url, + }; +} diff --git a/lib/models/saved_workspace_entry.dart b/lib/models/saved_workspace_entry.dart new file mode 100644 index 0000000000..cb27a55065 --- /dev/null +++ b/lib/models/saved_workspace_entry.dart @@ -0,0 +1,21 @@ +class SavedWorkspaceEntry { + const SavedWorkspaceEntry({ + required this.path, + required this.name, + }); + + final String path; + final String name; + + factory SavedWorkspaceEntry.fromJson(Map json) { + return SavedWorkspaceEntry( + path: json['path'] as String? ?? '', + name: json['name'] as String? ?? '', + ); + } + + Map toJson() => { + 'path': path, + 'name': name, + }; +} diff --git a/lib/models/settings_model.dart b/lib/models/settings_model.dart index b3222bf05c..faa040ac22 100644 --- a/lib/models/settings_model.dart +++ b/lib/models/settings_model.dart @@ -2,6 +2,7 @@ import 'package:apidash_core/apidash_core.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:apidash/consts.dart'; +import 'package:apidash/models/saved_workspace_entry.dart'; @immutable class SettingsModel { @@ -13,10 +14,12 @@ class SettingsModel { this.defaultUriScheme = kDefaultUriScheme, this.defaultCodeGenLang = CodegenLanguage.curl, this.saveResponses = true, + this.saveMediaResponsesAsFiles = false, this.promptBeforeClosing = true, this.activeEnvironmentId, this.historyRetentionPeriod = HistoryRetentionPeriod.oneWeek, this.workspaceFolderPath, + this.savedWorkspaces = const [], this.isSSLDisabled = false, this.isDashBotEnabled = true, this.defaultAIModel, @@ -29,10 +32,12 @@ class SettingsModel { final SupportedUriSchemes defaultUriScheme; final CodegenLanguage defaultCodeGenLang; final bool saveResponses; + final bool saveMediaResponsesAsFiles; final bool promptBeforeClosing; final String? activeEnvironmentId; final HistoryRetentionPeriod historyRetentionPeriod; final String? workspaceFolderPath; + final List savedWorkspaces; final bool isSSLDisabled; final bool isDashBotEnabled; final Map? defaultAIModel; @@ -45,10 +50,12 @@ class SettingsModel { SupportedUriSchemes? defaultUriScheme, CodegenLanguage? defaultCodeGenLang, bool? saveResponses, + bool? saveMediaResponsesAsFiles, bool? promptBeforeClosing, String? activeEnvironmentId, HistoryRetentionPeriod? historyRetentionPeriod, String? workspaceFolderPath, + List? savedWorkspaces, bool? isSSLDisabled, bool? isDashBotEnabled, Map? defaultAIModel, @@ -62,11 +69,14 @@ class SettingsModel { defaultCodeGenLang: defaultCodeGenLang ?? this.defaultCodeGenLang, offset: offset ?? this.offset, saveResponses: saveResponses ?? this.saveResponses, + saveMediaResponsesAsFiles: + saveMediaResponsesAsFiles ?? this.saveMediaResponsesAsFiles, promptBeforeClosing: promptBeforeClosing ?? this.promptBeforeClosing, activeEnvironmentId: activeEnvironmentId ?? this.activeEnvironmentId, historyRetentionPeriod: historyRetentionPeriod ?? this.historyRetentionPeriod, workspaceFolderPath: workspaceFolderPath ?? this.workspaceFolderPath, + savedWorkspaces: savedWorkspaces ?? this.savedWorkspaces, isSSLDisabled: isSSLDisabled ?? this.isSSLDisabled, isDashBotEnabled: isDashBotEnabled ?? this.isDashBotEnabled, defaultAIModel: defaultAIModel ?? this.defaultAIModel, @@ -84,10 +94,12 @@ class SettingsModel { defaultCodeGenLang: defaultCodeGenLang, offset: offset, saveResponses: saveResponses, + saveMediaResponsesAsFiles: saveMediaResponsesAsFiles, promptBeforeClosing: promptBeforeClosing, activeEnvironmentId: activeEnvironmentId, historyRetentionPeriod: historyRetentionPeriod, workspaceFolderPath: workspaceFolderPath, + savedWorkspaces: savedWorkspaces, isSSLDisabled: isSSLDisabled, isDashBotEnabled: isDashBotEnabled, defaultAIModel: defaultAIModel, @@ -131,6 +143,8 @@ class SettingsModel { } } final saveResponses = data["saveResponses"] as bool?; + final saveMediaResponsesAsFiles = + data["saveMediaResponsesAsFiles"] as bool?; final promptBeforeClosing = data["promptBeforeClosing"] as bool?; final activeEnvironmentId = data["activeEnvironmentId"] as String?; final historyRetentionPeriodStr = data["historyRetentionPeriod"] as String?; @@ -144,6 +158,17 @@ class SettingsModel { } } final workspaceFolderPath = data["workspaceFolderPath"] as String?; + final savedWorkspaces = []; + final rawWorkspaces = data['savedWorkspaces']; + if (rawWorkspaces is List) { + for (final item in rawWorkspaces) { + if (item is Map) { + savedWorkspaces.add( + SavedWorkspaceEntry.fromJson(Map.from(item)), + ); + } + } + } final isSSLDisabled = data["isSSLDisabled"] as bool?; final isDashBotEnabled = data["isDashBotEnabled"] as bool?; final defaultAIModel = data["defaultAIModel"] == null @@ -159,11 +184,13 @@ class SettingsModel { defaultUriScheme: defaultUriScheme, defaultCodeGenLang: defaultCodeGenLang, saveResponses: saveResponses, + saveMediaResponsesAsFiles: saveMediaResponsesAsFiles, promptBeforeClosing: promptBeforeClosing, activeEnvironmentId: activeEnvironmentId, historyRetentionPeriod: historyRetentionPeriod ?? HistoryRetentionPeriod.oneWeek, workspaceFolderPath: workspaceFolderPath, + savedWorkspaces: savedWorkspaces, isSSLDisabled: isSSLDisabled, isDashBotEnabled: isDashBotEnabled, defaultAIModel: defaultAIModel, @@ -181,10 +208,12 @@ class SettingsModel { "defaultUriScheme": defaultUriScheme.name, "defaultCodeGenLang": defaultCodeGenLang.name, "saveResponses": saveResponses, + "saveMediaResponsesAsFiles": saveMediaResponsesAsFiles, "promptBeforeClosing": promptBeforeClosing, "activeEnvironmentId": activeEnvironmentId, "historyRetentionPeriod": historyRetentionPeriod.name, "workspaceFolderPath": workspaceFolderPath, + "savedWorkspaces": savedWorkspaces.map((e) => e.toJson()).toList(), "isSSLDisabled": isSSLDisabled, "isDashBotEnabled": isDashBotEnabled, "defaultAIModel": defaultAIModel, @@ -208,10 +237,12 @@ class SettingsModel { other.defaultUriScheme == defaultUriScheme && other.defaultCodeGenLang == defaultCodeGenLang && other.saveResponses == saveResponses && + other.saveMediaResponsesAsFiles == saveMediaResponsesAsFiles && other.promptBeforeClosing == promptBeforeClosing && other.activeEnvironmentId == activeEnvironmentId && other.historyRetentionPeriod == historyRetentionPeriod && other.workspaceFolderPath == workspaceFolderPath && + listEquals(other.savedWorkspaces, savedWorkspaces) && other.isSSLDisabled == isSSLDisabled && other.isDashBotEnabled == isDashBotEnabled && mapEquals(other.defaultAIModel, defaultAIModel); @@ -228,10 +259,12 @@ class SettingsModel { defaultUriScheme, defaultCodeGenLang, saveResponses, + saveMediaResponsesAsFiles, promptBeforeClosing, activeEnvironmentId, historyRetentionPeriod, workspaceFolderPath, + Object.hashAll(savedWorkspaces), isSSLDisabled, isDashBotEnabled, defaultAIModel, diff --git a/lib/providers/collection_providers.dart b/lib/providers/active_collection_providers.dart similarity index 64% rename from lib/providers/collection_providers.dart rename to lib/providers/active_collection_providers.dart index 047b5b09ed..33609f2709 100644 --- a/lib/providers/collection_providers.dart +++ b/lib/providers/active_collection_providers.dart @@ -14,7 +14,7 @@ final selectedIdStateProvider = StateProvider((ref) => null); final selectedRequestModelProvider = StateProvider((ref) { final selectedId = ref.watch(selectedIdStateProvider); - final collection = ref.watch(collectionStateNotifierProvider); + final collection = ref.watch(activeCollectionProvider); if (selectedId == null || collection == null) { return null; } else { @@ -38,32 +38,234 @@ final selectedSubstitutedHttpRequestModelProvider = } }); -final requestSequenceProvider = StateProvider>((ref) { - var ids = hiveHandler.getIds(); - return ids ?? []; -}); - -final StateNotifierProvider?> -collectionStateNotifierProvider = StateNotifierProvider( - (ref) => CollectionStateNotifier(ref, hiveHandler), +final StateNotifierProvider?> +activeCollectionProvider = StateNotifierProvider( + (ref) => ActiveCollectionNotifier(ref, workspaceStorage), ); -class CollectionStateNotifier +class ActiveCollectionNotifier extends StateNotifier?> { - CollectionStateNotifier(this.ref, this.hiveHandler) : super(null) { - var status = loadData(); + ActiveCollectionNotifier(this.ref, this.workspaceStorage) : super(null) { Future.microtask(() { - if (status) { - ref.read(requestSequenceProvider.notifier).state = [state!.keys.first]; - } - ref.read(selectedIdStateProvider.notifier).state = ref.read( - requestSequenceProvider, - )[0]; + activateCollection(ref.read(selectedCollectionIdStateProvider)); }); } + List _catalogRequestIds(String collectionId) { + return ref + .read(collectionCatalogProvider)?[collectionId] + ?.requestIds ?? + const []; + } + + List summariesForSequence( + String collectionId, + List ids, + ) { + final byId = { + for (final summary + in ref.read(collectionCatalogProvider)?[collectionId] + ?.requests ?? + const []) + summary.id: summary, + }; + final useMemory = collectionId == _activeCollectionId; + return [ + for (final id in ids) + useMemory && state?[id] != null + ? RequestSummary.fromRequestModel(state![id]!) + : byId[id]!, + ]; + } + + void _syncActiveCollectionSummaries() { + final active = _activeCollectionId; + if (active == null) { + return; + } + ref.read(collectionCatalogProvider.notifier).syncRequests( + active, + summariesForSequence( + active, + ref.read(requestSequenceProvider), + ), + ); + } + + RequestModel? _requestModelFromDisk(String collectionId, String id) { + final jsonModel = workspaceStorage.getRequestModel(collectionId, id); + if (jsonModel == null) { + return null; + } + final jsonMap = Map.from(jsonModel); + var requestModel = RequestModel.fromJson(jsonMap); + if (requestModel.httpRequestModel == null && + requestModel.aiRequestModel == null) { + requestModel = requestModel.copyWith( + httpRequestModel: const HttpRequestModel(), + ); + } + return requestModel; + } + + Future _hydrateAiApiKey(String collectionId, String id) async { + final apiKey = await aiRequestSecretsStorage.readApiKey( + workspaceStorage.rootPath, + collectionId, + id, + ); + if (apiKey == null || apiKey.isEmpty) { + return; + } + final current = state?[id]; + if (current?.aiRequestModel == null) { + return; + } + state = { + ...state!, + id: current!.copyWith( + aiRequestModel: current.aiRequestModel?.copyWith(apiKey: apiKey), + ), + }; + } + + Future> _prepareRequestJsonForDisk( + String collectionId, + String requestId, + Map json, + ) async { + final apiKey = AiRequestSecretsStorage.apiKeyFromJson(json); + if (apiKey != null && apiKey.isNotEmpty) { + await aiRequestSecretsStorage.writeApiKey( + workspaceStorage.rootPath, + collectionId, + requestId, + apiKey, + ); + } else { + await aiRequestSecretsStorage.deleteApiKey( + workspaceStorage.rootPath, + collectionId, + requestId, + ); + } + return AiRequestSecretsStorage.stripApiKeyFromJson(json); + } + + void loadRequest(String id) { + if (state?[id] != null) { + return; + } + final active = _activeCollectionId; + if (active == null) { + return; + } + final model = _requestModelFromDisk(active, id); + if (model == null) { + return; + } + state = {...state ?? {}, id: model}; + if (model.aiRequestModel != null) { + unawaited(_hydrateAiApiKey(active, id)); + } + } + + String _storageLabelFor(RequestModel model) { + if (model.name.trim().isNotEmpty) { + return model.name; + } + final url = model.httpRequestModel?.url ?? model.aiRequestModel?.url; + return getRequestTitleFromUrl(url); + } + + void _rekeyRequest(String oldId, String newId, RequestModel model) { + if (oldId == newId) { + return; + } + final map = {...state!}..remove(oldId); + map[newId] = model.copyWith(id: newId); + state = map; + + final sequence = ref.read(requestSequenceProvider); + ref.read(requestSequenceProvider.notifier).state = [ + for (final id in sequence) id == oldId ? newId : id, + ]; + if (ref.read(selectedIdStateProvider) == oldId) { + ref.read(selectedIdStateProvider.notifier).state = newId; + } + final active = _activeCollectionId; + if (active == null) { + return; + } + workspaceStorage.renameRequestSync(active, oldId, newId); + unawaited( + aiRequestSecretsStorage.rekeyApiKey( + workspaceStorage.rootPath, + active, + oldId, + newId, + ), + ); + } + + void _seedDefaultRequest(String collectionId) { + final newId = makeStorageId(''); + state = { + newId: RequestModel( + id: newId, + httpRequestModel: const HttpRequestModel(), + ), + }; + ref.read(requestSequenceProvider.notifier).state = [newId]; + ref.read(selectedIdStateProvider.notifier).state = newId; + ref.read(collectionCatalogProvider.notifier).syncRequests( + collectionId, + [RequestSummary.fromRequestModel(state![newId]!)], + ); + } + + void activateCollection(String? collectionId) { + if (collectionId == null) { + state = {}; + ref.read(requestSequenceProvider.notifier).state = []; + ref.read(selectedIdStateProvider.notifier).state = null; + return; + } + final ids = _catalogRequestIds(collectionId); + if (ids.isEmpty) { + _seedDefaultRequest(collectionId); + return; + } + state = {}; + ref.read(requestSequenceProvider.notifier).state = [...ids]; + ref.read(selectedIdStateProvider.notifier).state = null; + } + + String? get _activeCollectionId => + ref.read(selectedCollectionIdStateProvider); + + Future ensureActive(String? collectionId) async { + if (_activeCollectionId == collectionId && state != null) { + return; + } + final collections = ref.read(collectionCatalogProvider.notifier); + final from = _activeCollectionId; + final fromStillExists = from != null && + (ref.read(collectionCatalogProvider)?.containsKey(from) ?? false); + if (state != null && from != collectionId && fromStillExists) { + collections.loadCollection(from); + await saveData(collectionId: from); + } + if (collectionId != null) { + collections.loadCollection(collectionId); + } + state = {}; + ref.read(selectedCollectionIdStateProvider.notifier).state = collectionId; + activateCollection(collectionId); + } + final Ref ref; - final HiveHandler hiveHandler; + final WorkspaceStorage workspaceStorage; final baseHttpResponseModel = const HttpResponseModel(); bool hasId(String id) => state?.keys.contains(id) ?? false; @@ -72,12 +274,8 @@ class CollectionStateNotifier return state?[id]; } - void unsave() { - ref.read(hasUnsavedChangesProvider.notifier).state = true; - } - void add() { - final id = getNewUuid(); + final id = makeStorageId(''); final newRequestModel = RequestModel( id: id, httpRequestModel: const HttpRequestModel(), @@ -89,11 +287,11 @@ class CollectionStateNotifier .read(requestSequenceProvider.notifier) .update((state) => [id, ...state]); ref.read(selectedIdStateProvider.notifier).state = newRequestModel.id; - unsave(); + _syncActiveCollectionSummaries(); } void addRequestModel(HttpRequestModel httpRequestModel, {String? name}) { - final id = getNewUuid(); + final id = makeStorageId(name ?? ''); final newRequestModel = RequestModel( id: id, name: name ?? "", @@ -106,7 +304,7 @@ class CollectionStateNotifier .read(requestSequenceProvider.notifier) .update((state) => [id, ...state]); ref.read(selectedIdStateProvider.notifier).state = newRequestModel.id; - unsave(); + _syncActiveCollectionSummaries(); } void reorder(int oldIdx, int newIdx) { @@ -114,7 +312,7 @@ class CollectionStateNotifier final itemId = itemIds.removeAt(oldIdx); itemIds.insert(newIdx, itemId); ref.read(requestSequenceProvider.notifier).state = [...itemIds]; - unsave(); + _syncActiveCollectionSummaries(); } void remove({String? id}) { @@ -139,7 +337,7 @@ class CollectionStateNotifier var map = {...state!}; map.remove(rId); state = map; - unsave(); + _syncActiveCollectionSummaries(); } void clearResponse({String? id}) { @@ -156,19 +354,19 @@ class CollectionStateNotifier var map = {...state!}; map[rId] = newModel; state = map; - unsave(); } void duplicate({String? id}) { final rId = id ?? ref.read(selectedIdStateProvider); - final newId = getNewUuid(); - + loadRequest(rId!); var itemIds = ref.read(requestSequenceProvider); - int idx = itemIds.indexOf(rId!); + int idx = itemIds.indexOf(rId); var currentModel = state![rId]!; + final copyName = "${currentModel.name} (copy)"; + final newId = makeStorageId(copyName); final newModel = currentModel.copyWith( id: newId, - name: "${currentModel.name} (copy)", + name: copyName, requestTabIndex: 0, responseStatus: null, message: null, @@ -186,19 +384,19 @@ class CollectionStateNotifier ref.read(requestSequenceProvider.notifier).state = [...itemIds]; ref.read(selectedIdStateProvider.notifier).state = newId; - unsave(); + _syncActiveCollectionSummaries(); } void duplicateFromHistory(HistoryRequestModel historyRequestModel) { - final newId = getNewUuid(); - var itemIds = ref.read(requestSequenceProvider); var currentModel = historyRequestModel; + final historyName = "${currentModel.metaData.name} (history)"; + final newId = makeStorageId(historyName); final newModel = RequestModel( apiType: currentModel.metaData.apiType, id: newId, - name: "${currentModel.metaData.name} (history)", + name: historyName, aiRequestModel: currentModel.aiRequestModel?.copyWith(), httpRequestModel: currentModel.httpRequestModel?.copyWith() ?? HttpRequestModel(), @@ -216,7 +414,7 @@ class CollectionStateNotifier ref.read(requestSequenceProvider.notifier).state = [...itemIds]; ref.read(selectedIdStateProvider.notifier).state = newId; - unsave(); + _syncActiveCollectionSummaries(); } void update({ @@ -307,10 +505,16 @@ class CollectionStateNotifier ); } - var map = {...state!}; - map[rId] = newModel; - state = map; - unsave(); + final storageLabel = _storageLabelFor(newModel); + final newId = renameStorageId(rId, storageLabel); + if (newId != rId) { + _rekeyRequest(rId, newId, newModel.copyWith(id: newId)); + } else { + var map = {...state!}; + map[rId] = newModel; + state = map; + } + _syncActiveCollectionSummaries(); } Future sendRequest() async { @@ -321,6 +525,7 @@ class CollectionStateNotifier return; } + loadRequest(requestId); RequestModel? requestModel = state![requestId]; if (requestModel?.httpRequestModel == null && requestModel?.aiRequestModel == null) { @@ -450,8 +655,6 @@ class CollectionStateNotifier ), ); } - unsave(); - if (historyModel != null && httpResponseModel != null) { historyModel = historyModel!.copyWith( httpResponseModel: httpResponseModel!, @@ -474,7 +677,6 @@ class CollectionStateNotifier ...state!, requestId: newRequestModel.copyWith(isStreaming: false), }; - unsave(); }, onError: (e) { if (!completer.isCompleted) { @@ -527,7 +729,14 @@ class CollectionStateNotifier duration: duration, ); - String newHistoryId = getNewUuid(); + final historyName = requestModel.name.isNotEmpty + ? requestModel.name + : substitutedHttpRequestModel.url; + final historyTimeStamp = DateTime.now(); + String newHistoryId = makeHistoryId( + timeStamp: historyTimeStamp, + name: historyName, + ); historyModel = HistoryRequestModel( historyId: newHistoryId, metaData: HistoryMetaModel( @@ -538,7 +747,7 @@ class CollectionStateNotifier url: substitutedHttpRequestModel.url, method: substitutedHttpRequestModel.method, responseStatus: statusCode, - timeStamp: DateTime.now(), + timeStamp: historyTimeStamp, ), httpRequestModel: substitutedHttpRequestModel, aiRequestModel: executionRequestModel.aiRequestModel, @@ -571,80 +780,99 @@ class CollectionStateNotifier } } - // Final state update state = {...state!, requestId: newRequestModel}; - unsave(); } void cancelRequest() { final id = ref.read(selectedIdStateProvider); cancelHttpRequest(id); - unsave(); } Future clearData() async { ref.read(clearDataStateProvider.notifier).state = true; ref.read(selectedIdStateProvider.notifier).state = null; - await hiveHandler.clear(); + await environmentSecretsStorage.deleteAllForWorkspace( + workspaceStorage.rootPath, + ); + await aiRequestSecretsStorage.deleteAllForWorkspace( + workspaceStorage.rootPath, + ); + await workspaceStorage.clear(); + await ref.read(environmentsStateNotifierProvider.notifier).loadEnvironments(); ref.read(clearDataStateProvider.notifier).state = false; ref.read(requestSequenceProvider.notifier).state = []; state = {}; - unsave(); } - bool loadData() { - var ids = hiveHandler.getIds(); - if (ids == null || ids.length == 0) { - String newId = getNewUuid(); - state = { - newId: RequestModel( - id: newId, - httpRequestModel: const HttpRequestModel(), - ), - }; - return true; - } else { - Map data = {}; - for (var id in ids) { - var jsonModel = hiveHandler.getRequestModel(id); - if (jsonModel != null) { - var jsonMap = Map.from(jsonModel); - var requestModel = RequestModel.fromJson(jsonMap); - if (requestModel.httpRequestModel == null) { - requestModel = requestModel.copyWith( - httpRequestModel: const HttpRequestModel(), - ); - } - data[id] = requestModel; - } - } - state = data; - return false; + Future saveData({String? collectionId}) async { + final targetId = collectionId ?? _activeCollectionId; + if (targetId == null) { + return; } - } - - Future saveData() async { ref.read(saveDataStateProvider.notifier).state = true; - final saveResponse = ref.read(settingsProvider).saveResponses; + final settings = ref.read(settingsProvider); + final saveResponse = settings.saveResponses; + final saveMediaAsFiles = settings.saveMediaResponsesAsFiles; final ids = ref.read(requestSequenceProvider); - await hiveHandler.setIds(ids); - for (var id in ids) { - await hiveHandler.setRequestModel( - id, - saveResponse - ? (state?[id])?.toJson() - : (state?[id]?.copyWith(httpResponseModel: null))?.toJson(), + final summaries = summariesForSequence(targetId, ids); + ref + .read(collectionCatalogProvider.notifier) + .syncRequests(targetId, summaries); + for (final requestId in ids) { + final inMemory = state?[requestId]; + Map? json; + if (inMemory != null) { + json = saveResponse + ? inMemory.toJson() + : inMemory.copyWith(httpResponseModel: null).toJson(); + json = await _prepareRequestJsonForDisk(targetId, requestId, json); + } else { + final diskJson = workspaceStorage.getRequestModel(targetId, requestId); + if (diskJson == null) { + continue; + } + if (saveResponse) { + json = Map.from(diskJson); + } else { + final diskModel = RequestModel.fromJson( + Map.from(diskJson), + ); + json = diskModel.copyWith(httpResponseModel: null).toJson(); + } + } + await workspaceStorage.setRequestModel( + targetId, + requestId, + json, + saveMediaAsFiles: saveMediaAsFiles, ); } - await hiveHandler.removeUnused(); + await workspaceStorage.removeUnused(targetId, requestIds: ids.toSet()); + await aiRequestSecretsStorage.deleteOrphansForCollection( + workspaceStorage.rootPath, + targetId, + ids.toSet(), + ); ref.read(saveDataStateProvider.notifier).state = false; ref.read(hasUnsavedChangesProvider.notifier).state = false; } Future> exportDataToHAR() async { - var result = await collectionToHAR(state?.values.toList()); + final collectionId = _activeCollectionId; + if (collectionId == null) { + return {}; + } + final models = []; + for (final id in ref.read(requestSequenceProvider)) { + final model = + state?[id] ?? _requestModelFromDisk(collectionId, id); + if (model != null) { + models.add(model); + } + } + var result = await collectionToHAR(models); return result; // return { // "data": state!.map((e) => e.toJson(includeResponse: false)).toList() diff --git a/lib/providers/auto_save.dart b/lib/providers/auto_save.dart new file mode 100644 index 0000000000..2204b1add0 --- /dev/null +++ b/lib/providers/auto_save.dart @@ -0,0 +1,109 @@ +import 'dart:async'; + +import 'package:apidash_core/apidash_core.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../consts.dart'; +import '../models/models.dart'; +import 'active_collection_providers.dart'; +import 'collection_catalog_providers.dart'; +import 'environment_providers.dart'; +import 'ui_providers.dart'; + +final autoSaveNotifierProvider = + NotifierProvider(AutoSaveNotifier.new); + +class AutoSaveNotifier extends Notifier { + Timer? _timer; + + @override + void build() { + void onWorkspaceDataChanged() { + if (ref.read(saveDataStateProvider) || ref.read(clearDataStateProvider)) { + return; + } + _schedule(); + } + + ref.listen?>( + activeCollectionProvider, + (previous, next) { + if (previous == null) { + return; + } + onWorkspaceDataChanged(); + }, + ); + + ref.listen?>( + environmentsStateNotifierProvider, + (previous, next) { + if (previous == null) { + return; + } + onWorkspaceDataChanged(); + }, + ); + + ref.listen?>( + collectionCatalogProvider, + (previous, next) { + if (previous == null) { + return; + } + onWorkspaceDataChanged(); + }, + ); + + ref.listen>(collectionSequenceProvider, (previous, next) { + if (previous == null) { + return; + } + onWorkspaceDataChanged(); + }); + + ref.listen>(requestSequenceProvider, (previous, next) { + if (previous == null) { + return; + } + onWorkspaceDataChanged(); + }); + + ref.listen>(environmentSequenceProvider, (previous, next) { + if (previous == null) { + return; + } + onWorkspaceDataChanged(); + }); + + ref.onDispose(() { + _timer?.cancel(); + }); + } + + void cancelPending() { + _timer?.cancel(); + _timer = null; + } + + void _schedule() { + ref.read(hasUnsavedChangesProvider.notifier).state = true; + _timer?.cancel(); + _timer = Timer(kAutoSaveDebounceDuration, () { + unawaited(_flush()); + }); + } + + Future _flush() async { + cancelPending(); + if (!ref.read(hasUnsavedChangesProvider)) { + return; + } + if (ref.read(saveDataStateProvider) || ref.read(clearDataStateProvider)) { + return; + } + await ref.read(activeCollectionProvider.notifier).saveData(); + await ref.read(collectionCatalogProvider.notifier).saveCollections(); + await ref.read(environmentsStateNotifierProvider.notifier).saveEnvironments(); + } +} diff --git a/lib/providers/collection_catalog_providers.dart b/lib/providers/collection_catalog_providers.dart new file mode 100644 index 0000000000..bb548df57c --- /dev/null +++ b/lib/providers/collection_catalog_providers.dart @@ -0,0 +1,227 @@ +import 'package:apidash/models/models.dart'; +import 'package:apidash/utils/utils.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; + +import '../services/services.dart'; +import 'active_collection_providers.dart'; + +final selectedCollectionIdStateProvider = StateProvider((ref) { + final index = workspaceStorage.getCollectionsIndex(); + if (index.isNotEmpty) { + return index.first.id; + } + return null; +}); + +final collectionSequenceProvider = Provider>((ref) { + ref.watch(collectionCatalogProvider); + return ref.read(collectionCatalogProvider.notifier).collectionSequence; +}); + +final requestSequenceProvider = StateProvider>((ref) => []); + +final expandedCollectionIdsProvider = StateProvider>((ref) => {}); + +final StateNotifierProvider?> +collectionCatalogProvider = StateNotifierProvider( + (ref) => CollectionCatalogNotifier(ref, workspaceStorage), +); + +class CollectionCatalogNotifier + extends StateNotifier?> { + CollectionCatalogNotifier(this.ref, this.workspaceStorage) : super(null) { + final index = _readCollectionsIndex(); + collectionSequence = index.map((e) => e.id).toList(); + state = { + for (final entry in index) + entry.id: CollectionModel(id: entry.id, name: entry.name), + }; + final active = ref.read(selectedCollectionIdStateProvider); + if (active != null) { + loadCollection(active); + } + } + + final Ref ref; + final WorkspaceStorage workspaceStorage; + List collectionSequence = []; + final Set _loadedCollectionIds = {}; + + List<({String id, String name})> _readCollectionsIndex() { + return workspaceStorage.getCollectionsIndex(); + } + + Future _persistIndex() async { + await workspaceStorage.setCollectionsIndex([ + for (final id in collectionSequence) + (id: id, name: state![id]!.name), + ]); + } + + void loadCollection(String collectionId) { + if (_loadedCollectionIds.contains(collectionId)) { + return; + } + final json = workspaceStorage.getCollection(collectionId); + final catalogName = state![collectionId]!.name; + final model = json != null + ? CollectionModel.fromJson(Map.from(json)) + : CollectionModel(id: collectionId, name: catalogName); + final requests = model.requests + .where((r) => workspaceStorage.requestExistsOnDisk(collectionId, r.id)) + .toList(); + _loadedCollectionIds.add(collectionId); + state = { + ...state!, + collectionId: model.copyWith( + name: catalogName, + requests: requests, + ), + }; + } + + void syncRequests(String collectionId, List requests) { + if (state == null || !state!.containsKey(collectionId)) { + return; + } + _loadedCollectionIds.add(collectionId); + state = { + ...state!, + collectionId: state![collectionId]!.copyWith(requests: requests), + }; + } + + bool _isCollectionIdTaken(String id, {String? excludeId}) { + final target = id.toLowerCase(); + for (final existing in collectionSequence) { + if (existing == excludeId) { + continue; + } + if (existing.toLowerCase() == target) { + return true; + } + } + return false; + } + + Future addCollection() async { + var next = state!.length + 1; + var name = 'Collection $next'; + while (_isCollectionIdTaken(makeCollectionId(name))) { + next++; + name = 'Collection $next'; + } + final id = makeCollectionId(name); + final model = CollectionModel(id: id, name: name); + collectionSequence = [...collectionSequence, id]; + _loadedCollectionIds.add(id); + state = {...state!, id: model}; + await workspaceStorage.setCollection(id, model.toJson()); + await _persistIndex(); + ref.read(expandedCollectionIdsProvider.notifier).update( + (ids) => {...ids, id}, + ); + await ref + .read(activeCollectionProvider.notifier) + .ensureActive(id); + } + + Future renameCollection(String id, String name) async { + final trimmed = name.trim(); + if (trimmed.isEmpty || + state == null || + !state!.containsKey(id) || + collectionNameHasIllegalChars(trimmed)) { + return false; + } + final newId = makeCollectionId(trimmed); + if (newId == id) { + state = {...state!, id: state![id]!.copyWith(name: trimmed)}; + await _persistIndex(); + return true; + } + if (_isCollectionIdTaken(newId, excludeId: id)) { + return false; + } + + await workspaceStorage.renameCollection(id, newId); + + final model = CollectionModel( + id: newId, + name: trimmed, + requests: state![id]!.requests, + ); + final seqIdx = collectionSequence.indexOf(id); + collectionSequence = [...collectionSequence]; + collectionSequence[seqIdx] = newId; + _loadedCollectionIds.remove(id); + _loadedCollectionIds.add(newId); + + state = {...state!}..remove(id); + state![newId] = model; + + if (ref.read(selectedCollectionIdStateProvider) == id) { + ref.read(selectedCollectionIdStateProvider.notifier).state = newId; + } + ref.read(expandedCollectionIdsProvider.notifier).update((expanded) { + final next = {...expanded}; + if (next.remove(id)) { + next.add(newId); + } + return next; + }); + + await _persistIndex(); + return true; + } + + Future deleteCollection(String id) async { + if (state == null || !state!.containsKey(id)) { + return; + } + final wasActive = ref.read(selectedCollectionIdStateProvider) == id; + collectionSequence = [...collectionSequence]..remove(id); + _loadedCollectionIds.remove(id); + state = {...state!}..remove(id); + ref.read(expandedCollectionIdsProvider.notifier).update((s) => {...s}..remove(id)); + await workspaceStorage.deleteCollection(id); + await _persistIndex(); + + if (wasActive) { + final nextId = + collectionSequence.isNotEmpty ? collectionSequence.first : null; + await ref + .read(activeCollectionProvider.notifier) + .ensureActive(nextId); + } + } + + Future saveCollections() async { + await _persistIndex(); + final activeId = ref.read(selectedCollectionIdStateProvider); + final activeSequence = ref.read(requestSequenceProvider); + final collectionNotifier = + ref.read(activeCollectionProvider.notifier); + for (final entry in state!.entries) { + if (!_loadedCollectionIds.contains(entry.key)) { + continue; + } + + final requests = entry.key == activeId + ? collectionNotifier.summariesForSequence(entry.key, activeSequence) + : entry.value.requests + .where((r) => + workspaceStorage.requestExistsOnDisk(entry.key, r.id)) + .toList(); + if (entry.key == activeId) { + syncRequests(entry.key, requests); + } + final model = entry.value.copyWith(requests: requests); + await workspaceStorage.setCollection(entry.key, model.toJson()); + if (entry.key != activeId) { + state = {...state!, entry.key: model}; + } + } + } +} diff --git a/lib/providers/environment_providers.dart b/lib/providers/environment_providers.dart index ff55af020e..16bdad83c3 100644 --- a/lib/providers/environment_providers.dart +++ b/lib/providers/environment_providers.dart @@ -1,10 +1,13 @@ +import 'dart:async'; + import 'package:apidash/consts.dart'; import 'package:apidash/providers/providers.dart'; import 'package:apidash/utils/file_utils.dart'; import 'package:apidash_core/apidash_core.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/legacy.dart'; -import '../services/services.dart' show hiveHandler, HiveHandler; +import '../services/services.dart' + show environmentSecretsStorage, workspaceStorage, WorkspaceStorage; final selectedEnvironmentIdStateProvider = StateProvider( (ref) => null, @@ -48,7 +51,7 @@ final availableEnvironmentVariablesStateProvider = }); final environmentSequenceProvider = StateProvider>((ref) { - var ids = hiveHandler.getEnvironmentIds(); + var ids = workspaceStorage.getEnvironmentIds(); return ids ?? [kGlobalEnvironmentId]; }); @@ -57,14 +60,14 @@ final StateNotifierProvider< Map? > environmentsStateNotifierProvider = StateNotifierProvider( - (ref) => EnvironmentsStateNotifier(ref, hiveHandler), + (ref) => EnvironmentsStateNotifier(ref, workspaceStorage), ); class EnvironmentsStateNotifier extends StateNotifier?> { - EnvironmentsStateNotifier(this.ref, this.hiveHandler) : super(null) { - var status = loadEnvironments(); - Future.microtask(() { + EnvironmentsStateNotifier(this.ref, this.workspaceStorage) : super(null) { + Future.microtask(() async { + final status = await loadEnvironments(); if (status) { ref.read(environmentSequenceProvider.notifier).state = [...state!.keys]; } @@ -74,10 +77,102 @@ class EnvironmentsStateNotifier } final Ref ref; - final HiveHandler hiveHandler; + final WorkspaceStorage workspaceStorage; + + String get _workspacePath => workspaceStorage.rootPath; + + Future> _hydrateSecretValues( + String environmentId, + List values, + ) async { + final hydrated = []; + for (final variable in values) { + if (variable.type != EnvironmentVariableType.secret || + variable.key.isEmpty) { + hydrated.add(variable); + continue; + } + + final storedValue = await environmentSecretsStorage.readSecret( + _workspacePath, + environmentId, + variable.key, + ); - bool loadEnvironments() { - List? environmentIds = hiveHandler.getEnvironmentIds(); + hydrated.add(variable.copyWith(value: storedValue ?? '')); + } + return hydrated; + } + + Future persistSecretValue( + String environmentId, + String variableKey, + String value, + ) { + return environmentSecretsStorage.writeSecret( + _workspacePath, + environmentId, + variableKey, + value, + ); + } + + Future removeSecretValue( + String environmentId, + String variableKey, + ) { + return environmentSecretsStorage.deleteSecret( + _workspacePath, + environmentId, + variableKey, + ); + } + + Future _cleanupRemovedSecrets( + String environmentId, + EnvironmentModel environment, + ) async { + final oldJson = workspaceStorage.getEnvironment(environmentId); + if (oldJson == null) { + return; + } + final oldEnvironment = EnvironmentModel.fromJson( + Map.from(oldJson), + ); + final currentSecretKeys = environment.values + .where( + (v) => + v.type == EnvironmentVariableType.secret && v.key.isNotEmpty, + ) + .map((v) => v.key) + .toSet(); + for (final oldVariable in oldEnvironment.values) { + if (oldVariable.type == EnvironmentVariableType.secret && + oldVariable.key.isNotEmpty && + !currentSecretKeys.contains(oldVariable.key)) { + await environmentSecretsStorage.deleteSecret( + _workspacePath, + environmentId, + oldVariable.key, + ); + } + } + } + + EnvironmentModel _stripSecretsForDisk(EnvironmentModel environment) { + return environment.copyWith( + values: environment.values + .map( + (variable) => variable.type == EnvironmentVariableType.secret + ? variable.copyWith(value: '') + : variable, + ) + .toList(), + ); + } + + Future loadEnvironments() async { + List? environmentIds = workspaceStorage.getEnvironmentIds(); if (environmentIds == null || environmentIds.isEmpty) { const globalEnvironment = EnvironmentModel( id: kGlobalEnvironmentId, @@ -89,15 +184,19 @@ class EnvironmentsStateNotifier } else { Map environmentsMap = {}; for (var environmentId in environmentIds) { - var jsonModel = hiveHandler.getEnvironment(environmentId); + var jsonModel = workspaceStorage.getEnvironment(environmentId); if (jsonModel != null) { var jsonMap = Map.from(jsonModel); var environmentModelFromJson = EnvironmentModel.fromJson(jsonMap); + final hydratedValues = await _hydrateSecretValues( + environmentId, + environmentModelFromJson.values, + ); EnvironmentModel environmentModel = EnvironmentModel( id: environmentModelFromJson.id, name: environmentModelFromJson.name, - values: environmentModelFromJson.values, + values: hydratedValues, ); environmentsMap[environmentId] = environmentModel; } @@ -108,7 +207,7 @@ class EnvironmentsStateNotifier } void addEnvironment() { - final id = getNewUuid(); + final id = makeStorageId(''); final newEnvironmentModel = EnvironmentModel(id: id, values: []); state = {...state!, id: newEnvironmentModel}; ref @@ -116,7 +215,6 @@ class EnvironmentsStateNotifier .update((state) => [...state, id]); ref.read(selectedEnvironmentIdStateProvider.notifier).state = newEnvironmentModel.id; - ref.read(hasUnsavedChangesProvider.notifier).state = true; } void updateEnvironment( @@ -129,20 +227,75 @@ class EnvironmentsStateNotifier name: name ?? environment.name, values: values ?? environment.values, ); + final newId = renameStorageId(id, updatedEnvironment.name); + if (newId != id) { + _rekeyEnvironment(id, newId, updatedEnvironment.copyWith(id: newId)); + return; + } state = {...state!, id: updatedEnvironment}; - ref.read(hasUnsavedChangesProvider.notifier).state = true; + } + + void _rekeyEnvironment( + String oldId, + String newId, + EnvironmentModel environment, + ) { + final environmentIds = [...ref.read(environmentSequenceProvider)]; + final idx = environmentIds.indexOf(oldId); + if (idx >= 0) { + environmentIds[idx] = newId; + ref.read(environmentSequenceProvider.notifier).state = environmentIds; + } + + state = {...state!}..remove(oldId); + state![newId] = environment; + + if (ref.read(selectedEnvironmentIdStateProvider) == oldId) { + ref.read(selectedEnvironmentIdStateProvider.notifier).state = newId; + } + if (ref.read(activeEnvironmentIdStateProvider) == oldId) { + ref.read(activeEnvironmentIdStateProvider.notifier).state = newId; + ref.read(settingsProvider.notifier).update(activeEnvironmentId: newId); + } + + unawaited(_persistEnvironmentRename(oldId, newId, environment)); + } + + Future _persistEnvironmentRename( + String oldId, + String newId, + EnvironmentModel environment, + ) async { + await workspaceStorage.renameEnvironment(oldId, newId); + for (final variable in environment.values) { + if (variable.type == EnvironmentVariableType.secret && + variable.key.isNotEmpty) { + await environmentSecretsStorage.writeSecret( + _workspacePath, + newId, + variable.key, + variable.value, + ); + await environmentSecretsStorage.deleteSecret( + _workspacePath, + oldId, + variable.key, + ); + } + } } void duplicateEnvironment(String id) { - final newId = getNewUuid(); final environment = state![id]!; + final copyName = "${environment.name} Copy"; + final newId = makeStorageId(copyName); final newEnvironment = environment.copyWith( id: newId, - name: "${environment.name} Copy", + name: copyName, ); - var environmentIds = ref.read(environmentSequenceProvider); + var environmentIds = [...ref.read(environmentSequenceProvider)]; final idx = environmentIds.indexOf(id); environmentIds.insert(idx + 1, newId); @@ -152,10 +305,33 @@ class EnvironmentsStateNotifier .read(environmentSequenceProvider.notifier) .update((state) => [...environmentIds]); ref.read(selectedEnvironmentIdStateProvider.notifier).state = newId; - ref.read(hasUnsavedChangesProvider.notifier).state = true; + + Future.microtask(() async { + for (final variable in newEnvironment.values) { + if (variable.type == EnvironmentVariableType.secret && + variable.key.isNotEmpty) { + await environmentSecretsStorage.writeSecret( + _workspacePath, + newId, + variable.key, + variable.value, + ); + } + } + }); } void removeEnvironment(String id) { + final environment = state![id]; + final secretKeys = environment?.values + .where( + (v) => + v.type == EnvironmentVariableType.secret && v.key.isNotEmpty, + ) + .map((v) => v.key) + .toList() ?? + []; + final environmentIds = ref.read(environmentSequenceProvider); final idx = environmentIds.indexOf(id); environmentIds.remove(id); @@ -174,7 +350,13 @@ class EnvironmentsStateNotifier state = {...state!}..remove(id); - ref.read(hasUnsavedChangesProvider.notifier).state = true; + Future.microtask(() { + environmentSecretsStorage.deleteAllForEnvironment( + _workspacePath, + id, + secretKeys, + ); + }); } void reorder(int oldIdx, int newIdx) { @@ -182,18 +364,35 @@ class EnvironmentsStateNotifier final id = environmentIds.removeAt(oldIdx); environmentIds.insert(newIdx, id); ref.read(environmentSequenceProvider.notifier).state = [...environmentIds]; - ref.read(hasUnsavedChangesProvider.notifier).state = true; } Future saveEnvironments() async { ref.read(saveDataStateProvider.notifier).state = true; final environmentIds = ref.read(environmentSequenceProvider); - await hiveHandler.setEnvironmentIds(environmentIds); + await workspaceStorage.setEnvironmentIds(environmentIds); for (var environmentId in environmentIds) { var environment = state![environmentId]!; - await hiveHandler.setEnvironment(environmentId, environment.toJson()); + for (final variable in environment.values) { + if (variable.type == EnvironmentVariableType.secret && + variable.key.isNotEmpty) { + await environmentSecretsStorage.writeSecret( + _workspacePath, + environmentId, + variable.key, + variable.value, + ); + } + } + await _cleanupRemovedSecrets(environmentId, environment); + await workspaceStorage.setEnvironment( + environmentId, + _stripSecretsForDisk(environment).toJson(), + ); + } + final collectionId = ref.read(selectedCollectionIdStateProvider); + if (collectionId != null) { + await workspaceStorage.removeUnused(collectionId); } - await hiveHandler.removeUnused(); ref.read(saveDataStateProvider.notifier).state = false; ref.read(hasUnsavedChangesProvider.notifier).state = false; } diff --git a/lib/providers/history_providers.dart b/lib/providers/history_providers.dart index 7109ca4ba1..7233ca4f9c 100644 --- a/lib/providers/history_providers.dart +++ b/lib/providers/history_providers.dart @@ -1,7 +1,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/legacy.dart'; import 'package:apidash/models/models.dart'; -import '../services/services.dart' show hiveHandler, HiveHandler; +import '../services/services.dart' + show + AiRequestSecretsStorage, + aiRequestSecretsStorage, + workspaceStorage, + WorkspaceStorage; import '../utils/history_utils.dart'; final selectedHistoryIdStateProvider = StateProvider((ref) => null); @@ -30,84 +35,99 @@ final StateNotifierProvider< Map? > historyMetaStateNotifier = StateNotifierProvider( - (ref) => HistoryMetaStateNotifier(ref, hiveHandler), + (ref) => HistoryMetaStateNotifier(ref, workspaceStorage), ); class HistoryMetaStateNotifier extends StateNotifier?> { - HistoryMetaStateNotifier(this.ref, this.hiveHandler) : super(null) { - var status = loadHistoryMetas(); - Future.microtask(() { - if (status) { - final temporalGroups = getTemporalGroups(state?.values.toList()); - final latestRequestId = getLatestRequestId(temporalGroups); - if (latestRequestId != null) { - loadHistoryRequest(latestRequestId); - } - } - }); + HistoryMetaStateNotifier(this.ref, this.workspaceStorage) : super(null) { + loadHistoryMetas(); } final Ref ref; - final HiveHandler hiveHandler; + final WorkspaceStorage workspaceStorage; bool loadHistoryMetas() { - List? historyIds = hiveHandler.getHistoryIds(); - if (historyIds == null || historyIds.isEmpty) { + final allMetas = workspaceStorage.getAllHistoryMetas(); + if (allMetas == null || allMetas.isEmpty) { state = null; return false; - } else { - Map historyMetaMap = {}; - for (var historyId in historyIds) { - var jsonModel = hiveHandler.getHistoryMeta(historyId); - if (jsonModel != null) { - var jsonMap = Map.from(jsonModel); - var historyMetaModelFromJson = HistoryMetaModel.fromJson(jsonMap); - historyMetaMap[historyId] = historyMetaModelFromJson; - } - } - state = historyMetaMap; - return true; } + final historyMetaMap = {}; + for (final entry in allMetas.entries) { + final jsonMap = Map.from(entry.value); + historyMetaMap[entry.key] = HistoryMetaModel.fromJson(jsonMap); + } + state = historyMetaMap; + return true; } Future loadHistoryRequest(String id) async { - var jsonModel = await hiveHandler.getHistoryRequest(id); + var jsonModel = await workspaceStorage.getHistoryRequest(id); if (jsonModel != null) { var jsonMap = Map.from(jsonModel); - var historyRequestModelFromJson = HistoryRequestModel.fromJson(jsonMap); + var historyRequestModel = HistoryRequestModel.fromJson(jsonMap); + final aiRequestModel = historyRequestModel.aiRequestModel; + if (aiRequestModel != null) { + final apiKey = await aiRequestSecretsStorage.readHistoryApiKey( + workspaceStorage.rootPath, + id, + ); + if (apiKey != null && apiKey.isNotEmpty) { + historyRequestModel = historyRequestModel.copyWith( + aiRequestModel: aiRequestModel.copyWith(apiKey: apiKey), + ); + } + } ref.read(selectedHistoryRequestModelProvider.notifier).state = - historyRequestModelFromJson; + historyRequestModel; ref.read(selectedHistoryIdStateProvider.notifier).state = id; } } + Future> _prepareHistoryJsonForDisk( + String historyId, + Map json, + ) async { + final apiKey = AiRequestSecretsStorage.apiKeyFromJson(json); + if (apiKey != null && apiKey.isNotEmpty) { + await aiRequestSecretsStorage.writeHistoryApiKey( + workspaceStorage.rootPath, + historyId, + apiKey, + ); + } else { + await aiRequestSecretsStorage.deleteHistoryApiKey( + workspaceStorage.rootPath, + historyId, + ); + } + return AiRequestSecretsStorage.stripApiKeyFromJson(json); + } + void addHistoryRequest(HistoryRequestModel model) async { final id = model.historyId; state = {...state ?? {}, id: model.metaData}; - final List updatedHistoryKeys = state == null - ? [id] - : [...state!.keys, id]; - hiveHandler.setHistoryIds(updatedHistoryKeys); - hiveHandler.setHistoryMeta(id, model.metaData.toJson()); - await hiveHandler.setHistoryRequest(id, model.toJson()); + await workspaceStorage.setHistoryMeta(id, model.metaData.toJson()); + final json = await _prepareHistoryJsonForDisk(id, model.toJson()); + await workspaceStorage.setHistoryRequest(id, json); await loadHistoryRequest(id); } void editHistoryRequest(HistoryRequestModel model) async { final id = model.historyId; state = {...state ?? {}, id: model.metaData}; - final existingKeys = state?.keys.toList() ?? []; - if (!existingKeys.contains(id)) { - hiveHandler.setHistoryIds([...existingKeys, id]); - } - hiveHandler.setHistoryMeta(id, model.metaData.toJson()); - await hiveHandler.setHistoryRequest(id, model.toJson()); + await workspaceStorage.setHistoryMeta(id, model.metaData.toJson()); + final json = await _prepareHistoryJsonForDisk(id, model.toJson()); + await workspaceStorage.setHistoryRequest(id, json); await loadHistoryRequest(id); } Future clearAllHistory() async { - await hiveHandler.clearAllHistory(); + await workspaceStorage.clearAllHistory(); + await aiRequestSecretsStorage.deleteAllHistoryForWorkspace( + workspaceStorage.rootPath, + ); ref.read(selectedHistoryIdStateProvider.notifier).state = null; ref.read(selectedHistoryRequestModelProvider.notifier).state = null; loadHistoryMetas(); diff --git a/lib/providers/providers.dart b/lib/providers/providers.dart index 6510f90dab..cfa45861ea 100644 --- a/lib/providers/providers.dart +++ b/lib/providers/providers.dart @@ -1,5 +1,7 @@ +export 'auto_save.dart'; export 'ai_providers.dart'; -export 'collection_providers.dart'; +export 'collection_catalog_providers.dart'; +export 'active_collection_providers.dart'; export 'environment_providers.dart'; export 'history_providers.dart'; export 'terminal_providers.dart'; diff --git a/lib/providers/settings_providers.dart b/lib/providers/settings_providers.dart index e6ba664824..05fb58da60 100644 --- a/lib/providers/settings_providers.dart +++ b/lib/providers/settings_providers.dart @@ -2,6 +2,7 @@ import 'package:apidash_core/apidash_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/legacy.dart'; +import 'package:path/path.dart' as p; import '../models/models.dart'; import '../services/services.dart'; import '../consts.dart'; @@ -22,9 +23,20 @@ settingsProvider = StateNotifierProvider((ref) => ThemeStateNotifier()); class ThemeStateNotifier extends StateNotifier { ThemeStateNotifier({this.settingsModel}) : super(const SettingsModel()) { state = settingsModel ?? const SettingsModel(); + _backfillWorkspaceListIfNeeded(); } final SettingsModel? settingsModel; + void _backfillWorkspaceListIfNeeded() { + final path = state.workspaceFolderPath; + if (path == null || path.isEmpty || state.savedWorkspaces.isNotEmpty) { + return; + } + Future.microtask(() async { + await rememberWorkspace(path: path, name: p.basename(path)); + }); + } + Future update({ bool? isDark, bool? alwaysShowCollectionPaneScrollbar, @@ -33,10 +45,12 @@ class ThemeStateNotifier extends StateNotifier { SupportedUriSchemes? defaultUriScheme, CodegenLanguage? defaultCodeGenLang, bool? saveResponses, + bool? saveMediaResponsesAsFiles, bool? promptBeforeClosing, String? activeEnvironmentId, HistoryRetentionPeriod? historyRetentionPeriod, String? workspaceFolderPath, + List? savedWorkspaces, bool? isSSLDisabled, bool? isDashBotEnabled, Map? defaultAIModel, @@ -49,14 +63,50 @@ class ThemeStateNotifier extends StateNotifier { defaultUriScheme: defaultUriScheme, defaultCodeGenLang: defaultCodeGenLang, saveResponses: saveResponses, + saveMediaResponsesAsFiles: saveMediaResponsesAsFiles, promptBeforeClosing: promptBeforeClosing, activeEnvironmentId: activeEnvironmentId, historyRetentionPeriod: historyRetentionPeriod, workspaceFolderPath: workspaceFolderPath, + savedWorkspaces: savedWorkspaces, isSSLDisabled: isSSLDisabled, isDashBotEnabled: isDashBotEnabled, defaultAIModel: defaultAIModel, ); await setSettingsToSharedPrefs(state); } + + Future rememberWorkspace({ + required String path, + required String name, + }) async { + final normalized = p.normalize(path); + final rest = state.savedWorkspaces + .where((e) => p.normalize(e.path) != normalized) + .toList(); + final list = [ + SavedWorkspaceEntry(path: normalized, name: name), + ...rest, + ].take(kMaxSavedWorkspaces).toList(); + await update( + workspaceFolderPath: normalized, + savedWorkspaces: list, + ); + } +} + +String? savedWorkspaceNameForPath( + List saved, + String? workspaceFolderPath, +) { + if (workspaceFolderPath == null || workspaceFolderPath.isEmpty) { + return null; + } + final normalized = p.normalize(workspaceFolderPath); + for (final entry in saved) { + if (p.normalize(entry.path) == normalized) { + return entry.name; + } + } + return null; } diff --git a/lib/screens/common_widgets/agentic_ui_features/tool_generation/generate_tool_dialog.dart b/lib/screens/common_widgets/agentic_ui_features/tool_generation/generate_tool_dialog.dart index cfa507d942..320f811523 100644 --- a/lib/screens/common_widgets/agentic_ui_features/tool_generation/generate_tool_dialog.dart +++ b/lib/screens/common_widgets/agentic_ui_features/tool_generation/generate_tool_dialog.dart @@ -1,6 +1,6 @@ import 'package:apidash/consts.dart'; import 'package:apidash/apitoolgen/request_consolidator.dart'; -import 'package:apidash/providers/collection_providers.dart'; +import 'package:apidash/providers/active_collection_providers.dart'; import 'package:apidash/screens/common_widgets/agentic_ui_features/ai_ui_designer/generate_ui_dialog.dart'; import 'package:apidash/services/agentic_services/apidash_agent_calls.dart'; import 'package:apidash_core/apidash_core.dart'; diff --git a/lib/screens/common_widgets/ai/ai_model_selector.dart b/lib/screens/common_widgets/ai/ai_model_selector.dart index d746889ac0..97689c9078 100644 --- a/lib/screens/common_widgets/ai/ai_model_selector.dart +++ b/lib/screens/common_widgets/ai/ai_model_selector.dart @@ -33,7 +33,7 @@ class AIModelSelector extends ConsumerWidget { aiRequestModel: aiRequestModel, onModelUpdated: (newAIRequestModel) { ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .update(aiRequestModel: newAIRequestModel.copyWith()); }, ); diff --git a/lib/screens/common_widgets/api_type_dropdown.dart b/lib/screens/common_widgets/api_type_dropdown.dart index c35645d6ed..26f71df3c1 100644 --- a/lib/screens/common_widgets/api_type_dropdown.dart +++ b/lib/screens/common_widgets/api_type_dropdown.dart @@ -15,7 +15,7 @@ class APITypeDropdown extends ConsumerWidget { apiType: apiType, onChanged: (type) { ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .update(apiType: type); }, ); diff --git a/lib/screens/common_widgets/auth/auth_page.dart b/lib/screens/common_widgets/auth/auth_page.dart index 65d9625950..cceee2bf4a 100644 --- a/lib/screens/common_widgets/auth/auth_page.dart +++ b/lib/screens/common_widgets/auth/auth_page.dart @@ -1,6 +1,8 @@ import 'package:apidash_design_system/apidash_design_system.dart'; import 'package:flutter/material.dart'; import 'package:apidash_core/apidash_core.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:apidash/providers/providers.dart'; import 'api_key_auth_fields.dart'; import 'basic_auth_fields.dart'; import 'bearer_auth_fields.dart'; @@ -10,7 +12,7 @@ import 'consts.dart'; import 'oauth1_fields.dart'; import 'oauth2_field.dart'; -class AuthPage extends StatelessWidget { +class AuthPage extends ConsumerWidget { final AuthModel? authModel; final bool readOnly; final Function(APIAuthType? newType)? onChangedAuthType; @@ -25,7 +27,7 @@ class AuthPage extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(16.0), @@ -50,7 +52,7 @@ class AuthPage extends StatelessWidget { isOutlined: true, onChanged: readOnly ? null : onChangedAuthType, ), - const SizedBox(height: 48), + const SizedBox(height: 24), switch (authModel?.type) { APIAuthType.basic => BasicAuthFields( readOnly: readOnly, @@ -92,10 +94,86 @@ class AuthPage extends StatelessWidget { _ => Text(readOnly ? "${authModel?.type.name} $kMsgAuthNotSupported" : kMsgNotImplemented), - } + }, + if (authModel?.type != null && authModel?.type != APIAuthType.none) ...[ + const SizedBox(height: 24), + AuthWarningTip( + onTap: () { + ref.read(navRailIndexStateProvider.notifier).state = 1; + }, + ), + ], ], ), ), ); } } + +class AuthWarningTip extends StatelessWidget { + final VoidCallback onTap; + + const AuthWarningTip({ + super.key, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + final backgroundColor = colorScheme.surfaceContainerHighest.withOpacity(0.3); + final borderColor = colorScheme.outlineVariant.withOpacity(0.5); + final iconColor = colorScheme.primary; + + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: backgroundColor, + border: Border.all( + color: borderColor, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.info_outline_rounded, + color: iconColor, + size: 18, + ), + const SizedBox(width: 8), + Expanded( + child: RichText( + text: TextSpan( + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + children: [ + const TextSpan( + text: "Tip: Use ", + ), + const TextSpan( + text: "secret variables", + style: TextStyle(fontWeight: FontWeight.w900,decoration: TextDecoration.underline), + ), + const TextSpan( + text: " for sensitive information. ", + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} + diff --git a/lib/screens/common_widgets/auth/oauth2_field.dart b/lib/screens/common_widgets/auth/oauth2_field.dart index 8f35665d06..3a745cfa0a 100644 --- a/lib/screens/common_widgets/auth/oauth2_field.dart +++ b/lib/screens/common_widgets/auth/oauth2_field.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'package:apidash/providers/settings_providers.dart'; -import 'package:apidash/providers/collection_providers.dart'; +import 'package:apidash/providers/active_collection_providers.dart'; import 'package:apidash/models/models.dart'; import 'package:apidash/utils/file_utils.dart'; import 'package:apidash_core/apidash_core.dart'; diff --git a/lib/screens/common_widgets/common_widgets.dart b/lib/screens/common_widgets/common_widgets.dart index e68b01fb43..3b4ce3c022 100644 --- a/lib/screens/common_widgets/common_widgets.dart +++ b/lib/screens/common_widgets/common_widgets.dart @@ -13,9 +13,9 @@ export 'envfield_cell.dart'; export 'envfield_header.dart'; export 'envfield_url.dart'; export 'environment_dropdown.dart'; +export 'workspace_dropdown.dart'; export 'envvar_indicator.dart'; export 'envvar_popover.dart'; export 'envvar_span.dart'; export 'sidebar_filter.dart'; export 'sidebar_header.dart'; -export 'sidebar_save_button.dart'; diff --git a/lib/screens/common_widgets/environment_dropdown.dart b/lib/screens/common_widgets/environment_dropdown.dart index 0459b9c788..40e9db3b6c 100644 --- a/lib/screens/common_widgets/environment_dropdown.dart +++ b/lib/screens/common_widgets/environment_dropdown.dart @@ -23,7 +23,6 @@ class EnvironmentDropdown extends ConsumerWidget { ref .read(settingsProvider.notifier) .update(activeEnvironmentId: value.id); - ref.read(hasUnsavedChangesProvider.notifier).state = true; } }, ); diff --git a/lib/screens/common_widgets/sidebar_header.dart b/lib/screens/common_widgets/sidebar_header.dart index 2da3aebf99..374535251a 100644 --- a/lib/screens/common_widgets/sidebar_header.dart +++ b/lib/screens/common_widgets/sidebar_header.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:apidash/providers/providers.dart'; import 'package:apidash/widgets/widgets.dart'; import 'package:apidash/consts.dart'; -import 'sidebar_save_button.dart'; +import 'workspace_dropdown.dart'; class SidebarHeader extends ConsumerWidget { const SidebarHeader({ @@ -23,8 +23,11 @@ class SidebarHeader extends ConsumerWidget { padding: kPe8, child: Row( children: [ - const SaveButton(), - const Spacer(), + if (kIsDesktop) + const Expanded( + child: WorkspaceDropdown(), + ), + if (kIsDesktop) kHSpacer4, ElevatedButton( onPressed: onAddNew, style: kButtonSidebarStyle, diff --git a/lib/screens/common_widgets/sidebar_save_button.dart b/lib/screens/common_widgets/sidebar_save_button.dart deleted file mode 100644 index d92b88b024..0000000000 --- a/lib/screens/common_widgets/sidebar_save_button.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:apidash_design_system/apidash_design_system.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:apidash/consts.dart'; -import 'package:apidash/providers/providers.dart'; -import 'package:apidash/utils/utils.dart'; - -class SaveButton extends ConsumerWidget { - const SaveButton({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final savingData = ref.watch(saveDataStateProvider); - final hasUnsavedChanges = ref.watch(hasUnsavedChangesProvider); - return TextButton.icon( - onPressed: (savingData || !hasUnsavedChanges) - ? null - : () async { - await saveAndShowDialog(context, onSave: () async { - await ref - .read(collectionStateNotifierProvider.notifier) - .saveData(); - await ref - .read(environmentsStateNotifierProvider.notifier) - .saveEnvironments(); - }); - }, - icon: const Icon( - Icons.save, - size: 20, - ), - label: const Text( - kLabelSave, - style: kTextStyleButton, - ), - ); - } -} diff --git a/lib/screens/common_widgets/workspace_dropdown.dart b/lib/screens/common_widgets/workspace_dropdown.dart new file mode 100644 index 0000000000..0d4e5f3852 --- /dev/null +++ b/lib/screens/common_widgets/workspace_dropdown.dart @@ -0,0 +1,68 @@ +import 'package:apidash/consts.dart'; +import 'package:apidash/providers/providers.dart'; +import 'package:apidash/services/services.dart'; +import 'package:apidash/widgets/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; + +class WorkspaceDropdown extends ConsumerWidget { + const WorkspaceDropdown({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(settingsProvider); + final currentPath = settings.workspaceFolderPath; + final label = savedWorkspaceNameForPath( + settings.savedWorkspaces, + currentPath, + ) ?? + kLabelSelectWorkspace; + + return LayoutBuilder( + builder: (context, constraints) { + return WorkspacePopupMenu( + width: constraints.maxWidth, + currentLabel: label, + workspaces: settings.savedWorkspaces, + onPathSelected: (path) async { + if (p.normalize(path) == p.normalize(currentPath ?? '')) { + return; + } + final ok = await activateWorkspace( + ref, + path, + createIfMissing: false, + ); + if (!ok && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(kMsgWorkspaceOpenFailed)), + ); + } + }, + onOpenWorkspace: () => _openWorkspaceSelector(context, ref), + ); + }, + ); + } + + Future _openWorkspaceSelector(BuildContext context, WidgetRef ref) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (selectorContext) => WorkspaceSelector( + onContinue: (path) async { + final ok = await activateWorkspace(ref, path); + if (ok && selectorContext.mounted) { + Navigator.of(selectorContext).pop(); + } + }, + onCancel: () async { + if (selectorContext.mounted) { + Navigator.of(selectorContext).pop(); + } + }, + ), + ), + ); + } +} diff --git a/lib/screens/envvar/editor_pane/variables_pane.dart b/lib/screens/envvar/editor_pane/variables_pane.dart index 826bc09311..fc235ded92 100644 --- a/lib/screens/envvar/editor_pane/variables_pane.dart +++ b/lib/screens/envvar/editor_pane/variables_pane.dart @@ -6,7 +6,6 @@ import 'package:data_table_2/data_table_2.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:apidash/consts.dart'; import 'package:apidash/providers/providers.dart'; -import 'package:apidash/utils/utils.dart'; import 'package:apidash/widgets/widgets.dart'; class EditEnvironmentVariables extends ConsumerStatefulWidget { @@ -31,42 +30,60 @@ class EditEnvironmentVariablesState } void _onFieldChange(String selectedId) { - final environment = ref.read(selectedEnvironmentModelProvider); - final secrets = getEnvironmentSecrets(environment); ref .read(environmentsStateNotifierProvider.notifier) .updateEnvironment( selectedId, values: [ ...variableRows.sublist(0, variableRows.length - 1), - ...secrets, ], ); } + void _toggleSecret(String selectedId, int index) { + final current = variableRows[index]; + final notifier = ref.read(environmentsStateNotifierProvider.notifier); + if (current.type == EnvironmentVariableType.variable) { + if (current.key.isNotEmpty && current.value.isNotEmpty) { + notifier.persistSecretValue(selectedId, current.key, current.value); + } + setState(() { + variableRows[index] = current.copyWith( + type: EnvironmentVariableType.secret, + ); + }); + } else { + if (current.key.isNotEmpty) { + notifier.removeSecretValue(selectedId, current.key); + } + setState(() { + variableRows[index] = current.copyWith( + type: EnvironmentVariableType.variable, + ); + }); + } + _onFieldChange(selectedId); + } + @override Widget build(BuildContext context) { dataTableShowLogs = false; final selectedId = ref.watch(selectedEnvironmentIdStateProvider); ref.watch( - selectedEnvironmentModelProvider.select( - (environment) => getEnvironmentVariables(environment).length, - ), - ); - var rows = getEnvironmentVariables( - ref.read(selectedEnvironmentModelProvider), + selectedEnvironmentModelProvider.select((environment) => environment?.values.length), ); - variableRows = rows.isEmpty - ? [kEnvironmentVariableEmptyModel] - : rows + [kEnvironmentVariableEmptyModel]; + final env = ref.read(selectedEnvironmentModelProvider); + final rows = env?.values ?? []; + variableRows = rows.isEmpty ? [kEnvironmentVariableEmptyModel] : [...rows, kEnvironmentVariableEmptyModel]; isAddingRow = false; List columns = const [ DataColumn2(label: Text(kNameCheckbox), fixedWidth: 30), - DataColumn2(label: Text(kLabelVariableName)), + DataColumn2(label: Text(kLabelVariableName), size: ColumnSize.M), DataColumn2(label: Text('='), fixedWidth: 30), - DataColumn2(label: Text(kLabelVariableValue)), - DataColumn2(label: Text(''), fixedWidth: 32), + DataColumn2(label: Text(kLabelVariableValue), size: ColumnSize.M), + DataColumn2(label: Text('Secret'), size: ColumnSize.S), + DataColumn2(label: Text(''), fixedWidth: 40), ]; List dataRows = List.generate(variableRows.length, ( @@ -120,46 +137,97 @@ class EditEnvironmentVariablesState ), DataCell(Center(child: Text("=", style: kCodeStyle))), DataCell( - CellField( - keyId: "$selectedId-$index-variables-v-$seed", - initialValue: variableRows[index].value, - hintText: kHintAddValue, - onChanged: (value) { - if (isLast && !isAddingRow) { - isAddingRow = true; - variableRows[index] = variableRows[index].copyWith( - value: value, - enabled: true, - ); - variableRows.add(kEnvironmentVariableEmptyModel); - } else { - variableRows[index] = variableRows[index].copyWith( - value: value, - ); - } - _onFieldChange(selectedId!); - }, - colorScheme: Theme.of(context).colorScheme, - ), - ), - DataCell( - InkWell( - onTap: isLast - ? null - : () { - seed = random.nextInt(kRandMax); - if (variableRows.length == 2) { - setState(() { - variableRows = [kEnvironmentVariableEmptyModel]; - }); + variableRows[index].type == EnvironmentVariableType.secret + ? ObscurableCellField( + keyId: "$selectedId-$index-variables-v-$seed", + initialValue: variableRows[index].value, + hintText: kHintAddValue, + onChanged: (value) { + if (isLast && !isAddingRow) { + isAddingRow = true; + variableRows[index] = variableRows[index].copyWith( + value: value, + enabled: true, + ); + variableRows.add(kEnvironmentVariableEmptyModel); + } else { + variableRows[index] = variableRows[index].copyWith( + value: value, + ); + } + _onFieldChange(selectedId!); + }, + colorScheme: Theme.of(context).colorScheme, + ) + : CellField( + keyId: "$selectedId-$index-variables-v-$seed", + initialValue: variableRows[index].value, + hintText: kHintAddValue, + onChanged: (value) { + if (isLast && !isAddingRow) { + isAddingRow = true; + variableRows[index] = variableRows[index].copyWith( + value: value, + enabled: true, + ); + variableRows.add(kEnvironmentVariableEmptyModel); } else { - variableRows.removeAt(index); + variableRows[index] = variableRows[index].copyWith( + value: value, + ); } _onFieldChange(selectedId!); }, - child: Theme.of(context).brightness == Brightness.dark - ? kIconRemoveDark - : kIconRemoveLight, + colorScheme: Theme.of(context).colorScheme, + ), + ), + DataCell( + isLast + ? const SizedBox.shrink() + : Container( + height: 36, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLowest, + border: Border.all( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + borderRadius: BorderRadius.circular(4), + ), + child: Center( + child: ADCheckBox( + keyId: "$selectedId-$index-variables-secret-c-$seed", + value: variableRows[index].type == EnvironmentVariableType.secret, + onChanged: (value) { + if (value != null) { + _toggleSecret(selectedId!, index); + } + }, + colorScheme: Theme.of(context).colorScheme, + ), + ), + ), + ), + DataCell( + Align( + alignment: Alignment.centerRight, + child: isLast + ? const SizedBox.shrink() + : InkWell( + onTap: () { + seed = random.nextInt(kRandMax); + if (variableRows.length == 2) { + setState(() { + variableRows = [kEnvironmentVariableEmptyModel]; + }); + } else { + variableRows.removeAt(index); + } + _onFieldChange(selectedId!); + }, + child: Theme.of(context).brightness == Brightness.dark + ? kIconRemoveDark + : kIconRemoveLight, + ), ), ), ], @@ -190,6 +258,7 @@ class EditEnvironmentVariablesState dataRowHeight: kDataTableRowHeight, bottomMargin: kDataTableBottomPadding, isVerticalScrollBarVisible: true, + smRatio: 0.25, columns: columns, rows: dataRows, ), diff --git a/lib/screens/envvar/environment_editor.dart b/lib/screens/envvar/environment_editor.dart index 27de525574..c392744ee6 100644 --- a/lib/screens/envvar/environment_editor.dart +++ b/lib/screens/envvar/environment_editor.dart @@ -36,20 +36,23 @@ class EnvironmentEditor extends ConsumerWidget { ), const SizedBox(width: 6), EditorTitleActions( - onRenamePressed: () { - showRenameDialog( - context, - kLabelRenameEnvironment, - name, - (val) { - ref - .read( - environmentsStateNotifierProvider.notifier, - ) - .updateEnvironment(id!, name: val); - }, - ); - }, + onRenamePressed: id != kGlobalEnvironmentId + ? () { + showRenameDialog( + context, + kLabelRenameEnvironment, + name, + (val) { + ref + .read( + environmentsStateNotifierProvider + .notifier, + ) + .updateEnvironment(id!, name: val); + }, + ); + } + : null, onDuplicatePressed: () => ref .read(environmentsStateNotifierProvider.notifier) .duplicateEnvironment(id!), @@ -86,24 +89,49 @@ class EnvironmentEditor extends ConsumerWidget { borderRadius: kBorderRadius12, ), elevation: 0, - child: const Padding( + child: Padding( padding: kPv6, child: Column( children: [ kHSpacer40, Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - SizedBox(width: 30), - Text(kLabelVariable), - SizedBox(width: 30), - Text(kLabelValue), - SizedBox(width: 40), + const SizedBox(width: 50), + Expanded( + flex: 4, + child: Text( + kLabelVariable, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 35), + Expanded( + flex: 4, + child: Text( + kLabelValue, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Expanded( + flex: 1, + child: Text( + "Secret", + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 50), ], ), kHSpacer40, - Divider(), - Expanded(child: EditEnvironmentVariables()), + const Divider(), + const Expanded(child: EditEnvironmentVariables()), ], ), ), diff --git a/lib/screens/envvar/environment_page.dart b/lib/screens/envvar/environment_page.dart index 57d2327248..90535be316 100644 --- a/lib/screens/envvar/environment_page.dart +++ b/lib/screens/envvar/environment_page.dart @@ -12,6 +12,10 @@ import 'environment_editor.dart'; class EnvironmentPage extends ConsumerWidget { const EnvironmentPage({super.key}); + static bool _shouldShowEnvironmentMenu(String environmentId) { + return environmentId != kGlobalEnvironmentId; + } + @override Widget build(BuildContext context, WidgetRef ref) { final id = ref.watch(selectedEnvironmentIdStateProvider); @@ -26,9 +30,10 @@ class EnvironmentPage extends ConsumerWidget { mainContent: const EnvironmentEditor(), title: EditorTitle( title: name, - showMenu: id != kGlobalEnvironmentId, + showMenu: _shouldShowEnvironmentMenu(id ?? ''), onSelected: (ItemMenuOption item) { - if (item == ItemMenuOption.edit) { + if (item == ItemMenuOption.edit && + (id ?? '') != kGlobalEnvironmentId) { showRenameDialog(context, kLabelRenameEnvironment, name, (val) { ref .read(environmentsStateNotifierProvider.notifier) diff --git a/lib/screens/envvar/environments_pane.dart b/lib/screens/envvar/environments_pane.dart index 480dae730f..f6078ad6b4 100644 --- a/lib/screens/envvar/environments_pane.dart +++ b/lib/screens/envvar/environments_pane.dart @@ -16,7 +16,7 @@ class EnvironmentsPane extends ConsumerWidget { return Padding( padding: (!context.isMediumWindow && kIsMacOS ? kPt24l4 : kPt8l4) + - (context.isMediumWindow ? kPb70 : EdgeInsets.zero), + (context.isMediumWindow ? kPb70 : EdgeInsets.zero), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -50,7 +50,10 @@ class EnvironmentsList extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final environmentSequence = ref.watch(environmentSequenceProvider); - final environmentItems = ref.watch(environmentsStateNotifierProvider)!; + final environmentItems = ref.watch(environmentsStateNotifierProvider); + if (environmentItems == null) { + return const Center(child: CircularProgressIndicator()); + } final alwaysShowEnvironmentsPaneScrollbar = ref.watch( settingsProvider.select( (value) => value.alwaysShowCollectionPaneScrollbar, @@ -193,23 +196,27 @@ class EnvironmentItem extends ConsumerWidget { focusNode: ref.watch(nameTextFieldFocusNodeProvider), onChangedNameEditor: (value) { value = value.trim(); - ref - .read(environmentsStateNotifierProvider.notifier) - .updateEnvironment(editRequestId!, name: value); + if (id != kGlobalEnvironmentId) { + ref + .read(environmentsStateNotifierProvider.notifier) + .updateEnvironment(editRequestId!, name: value); + } }, onTapOutsideNameEditor: () { ref.read(selectedIdEditStateProvider.notifier).state = null; }, onMenuSelected: (ItemMenuOption item) { if (item == ItemMenuOption.edit) { - ref.read(selectedIdEditStateProvider.notifier).state = id; - Future.delayed( - const Duration(milliseconds: 150), - () => ref - .read(nameTextFieldFocusNodeProvider.notifier) - .state - .requestFocus(), - ); + if (id != kGlobalEnvironmentId) { + ref.read(selectedIdEditStateProvider.notifier).state = id; + Future.delayed( + const Duration(milliseconds: 150), + () => ref + .read(nameTextFieldFocusNodeProvider.notifier) + .state + .requestFocus(), + ); + } } if (item == ItemMenuOption.delete) { ref diff --git a/lib/screens/history/history_widgets/his_action_buttons.dart b/lib/screens/history/history_widgets/his_action_buttons.dart index 5aad0bd063..61c3e26ce7 100644 --- a/lib/screens/history/history_widgets/his_action_buttons.dart +++ b/lib/screens/history/history_widgets/his_action_buttons.dart @@ -12,13 +12,10 @@ class HistoryActionButtons extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final collectionStateNotifier = ref.watch(collectionStateNotifierProvider); - final isAvailable = - collectionStateNotifier?.values.any( - (element) => element.id == historyRequestModel?.metaData.requestId, - ) ?? - false; final requestId = historyRequestModel?.metaData.requestId; + final isAvailable = + requestId != null && + ref.watch(requestSequenceProvider).contains(requestId); return FilledButtonGroup( buttons: [ @@ -28,7 +25,7 @@ class HistoryActionButtons extends ConsumerWidget { onPressed: requestId != null ? () { ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .duplicateFromHistory(historyRequestModel!); ref.read(navRailIndexStateProvider.notifier).state = 0; } @@ -38,8 +35,11 @@ class HistoryActionButtons extends ConsumerWidget { ButtonData( icon: Icons.north_east_rounded, label: kLabelRequest, - onPressed: isAvailable && requestId != null + onPressed: isAvailable ? () { + ref + .read(activeCollectionProvider.notifier) + .loadRequest(requestId); ref.read(selectedIdStateProvider.notifier).state = requestId; ref.read(navRailIndexStateProvider.notifier).state = 0; } diff --git a/lib/screens/history/history_widgets/his_scripts_tab.dart b/lib/screens/history/history_widgets/his_scripts_tab.dart index e76657adb5..0e1052fd95 100644 --- a/lib/screens/history/history_widgets/his_scripts_tab.dart +++ b/lib/screens/history/history_widgets/his_scripts_tab.dart @@ -34,13 +34,13 @@ class _ScriptsCodePaneState extends ConsumerState { preReqCodeController.addListener(() { ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .update(preRequestScript: preReqCodeController.text); }); postResCodeController.addListener(() { ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .update(postRequestScript: postResCodeController.text); }); diff --git a/lib/screens/home_page/collection_pane.dart b/lib/screens/home_page/collection_pane.dart index c62e3ce96d..fa6b1d3df6 100644 --- a/lib/screens/home_page/collection_pane.dart +++ b/lib/screens/home_page/collection_pane.dart @@ -5,6 +5,7 @@ import 'package:apidash/importer/import_dialog.dart'; import 'package:apidash/providers/providers.dart'; import 'package:apidash/widgets/widgets.dart'; import 'package:apidash/models/models.dart'; +import 'package:apidash/utils/utils.dart'; import 'package:apidash/consts.dart'; import '../common_widgets/common_widgets.dart'; @@ -13,7 +14,9 @@ class CollectionPane extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final collection = ref.watch(collectionStateNotifierProvider); + ref.watch(autoSaveNotifierProvider); + ref.watch(collectionCatalogProvider); + final collection = ref.watch(activeCollectionProvider); var sm = ScaffoldMessenger.of(context); if (collection == null) { return const Center(child: CircularProgressIndicator()); @@ -27,7 +30,7 @@ class CollectionPane extends ConsumerWidget { children: [ SidebarHeader( onAddNew: () { - ref.read(collectionStateNotifierProvider.notifier).add(); + ref.read(collectionCatalogProvider.notifier).addCollection(); }, onImport: () { importToCollectionPane(context, ref, sm); @@ -45,7 +48,7 @@ class CollectionPane extends ConsumerWidget { }, ), kVSpacer10, - const Expanded(child: RequestList()), + const Expanded(child: CollectionRequestList()), kVSpacer5, ], ), @@ -53,20 +56,28 @@ class CollectionPane extends ConsumerWidget { } } -class RequestList extends ConsumerStatefulWidget { - const RequestList({super.key}); +class CollectionRequestList extends ConsumerStatefulWidget { + const CollectionRequestList({super.key}); @override - ConsumerState createState() => _RequestListState(); + ConsumerState createState() => + _CollectionRequestListState(); } -class _RequestListState extends ConsumerState { +class _CollectionRequestListState extends ConsumerState { late final ScrollController controller; @override void initState() { super.initState(); controller = ScrollController(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final selected = ref.read(selectedCollectionIdStateProvider); + final expanded = ref.read(expandedCollectionIdsProvider); + if (selected != null && expanded.isEmpty) { + ref.read(expandedCollectionIdsProvider.notifier).state = {selected}; + } + }); } @override @@ -77,127 +88,446 @@ class _RequestListState extends ConsumerState { @override Widget build(BuildContext context) { - final requestSequence = ref.watch(requestSequenceProvider); - final requestItems = ref.watch(collectionStateNotifierProvider)!; - final alwaysShowCollectionPaneScrollbar = ref.watch( + final collectionSequence = ref.watch(collectionSequenceProvider); + final collections = ref.watch(collectionCatalogProvider)!; + final expandedIds = ref.watch(expandedCollectionIdsProvider); + final selectedCollectionId = ref.watch(selectedCollectionIdStateProvider); + final filterQuery = ref.watch(collectionSearchQueryProvider).trim(); + final alwaysShowScrollbar = ref.watch( settingsProvider.select( (value) => value.alwaysShowCollectionPaneScrollbar, ), ); - final filterQuery = ref.watch(collectionSearchQueryProvider).trim(); + + if (collectionSequence.isEmpty) { + return _EmptyCollections( + onCreate: () => ref + .read(collectionCatalogProvider.notifier) + .addCollection(), + ); + } return Scrollbar( controller: controller, - thumbVisibility: alwaysShowCollectionPaneScrollbar ? true : null, + thumbVisibility: alwaysShowScrollbar ? true : null, radius: const Radius.circular(12), - child: filterQuery.isEmpty - ? ReorderableListView.builder( - padding: context.isMediumWindow - ? EdgeInsets.only( - bottom: MediaQuery.paddingOf(context).bottom, - right: 8, - ) - : kPe8, - scrollController: controller, - buildDefaultDragHandles: false, - itemCount: requestSequence.length, - onReorder: (int oldIndex, int newIndex) { - if (oldIndex < newIndex) { - newIndex -= 1; - } - if (oldIndex != newIndex) { - ref - .read(collectionStateNotifierProvider.notifier) - .reorder(oldIndex, newIndex); - } - }, - itemBuilder: (context, index) { - var id = requestSequence[index]; - if (kIsMobile) { - return ReorderableDelayedDragStartListener( - key: ValueKey(id), - index: index, - child: Padding( - padding: kP1, - child: RequestItem( - id: id, - requestModel: requestItems[id]!, - ), - ), - ); - } - return ReorderableDragStartListener( - key: ValueKey(id), - index: index, - child: Padding( - padding: kP1, - child: RequestItem(id: id, requestModel: requestItems[id]!), + child: ListView( + padding: context.isMediumWindow + ? EdgeInsets.only( + bottom: MediaQuery.paddingOf(context).bottom, + right: 8, + ) + : kPe8, + controller: controller, + children: [ + for (final collectionId in collectionSequence) + _CollectionSection( + collectionId: collectionId, + collection: collections[collectionId]!, + isExpanded: expandedIds.contains(collectionId), + isActive: collectionId == selectedCollectionId, + filterQuery: filterQuery, + ), + ], + ), + ); + } +} + +class _EmptyCollections extends StatelessWidget { + const _EmptyCollections({required this.onCreate}); + + final VoidCallback onCreate; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Center( + child: Padding( + padding: kP20, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.folder_off_outlined, + size: 36, + color: colorScheme.onSurfaceVariant, + ), + kVSpacer10, + Text( + kMsgNoCollections, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, ), - ); - }, - ) - : ListView( - padding: context.isMediumWindow - ? EdgeInsets.only( - bottom: MediaQuery.paddingOf(context).bottom, - right: 8, - ) - : kPe8, - controller: controller, - children: requestSequence.map((id) { - var item = requestItems[id]!; - if (item.httpRequestModel!.url.toLowerCase().contains( - filterQuery, - ) || - item.name.toLowerCase().contains(filterQuery)) { - return Padding( + ), + kVSpacer16, + FilledButton.icon( + onPressed: onCreate, + icon: const Icon(Icons.add), + label: const Text(kLabelCreateCollection), + ), + ], + ), + ), + ); + } +} + +class _CollectionSection extends ConsumerWidget { + const _CollectionSection({ + required this.collectionId, + required this.collection, + required this.isExpanded, + required this.isActive, + required this.filterQuery, + }); + + final String collectionId; + final CollectionModel collection; + final bool isExpanded; + final bool isActive; + final String filterQuery; + + List _requestSummaries(WidgetRef ref) { + if (!isActive) { + return collection.requests; + } + ref.watch(activeCollectionProvider); + final sequence = ref.watch(requestSequenceProvider); + return ref + .read(activeCollectionProvider.notifier) + .summariesForSequence(collectionId, sequence); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (isExpanded) { + ref.read(collectionCatalogProvider.notifier).loadCollection( + collectionId, + ); + } + final summaries = _requestSummaries(ref); + final visibleSummaries = filterQuery.isEmpty + ? summaries + : summaries.where((summary) { + return summary.url.toLowerCase().contains(filterQuery) || + summary.name.toLowerCase().contains(filterQuery); + }).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _CollectionSectionHeader( + collectionId: collectionId, + name: collection.name, + isExpanded: isExpanded, + isActive: isActive, + ), + if (isExpanded) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Column( + children: [ + for (final summary in visibleSummaries) + Padding( padding: kP1, - child: RequestItem(id: id, requestModel: item), - ); - } - return kSizedBoxEmpty; - }).toList(), + child: RequestItem( + summary: summary, + collectionId: collectionId, + ), + ), + ], ), + ), + ], + ); + } +} + +class _CollectionSectionHeader extends ConsumerWidget { + const _CollectionSectionHeader({ + required this.collectionId, + required this.name, + required this.isExpanded, + required this.isActive, + }); + + final String collectionId; + final String name; + final bool isExpanded; + final bool isActive; + + Future _showRenameDialog( + BuildContext context, + Set takenIds, + ) async { + final controller = TextEditingController(text: name); + + String? errorFor(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return null; + } + if (collectionNameHasIllegalChars(trimmed)) { + return kMsgCollectionNameInvalidChars; + } + final candidate = makeCollectionId(trimmed).toLowerCase(); + if (takenIds.any((t) => t.toLowerCase() == candidate)) { + return kMsgCollectionNameInUse; + } + return null; + } + + try { + return await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) { + final error = errorFor(controller.text); + final canSubmit = + controller.text.trim().isNotEmpty && error == null; + + void submit() { + if (canSubmit) { + Navigator.of(context).pop(controller.text); + } + } + + return AlertDialog( + title: const Text(kLabelRenameCollection), + content: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + labelText: kLabelCollectionName, + errorText: error, + ), + onChanged: (_) => setState(() {}), + onSubmitted: (_) => submit(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text(kLabelCancel), + ), + FilledButton( + onPressed: canSubmit ? submit : null, + child: const Text(kLabelOk), + ), + ], + ); + }, + ), + ); + } finally { + controller.dispose(); + } + } + + Future _confirmDelete(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text(kLabelDeleteCollection), + content: Text('Delete "$name" and all its requests?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text(kLabelCancel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(ItemMenuOption.delete.label), + ), + ], + ), + ); + if (confirmed == true) { + await ref + .read(collectionCatalogProvider.notifier) + .deleteCollection(collectionId); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + + return Material( + color: isActive ? colorScheme.surfaceContainerHighest : Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () async { + if (!isActive) { + await ref + .read(activeCollectionProvider.notifier) + .ensureActive(collectionId); + } + ref.read(expandedCollectionIdsProvider.notifier).update( + (ids) => {...ids, collectionId}, + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + child: Row( + children: [ + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + iconSize: 18, + onPressed: () { + final expanding = + !ref.read(expandedCollectionIdsProvider).contains( + collectionId, + ); + if (expanding) { + ref + .read(collectionCatalogProvider.notifier) + .loadCollection(collectionId); + } + ref.read(expandedCollectionIdsProvider.notifier).update( + (ids) { + final next = {...ids}; + if (next.contains(collectionId)) { + next.remove(collectionId); + } else { + next.add(collectionId); + } + return next; + }, + ); + }, + icon: Icon( + isExpanded + ? Icons.keyboard_arrow_down + : Icons.keyboard_arrow_right, + ), + ), + Icon( + Icons.folder_outlined, + size: 18, + color: colorScheme.onSurfaceVariant, + ), + kHSpacer4, + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + IconButton( + tooltip: kLabelPlusNew, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + iconSize: 18, + onPressed: () async { + await ref + .read(activeCollectionProvider.notifier) + .ensureActive(collectionId); + ref.read(activeCollectionProvider.notifier).add(); + ref.read(expandedCollectionIdsProvider.notifier).update( + (ids) => {...ids, collectionId}, + ); + }, + icon: const Icon(Icons.add), + ), + PopupMenuButton( + padding: EdgeInsets.zero, + icon: const Icon(Icons.more_horiz, size: 18), + splashRadius: 18, + onSelected: (option) async { + if (option == ItemMenuOption.edit) { + final takenIds = { + ...?ref.read(collectionCatalogProvider)?.keys, + }..remove(collectionId); + final messenger = ScaffoldMessenger.of(context); + final result = await _showRenameDialog(context, takenIds); + if (!context.mounted) { + return; + } + if (result != null && result.trim().isNotEmpty) { + final ok = await ref + .read(collectionCatalogProvider.notifier) + .renameCollection(collectionId, result); + if (!ok) { + messenger.showSnackBar( + const SnackBar( + content: Text(kMsgCollectionNameInUse), + ), + ); + } + } + } + if (option == ItemMenuOption.delete) { + if (!context.mounted) { + return; + } + await _confirmDelete(context, ref); + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: ItemMenuOption.edit, + child: Text(kLabelRenameCollection), + ), + PopupMenuItem( + value: ItemMenuOption.delete, + child: Text(ItemMenuOption.delete.label), + ), + ], + ), + ], + ), + ), + ), ); } } class RequestItem extends ConsumerWidget { - const RequestItem({super.key, required this.id, required this.requestModel}); + const RequestItem({ + super.key, + required this.summary, + required this.collectionId, + }); - final String id; - final RequestModel requestModel; + final RequestSummary summary; + final String collectionId; @override Widget build(BuildContext context, WidgetRef ref) { final selectedId = ref.watch(selectedIdStateProvider); final editRequestId = ref.watch(selectedIdEditStateProvider); + final id = summary.id; return SidebarRequestCard( id: id, - apiType: requestModel.apiType, - method: requestModel.httpRequestModel?.method, - name: requestModel.name, - url: requestModel.httpRequestModel?.url, + apiType: summary.apiType, + method: summary.method, + name: summary.name, + url: summary.url, selectedId: selectedId, editRequestId: editRequestId, - onTap: () { + onTap: () async { + await ref + .read(activeCollectionProvider.notifier) + .ensureActive(collectionId); + ref.read(activeCollectionProvider.notifier).loadRequest(id); ref.read(selectedIdStateProvider.notifier).state = id; kHomeScaffoldKey.currentState?.closeDrawer(); }, onSecondaryTap: () { ref.read(selectedIdStateProvider.notifier).state = id; }, - // onDoubleTap: () { - // ref.read(selectedIdStateProvider.notifier).state = id; - // ref.read(selectedIdEditStateProvider.notifier).state = id; - // }, - // controller: ref.watch(nameTextFieldControllerProvider), focusNode: ref.watch(nameTextFieldFocusNodeProvider), onChangedNameEditor: (value) { value = value.trim(); ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .update(id: editRequestId!, name: value); }, onTapOutsideNameEditor: () { @@ -205,12 +535,6 @@ class RequestItem extends ConsumerWidget { }, onMenuSelected: (ItemMenuOption item) { if (item == ItemMenuOption.edit) { - // var controller = - // ref.read(nameTextFieldControllerProvider.notifier).state; - // controller.text = requestModel.name; - // controller.selection = TextSelection.fromPosition( - // TextPosition(offset: controller.text.length), - // ); ref.read(selectedIdEditStateProvider.notifier).state = id; Future.delayed( const Duration(milliseconds: 150), @@ -221,10 +545,10 @@ class RequestItem extends ConsumerWidget { ); } if (item == ItemMenuOption.delete) { - ref.read(collectionStateNotifierProvider.notifier).remove(id: id); + ref.read(activeCollectionProvider.notifier).remove(id: id); } if (item == ItemMenuOption.duplicate) { - ref.read(collectionStateNotifierProvider.notifier).duplicate(id: id); + ref.read(activeCollectionProvider.notifier).duplicate(id: id); } }, ); diff --git a/lib/screens/home_page/editor_pane/details_card/request_pane/ai_request/aireq_authorization.dart b/lib/screens/home_page/editor_pane/details_card/request_pane/ai_request/aireq_authorization.dart index 9f629bdf08..e3b8cb42fb 100644 --- a/lib/screens/home_page/editor_pane/details_card/request_pane/ai_request/aireq_authorization.dart +++ b/lib/screens/home_page/editor_pane/details_card/request_pane/ai_request/aireq_authorization.dart @@ -17,7 +17,7 @@ class AIRequestAuthorizationSection extends ConsumerWidget { ), ); final requestModel = ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .getRequestModel(selectedId!); final aiReqM = requestModel?.aiRequestModel; if (aiReqM == null) { @@ -37,7 +37,7 @@ class AIRequestAuthorizationSection extends ConsumerWidget { initialValue: apiKey, onChanged: (String value) { ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .update(aiRequestModel: aiReqM.copyWith(apiKey: value)); }, hintText: kHintEnterApiKey, diff --git a/lib/screens/home_page/editor_pane/details_card/request_pane/ai_request/aireq_configs.dart b/lib/screens/home_page/editor_pane/details_card/request_pane/ai_request/aireq_configs.dart index 94e6830793..d254170ee5 100644 --- a/lib/screens/home_page/editor_pane/details_card/request_pane/ai_request/aireq_configs.dart +++ b/lib/screens/home_page/editor_pane/details_card/request_pane/ai_request/aireq_configs.dart @@ -13,7 +13,7 @@ class AIRequestConfigSection extends ConsumerWidget { final modelConfigs = ref.watch(selectedRequestModelProvider .select((value) => value?.aiRequestModel?.modelConfigs)); final requestModel = ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .getRequestModel(selectedId!); final aiReqM = requestModel?.aiRequestModel; if (aiReqM == null || modelConfigs == null) { @@ -22,14 +22,14 @@ class AIRequestConfigSection extends ConsumerWidget { updateRequestModel(ModelConfig modelConfig) { final aiRequestModel = ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .getRequestModel(selectedId) ?.aiRequestModel; final idx = aiRequestModel?.getModelConfigIdx(modelConfig.id); if (idx != null && aiRequestModel != null) { var l = [...aiRequestModel.modelConfigs]; l[idx] = modelConfig; - ref.read(collectionStateNotifierProvider.notifier).update( + ref.read(activeCollectionProvider.notifier).update( aiRequestModel: aiRequestModel.copyWith(modelConfigs: l), ); } diff --git a/lib/screens/home_page/editor_pane/details_card/request_pane/ai_request/aireq_prompt.dart b/lib/screens/home_page/editor_pane/details_card/request_pane/ai_request/aireq_prompt.dart index 57564a787a..8f664ffbdd 100644 --- a/lib/screens/home_page/editor_pane/details_card/request_pane/ai_request/aireq_prompt.dart +++ b/lib/screens/home_page/editor_pane/details_card/request_pane/ai_request/aireq_prompt.dart @@ -22,7 +22,7 @@ class AIRequestPromptSection extends ConsumerWidget { ), ); final aiRequestModel = ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .getRequestModel(selectedId!) ?.aiRequestModel; if (aiRequestModel == null) { @@ -48,7 +48,7 @@ class AIRequestPromptSection extends ConsumerWidget { initialValue: systemPrompt, onChanged: (String value) { ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .update( aiRequestModel: aiRequestModel.copyWith( systemPrompt: value, @@ -74,7 +74,7 @@ class AIRequestPromptSection extends ConsumerWidget { initialValue: userPrompt, onChanged: (String value) { ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .update( aiRequestModel: aiRequestModel.copyWith( userPrompt: value, diff --git a/lib/screens/home_page/editor_pane/details_card/request_pane/ai_request/request_pane_ai.dart b/lib/screens/home_page/editor_pane/details_card/request_pane/ai_request/request_pane_ai.dart index aeafe3b6bb..905d8e2153 100644 --- a/lib/screens/home_page/editor_pane/details_card/request_pane/ai_request/request_pane_ai.dart +++ b/lib/screens/home_page/editor_pane/details_card/request_pane/ai_request/request_pane_ai.dart @@ -31,7 +31,7 @@ class EditAIRequestPane extends ConsumerWidget { }, onTapTabBar: (index) { ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .update(requestTabIndex: index); }, showIndicators: [false, false, false], diff --git a/lib/screens/home_page/editor_pane/details_card/request_pane/request_auth.dart b/lib/screens/home_page/editor_pane/details_card/request_pane/request_auth.dart index daf0e0d103..55650567f1 100644 --- a/lib/screens/home_page/editor_pane/details_card/request_pane/request_auth.dart +++ b/lib/screens/home_page/editor_pane/details_card/request_pane/request_auth.dart @@ -31,7 +31,7 @@ class EditAuthType extends ConsumerWidget { onChangedAuthType: (newType) { final selectedRequest = ref.read(selectedRequestModelProvider); if (newType != null) { - ref.read(collectionStateNotifierProvider.notifier).update( + ref.read(activeCollectionProvider.notifier).update( authModel: selectedRequest?.httpRequestModel?.authModel ?.copyWith(type: newType) ?? AuthModel(type: newType), @@ -40,11 +40,11 @@ class EditAuthType extends ConsumerWidget { }, updateAuthData: (model) { if (model == null) { - ref.read(collectionStateNotifierProvider.notifier).update( + ref.read(activeCollectionProvider.notifier).update( authModel: AuthModel(type: APIAuthType.none), ); } - ref.read(collectionStateNotifierProvider.notifier).update( + ref.read(activeCollectionProvider.notifier).update( authModel: model, ); }, diff --git a/lib/screens/home_page/editor_pane/details_card/request_pane/request_body.dart b/lib/screens/home_page/editor_pane/details_card/request_pane/request_body.dart index e6c9826bb6..6c37db7342 100644 --- a/lib/screens/home_page/editor_pane/details_card/request_pane/request_body.dart +++ b/lib/screens/home_page/editor_pane/details_card/request_pane/request_body.dart @@ -14,7 +14,7 @@ class EditRequestBody extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final selectedId = ref.watch(selectedIdStateProvider); final requestModel = ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .getRequestModel(selectedId!); final contentType = ref.watch( selectedRequestModelProvider.select( @@ -58,7 +58,7 @@ class EditRequestBody extends ConsumerWidget { initialValue: requestModel?.httpRequestModel?.body, onChanged: (String value) { ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .update(body: value); }, hintText: kHintJson, @@ -72,7 +72,7 @@ class EditRequestBody extends ConsumerWidget { initialValue: requestModel?.httpRequestModel?.body, onChanged: (String value) { ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .update(body: value); }, hintText: kHintText, @@ -89,7 +89,7 @@ class EditRequestBody extends ConsumerWidget { initialValue: requestModel?.httpRequestModel?.query, onChanged: (String value) { ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .update(query: value); }, hintText: kHintQuery, @@ -118,7 +118,7 @@ class DropdownButtonBodyContentType extends ConsumerWidget { contentType: requestBodyContentType, onChanged: (ContentType? value) { ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .update(bodyContentType: value); }, ); diff --git a/lib/screens/home_page/editor_pane/details_card/request_pane/request_form_data.dart b/lib/screens/home_page/editor_pane/details_card/request_pane/request_form_data.dart index c007cc7362..99e8c6b3b2 100644 --- a/lib/screens/home_page/editor_pane/details_card/request_pane/request_form_data.dart +++ b/lib/screens/home_page/editor_pane/details_card/request_pane/request_form_data.dart @@ -29,7 +29,7 @@ class _FormDataBodyState extends ConsumerState { } void _onFieldChange() { - ref.read(collectionStateNotifierProvider.notifier).update( + ref.read(activeCollectionProvider.notifier).update( formData: formRows.sublist(0, formRows.length - 1), ); } diff --git a/lib/screens/home_page/editor_pane/details_card/request_pane/request_headers.dart b/lib/screens/home_page/editor_pane/details_card/request_pane/request_headers.dart index 435346de62..b6de134fdd 100644 --- a/lib/screens/home_page/editor_pane/details_card/request_pane/request_headers.dart +++ b/lib/screens/home_page/editor_pane/details_card/request_pane/request_headers.dart @@ -29,7 +29,7 @@ class EditRequestHeadersState extends ConsumerState { } void _onFieldChange() { - ref.read(collectionStateNotifierProvider.notifier).update( + ref.read(activeCollectionProvider.notifier).update( headers: headerRows.sublist(0, headerRows.length - 1), isHeaderEnabledList: isRowEnabledList.sublist(0, headerRows.length - 1), diff --git a/lib/screens/home_page/editor_pane/details_card/request_pane/request_pane_graphql.dart b/lib/screens/home_page/editor_pane/details_card/request_pane/request_pane_graphql.dart index 2f19903aae..48a82fb23f 100644 --- a/lib/screens/home_page/editor_pane/details_card/request_pane/request_pane_graphql.dart +++ b/lib/screens/home_page/editor_pane/details_card/request_pane/request_pane_graphql.dart @@ -50,7 +50,7 @@ class EditGraphQLRequestPane extends ConsumerWidget { }, onTapTabBar: (index) { ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .update(requestTabIndex: index); }, showIndicators: [ diff --git a/lib/screens/home_page/editor_pane/details_card/request_pane/request_pane_rest.dart b/lib/screens/home_page/editor_pane/details_card/request_pane/request_pane_rest.dart index bc241f9a0e..156c2a0ce8 100644 --- a/lib/screens/home_page/editor_pane/details_card/request_pane/request_pane_rest.dart +++ b/lib/screens/home_page/editor_pane/details_card/request_pane/request_pane_rest.dart @@ -55,7 +55,7 @@ class EditRestRequestPane extends ConsumerWidget { }, onTapTabBar: (index) { ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .update(requestTabIndex: index); }, showIndicators: [ diff --git a/lib/screens/home_page/editor_pane/details_card/request_pane/request_params.dart b/lib/screens/home_page/editor_pane/details_card/request_pane/request_params.dart index be752217c9..4b5cdbe5fd 100644 --- a/lib/screens/home_page/editor_pane/details_card/request_pane/request_params.dart +++ b/lib/screens/home_page/editor_pane/details_card/request_pane/request_params.dart @@ -30,7 +30,7 @@ class EditRequestURLParamsState extends ConsumerState { } void _onFieldChange() { - ref.read(collectionStateNotifierProvider.notifier).update( + ref.read(activeCollectionProvider.notifier).update( params: paramRows.sublist(0, paramRows.length - 1), isParamEnabledList: isRowEnabledList.sublist(0, paramRows.length - 1), ); diff --git a/lib/screens/home_page/editor_pane/details_card/request_pane/request_scripts.dart b/lib/screens/home_page/editor_pane/details_card/request_pane/request_scripts.dart index 21a48fc1c1..d0202ae139 100644 --- a/lib/screens/home_page/editor_pane/details_card/request_pane/request_scripts.dart +++ b/lib/screens/home_page/editor_pane/details_card/request_pane/request_scripts.dart @@ -34,13 +34,13 @@ class _EditRequestScriptsState extends ConsumerState { preReqCodeController.addListener(() { ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .update(preRequestScript: preReqCodeController.text); }); postResCodeController.addListener(() { ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .update(postRequestScript: postResCodeController.text); }); diff --git a/lib/screens/home_page/editor_pane/details_card/response_pane.dart b/lib/screens/home_page/editor_pane/details_card/response_pane.dart index 0e701d5f99..4e9783e4ab 100644 --- a/lib/screens/home_page/editor_pane/details_card/response_pane.dart +++ b/lib/screens/home_page/editor_pane/details_card/response_pane.dart @@ -62,7 +62,7 @@ class ResponseDetails extends ConsumerWidget { message: message, time: responseModel?.time, onClearResponse: () { - ref.read(collectionStateNotifierProvider.notifier).clearResponse(); + ref.read(activeCollectionProvider.notifier).clearResponse(); }, ), const Expanded( diff --git a/lib/screens/home_page/editor_pane/editor_default.dart b/lib/screens/home_page/editor_pane/editor_default.dart index bac27717be..6d965bae9b 100644 --- a/lib/screens/home_page/editor_pane/editor_default.dart +++ b/lib/screens/home_page/editor_pane/editor_default.dart @@ -51,7 +51,15 @@ class RequestEditorDefault extends ConsumerWidget { // Primary action button ElevatedButton( onPressed: () { - ref.read(collectionStateNotifierProvider.notifier).add(); + final hasCollections = + ref.read(collectionSequenceProvider).isNotEmpty; + if (hasCollections) { + ref.read(activeCollectionProvider.notifier).add(); + } else { + ref + .read(collectionCatalogProvider.notifier) + .addCollection(); + } }, style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric( diff --git a/lib/screens/home_page/editor_pane/request_editor_top_bar.dart b/lib/screens/home_page/editor_pane/request_editor_top_bar.dart index 1bd31c06d6..577abc0047 100644 --- a/lib/screens/home_page/editor_pane/request_editor_top_bar.dart +++ b/lib/screens/home_page/editor_pane/request_editor_top_bar.dart @@ -35,14 +35,14 @@ class RequestEditorTopBar extends ConsumerWidget { onRenamePressed: () { showRenameDialog(context, kLabelRenameRequest, name, (val) { ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .update(name: val); }); }, onDuplicatePressed: () => - ref.read(collectionStateNotifierProvider.notifier).duplicate(), + ref.read(activeCollectionProvider.notifier).duplicate(), onDeletePressed: () => - ref.read(collectionStateNotifierProvider.notifier).remove(), + ref.read(activeCollectionProvider.notifier).remove(), ), kHSpacer10, const EnvironmentDropdown(), diff --git a/lib/screens/home_page/editor_pane/url_card.dart b/lib/screens/home_page/editor_pane/url_card.dart index d84f27a7f4..e6dcc503a3 100644 --- a/lib/screens/home_page/editor_pane/url_card.dart +++ b/lib/screens/home_page/editor_pane/url_card.dart @@ -87,7 +87,7 @@ class DropdownButtonHTTPMethod extends ConsumerWidget { method: method, onChanged: (HTTPVerb? value) { ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .update(method: value); }, ); @@ -102,13 +102,10 @@ class URLTextField extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final selectedId = ref.watch(selectedIdStateProvider); - ref.watch(selectedRequestModelProvider - .select((value) => value?.aiRequestModel?.url)); - ref.watch(selectedRequestModelProvider - .select((value) => value?.httpRequestModel?.url)); - final requestModel = ref - .read(collectionStateNotifierProvider.notifier) - .getRequestModel(selectedId!)!; + final requestModel = ref.watch(selectedRequestModelProvider); + if (selectedId == null || requestModel == null) { + return const SizedBox.shrink(); + } return EnvURLField( selectedId: selectedId, initialValue: switch (requestModel.apiType) { @@ -117,15 +114,15 @@ class URLTextField extends ConsumerWidget { }, onChanged: (value) { if (requestModel.apiType == APIType.ai) { - ref.read(collectionStateNotifierProvider.notifier).update( + ref.read(activeCollectionProvider.notifier).update( aiRequestModel: requestModel.aiRequestModel?.copyWith(url: value)); } else { - ref.read(collectionStateNotifierProvider.notifier).update(url: value); + ref.read(activeCollectionProvider.notifier).update(url: value); } }, onFieldSubmitted: (value) { - ref.read(collectionStateNotifierProvider.notifier).sendRequest(); + ref.read(activeCollectionProvider.notifier).sendRequest(); }, ); } @@ -151,10 +148,10 @@ class SendRequestButton extends ConsumerWidget { isWorking: isWorking ?? false, onTap: () { onTap?.call(); - ref.read(collectionStateNotifierProvider.notifier).sendRequest(); + ref.read(activeCollectionProvider.notifier).sendRequest(); }, onCancel: () { - ref.read(collectionStateNotifierProvider.notifier).cancelRequest(); + ref.read(activeCollectionProvider.notifier).cancelRequest(); }, ); } diff --git a/lib/screens/mobile/requests_page/request_response_page.dart b/lib/screens/mobile/requests_page/request_response_page.dart index 33cfa59616..9d2d45e00e 100644 --- a/lib/screens/mobile/requests_page/request_response_page.dart +++ b/lib/screens/mobile/requests_page/request_response_page.dart @@ -44,16 +44,16 @@ class _RequestResponsePageState extends ConsumerState if (item == ItemMenuOption.edit) { showRenameDialog(context, kLabelRenameRequest, name, (val) { ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .update(name: val); }); } if (item == ItemMenuOption.delete) { - ref.read(collectionStateNotifierProvider.notifier).remove(); + ref.read(activeCollectionProvider.notifier).remove(); } if (item == ItemMenuOption.duplicate) { ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .duplicate(); } }, diff --git a/lib/screens/settings_page.dart b/lib/screens/settings_page.dart index bbfebc76d7..c690e0a533 100644 --- a/lib/screens/settings_page.dart +++ b/lib/screens/settings_page.dart @@ -147,6 +147,18 @@ class SettingsPage extends ConsumerWidget { .update(saveResponses: value); }, ), + CheckboxListTile( + title: const Text(kLabelSaveMediaResponsesAsFiles), + subtitle: const Text(kLabelSaveMediaResponsesAsFilesSubtitle), + value: settings.saveMediaResponsesAsFiles, + onChanged: settings.saveResponses + ? (value) { + ref + .read(settingsProvider.notifier) + .update(saveMediaResponsesAsFiles: value); + } + : null, + ), CheckboxListTile( title: const Text(kLabelShowSaveAlert), subtitle: const Text(kLabelShowSaveAlertSubtitle), @@ -179,7 +191,7 @@ class SettingsPage extends ConsumerWidget { trailing: FilledButton.icon( onPressed: () async { var data = await ref - .read(collectionStateNotifierProvider.notifier) + .read(activeCollectionProvider.notifier) .exportDataToHAR(); await saveCollection(data, sm); }, @@ -228,11 +240,10 @@ class SettingsPage extends ConsumerWidget { await clearSharedPrefs(); await ref .read( - collectionStateNotifierProvider + activeCollectionProvider .notifier, ) .clearData(); - sm.hideCurrentSnackBar(); sm.showSnackBar( getSnackBar(kMsgRequestsDataCleared), diff --git a/lib/screens/terminal/terminal_page.dart b/lib/screens/terminal/terminal_page.dart index 6d1d2e8bf0..240f29c818 100644 --- a/lib/screens/terminal/terminal_page.dart +++ b/lib/screens/terminal/terminal_page.dart @@ -34,7 +34,7 @@ class _TerminalPageState extends ConsumerState { @override Widget build(BuildContext context) { final state = ref.watch(terminalStateProvider); - final collection = ref.watch(collectionStateNotifierProvider); + final collection = ref.watch(activeCollectionProvider); final allEntries = state.entries; final filtered = _applyFilters(allEntries); diff --git a/lib/services/history_service.dart b/lib/services/history_service.dart index 487b91640c..cad82fb267 100644 --- a/lib/services/history_service.dart +++ b/lib/services/history_service.dart @@ -1,6 +1,6 @@ import 'package:apidash/models/models.dart'; import 'package:apidash/utils/utils.dart'; -import 'hive_services.dart'; +import 'storage/workspace_storage.dart'; Future autoClearHistory({SettingsModel? settingsModel}) async { final historyRetentionPeriod = settingsModel?.historyRetentionPeriod; @@ -9,22 +9,18 @@ Future autoClearHistory({SettingsModel? settingsModel}) async { if (retentionDate == null) { return; } else { - List? historyIds = hiveHandler.getHistoryIds(); - List toRemoveIds = []; - - if (historyIds == null || historyIds.isEmpty) { + final allMetas = workspaceStorage.getAllHistoryMetas(); + if (allMetas == null || allMetas.isEmpty) { return; } - for (var historyId in historyIds) { - var jsonModel = hiveHandler.getHistoryMeta(historyId); - if (jsonModel != null) { - var jsonMap = Map.from(jsonModel); - HistoryMetaModel historyMetaModelFromJson = - HistoryMetaModel.fromJson(jsonMap); - if (historyMetaModelFromJson.timeStamp.isBefore(retentionDate)) { - toRemoveIds.add(historyId); - } + final toRemoveIds = []; + + for (final entry in allMetas.entries) { + final jsonMap = Map.from(entry.value); + final historyMetaModelFromJson = HistoryMetaModel.fromJson(jsonMap); + if (historyMetaModelFromJson.timeStamp.isBefore(retentionDate)) { + toRemoveIds.add(entry.key); } } @@ -32,11 +28,14 @@ Future autoClearHistory({SettingsModel? settingsModel}) async { return; } - for (var id in toRemoveIds) { - await hiveHandler.deleteHistoryRequest(id); - hiveHandler.deleteHistoryMeta(id); + for (final id in toRemoveIds) { + await workspaceStorage.deleteHistoryRequest(id); } - hiveHandler.setHistoryIds( - historyIds..removeWhere((id) => toRemoveIds.contains(id))); + final remainingMetas = Map>.from( + workspaceStorage.getAllHistoryMetas() ?? {}, + )..removeWhere((id, _) => toRemoveIds.contains(id)); + await workspaceStorage.setAllHistoryMetas( + remainingMetas.isEmpty ? null : remainingMetas, + ); } } diff --git a/lib/services/hive_services.dart b/lib/services/hive_services.dart deleted file mode 100644 index 3afba422c1..0000000000 --- a/lib/services/hive_services.dart +++ /dev/null @@ -1,193 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:hive_ce_flutter/hive_flutter.dart'; - -enum HiveBoxType { normal, lazy } - -const String kDataBox = "apidash-data"; -const String kKeyDataBoxIds = "ids"; - -const String kEnvironmentBox = "apidash-environments"; -const String kKeyEnvironmentBoxIds = "environmentIds"; - -const String kHistoryMetaBox = "apidash-history-meta"; -const String kHistoryBoxIds = "historyIds"; -const String kHistoryLazyBox = "apidash-history-lazy"; - -const String kDashBotBox = "apidash-dashbot-data"; -const String kKeyDashBotBoxIds = 'messages'; - -const kHiveBoxes = [ - (kDataBox, HiveBoxType.normal), - (kEnvironmentBox, HiveBoxType.normal), - (kHistoryMetaBox, HiveBoxType.normal), - (kHistoryLazyBox, HiveBoxType.lazy), - (kDashBotBox, HiveBoxType.lazy), -]; - -Future initHiveBoxes( - bool initializeUsingPath, - String? workspaceFolderPath, -) async { - try { - if (initializeUsingPath) { - if (workspaceFolderPath != null) { - Hive.init(workspaceFolderPath); - } else { - return false; - } - } else { - await Hive.initFlutter(); - } - final openHiveBoxesStatus = await openHiveBoxes(); - return openHiveBoxesStatus; - } catch (e) { - return false; - } -} - -Future openHiveBoxes() async { - try { - for (var box in kHiveBoxes) { - if (box.$2 == HiveBoxType.normal) { - await Hive.openBox(box.$1); - } else if (box.$2 == HiveBoxType.lazy) { - await Hive.openLazyBox(box.$1); - } - } - return true; - } catch (e) { - debugPrint("ERROR OPEN HIVE BOXES: $e"); - return false; - } -} - -Future clearHiveBoxes() async { - try { - for (var box in kHiveBoxes) { - if (Hive.isBoxOpen(box.$1)) { - if (box.$2 == HiveBoxType.normal) { - await Hive.box(box.$1).clear(); - } else if (box.$2 == HiveBoxType.lazy) { - await Hive.lazyBox(box.$1).clear(); - } - } - } - } catch (e) { - debugPrint("ERROR CLEAR HIVE BOXES: $e"); - } -} - -Future deleteHiveBoxes() async { - try { - for (var box in kHiveBoxes) { - if (Hive.isBoxOpen(box.$1)) { - if (box.$2 == HiveBoxType.normal) { - await Hive.box(box.$1).deleteFromDisk(); - } else if (box.$2 == HiveBoxType.lazy) { - await Hive.lazyBox(box.$1).deleteFromDisk(); - } - } - } - await Hive.close(); - } catch (e) { - debugPrint("ERROR DELETE HIVE BOXES: $e"); - } -} - -final hiveHandler = HiveHandler(); - -class HiveHandler { - late final Box dataBox; - late final Box environmentBox; - late final Box historyMetaBox; - late final LazyBox historyLazyBox; - late final LazyBox dashBotBox; - - HiveHandler() { - debugPrint("Trying to open Hive boxes"); - dataBox = Hive.box(kDataBox); - environmentBox = Hive.box(kEnvironmentBox); - historyMetaBox = Hive.box(kHistoryMetaBox); - historyLazyBox = Hive.lazyBox(kHistoryLazyBox); - dashBotBox = Hive.lazyBox(kDashBotBox); - } - - dynamic getIds() => dataBox.get(kKeyDataBoxIds); - Future setIds(List? ids) => dataBox.put(kKeyDataBoxIds, ids); - - dynamic getRequestModel(String id) => dataBox.get(id); - Future setRequestModel( - String id, Map? requestModelJson) => - dataBox.put(id, requestModelJson); - - void delete(String key) => dataBox.delete(key); - - dynamic getEnvironmentIds() => environmentBox.get(kKeyEnvironmentBoxIds); - Future setEnvironmentIds(List? ids) => - environmentBox.put(kKeyEnvironmentBoxIds, ids); - - dynamic getEnvironment(String id) => environmentBox.get(id); - Future setEnvironment( - String id, Map? environmentJson) => - environmentBox.put(id, environmentJson); - - Future deleteEnvironment(String id) => environmentBox.delete(id); - - dynamic getHistoryIds() => historyMetaBox.get(kHistoryBoxIds); - Future setHistoryIds(List? ids) => - historyMetaBox.put(kHistoryBoxIds, ids); - - dynamic getHistoryMeta(String id) => historyMetaBox.get(id); - Future setHistoryMeta( - String id, Map? historyMetaJson) => - historyMetaBox.put(id, historyMetaJson); - - Future deleteHistoryMeta(String id) => historyMetaBox.delete(id); - - Future getHistoryRequest(String id) async => - await historyLazyBox.get(id); - Future setHistoryRequest( - String id, Map? historyRequestJson) => - historyLazyBox.put(id, historyRequestJson); - - Future deleteHistoryRequest(String id) => historyLazyBox.delete(id); - - Future getDashbotMessages() async => - await dashBotBox.get(kKeyDashBotBoxIds); - Future saveDashbotMessages(String messages) => - dashBotBox.put(kKeyDashBotBoxIds, messages); - - Future clearAllHistory() async { - await historyMetaBox.clear(); - await historyLazyBox.clear(); - } - - Future clear() async { - await dataBox.clear(); - await environmentBox.clear(); - await historyMetaBox.clear(); - await historyLazyBox.clear(); - await dashBotBox.clear(); - } - - Future removeUnused() async { - var ids = getIds(); - if (ids != null) { - ids = ids as List; - for (var key in dataBox.keys.toList()) { - if (key != kKeyDataBoxIds && !ids.contains(key)) { - await dataBox.delete(key); - } - } - } - var environmentIds = getEnvironmentIds(); - if (environmentIds != null) { - environmentIds = environmentIds as List; - for (var key in environmentBox.keys.toList()) { - if (key != kKeyEnvironmentBoxIds && !environmentIds.contains(key)) { - await environmentBox.delete(key); - } - } - } - } -} diff --git a/lib/services/secure_storage.dart b/lib/services/secure_storage.dart new file mode 100644 index 0000000000..46ea1e5bc3 --- /dev/null +++ b/lib/services/secure_storage.dart @@ -0,0 +1,300 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'storage/workspace_storage.dart'; + +FlutterSecureStorage _createSecureStorage() => const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + mOptions: MacOsOptions(useDataProtectionKeyChain: false), + ); + +String _workspaceId(String workspacePath) => + base64Url.encode(utf8.encode(workspacePath)); + +const _envStorageKeyPrefix = 'apidash_env_secret'; +const _aiRequestStorageKeyPrefix = 'apidash_ai_secret'; +const _aiHistoryStorageKeyPrefix = 'apidash_ai_history_secret'; +const _defaultAiApiKeyStorageKey = 'apidash_default_ai_api_key'; + +class EnvironmentSecretsStorage { + EnvironmentSecretsStorage({FlutterSecureStorage? storage}) + : _storage = storage ?? _createSecureStorage(); + + final FlutterSecureStorage _storage; + + String _key(String workspacePath, String environmentId, String variableKey) => + '$_envStorageKeyPrefix/${_workspaceId(workspacePath)}/$environmentId/$variableKey'; + + Future readSecret( + String workspacePath, + String environmentId, + String variableKey, + ) { + return _storage.read( + key: _key(workspacePath, environmentId, variableKey), + ); + } + + Future writeSecret( + String workspacePath, + String environmentId, + String variableKey, + String value, + ) { + return _storage.write( + key: _key(workspacePath, environmentId, variableKey), + value: value, + ); + } + + Future deleteSecret( + String workspacePath, + String environmentId, + String variableKey, + ) { + return _storage.delete( + key: _key(workspacePath, environmentId, variableKey), + ); + } + + Future deleteAllForEnvironment( + String workspacePath, + String environmentId, + Iterable variableKeys, + ) async { + for (final key in variableKeys) { + await deleteSecret(workspacePath, environmentId, key); + } + } + + Future deleteAllForWorkspace(String workspacePath) async { + try { + final prefix = '$_envStorageKeyPrefix/${_workspaceId(workspacePath)}/'; + final all = await _storage.readAll(); + for (final key in all.keys) { + if (key.startsWith(prefix)) { + await _storage.delete(key: key); + } + } + } catch (_) { + for (final entry in _secretKeysByEnvironmentFromWorkspace().entries) { + await deleteAllForEnvironment( + workspacePath, + entry.key, + entry.value, + ); + } + } + } + + Map> _secretKeysByEnvironmentFromWorkspace() { + final byEnvironment = >{}; + for (final environmentId + in workspaceStorage.getEnvironmentIds() ?? const []) { + final json = workspaceStorage.getEnvironment(environmentId); + if (json == null) { + continue; + } + final values = json['values']; + if (values is! List) { + continue; + } + for (final value in values) { + if (value is! Map) { + continue; + } + if (value['type'] != 'secret') { + continue; + } + final variableKey = value['key'] as String?; + if (variableKey == null || variableKey.isEmpty) { + continue; + } + byEnvironment.putIfAbsent(environmentId, () => []).add(variableKey); + } + } + return byEnvironment; + } +} + +class AiRequestSecretsStorage { + AiRequestSecretsStorage({FlutterSecureStorage? storage}) + : _storage = storage ?? _createSecureStorage(); + + final FlutterSecureStorage _storage; + + String _requestKey( + String workspacePath, + String collectionId, + String requestId, + ) => + '$_aiRequestStorageKeyPrefix/${_workspaceId(workspacePath)}/$collectionId/$requestId'; + + String _historyKey(String workspacePath, String historyId) => + '$_aiHistoryStorageKeyPrefix/${_workspaceId(workspacePath)}/$historyId'; + + Future readApiKey( + String workspacePath, + String collectionId, + String requestId, + ) { + return _storage.read( + key: _requestKey(workspacePath, collectionId, requestId), + ); + } + + Future writeApiKey( + String workspacePath, + String collectionId, + String requestId, + String value, + ) { + return _storage.write( + key: _requestKey(workspacePath, collectionId, requestId), + value: value, + ); + } + + Future deleteApiKey( + String workspacePath, + String collectionId, + String requestId, + ) { + return _storage.delete( + key: _requestKey(workspacePath, collectionId, requestId), + ); + } + + Future rekeyApiKey( + String workspacePath, + String collectionId, + String oldRequestId, + String newRequestId, + ) async { + if (oldRequestId == newRequestId) { + return; + } + final value = await readApiKey(workspacePath, collectionId, oldRequestId); + if (value == null) { + return; + } + await writeApiKey(workspacePath, collectionId, newRequestId, value); + await deleteApiKey(workspacePath, collectionId, oldRequestId); + } + + Future deleteOrphansForCollection( + String workspacePath, + String collectionId, + Set activeRequestIds, + ) async { + try { + final prefix = + '$_aiRequestStorageKeyPrefix/${_workspaceId(workspacePath)}/$collectionId/'; + final all = await _storage.readAll(); + for (final key in all.keys) { + if (!key.startsWith(prefix)) { + continue; + } + final requestId = key.substring(prefix.length); + if (!activeRequestIds.contains(requestId)) { + await _storage.delete(key: key); + } + } + } catch (_) { + // Best-effort cleanup. + } + } + + Future readHistoryApiKey(String workspacePath, String historyId) { + return _storage.read(key: _historyKey(workspacePath, historyId)); + } + + Future writeHistoryApiKey( + String workspacePath, + String historyId, + String value, + ) { + return _storage.write(key: _historyKey(workspacePath, historyId), value: value); + } + + Future deleteHistoryApiKey(String workspacePath, String historyId) { + return _storage.delete(key: _historyKey(workspacePath, historyId)); + } + + Future readDefaultApiKey() { + return _storage.read(key: _defaultAiApiKeyStorageKey); + } + + Future writeDefaultApiKey(String value) { + return _storage.write(key: _defaultAiApiKeyStorageKey, value: value); + } + + Future deleteDefaultApiKey() { + return _storage.delete(key: _defaultAiApiKeyStorageKey); + } + + Future deleteAllForWorkspace(String workspacePath) async { + final workspaceToken = _workspaceId(workspacePath); + final requestPrefix = '$_aiRequestStorageKeyPrefix/$workspaceToken/'; + final historyPrefix = '$_aiHistoryStorageKeyPrefix/$workspaceToken/'; + try { + final all = await _storage.readAll(); + for (final key in all.keys) { + if (key.startsWith(requestPrefix) || key.startsWith(historyPrefix)) { + await _storage.delete(key: key); + } + } + } catch (_) { + // Best-effort cleanup. + } + } + + Future deleteAllHistoryForWorkspace(String workspacePath) async { + final prefix = '$_aiHistoryStorageKeyPrefix/${_workspaceId(workspacePath)}/'; + try { + final all = await _storage.readAll(); + for (final key in all.keys) { + if (key.startsWith(prefix)) { + await _storage.delete(key: key); + } + } + } catch (_) { + // Best-effort cleanup. + } + } + + static String? apiKeyFromJson(Map json) { + final ai = json['aiRequestModel']; + if (ai is! Map) { + return null; + } + return ai['apiKey'] as String?; + } + + static Map stripApiKeyFromJson(Map json) { + final ai = json['aiRequestModel']; + if (ai is! Map) { + return json; + } + final result = Map.from(json); + final aiCopy = Map.from(ai); + aiCopy['apiKey'] = null; + result['aiRequestModel'] = aiCopy; + return result; + } + + static Map? stripApiKeyFromDefaultAiModel( + Map? model, + ) { + if (model == null) { + return null; + } + final copy = Map.from(model); + copy['apiKey'] = null; + return copy; + } +} + +final environmentSecretsStorage = EnvironmentSecretsStorage(); +final aiRequestSecretsStorage = AiRequestSecretsStorage(); diff --git a/lib/services/services.dart b/lib/services/services.dart index 7fa8128d69..50a3b4d3e1 100644 --- a/lib/services/services.dart +++ b/lib/services/services.dart @@ -1,4 +1,7 @@ -export 'hive_services.dart'; +export 'secure_storage.dart'; +export 'storage/workspace_storage.dart'; +export 'workspace_service.dart'; export 'history_service.dart'; export 'window_services.dart'; export 'shared_preferences_services.dart'; +export 'storage/storage.dart'; diff --git a/lib/services/shared_preferences_services.dart b/lib/services/shared_preferences_services.dart index dae5b6c6ec..354163784c 100644 --- a/lib/services/shared_preferences_services.dart +++ b/lib/services/shared_preferences_services.dart @@ -1,6 +1,7 @@ import 'package:apidash_core/apidash_core.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/models.dart'; +import 'secure_storage.dart'; const String kSharedPrefSettingsKey = 'apidash-settings'; const String kSharedPrefOnboardingKey = 'apidash-onboarding-status'; @@ -12,6 +13,15 @@ Future getSettingsFromSharedPrefs() async { var jsonSettings = kJsonDecoder.convert(settingsStr); var jsonMap = Map.from(jsonSettings); var settingsModel = SettingsModel.fromJson(jsonMap); + final defaultAi = settingsModel.defaultAIModel; + if (defaultAi != null) { + final apiKey = await aiRequestSecretsStorage.readDefaultApiKey(); + if (apiKey != null && apiKey.isNotEmpty) { + settingsModel = settingsModel.copyWith( + defaultAIModel: {...defaultAi, 'apiKey': apiKey}, + ); + } + } return settingsModel; } else { return null; @@ -20,11 +30,27 @@ Future getSettingsFromSharedPrefs() async { Future setSettingsToSharedPrefs(SettingsModel settingsModel) async { final prefs = await SharedPreferences.getInstance(); - await prefs.setString(kSharedPrefSettingsKey, settingsModel.toString()); + var model = settingsModel; + final defaultAi = model.defaultAIModel; + if (defaultAi != null) { + final apiKey = defaultAi['apiKey'] as String?; + if (apiKey != null && apiKey.isNotEmpty) { + await aiRequestSecretsStorage.writeDefaultApiKey(apiKey); + } else { + await aiRequestSecretsStorage.deleteDefaultApiKey(); + } + model = model.copyWith( + defaultAIModel: AiRequestSecretsStorage.stripApiKeyFromDefaultAiModel( + defaultAi, + ), + ); + } + await prefs.setString(kSharedPrefSettingsKey, model.toString()); } -Future setOnboardingStatusToSharedPrefs( - {required bool isOnboardingComplete}) async { +Future setOnboardingStatusToSharedPrefs({ + required bool isOnboardingComplete, +}) async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(kSharedPrefOnboardingKey, isOnboardingComplete); } diff --git a/lib/services/storage/atomic_file_io.dart b/lib/services/storage/atomic_file_io.dart new file mode 100644 index 0000000000..6a32bad078 --- /dev/null +++ b/lib/services/storage/atomic_file_io.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +Future writeFileAtomic(String path, List bytes) async { + final file = File(path); + final parent = file.parent; + if (!await parent.exists()) { + await parent.create(recursive: true); + } + final tmpPath = '$path.tmp'; + final tmpFile = File(tmpPath); + try { + await tmpFile.writeAsBytes(bytes, flush: true); + if (await file.exists()) { + await file.delete(); + } + await tmpFile.rename(path); + } catch (e, st) { + debugPrint('writeFileAtomic failed for $path: $e\n$st'); + if (await tmpFile.exists()) { + try { + await tmpFile.delete(); + } catch (_) {} + } + rethrow; + } +} + +Future writeJsonAtomic(String path, Map json) async { + final encoded = const JsonEncoder.withIndent(' ').convert(json); + await writeFileAtomic(path, utf8.encode(encoded)); +} + +Future readFileString(String path) async { + final file = File(path); + if (!await file.exists()) { + return null; + } + return file.readAsString(); +} + +Future?> readJsonFile(String path) async { + final raw = await readFileString(path); + if (raw == null || raw.isEmpty) { + return null; + } + final decoded = jsonDecode(raw); + if (decoded is! Map) { + return null; + } + return Map.from(decoded); +} diff --git a/lib/services/storage/storage.dart b/lib/services/storage/storage.dart new file mode 100644 index 0000000000..d25964b0e1 --- /dev/null +++ b/lib/services/storage/storage.dart @@ -0,0 +1,3 @@ +export 'atomic_file_io.dart'; +export 'workspace_paths.dart'; +export 'workspace_storage.dart'; diff --git a/lib/services/storage/workspace_paths.dart b/lib/services/storage/workspace_paths.dart new file mode 100644 index 0000000000..ec37ccf8b5 --- /dev/null +++ b/lib/services/storage/workspace_paths.dart @@ -0,0 +1,17 @@ +import 'package:apidash/consts.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +Future resolveWorkspaceRoot({ + required bool useDesktopPath, + String? desktopPath, +}) async { + if (useDesktopPath) { + if (desktopPath == null || desktopPath.isEmpty) { + return null; + } + return desktopPath; + } + final documents = await getApplicationDocumentsDirectory(); + return p.join(documents.path, kDefaultMobileWorkspaceSubpath); +} diff --git a/lib/services/storage/workspace_storage.dart b/lib/services/storage/workspace_storage.dart new file mode 100644 index 0000000000..52b8f6ffc7 --- /dev/null +++ b/lib/services/storage/workspace_storage.dart @@ -0,0 +1,828 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:apidash/consts.dart'; +import 'package:apidash/utils/file_utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; + +import 'atomic_file_io.dart'; +import 'workspace_paths.dart'; + +Directory? _workspaceRoot; + +String _environmentFileName(String id) => '$id$kJsonFileExtension'; + +String _historyRecordPath(String id) => + p.join(kWorkspaceHistoryDir, '$id$kJsonFileExtension'); + +String _collectionDir(String collectionId) => + p.join(kWorkspaceCollectionsDir, collectionId); + +String _collectionFilePath(String collectionId) => + p.join(_collectionDir(collectionId), kWorkspaceRequestIndexFile); + +String _requestDirRelative(String collectionId, String requestId) => p.join( + _collectionDir(collectionId), + requestId, + ); + +String _requestJsonRelative(String collectionId, String requestId) => p.join( + _requestDirRelative(collectionId, requestId), + kWorkspaceRequestFile, + ); + +String _responseJsonRelative(String collectionId, String requestId) => p.join( + _requestDirRelative(collectionId, requestId), + kWorkspaceResponseFile, + ); + +const Set _kMediaFileTypes = {'image', 'audio', 'video'}; + +String? _contentTypeFromResponseMap(Map response) { + final headers = response['headers']; + if (headers is! Map) { + return null; + } + for (final entry in headers.entries) { + if (entry.key.toString().toLowerCase() == 'content-type') { + return entry.value?.toString(); + } + } + return null; +} + +bool _isBinaryMediaContentType(String? contentType) { + if (contentType == null) { + return false; + } + final value = contentType.split(';').first.trim().toLowerCase(); + final parts = value.split('/'); + if (parts.length != 2 || parts[0].isEmpty || parts[1].isEmpty) { + return false; + } + final type = parts[0]; + final subtype = parts[1]; + if (_kMediaFileTypes.contains(type)) { + return true; + } + if (type == 'application') { + return subtype == 'pdf' || subtype == 'octet-stream'; + } + return false; +} + +String _responseBodyFileName(String? contentType) { + final mimeType = contentType?.split(';').first.trim(); + final ext = getFileExtension(mimeType) ?? 'bin'; + return '$kWorkspaceResponseBodyFilePrefix.$ext'; +} + +Uint8List? _bytesFromJsonList(Object? value) { + if (value is! List) { + return null; + } + return Uint8List.fromList( + value.map((e) => (e as num).toInt()).toList(growable: false), + ); +} + +Future initWorkspaceStorage( + bool initializeUsingPath, + String? workspaceFolderPath, { + bool createIfMissing = false, +}) async { + try { + final rootPath = await resolveWorkspaceRoot( + useDesktopPath: initializeUsingPath, + desktopPath: workspaceFolderPath, + ); + if (rootPath == null) { + return false; + } + final root = Directory(rootPath); + if (!await root.exists()) { + if (initializeUsingPath && !createIfMissing) { + return false; + } + await root.create(recursive: true); + } + _workspaceRoot = root; + await _ensureWorkspaceStructure(root); + debugPrint('Workspace opened at ${root.path}'); + return true; + } catch (e, st) { + debugPrint('initWorkspaceStorage failed: $e\n$st'); + return false; + } +} + +Future _ensureWorkspaceStructure(Directory root) async { + final collectionsRoot = Directory(p.join(root.path, kWorkspaceCollectionsDir)); + if (!await collectionsRoot.exists()) { + await collectionsRoot.create(recursive: true); + } + + final indexFile = File( + p.join( + root.path, + kWorkspaceCollectionsDir, + kWorkspaceCollectionsIndexFile, + ), + ); + // Seed the default collection only for a brand-new workspace (no index file + // yet). Once the index exists we respect it verbatim, including an empty + // list, so a workspace the user has fully emptied is not reseeded on restart. + final indexExists = await indexFile.exists(); + + if (!indexExists) { + await writeJsonAtomic(indexFile.path, { + kWorkspaceCollectionsIndexKey: [ + {kWorkspaceCollectionNameKey: kDefaultCollectionName}, + ], + }); + + final defaultCollectionDir = Directory( + p.join( + root.path, + kWorkspaceCollectionsDir, + kDefaultCollectionName, + ), + ); + if (!await defaultCollectionDir.exists()) { + await defaultCollectionDir.create(recursive: true); + } + + final collectionFile = File( + p.join( + root.path, + kWorkspaceCollectionsDir, + kDefaultCollectionName, + kWorkspaceRequestIndexFile, + ), + ); + if (!await collectionFile.exists()) { + await writeJsonAtomic(collectionFile.path, { + kWorkspaceCollectionNameKey: kDefaultCollectionName, + kWorkspaceRequestsKey: >[], + }); + } + } + + final environmentsDir = Directory(p.join(root.path, kWorkspaceEnvironmentsDir)); + if (!await environmentsDir.exists()) { + await environmentsDir.create(recursive: true); + } + final historyDir = Directory(p.join(root.path, kWorkspaceHistoryDir)); + if (!await historyDir.exists()) { + await historyDir.create(recursive: true); + } + + final envIndexFile = File( + p.join(root.path, kWorkspaceEnvironmentsDir, kWorkspaceEnvironmentIndexFile), + ); + if (!await envIndexFile.exists()) { + await writeJsonAtomic(envIndexFile.path, { + kWorkspaceEnvironmentIdsKey: [kGlobalEnvironmentId], + }); + } + + final globalEnvFile = File( + p.join( + root.path, + kWorkspaceEnvironmentsDir, + _environmentFileName(kGlobalEnvironmentId), + ), + ); + if (!await globalEnvFile.exists()) { + await writeJsonAtomic(globalEnvFile.path, { + 'id': kGlobalEnvironmentId, + 'name': kGlobalEnvironmentName, + 'values': >[], + }); + } + +} + +final workspaceStorage = WorkspaceStorage(); + +class WorkspaceStorage { + WorkspaceStorage(); + + String get rootPath => _root.path; + + Directory get _root { + if (_workspaceRoot == null) { + throw StateError( + 'Workspace not initialized. Call initWorkspaceStorage before using workspaceStorage.', + ); + } + return _workspaceRoot!; + } + + String _path(String relative) => p.join(_root.path, relative); + + List<({String id, String name})> getCollectionsIndex() { + final json = _readJsonSync( + p.join(kWorkspaceCollectionsDir, kWorkspaceCollectionsIndexFile), + ); + if (json == null) { + return []; + } + final entries = json[kWorkspaceCollectionsIndexKey]; + if (entries is! List) { + return []; + } + final result = <({String id, String name})>[]; + for (final item in entries) { + if (item is! Map) { + continue; + } + final name = item[kWorkspaceCollectionNameKey] as String?; + if (name == null || name.trim().isEmpty) { + continue; + } + result.add((id: makeCollectionId(name), name: name)); + } + return result; + } + + Future setCollectionsIndex( + List<({String id, String name})> collections, + ) async { + await writeJsonAtomic( + _path( + p.join(kWorkspaceCollectionsDir, kWorkspaceCollectionsIndexFile), + ), + { + kWorkspaceCollectionsIndexKey: [ + for (final entry in collections) + {kWorkspaceCollectionNameKey: entry.name}, + ], + }, + ); + } + + Map? getCollection(String collectionId) { + final json = _readJsonSync(_collectionFilePath(collectionId)); + if (json == null) { + return null; + } + // The folder name is the authoritative id; inject it so callers never + // depend on an id field persisted inside collection.json. + json[kWorkspaceCollectionIdKey] = collectionId; + return Map.from(json); + } + + Future setCollection( + String collectionId, + Map collectionJson, + ) async { + final dir = Directory(_path(_collectionDir(collectionId))); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + final payload = Map.from(collectionJson) + ..remove(kWorkspaceCollectionIdKey); + await writeJsonAtomic( + _path(_collectionFilePath(collectionId)), + payload, + ); + } + + Future deleteCollection(String collectionId) async { + final dir = Directory(_path(_collectionDir(collectionId))); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + } + + Future renameCollection(String oldId, String newId) async { + if (oldId == newId) { + return; + } + final oldDir = Directory(_path(_collectionDir(oldId))); + if (!await oldDir.exists()) { + return; + } + final newDirPath = _path(_collectionDir(newId)); + final isCaseOnlyRename = + oldDir.path.toLowerCase() == newDirPath.toLowerCase(); + if (!isCaseOnlyRename && await Directory(newDirPath).exists()) { + return; + } + oldDir.renameSync(newDirPath); + } + + List getIds(String collectionId) { + final json = _readJsonSync(_collectionFilePath(collectionId)); + if (json == null) { + return []; + } + final requests = json[kWorkspaceRequestsKey]; + if (requests is! List) { + return []; + } + return [ + for (final item in requests) + if (item is Map && item['id'] != null) item['id'].toString(), + ]; + } + + bool requestExistsOnDisk(String collectionId, String id) { + return File(_path(_requestJsonRelative(collectionId, id))).existsSync(); + } + + Map? getRequestModel(String collectionId, String id) { + final requestJson = _readJsonSync(_requestJsonRelative(collectionId, id)); + if (requestJson == null) { + return null; + } + final merged = Map.from(requestJson); + final responseJson = _readJsonSync(_responseJsonRelative(collectionId, id)); + if (responseJson != null) { + final requestDirPath = _path(_requestDirRelative(collectionId, id)); + merged['httpResponseModel'] = + _inlineResponseBodyFile(requestDirPath, responseJson); + } + return Map.from(_fixBodyBytesForFromJson(merged)); + } + + Future setRequestModel( + String collectionId, + String id, + Map? requestModelJson, { + bool saveMediaAsFiles = false, + }) async { + if (requestModelJson == null) { + await _deleteRequestStorage(collectionId, id); + return; + } + + final payload = Map.from(requestModelJson); + final response = payload.remove('httpResponseModel'); + + final requestDirPath = _path(_requestDirRelative(collectionId, id)); + final requestDir = Directory(requestDirPath); + if (!await requestDir.exists()) { + await requestDir.create(recursive: true); + } + + await writeJsonAtomic( + p.join(requestDirPath, kWorkspaceRequestFile), + payload, + ); + + final responsePath = p.join(requestDirPath, kWorkspaceResponseFile); + if (response is Map) { + await _writeResponseStorage( + requestDirPath, + responsePath, + Map.from(response), + saveMediaAsFiles, + ); + } else { + await _deleteResponseStorage(requestDirPath, responsePath); + } + } + + Future _writeResponseStorage( + String requestDirPath, + String responsePath, + Map response, + bool saveMediaAsFiles, + ) async { + await _deleteExistingResponseBodyFile(requestDirPath, responsePath); + + final contentType = _contentTypeFromResponseMap(response); + final bytes = _bytesFromJsonList(response['bodyBytes']); + final shouldExtract = saveMediaAsFiles && + bytes != null && + bytes.isNotEmpty && + _isBinaryMediaContentType(contentType); + + if (shouldExtract) { + final fileName = _responseBodyFileName(contentType); + await saveFile(p.join(requestDirPath, fileName), bytes); + response.remove('bodyBytes'); + response[kWorkspaceResponseBodyFileKey] = fileName; + } else { + response.remove(kWorkspaceResponseBodyFileKey); + } + + await writeJsonAtomic(responsePath, response); + } + + Future _deleteResponseStorage( + String requestDirPath, + String responsePath, + ) async { + await _deleteExistingResponseBodyFile(requestDirPath, responsePath); + final responseFile = File(responsePath); + if (await responseFile.exists()) { + await responseFile.delete(); + } + } + + Future _deleteExistingResponseBodyFile( + String requestDirPath, + String responsePath, + ) async { + final existing = _readJsonFileSync(responsePath); + final fileName = existing?[kWorkspaceResponseBodyFileKey]; + if (fileName is String && fileName.isNotEmpty) { + final bodyFile = File(p.join(requestDirPath, fileName)); + if (await bodyFile.exists()) { + await bodyFile.delete(); + } + } + } + + /// If [responseJson] points to a separate body file, reads it back into + /// `bodyBytes` so the in-memory model is identical to an inlined response. + Map _inlineResponseBodyFile( + String requestDirPath, + Map responseJson, + ) { + final fileName = responseJson[kWorkspaceResponseBodyFileKey]; + if (fileName is! String || fileName.isEmpty) { + return responseJson; + } + final result = Map.from(responseJson); + result.remove(kWorkspaceResponseBodyFileKey); + try { + final bodyFile = File(p.join(requestDirPath, fileName)); + if (bodyFile.existsSync()) { + result['bodyBytes'] = bodyFile.readAsBytesSync(); + } + } catch (e) { + debugPrint('Failed to read response body file $fileName: $e'); + } + return result; + } + + Future _deleteRequestStorage(String collectionId, String id) async { + final requestDir = Directory(_path(_requestDirRelative(collectionId, id))); + if (await requestDir.exists()) { + await requestDir.delete(recursive: true); + } + } + + Future renameRequest( + String collectionId, + String oldId, + String newId, + ) async { + renameRequestSync(collectionId, oldId, newId); + } + + void renameRequestSync(String collectionId, String oldId, String newId) { + if (oldId == newId) { + return; + } + final oldDir = Directory(_path(_requestDirRelative(collectionId, oldId))); + if (!oldDir.existsSync()) { + return; + } + final newDirPath = _path(_requestDirRelative(collectionId, newId)); + oldDir.renameSync(newDirPath); + final requestFile = File(p.join(newDirPath, kWorkspaceRequestFile)); + if (!requestFile.existsSync()) { + return; + } + final json = _readJsonSync( + p.join( + _requestDirRelative(collectionId, newId), + kWorkspaceRequestFile, + ), + ); + if (json == null) { + return; + } + json['id'] = newId; + unawaited( + writeJsonAtomic(requestFile.path, Map.from(json)), + ); + } + + // --- Environments --- + + List? getEnvironmentIds() { + final json = _readJsonSync( + p.join(kWorkspaceEnvironmentsDir, kWorkspaceEnvironmentIndexFile), + ); + if (json == null) { + return null; + } + final ids = json[kWorkspaceEnvironmentIdsKey]; + if (ids is List) { + return ids.map((e) => e.toString()).toList(); + } + return null; + } + + Future setEnvironmentIds(List? ids) async { + await writeJsonAtomic( + _path(p.join(kWorkspaceEnvironmentsDir, kWorkspaceEnvironmentIndexFile)), + {kWorkspaceEnvironmentIdsKey: ids ?? []}, + ); + } + + Map? getEnvironment(String id) { + final json = _readJsonSync( + p.join(kWorkspaceEnvironmentsDir, _environmentFileName(id)), + ); + if (json == null) { + return null; + } + return Map.from(json); + } + + Future setEnvironment( + String id, + Map? environmentJson, + ) async { + if (environmentJson == null) { + await deleteEnvironment(id); + return; + } + await writeJsonAtomic( + _path(p.join(kWorkspaceEnvironmentsDir, _environmentFileName(id))), + Map.from(environmentJson), + ); + } + + Future deleteEnvironment(String id) async { + final file = File( + _path(p.join(kWorkspaceEnvironmentsDir, _environmentFileName(id))), + ); + if (await file.exists()) { + await file.delete(); + } + } + + Future renameEnvironment(String oldId, String newId) async { + renameEnvironmentSync(oldId, newId); + } + + void renameEnvironmentSync(String oldId, String newId) { + if (oldId == newId) { + return; + } + final oldPath = _path( + p.join(kWorkspaceEnvironmentsDir, _environmentFileName(oldId)), + ); + final newPath = _path( + p.join(kWorkspaceEnvironmentsDir, _environmentFileName(newId)), + ); + final oldFile = File(oldPath); + if (!oldFile.existsSync()) { + return; + } + oldFile.renameSync(newPath); + final json = getEnvironment(newId); + if (json != null) { + json['id'] = newId; + unawaited( + writeJsonAtomic(newPath, Map.from(json)), + ); + } + } + + String _historyMetasPath() => + p.join(kWorkspaceHistoryDir, kWorkspaceHistoryIndexFile); + + Map>? getAllHistoryMetas() { + final json = _readJsonSync(_historyMetasPath()); + if (json == null) { + return null; + } + final metas = json[kWorkspaceHistoryMetasKey]; + if (metas is! Map || metas.isEmpty) { + return null; + } + return Map>.fromEntries( + metas.entries.map((e) { + final value = e.value; + if (value is! Map) { + return MapEntry(e.key.toString(), {}); + } + return MapEntry( + e.key.toString(), + Map.from(value), + ); + }), + ); + } + + Map? getHistoryMeta(String id) { + return getAllHistoryMetas()?[id]; + } + + Future setAllHistoryMetas( + Map>? metas, + ) async { + await writeJsonAtomic( + _path(_historyMetasPath()), + { + kWorkspaceHistoryMetasKey: + metas?.map((k, v) => MapEntry(k, Map.from(v))) ?? + >{}, + }, + ); + } + + Future setHistoryMeta( + String id, + Map? historyMetaJson, + ) async { + if (historyMetaJson == null) { + await deleteHistoryMeta(id); + return; + } + final all = Map>.from( + getAllHistoryMetas() ?? {}, + ); + all[id] = Map.from(historyMetaJson); + await setAllHistoryMetas(all); + } + + Future deleteHistoryMeta(String id) async { + final all = getAllHistoryMetas(); + if (all == null || !all.containsKey(id)) { + return; + } + all.remove(id); + await setAllHistoryMetas(all.isEmpty ? null : all); + } + + Future getHistoryRequest(String id) async { + final json = await readJsonFile(_path(_historyRecordPath(id))); + if (json == null) { + return null; + } + return _fixBodyBytesForFromJson(json); + } + + Future setHistoryRequest( + String id, + Map? historyRequestJson, + ) async { + if (historyRequestJson == null) { + await deleteHistoryRequest(id); + return; + } + await writeJsonAtomic( + _path(_historyRecordPath(id)), + Map.from(historyRequestJson), + ); + } + + Future deleteHistoryRequest(String id) async { + final recordFile = File(_path(_historyRecordPath(id))); + if (await recordFile.exists()) { + await recordFile.delete(); + } + } + + + Future clearAllHistory() async { + final historyDir = Directory(_path(kWorkspaceHistoryDir)); + if (await historyDir.exists()) { + await for (final entity in historyDir.list()) { + if (entity is File) { + final name = p.basename(entity.path); + if (name == kWorkspaceHistoryIndexFile) { + continue; + } + if (name.endsWith(kJsonFileExtension)) { + await entity.delete(); + } + } else if (entity is Directory) { + await entity.delete(recursive: true); + } + } + } + await setAllHistoryMetas(null); + } + + Future clear() async { + final collectionIds = getCollectionsIndex().map((e) => e.id).toList(); + if (collectionIds.isEmpty) { + collectionIds.add(kDefaultCollectionName); + } + for (final collectionId in collectionIds) { + final existing = getCollection(collectionId); + await setCollection(collectionId, { + kWorkspaceCollectionIdKey: collectionId, + kWorkspaceCollectionNameKey: + existing?[kWorkspaceCollectionNameKey] as String? ?? + (collectionId == kDefaultCollectionName + ? kDefaultCollectionName + : collectionId), + kWorkspaceRequestsKey: [], + }); + final collectionDir = Directory(_path(_collectionDir(collectionId))); + if (await collectionDir.exists()) { + await for (final entity in collectionDir.list()) { + if (entity is Directory) { + await entity.delete(recursive: true); + } + } + } + } + + final envIds = getEnvironmentIds() ?? [kGlobalEnvironmentId]; + for (final id in envIds) { + if (id != kGlobalEnvironmentId) { + await deleteEnvironment(id); + } + } + await setEnvironmentIds([kGlobalEnvironmentId]); + final globalJson = getEnvironment(kGlobalEnvironmentId); + if (globalJson != null) { + globalJson['values'] = >[]; + await setEnvironment(kGlobalEnvironmentId, globalJson); + } + + await clearAllHistory(); + } + + Future removeUnused( + String collectionId, { + Set? requestIds, + }) async { + final ids = requestIds ?? getIds(collectionId).toSet(); + final collectionDir = Directory(_path(_collectionDir(collectionId))); + if (await collectionDir.exists()) { + await for (final entity in collectionDir.list()) { + if (entity is Directory) { + final dirName = p.basename(entity.path); + if (!ids.contains(dirName)) { + await entity.delete(recursive: true); + } + } + } + } + + final environmentIds = getEnvironmentIds(); + if (environmentIds != null) { + final envDir = Directory(_path(kWorkspaceEnvironmentsDir)); + if (await envDir.exists()) { + await for (final entity in envDir.list()) { + if (entity is! File || !entity.path.endsWith(kJsonFileExtension)) { + continue; + } + final fileName = p.basenameWithoutExtension(entity.path); + if (fileName == + p.basenameWithoutExtension(kWorkspaceEnvironmentIndexFile)) { + continue; + } + if (!environmentIds.contains(fileName)) { + await entity.delete(); + } + } + } + } + } + + static Map _fixBodyBytesForFromJson(Map json) { + final http = json['httpResponseModel']; + if (http is Map) { + final response = Map.from(http); + final bytes = response['bodyBytes']; + if (bytes is List && bytes is! List) { + response['bodyBytes'] = + bytes.map((e) => (e as num).toInt()).toList(growable: false); + } + return {...json, 'httpResponseModel': response}; + } + return json; + } + + Map? _readJsonSync(String relativePath) { + return _readJsonFileSync(_path(relativePath)); + } + + Map? _readJsonFileSync(String absolutePath) { + final file = File(absolutePath); + if (!file.existsSync()) { + return null; + } + try { + final raw = file.readAsStringSync(); + if (raw.isEmpty) { + return null; + } + final decoded = jsonDecode(raw); + if (decoded is! Map) { + return null; + } + return Map.from(decoded); + } catch (e) { + debugPrint('_readJsonFileSync failed for $absolutePath: $e'); + return null; + } + } +} diff --git a/lib/services/workspace_service.dart b/lib/services/workspace_service.dart new file mode 100644 index 0000000000..1331a0df36 --- /dev/null +++ b/lib/services/workspace_service.dart @@ -0,0 +1,50 @@ +import 'package:apidash/consts.dart'; +import 'package:apidash/providers/providers.dart'; +import 'package:apidash/services/storage/workspace_storage.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; + +Future activateWorkspace( + WidgetRef ref, + String path, { + bool createIfMissing = true, +}) async { + final opened = await initWorkspaceStorage( + kIsDesktop, + path, + createIfMissing: createIfMissing, + ); + if (!opened) { + return false; + } + + try { + ref.read(autoSaveNotifierProvider.notifier).cancelPending(); + } catch (_) { + // ignore + } + final existingName = savedWorkspaceNameForPath( + ref.read(settingsProvider).savedWorkspaces, + path, + ); + await ref.read(settingsProvider.notifier).rememberWorkspace( + path: path, + name: existingName ?? p.basename(path), + ); + _invalidateWorkspaceProviders(ref); + return true; +} + +void _invalidateWorkspaceProviders(WidgetRef ref) { + ref.invalidate(collectionCatalogProvider); + ref.invalidate(activeCollectionProvider); + ref.invalidate(environmentsStateNotifierProvider); + ref.invalidate(historyMetaStateNotifier); + ref.invalidate(selectedIdStateProvider); + ref.invalidate(selectedCollectionIdStateProvider); + ref.invalidate(selectedEnvironmentIdStateProvider); + ref.invalidate(selectedHistoryIdStateProvider); + ref.invalidate(selectedHistoryRequestModelProvider); + ref.invalidate(requestSequenceProvider); + ref.invalidate(expandedCollectionIdsProvider); +} diff --git a/lib/utils/envvar_utils.dart b/lib/utils/envvar_utils.dart index f817dd967c..9d20537019 100644 --- a/lib/utils/envvar_utils.dart +++ b/lib/utils/envvar_utils.dart @@ -57,11 +57,12 @@ String? substituteVariables( HttpRequestModel substituteHttpRequestModel( HttpRequestModel httpRequestModel, Map> envMap, - String? activeEnvironmentId, -) { + String? activeEnvironmentId, { + String globalEnvironmentId = kGlobalEnvironmentId, +}) { final Map combinedEnvVarMap = {}; final activeEnv = envMap[activeEnvironmentId] ?? []; - final globalEnv = envMap[kGlobalEnvironmentId] ?? []; + final globalEnv = envMap[globalEnvironmentId] ?? []; for (var variable in globalEnv) { combinedEnvVarMap[variable.key] = variable.value; @@ -254,9 +255,11 @@ AuthModel? substituteAuthModel( } List? getEnvironmentTriggerSuggestions( - String query, - Map> envMap, - String? activeEnvironmentId) { + String query, + Map> envMap, + String? activeEnvironmentId, { + String globalEnvironmentId = kGlobalEnvironmentId, +}) { final suggestions = []; final Set addedVariableKeys = {}; @@ -271,11 +274,11 @@ List? getEnvironmentTriggerSuggestions( } } - envMap[kGlobalEnvironmentId]?.forEach((variable) { + envMap[globalEnvironmentId]?.forEach((variable) { if ((query.isEmpty || variable.key.contains(query)) && !addedVariableKeys.contains(variable.key)) { suggestions.add(EnvironmentVariableSuggestion( - environmentId: kGlobalEnvironmentId, variable: variable)); + environmentId: globalEnvironmentId, variable: variable)); addedVariableKeys.add(variable.key); } }); @@ -284,9 +287,11 @@ List? getEnvironmentTriggerSuggestions( } EnvironmentVariableSuggestion getVariableStatus( - String key, - Map> envMap, - String? activeEnvironmentId) { + String key, + Map> envMap, + String? activeEnvironmentId, { + String globalEnvironmentId = kGlobalEnvironmentId, +}) { if (activeEnvironmentId != null) { final variable = envMap[activeEnvironmentId]!.firstWhereOrNull((v) => v.key == key); @@ -300,10 +305,10 @@ EnvironmentVariableSuggestion getVariableStatus( } final globalVariable = - envMap[kGlobalEnvironmentId]?.firstWhereOrNull((v) => v.key == key); + envMap[globalEnvironmentId]?.firstWhereOrNull((v) => v.key == key); if (globalVariable != null) { return EnvironmentVariableSuggestion( - environmentId: kGlobalEnvironmentId, + environmentId: globalEnvironmentId, variable: globalVariable, isUnknown: false, ); diff --git a/lib/utils/file_utils.dart b/lib/utils/file_utils.dart index 0721784441..b98a5d1bad 100644 --- a/lib/utils/file_utils.dart +++ b/lib/utils/file_utils.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:file_selector/file_selector.dart'; +import 'package:intl/intl.dart'; import 'package:path/path.dart' as p; import 'package:mime_dart/mime_dart.dart'; import 'package:uuid/uuid.dart'; @@ -12,6 +13,84 @@ String getNewUuid() { return uuid.v1(); } +final _storageIdSuffixPattern = RegExp(r'_[0-9a-f]{8}$'); + +String _slugifyStorageName(String name) { + final trimmed = name.trim().toLowerCase(); + if (trimmed.isEmpty) { + return 'untitled'; + } + final slug = trimmed + .replaceAll(RegExp(r'[/\\:*?"<>|]'), '') + .replaceAll(RegExp(r'\s+'), '-') + .replaceAll(RegExp(r'-+'), '-') + .replaceAll(RegExp(r'^-|-$'), ''); + return slug.isEmpty ? 'untitled' : slug; +} + +String makeStorageId(String name, {String? suffix}) { + final stableSuffix = + suffix ?? getNewUuid().replaceAll('-', '').substring(0, 8); + return '${_slugifyStorageName(name)}_$stableSuffix'; +} + +String _sanitizeForFileName(String name) { + final cleaned = name + .trim() + .replaceAll(RegExp(r'[/\\:*?"<>|]'), '-') + .replaceAll(RegExp(r'-+'), '-') + .replaceAll(RegExp(r'\s+'), ' ') + .replaceAll(RegExp(r'^-+|-+$'), '') + .trim(); + return cleaned.isEmpty ? 'request' : cleaned; +} + +String makeHistoryId({ + required DateTime timeStamp, + required String name, +}) { + final label = _sanitizeForFileName(name); + final stamp = DateFormat('yyyy-MM-dd hh.mm.ss a').format(timeStamp); + return '$label $stamp'; +} + +String makeCollectionId(String name) { + final cleaned = name.trim(); + return cleaned.isEmpty ? 'untitled' : cleaned; +} + +final _illegalFileNameChars = RegExp(r'[/\\:*?"<>|]'); + +bool collectionNameHasIllegalChars(String name) => + _illegalFileNameChars.hasMatch(name); + +String? storageIdSuffix(String id) { + if (!_storageIdSuffixPattern.hasMatch(id)) { + return null; + } + return id.substring(id.length - 8); +} + +String renameStorageId(String oldId, String newName) { + final suffix = storageIdSuffix(oldId); + if (suffix == null) { + return oldId; + } + return makeStorageId(newName, suffix: suffix); +} + +String renameEnvironmentStorageId(String currentId, String name) { + final renamed = renameStorageId(currentId, name); + if (renamed != currentId) { + return renamed; + } + if (storageIdSuffix(currentId) != null) { + return currentId; + } + final trimmed = name.trim(); + return trimmed.isEmpty ? currentId : makeStorageId(trimmed); +} + String? getFileExtension(String? mimeType) { if (mimeType == null) { return null; diff --git a/lib/utils/save_utils.dart b/lib/utils/save_utils.dart index 6370ae62d5..2e286096e3 100644 --- a/lib/utils/save_utils.dart +++ b/lib/utils/save_utils.dart @@ -2,8 +2,6 @@ import 'package:apidash_design_system/apidash_design_system.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:apidash/utils/utils.dart'; -import 'package:apidash/widgets/widgets.dart'; - Future saveCollection( Map data, ScaffoldMessengerState sm, @@ -50,16 +48,3 @@ Future saveToDownloads( sm.hideCurrentSnackBar(); sm.showSnackBar(getSnackBar(message, small: false)); } - -Future saveAndShowDialog( - BuildContext context, { - AsyncCallback? onSave, -}) async { - final overlayWidget = OverlayWidgetTemplate(context: context); - overlayWidget.show(widget: const SavingOverlay(saveCompleted: false)); - await onSave?.call(); - overlayWidget.hide(); - overlayWidget.show(widget: const SavingOverlay(saveCompleted: true)); - await Future.delayed(const Duration(seconds: 1)); - overlayWidget.hide(); -} diff --git a/lib/widgets/card_sidebar_environment.dart b/lib/widgets/card_sidebar_environment.dart index f0a5d2ea89..e83766e5ab 100644 --- a/lib/widgets/card_sidebar_environment.dart +++ b/lib/widgets/card_sidebar_environment.dart @@ -2,7 +2,8 @@ import 'package:apidash_design_system/apidash_design_system.dart'; import 'package:flutter/material.dart'; import 'package:apidash/consts.dart'; import 'package:apidash/utils/utils.dart'; -import 'menu_item_card.dart'; +import 'package:apidash/widgets/menu_item_card.dart'; +import 'package:apidash/widgets/env_menu_helper.dart'; class SidebarEnvironmentCard extends StatelessWidget { const SidebarEnvironmentCard({ @@ -70,16 +71,14 @@ class SidebarEnvironmentCard extends StatelessWidget { focusColor: colorVariant, onTap: inEditMode ? null : onTap, // onSecondaryTap: onSecondaryTap, - onSecondaryTapUp: (isGlobal) - ? null - : (details) { - onSecondaryTap?.call(); - showItemCardMenu(context, details, onMenuSelected); - }, + onSecondaryTapUp: (details) { + onSecondaryTap?.call(); + showItemCardMenu(context, details, envMenuOptions(id), onMenuSelected); + }, child: Padding( - padding: EdgeInsets.only( + padding: const EdgeInsets.only( left: 6, - right: isSelected ? 6 : 10, + right: 6, top: 5, bottom: 5, ), @@ -89,7 +88,7 @@ class SidebarEnvironmentCard extends StatelessWidget { children: [ kHSpacer4, Expanded( - child: inEditMode + child: (inEditMode && !isGlobal) ? TextFormField( key: ValueKey("$id-name"), initialValue: name, diff --git a/lib/widgets/card_sidebar_request.dart b/lib/widgets/card_sidebar_request.dart index 372d88b5b9..3dca0c8aa1 100644 --- a/lib/widgets/card_sidebar_request.dart +++ b/lib/widgets/card_sidebar_request.dart @@ -76,7 +76,12 @@ class SidebarRequestCard extends StatelessWidget { // onDoubleTap: inEditMode ? null : onDoubleTap, onSecondaryTapUp: (details) { onSecondaryTap?.call(); - showItemCardMenu(context, details, onMenuSelected); + showItemCardMenu( + context, + details, + ItemMenuOption.values, + onMenuSelected, + ); }, child: Padding( padding: EdgeInsets.only( diff --git a/lib/widgets/env_menu_helper.dart b/lib/widgets/env_menu_helper.dart new file mode 100644 index 0000000000..c8bee84586 --- /dev/null +++ b/lib/widgets/env_menu_helper.dart @@ -0,0 +1,9 @@ +import 'package:apidash/widgets/menu_item_card.dart'; +import 'package:apidash/consts.dart'; + +List envMenuOptions(String environmentId) { + if (environmentId == kGlobalEnvironmentId) { + return const [ItemMenuOption.duplicate]; + } + return ItemMenuOption.values; +} diff --git a/lib/widgets/field_cell_obscurable.dart b/lib/widgets/field_cell_obscurable.dart index 253170f5c8..7a5540e180 100644 --- a/lib/widgets/field_cell_obscurable.dart +++ b/lib/widgets/field_cell_obscurable.dart @@ -29,11 +29,10 @@ class ObscurableCellField extends HookWidget { color: clrScheme.onSurface, ), obscureText: obscureText.value, - decoration: InputDecoration( - hintStyle: kCodeStyle.copyWith( - color: clrScheme.outlineVariant, - ), + decoration: getTextFieldInputDecoration( + clrScheme, hintText: hintText, + hintTextFontSize: Theme.of(context).textTheme.bodySmall?.fontSize, suffixIcon: IconButton( padding: kP4, icon: Icon( @@ -45,17 +44,6 @@ class ObscurableCellField extends HookWidget { obscureText.value = !obscureText.value; }, ), - contentPadding: const EdgeInsets.only(bottom: 12), - focusedBorder: UnderlineInputBorder( - borderSide: BorderSide( - color: clrScheme.outline, - ), - ), - enabledBorder: UnderlineInputBorder( - borderSide: BorderSide( - color: clrScheme.surfaceContainerHighest, - ), - ), ), onChanged: onChanged, ); diff --git a/lib/widgets/menu_item_card.dart b/lib/widgets/menu_item_card.dart index e31a68b468..00e7ee491b 100644 --- a/lib/widgets/menu_item_card.dart +++ b/lib/widgets/menu_item_card.dart @@ -5,6 +5,7 @@ class ItemCardMenu extends StatelessWidget { const ItemCardMenu({ super.key, this.onSelected, + this.availableOptions, this.child, this.offset = Offset.zero, this.splashRadius = 14, @@ -16,11 +17,12 @@ class ItemCardMenu extends StatelessWidget { final double splashRadius; final String? tooltip; final ShapeBorder? shape; - final Function(ItemMenuOption)? onSelected; + final List? availableOptions; @override Widget build(BuildContext context) { + final options = availableOptions ?? ItemMenuOption.values; return PopupMenuButton( tooltip: tooltip, padding: EdgeInsets.zero, @@ -29,7 +31,7 @@ class ItemCardMenu extends StatelessWidget { offset: offset, onSelected: onSelected, shape: shape, - itemBuilder: (BuildContext context) => ItemMenuOption.values + itemBuilder: (BuildContext context) => options .map>( (e) => PopupMenuItem( value: e, @@ -46,6 +48,7 @@ class ItemCardMenu extends StatelessWidget { Future showItemCardMenu( BuildContext context, TapUpDetails details, + List availableOptions, Function(ItemMenuOption)? onSelected, ) async { showMenu( @@ -56,7 +59,7 @@ Future showItemCardMenu( details.globalPosition.dx, details.globalPosition.dy, ), - items: ItemMenuOption.values + items: availableOptions .map>( (e) => PopupMenuItem( onTap: () => onSelected?.call(e), diff --git a/lib/widgets/popup_menu_workspace.dart b/lib/widgets/popup_menu_workspace.dart new file mode 100644 index 0000000000..212e031450 --- /dev/null +++ b/lib/widgets/popup_menu_workspace.dart @@ -0,0 +1,52 @@ +import 'package:apidash_core/apidash_core.dart'; +import 'package:apidash_design_system/apidash_design_system.dart'; +import 'package:flutter/material.dart'; +import '../consts.dart'; +import '../models/models.dart'; + +const kWorkspaceMenuOpenSentinel = '__open_workspace__'; + +class WorkspacePopupMenu extends StatelessWidget { + const WorkspacePopupMenu({ + super.key, + required this.currentLabel, + required this.workspaces, + required this.onPathSelected, + required this.onOpenWorkspace, + this.width, + }); + + final String currentLabel; + final List workspaces; + final void Function(String path) onPathSelected; + final VoidCallback onOpenWorkspace; + final double? width; + + @override + Widget build(BuildContext context) { + final menuWidth = width ?? (context.isCompactWindow ? 100.0 : 130.0); + final items = <(String, String)>[ + for (final w in workspaces) + (w.path, w.name.clip(30)), + (kWorkspaceMenuOpenSentinel, kLabelOpenWorkspaceMenu), + ]; + + return ADPopupMenu( + value: currentLabel.clip(30), + values: items, + width: menuWidth, + tooltip: kLabelSelectWorkspace, + isOutlined: true, + onChanged: (value) { + if (value == null) { + return; + } + if (value == kWorkspaceMenuOpenSentinel) { + onOpenWorkspace(); + } else { + onPathSelected(value); + } + }, + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 85e56b3308..afa4c70416 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -45,6 +45,7 @@ export 'overlay_widget.dart'; export 'popup_menu_api_type.dart'; export 'popup_menu_codegen.dart'; export 'popup_menu_env.dart'; +export 'popup_menu_workspace.dart'; export 'popup_menu_history.dart'; export 'popup_menu_uri.dart'; export 'previewer.dart'; diff --git a/lib/widgets/workspace_selector.dart b/lib/widgets/workspace_selector.dart index 3bf9828a2f..c2ddd70ee0 100644 --- a/lib/widgets/workspace_selector.dart +++ b/lib/widgets/workspace_selector.dart @@ -12,7 +12,7 @@ class WorkspaceSelector extends HookWidget { this.onCancel, }); - final Future Function(String)? onContinue; + final Future Function(String path)? onContinue; final Future Function()? onCancel; @override diff --git a/pubspec.yaml b/pubspec.yaml index 4a4ceb87d2..28e71dfb98 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,10 +33,10 @@ dependencies: flutter_portal: ^1.1.4 flutter_riverpod: ^3.2.1 flutter_svg: ^2.2.4 + flutter_secure_storage: ^9.2.4 fvp: ^0.35.2 highlight: ^0.7.0 highlighter: ^0.1.1 - hive_ce_flutter: ^2.3.4 hooks_riverpod: ^3.2.1 intl: ^0.20.2 jinja: ^0.6.5 diff --git a/test/dashbot/core/providers/dashbot_active_route_provider_test.dart b/test/dashbot/core/providers/dashbot_active_route_provider_test.dart index 5c1094a8f6..9ff718b86c 100644 --- a/test/dashbot/core/providers/dashbot_active_route_provider_test.dart +++ b/test/dashbot/core/providers/dashbot_active_route_provider_test.dart @@ -3,7 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/legacy.dart'; import 'package:apidash/dashbot/routes/routes.dart'; import 'package:apidash/models/models.dart'; -import 'package:apidash/providers/collection_providers.dart'; +import 'package:apidash/providers/active_collection_providers.dart'; import 'package:apidash_core/apidash_core.dart'; import '../../../providers/helpers.dart'; diff --git a/test/dashbot/features/chat/viewmodel/chat_viewmodel_ai_flow_test.dart b/test/dashbot/features/chat/viewmodel/chat_viewmodel_ai_flow_test.dart index e8bdb6eafb..ace019f7cb 100644 --- a/test/dashbot/features/chat/viewmodel/chat_viewmodel_ai_flow_test.dart +++ b/test/dashbot/features/chat/viewmodel/chat_viewmodel_ai_flow_test.dart @@ -8,7 +8,7 @@ import 'package:apidash/models/models.dart'; import 'package:apidash/dashbot/services/agent/prompt_builder.dart'; import 'package:apidash/providers/settings_providers.dart'; import 'package:apidash_core/apidash_core.dart'; -import 'package:apidash/providers/collection_providers.dart'; +import 'package:apidash/providers/active_collection_providers.dart'; import '../../../../providers/helpers.dart'; /// AI-enabled flow tests for ChatViewmodel. @@ -55,7 +55,7 @@ void main() { late _PromptCaptureBuilder promptCapture; setUp(() async { - await testSetUpTempDirForHive(); + await testSetUpWorkspaceStorage(); }); // Helper to obtain a default PromptBuilder by reading the real provider in a temp container diff --git a/test/dashbot/features/chat/viewmodel/chat_viewmodel_test.dart b/test/dashbot/features/chat/viewmodel/chat_viewmodel_test.dart index c4d824bfef..be2abc16d5 100644 --- a/test/dashbot/features/chat/viewmodel/chat_viewmodel_test.dart +++ b/test/dashbot/features/chat/viewmodel/chat_viewmodel_test.dart @@ -29,7 +29,7 @@ void main() { late MockChatRemoteRepository mockRepo; setUp(() async { - await testSetUpTempDirForHive(); + await testSetUpWorkspaceStorage(); mockRepo = MockChatRemoteRepository(); container = createContainer( @@ -38,6 +38,7 @@ void main() { selectedRequestModelProvider.overrideWith((ref) => null), ], ); + await ensureCollectionReady(container); }); group('ChatViewmodel Basic Tests', () { @@ -1225,7 +1226,7 @@ paths: overrides: [ chatRepositoryProvider.overrideWith((ref) => mockRepo), selectedRequestModelProvider.overrideWith((ref) => mockRequest), - // We don't override collectionStateNotifierProvider, so it may fail + // We don't override activeCollectionProvider, so it may fail // but the routing logic should be triggered ], ); diff --git a/test/dashbot/pages/dashbot_chat_page_test.dart b/test/dashbot/pages/dashbot_chat_page_test.dart index a63aebf0c4..f6e713e788 100644 --- a/test/dashbot/pages/dashbot_chat_page_test.dart +++ b/test/dashbot/pages/dashbot_chat_page_test.dart @@ -3,7 +3,7 @@ import 'package:apidash/dashbot/models/models.dart'; import 'package:apidash/dashbot/pages/pages.dart'; import 'package:apidash/dashbot/providers/providers.dart'; import 'package:apidash/dashbot/widgets/widgets.dart'; -import 'package:apidash/providers/collection_providers.dart'; +import 'package:apidash/providers/active_collection_providers.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; diff --git a/test/dashbot/pages/dashbot_home_page_test.dart b/test/dashbot/pages/dashbot_home_page_test.dart index 2400fc2bc6..5e8b8f3f93 100644 --- a/test/dashbot/pages/dashbot_home_page_test.dart +++ b/test/dashbot/pages/dashbot_home_page_test.dart @@ -4,7 +4,7 @@ import 'package:apidash/dashbot/providers/providers.dart'; import 'package:apidash/dashbot/routes/routes.dart'; import 'package:apidash/dashbot/widgets/widgets.dart'; import 'package:apidash/models/request_model.dart'; -import 'package:apidash/providers/collection_providers.dart'; +import 'package:apidash/providers/active_collection_providers.dart'; import 'package:apidash_core/apidash_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; diff --git a/test/dashbot/providers/service_providers_test.dart b/test/dashbot/providers/service_providers_test.dart index 582a62c44b..7753eeb23e 100644 --- a/test/dashbot/providers/service_providers_test.dart +++ b/test/dashbot/providers/service_providers_test.dart @@ -428,10 +428,10 @@ void main() { late ProviderContainer realContainer; setUpAll(() async { - await testSetUpForHive(); + await testSetUpWorkspaceStorage(); }); - setUp(() { + setUp(() async { realContainer = createContainer( overrides: [ selectedRequestModelProvider.overrideWith((ref) { @@ -449,6 +449,7 @@ void main() { ), ], ); + await ensureCollectionReady(realContainer); }); tearDown(() { diff --git a/test/dashbot/utils/show_dashbot_test.dart b/test/dashbot/utils/show_dashbot_test.dart index eb9e4f409a..47b3428fe6 100644 --- a/test/dashbot/utils/show_dashbot_test.dart +++ b/test/dashbot/utils/show_dashbot_test.dart @@ -1,7 +1,7 @@ import 'package:apidash/dashbot/providers/providers.dart'; import 'package:apidash/dashbot/utils/utils.dart'; import 'package:apidash/dashbot/dashbot_dashboard.dart'; -import 'package:apidash/providers/collection_providers.dart'; +import 'package:apidash/providers/active_collection_providers.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/misc.dart'; diff --git a/test/dashbot/widgets/dashbot_task_buttons_test.dart b/test/dashbot/widgets/dashbot_task_buttons_test.dart index 84957102ad..ed4a2bffd6 100644 --- a/test/dashbot/widgets/dashbot_task_buttons_test.dart +++ b/test/dashbot/widgets/dashbot_task_buttons_test.dart @@ -4,7 +4,7 @@ import 'package:apidash/dashbot/providers/chat_viewmodel.dart'; import 'package:apidash/dashbot/providers/dashbot_window_notifier.dart'; import 'package:apidash/dashbot/widgets/dashbot_task_buttons.dart'; import 'package:apidash/models/request_model.dart'; -import 'package:apidash/providers/collection_providers.dart'; +import 'package:apidash/providers/active_collection_providers.dart'; import 'package:apidash_core/apidash_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; diff --git a/test/models/settings_model_test.dart b/test/models/settings_model_test.dart index 0ea7746a6c..21d9b70895 100644 --- a/test/models/settings_model_test.dart +++ b/test/models/settings_model_test.dart @@ -33,10 +33,12 @@ void main() { "defaultUriScheme": "http", "defaultCodeGenLang": "curl", "saveResponses": true, + "saveMediaResponsesAsFiles": false, "promptBeforeClosing": true, "activeEnvironmentId": null, "historyRetentionPeriod": "oneWeek", "workspaceFolderPath": null, + "savedWorkspaces": [], "isSSLDisabled": true, "isDashBotEnabled": true, "defaultAIModel": {"model": "llama"} @@ -103,10 +105,12 @@ void main() { "defaultUriScheme": "http", "defaultCodeGenLang": "curl", "saveResponses": true, + "saveMediaResponsesAsFiles": false, "promptBeforeClosing": true, "activeEnvironmentId": null, "historyRetentionPeriod": "oneWeek", "workspaceFolderPath": null, + "savedWorkspaces": [], "isSSLDisabled": true, "isDashBotEnabled": true, "defaultAIModel": { @@ -119,4 +123,15 @@ void main() { test('Testing hashcode', () { expect(sm.hashCode, greaterThan(0)); }); + + test('saveMediaResponsesAsFiles defaults to false and round-trips', () { + expect(const SettingsModel().saveMediaResponsesAsFiles, isFalse); + final updated = sm.copyWith(saveMediaResponsesAsFiles: true); + expect(updated.saveMediaResponsesAsFiles, isTrue); + expect(updated.toJson()["saveMediaResponsesAsFiles"], isTrue); + expect( + SettingsModel.fromJson(updated.toJson()).saveMediaResponsesAsFiles, + isTrue, + ); + }); } diff --git a/test/providers/collection_providers_test.dart b/test/providers/collection_providers_test.dart index 8fc1779985..329f30e064 100644 --- a/test/providers/collection_providers_test.dart +++ b/test/providers/collection_providers_test.dart @@ -12,7 +12,7 @@ import 'helpers.dart'; void main() async { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() async { - await testSetUpTempDirForHive(); + await testSetUpWorkspaceStorage(); }); testWidgets( @@ -20,7 +20,7 @@ void main() async { (WidgetTester tester) async { // Set up the test environment final container = createContainer(); - final notifier = container.read(collectionStateNotifierProvider.notifier); + final notifier = container.read(activeCollectionProvider.notifier); // Ensure the initial request is a GET request with no body final id = notifier.state!.entries.first.key; @@ -60,7 +60,8 @@ void main() async { HttpOverrides.global = null; //enable networking in flutter_test final container = createContainer(); - final notifier = container.read(collectionStateNotifierProvider.notifier); + await ensureCollectionReady(container); + final notifier = container.read(activeCollectionProvider.notifier); const model = HttpRequestModel( url: 'https://sse-demo.netlify.app/sse', @@ -119,13 +120,14 @@ void main() async { }); }); - group('CollectionStateNotifier Auth Tests', () { + group('ActiveCollectionNotifier Auth Tests', () { late ProviderContainer container; - late CollectionStateNotifier notifier; + late ActiveCollectionNotifier notifier; - setUp(() { + setUp(() async { container = createContainer(); - notifier = container.read(collectionStateNotifierProvider.notifier); + await ensureCollectionReady(container); + notifier = container.read(activeCollectionProvider.notifier); }); test('should update request with basic authentication', () { @@ -433,7 +435,7 @@ void main() async { }); test('should save and load auth data correctly', () async { - final notifier = container.read(collectionStateNotifierProvider.notifier); + final notifier = container.read(activeCollectionProvider.notifier); final id = notifier.state!.entries.first.key; const jwtAuth = AuthJwtModel( @@ -449,20 +451,31 @@ void main() async { const authModel = AuthModel(type: APIAuthType.jwt, jwt: jwtAuth); notifier.update(id: id, authModel: authModel); + expect( + notifier.getRequestModel(id)?.httpRequestModel?.authModel?.type, + APIAuthType.jwt, + ); + final collectionId = container.read(selectedCollectionIdStateProvider)!; + final ids = container.read(requestSequenceProvider); + container.read(collectionCatalogProvider.notifier).syncRequests( + collectionId, + notifier.summariesForSequence(collectionId, ids), + ); + await container + .read(collectionCatalogProvider.notifier) + .saveCollections(); await notifier.saveData(); // Create new container and load data late ProviderContainer newContainer; try { newContainer = ProviderContainer(); + await ensureCollectionReady(newContainer); - // Wait for the container to initialize by accessing the provider final newNotifier = newContainer.read( - collectionStateNotifierProvider.notifier, + activeCollectionProvider.notifier, ); - - // Give some time for the microtask in the constructor to complete - await Future.delayed(const Duration(milliseconds: 10)); + newNotifier.loadRequest(id); final loadedRequest = newNotifier.getRequestModel(id); @@ -752,13 +765,14 @@ void main() async { }); }); - group('CollectionStateNotifier Scripting Tests', () { + group('ActiveCollectionNotifier Scripting Tests', () { late ProviderContainer container; - late CollectionStateNotifier notifier; + late ActiveCollectionNotifier notifier; - setUp(() { + setUp(() async { container = createContainer(); - notifier = container.read(collectionStateNotifierProvider.notifier); + await ensureCollectionReady(container); + notifier = container.read(activeCollectionProvider.notifier); }); test('should update request with pre-request script', () { @@ -944,18 +958,26 @@ void main() async { preRequestScript: preRequestScript, postRequestScript: postResponseScript, ); + final collectionId = container.read(selectedCollectionIdStateProvider)!; + final ids = container.read(requestSequenceProvider); + container.read(collectionCatalogProvider.notifier).syncRequests( + collectionId, + notifier.summariesForSequence(collectionId, ids), + ); + await container + .read(collectionCatalogProvider.notifier) + .saveCollections(); await notifier.saveData(); // Create new container and load data late ProviderContainer newContainer; try { newContainer = ProviderContainer(); + await ensureCollectionReady(newContainer); final newNotifier = newContainer.read( - collectionStateNotifierProvider.notifier, + activeCollectionProvider.notifier, ); - - // Give some time for the microtask in the constructor to complete - await Future.delayed(const Duration(milliseconds: 10)); + newNotifier.loadRequest(id); final loadedRequest = newNotifier.getRequestModel(id); @@ -1108,6 +1130,7 @@ void main() async { 'should handle script updates without affecting other request properties', () { final id = notifier.state!.entries.first.key; + notifier.loadRequest(id); // First set up a complete request notifier.update( @@ -1123,19 +1146,21 @@ void main() async { description: 'A test request with scripts', ); - final beforeRequest = notifier.getRequestModel(id); + final activeId = container.read(selectedIdStateProvider)!; + final beforeRequest = notifier.getRequestModel(activeId); + expect(beforeRequest, isNotNull); // Now update only scripts const newPreScript = 'ad.console.log("Updated pre-script");'; const newPostScript = 'ad.console.log("Updated post-script");'; notifier.update( - id: id, + id: activeId, preRequestScript: newPreScript, postRequestScript: newPostScript, ); - final afterRequest = notifier.getRequestModel(id); + final afterRequest = notifier.getRequestModel(activeId); // Verify scripts were updated expect(afterRequest?.preRequestScript, equals(newPreScript)); diff --git a/test/providers/helpers.dart b/test/providers/helpers.dart index 0655736729..98f677240f 100644 --- a/test/providers/helpers.dart +++ b/test/providers/helpers.dart @@ -1,5 +1,7 @@ import 'dart:io'; -import 'package:apidash/services/hive_services.dart'; + +import 'package:apidash/providers/providers.dart'; +import 'package:apidash/services/services.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/misc.dart'; @@ -12,50 +14,96 @@ ProviderContainer createContainer({ List overrides = const [], List? observers, }) { - // Create a ProviderContainer, and optionally allow specifying parameters. final container = ProviderContainer( parent: parent, overrides: overrides, observers: observers, ); - // When the test ends, dispose the container. addTearDown(container.dispose); return container; } -Future testSetUpForHive() async { - // override path_provider methodCall to point - // path to temporary location for all unit tests - const MethodChannel channel = MethodChannel( - 'plugins.flutter.io/path_provider', - ); - +Future _mockPathProvider( + Future Function() createTempDir, +) async { + const channel = MethodChannel('plugins.flutter.io/path_provider'); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - return './test-hive-storage/'; - }); - - await initHiveBoxes(false, null); - // await deleteHiveBoxes(); - // await openHiveBoxes(); + if (methodCall.method == 'getApplicationDocumentsDirectory') { + final tempDir = await createTempDir(); + return tempDir.path; + } + return null; + }); } -Future testSetUpTempDirForHive() async { - const MethodChannel channel = MethodChannel( - 'plugins.flutter.io/path_provider', - ); +Future _mockSecureStorage() async { + const channel = MethodChannel('plugins.it_nomads.com/flutter_secure_storage'); + final store = {}; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - if (methodCall.method == 'getApplicationDocumentsDirectory') { - // Create a mock app doc directory for testing - Directory tempDir = await Directory.systemTemp.createTemp( - 'mock_app_doc_dir', - ); - return tempDir.path; // Return the path to the mock directory - } + switch (methodCall.method) { + case 'read': + return store[methodCall.arguments['key'] as String]; + case 'write': + store[methodCall.arguments['key'] as String] = + methodCall.arguments['value'] as String; + return null; + case 'delete': + store.remove(methodCall.arguments['key'] as String); + return null; + case 'deleteAll': + store.clear(); return null; - }); - await initHiveBoxes(false, null); + case 'readAll': + return Map.from(store); + default: + return null; + } + }); +} + +/// Initializes an isolated filesystem workspace for unit/widget tests. +Future testSetUpWorkspaceStorage() async { + await _mockSecureStorage(); + await _mockPathProvider( + () => Directory.systemTemp.createTemp('apidash_test_workspace_'), + ); + await initWorkspaceStorage(false, null); +} + +/// Waits until collection providers finish their async bootstrap microtask. +Future ensureCollectionReady( + ProviderContainer container, [ + WidgetTester? tester, +]) async { + if (tester != null) { + await tester.pump(); + } else { + await Future.delayed(Duration.zero); + } + for (var i = 0; i < 100; i++) { + final state = container.read(activeCollectionProvider); + if (state != null) { + if (state.isEmpty) { + final ids = container.read(requestSequenceProvider); + final notifier = container.read(activeCollectionProvider.notifier); + for (final id in ids) { + notifier.loadRequest(id); + } + } + if (container.read(activeCollectionProvider)!.isNotEmpty || + container.read(requestSequenceProvider).isEmpty) { + return; + } + } + if (tester != null) { + await tester.pump(const Duration(milliseconds: 10)); + } else { + await Future.delayed(const Duration(milliseconds: 10)); + } + } + throw StateError('activeCollectionProvider did not initialize'); } diff --git a/test/providers/ui_providers_test.dart b/test/providers/ui_providers_test.dart index 27c7e84b16..05a4409cbd 100644 --- a/test/providers/ui_providers_test.dart +++ b/test/providers/ui_providers_test.dart @@ -3,7 +3,6 @@ import 'package:apidash/providers/providers.dart'; import 'package:apidash/screens/common_widgets/common_widgets.dart'; import 'package:apidash/screens/envvar/environment_page.dart'; import 'package:apidash/screens/home_page/editor_pane/details_card/response_pane.dart'; -import 'package:apidash/screens/home_page/editor_pane/editor_default.dart'; import 'package:apidash/screens/home_page/editor_pane/editor_pane.dart'; import 'package:apidash/screens/home_page/editor_pane/url_card.dart'; import 'package:apidash/screens/home_page/home_page.dart'; @@ -22,11 +21,58 @@ import '../extensions/widget_tester_extensions.dart'; import '../test_consts.dart'; import 'helpers.dart'; +Future pumpCollectionPane( + WidgetTester tester, { + Widget? home, +}) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: home ?? const Scaffold(body: CollectionPane()), + ), + ), + ); + await tester.pump(); + final container = ProviderScope.containerOf( + tester.element(find.byType(CollectionPane)), + ); + await ensureCollectionReady(container, tester); + final collectionId = container.read(selectedCollectionIdStateProvider)!; + container.read(expandedCollectionIdsProvider.notifier).state = {collectionId}; + await tester.pump(); + return container; +} + +Future pumpRequestEditorPane( + WidgetTester tester, { + bool largeScreen = false, +}) async { + if (largeScreen) { + await tester.setScreenSize(largeWidthDevice); + } + final container = createContainer(); + await ensureCollectionReady(container, tester); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const Portal( + child: MaterialApp( + home: Scaffold( + body: RequestEditorPane(), + ), + ), + ), + ), + ); + await tester.pump(); + return container; +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() async { - await testSetUpTempDirForHive(); + await testSetUpWorkspaceStorage(); // FIXME: Font file moved to design system so this must be fixed if spot screenshot is used // final flamante = rootBundle.load('google_fonts/OpenSans-Medium.ttf'); // final fontLoader = FontLoader('OpenSans')..addFont(flamante); @@ -320,37 +366,19 @@ void main() { testWidgets( 'selectedIdEditStateProvider should not be null after Duplicate button has been tapped', (tester) async { - await tester.pumpWidget( - ProviderScope( - child: MaterialApp( - theme: ThemeData( - fontFamily: 'OpenSans', - ), - home: const Scaffold( - body: CollectionPane(), - ), - ), - ), - ); + final container = await pumpCollectionPane(tester); - final collectionPane = tester.element(find.byType(CollectionPane)); - final container = ProviderScope.containerOf(collectionPane); var orig = container.read(selectedIdStateProvider); expect(orig, isNotNull); - // Tap on the three dots to open the request card menu - await tester.tap(find.byType(RequestList)); - await tester.pump(); await tester.tap(find.byType(RequestItem)); await tester.pump(); - //await tester.tap(find.byIcon(Icons.more_vert).at(1)); await tester.tap( find.byType(RequestItem), buttons: kSecondaryButton, ); await tester.pumpAndSettle(); - // Tap on the "Duplicate" option in the menu var byType = find.text('Duplicate', findRichText: true); expect(byType, findsOneWidget); @@ -368,37 +396,24 @@ void main() { testWidgets( 'It should be set back to null when user taps outside name editor', (tester) async { - await tester.pumpWidget( - const ProviderScope( - child: MaterialApp( - home: Scaffold( - body: CollectionPane(), - ), - ), - ), - ); - - // Grab the CollectionPane widget and its ProviderContainer - final collectionPane = tester.element(find.byType(CollectionPane)); - final container = ProviderScope.containerOf(collectionPane); + final container = await pumpCollectionPane(tester); - // Tap on the three dots to open the request card menu - await tester.tap(find.byType(RequestList)); - await tester.pump(); await tester.tap(find.byType(RequestItem)); await tester.pump(); - await tester.tap(find.byIcon(Icons.more_vert).at(1)); + await tester.tap( + find.descendant( + of: find.byType(RequestItem), + matching: find.byIcon(Icons.more_vert), + ), + ); await tester.pumpAndSettle(); - // Tap on the "Rename" option in the menu await tester.tap(find.text('Rename')); await tester.pumpAndSettle(); - // Verify that the selectedIdEditStateProvider is not null expect(container.read(selectedIdEditStateProvider), isNotNull); expect((container.read(selectedIdEditStateProvider)).runtimeType, String); - // Tap on the screen to simulate tapping outside the name editor await tester.tap(find.byType(CollectionPane)); await tester.pumpAndSettle(); @@ -458,30 +473,8 @@ void main() { testWidgets("When state is false ResponsePane should be visible", (tester) async { - await tester.setScreenSize(largeWidthDevice); - await tester.pumpWidget( - const ProviderScope( - child: Portal( - child: MaterialApp( - home: Material( - child: RequestEditorPane(), - ), - ), - ), - ), - ); + await pumpRequestEditorPane(tester, largeScreen: true); - expect(find.byType(RequestEditorDefault), findsOneWidget); - - // Tap on the "Plus New" button - Finder plusNewButton = find.descendant( - of: find.byType(RequestEditorDefault), - matching: find.byType(ElevatedButton), - ); - await tester.tap(plusNewButton); - await tester.pump(); - - // Verify that NotSentWidget is visible expect(find.byType(NotSentWidget), findsOneWidget); // Add some data in URLTextField @@ -506,30 +499,8 @@ void main() { testWidgets("When state is true CodePane should be visible", (tester) async { - await tester.setScreenSize(largeWidthDevice); - await tester.pumpWidget( - const ProviderScope( - child: Portal( - child: MaterialApp( - home: Scaffold( - body: RequestEditorPane(), - ), - ), - ), - ), - ); - - expect(find.byType(RequestEditorDefault), findsOneWidget); - - // Tap on the "Plus New" button - Finder plusNewButton = find.descendant( - of: find.byType(RequestEditorDefault), - matching: find.byType(ElevatedButton), - ); - await tester.tap(plusNewButton); - await tester.pump(); + await pumpRequestEditorPane(tester, largeScreen: true); - // Verify that NotSentWidget is visible expect(find.byType(NotSentWidget), findsOneWidget); // Add some data in URLTextField @@ -558,30 +529,8 @@ void main() { }); testWidgets("Hide/View Code button toggles the state", (tester) async { - await tester.setScreenSize(largeWidthDevice); - await tester.pumpWidget( - const ProviderScope( - child: Portal( - child: MaterialApp( - home: Scaffold( - body: RequestEditorPane(), - ), - ), - ), - ), - ); + await pumpRequestEditorPane(tester, largeScreen: true); - expect(find.byType(RequestEditorDefault), findsOneWidget); - - // Tap on the "Plus New" button - Finder plusNewButton = find.descendant( - of: find.byType(RequestEditorDefault), - matching: find.byType(ElevatedButton), - ); - await tester.tap(plusNewButton); - await tester.pump(); - - // Verify that NotSentWidget is visible expect(find.byType(NotSentWidget), findsOneWidget); // Add some data in URLTextField @@ -619,30 +568,8 @@ void main() { }); testWidgets("That state persists across widget rebuilds", (tester) async { - await tester.setScreenSize(largeWidthDevice); - await tester.pumpWidget( - const ProviderScope( - child: Portal( - child: MaterialApp( - home: Scaffold( - body: RequestEditorPane(), - ), - ), - ), - ), - ); - - expect(find.byType(RequestEditorDefault), findsOneWidget); - - // Tap on the "Plus New" button - Finder plusNewButton = find.descendant( - of: find.byType(RequestEditorDefault), - matching: find.byType(ElevatedButton), - ); - await tester.tap(plusNewButton); - await tester.pump(); + final container = await pumpRequestEditorPane(tester, largeScreen: true); - // Verify that NotSentWidget is visible expect(find.byType(NotSentWidget), findsOneWidget); // Add some data in URLTextField @@ -659,8 +586,6 @@ void main() { await tester.tap(sendButton); await tester.pump(); - final editorPane = tester.element(find.byType(RequestEditorPane)); - final container = ProviderScope.containerOf(editorPane); final bool currentValue = container.read(codePaneVisibleStateProvider); // Click on View Code button @@ -671,10 +596,11 @@ void main() { expect(container.read(codePaneVisibleStateProvider), !currentValue); bool matcher = !currentValue; - // Rebuild the widget tree + // Rebuild the widget tree with the same provider container await tester.pumpWidget( - const ProviderScope( - child: Portal( + UncontrolledProviderScope( + container: container, + child: const Portal( child: MaterialApp( home: Scaffold( body: RequestEditorPane(), @@ -685,8 +611,7 @@ void main() { ); // Verify that the value of codePaneVisibleStateProvider is still true - final containerAfterRebuild = ProviderScope.containerOf(editorPane); - bool actual = containerAfterRebuild.read(codePaneVisibleStateProvider); + bool actual = container.read(codePaneVisibleStateProvider); expect(actual, matcher); }); diff --git a/test/screens/common_widgets/auth/oauth1_fields_test.dart b/test/screens/common_widgets/auth/oauth1_fields_test.dart index 4a61e318a6..96a5c387ec 100644 --- a/test/screens/common_widgets/auth/oauth1_fields_test.dart +++ b/test/screens/common_widgets/auth/oauth1_fields_test.dart @@ -8,7 +8,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:apidash/providers/settings_providers.dart'; -import 'package:apidash/providers/collection_providers.dart'; +import 'package:apidash/providers/active_collection_providers.dart'; import 'package:apidash/models/models.dart'; void main() { diff --git a/test/screens/common_widgets/auth/oauth2_fields_test.dart b/test/screens/common_widgets/auth/oauth2_fields_test.dart index beedd5c10d..0c95e233af 100644 --- a/test/screens/common_widgets/auth/oauth2_fields_test.dart +++ b/test/screens/common_widgets/auth/oauth2_fields_test.dart @@ -1,5 +1,5 @@ import 'package:apidash/providers/settings_providers.dart'; -import 'package:apidash/providers/collection_providers.dart'; +import 'package:apidash/providers/active_collection_providers.dart'; import 'package:apidash/models/models.dart'; import 'package:apidash/screens/common_widgets/auth/oauth2_field.dart'; import 'package:apidash/screens/common_widgets/common_widgets.dart'; diff --git a/test/screens/common_widgets/sidebar_save_button_test.dart b/test/screens/common_widgets/sidebar_save_button_test.dart deleted file mode 100644 index 5764d0792d..0000000000 --- a/test/screens/common_widgets/sidebar_save_button_test.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:apidash/providers/ui_providers.dart'; -import 'package:apidash/screens/common_widgets/sidebar_save_button.dart'; -import '../../test_consts.dart'; - -void main() { - group("Testing Save Button", () { - testWidgets('Testing for Save button enabled', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - saveDataStateProvider.overrideWith((ref) => false), - hasUnsavedChangesProvider.overrideWith((ref) => true), - ], - child: MaterialApp( - title: 'Save button', - theme: kThemeDataLight, - home: const Scaffold( - body: SaveButton(), - ), - ), - ), - ); - final icon = find.byIcon(Icons.save); - expect(icon, findsOneWidget); - - final saveButton = find.ancestor( - of: icon, - matching: find.byWidgetPredicate((widget) => widget is TextButton)); - expect(saveButton, findsOneWidget); - - final saveButtonWidget = tester.widget(saveButton); - expect(saveButtonWidget.enabled, true); - }); - - testWidgets('Testing for Save button disabled', (tester) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - saveDataStateProvider.overrideWith((ref) => false), - hasUnsavedChangesProvider.overrideWith((ref) => false), - ], - child: MaterialApp( - title: 'Save button', - theme: kThemeDataLight, - home: const Scaffold( - body: SaveButton(), - ), - ), - ), - ); - - final icon = find.byIcon(Icons.save); - expect(icon, findsOneWidget); - - final saveButton = find.ancestor( - of: icon, - matching: find.byWidgetPredicate((widget) => widget is TextButton)); - expect(saveButton, findsOneWidget); - - final saveButtonWidget = tester.widget(saveButton); - expect(saveButtonWidget.enabled, false); - }); - }); -} diff --git a/test/screens/terminal/terminal_page_test.dart b/test/screens/terminal/terminal_page_test.dart index 315ee21c48..f7bcd2e752 100644 --- a/test/screens/terminal/terminal_page_test.dart +++ b/test/screens/terminal/terminal_page_test.dart @@ -5,7 +5,7 @@ import 'package:apidash/screens/terminal/terminal_page.dart'; import 'package:apidash/providers/terminal_providers.dart'; import 'package:apidash_core/apidash_core.dart'; import 'package:apidash/models/request_model.dart'; -import 'package:apidash/providers/collection_providers.dart'; +import 'package:apidash/providers/active_collection_providers.dart'; import 'package:apidash/terminal/terminal.dart'; import '../../providers/helpers.dart'; @@ -20,7 +20,7 @@ void main() { } setUp(() async { - await testSetUpTempDirForHive(); + await testSetUpWorkspaceStorage(); }); testWidgets('shows empty state initially', (tester) async { @@ -122,13 +122,12 @@ void main() { tester, ) async { const reqId = 'req-empty-name'; - final container = ProviderContainer(); + final container = createContainer(); + await ensureCollectionReady(container, tester); - // Seed collection with a RequestModel having empty name - final collectionNotifier = container.read( - collectionStateNotifierProvider.notifier, - ); - collectionNotifier.state = {reqId: RequestModel(id: reqId, name: '')}; + container.read(activeCollectionProvider.notifier).state = { + reqId: RequestModel(id: reqId, name: ''), + }; final term = container.read(terminalStateProvider.notifier); diff --git a/test/services/storage/workspace_storage_media_test.dart b/test/services/storage/workspace_storage_media_test.dart new file mode 100644 index 0000000000..6451890c9c --- /dev/null +++ b/test/services/storage/workspace_storage_media_test.dart @@ -0,0 +1,174 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:apidash/consts.dart'; +import 'package:apidash/services/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +import '../../providers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const collectionId = 'col1'; + const requestId = 'req1'; + + // Minimal PNG header + payload (content does not matter for the test). + final imageBytes = Uint8List.fromList( + [137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3, 4, 5, 6, 7, 8], + ); + + Map requestJsonWith( + Map responseModel, + ) => + { + 'id': requestId, + 'httpResponseModel': responseModel, + }; + + String requestDirPath() => p.join( + workspaceStorage.rootPath, + kWorkspaceCollectionsDir, + collectionId, + requestId, + ); + + setUp(() async { + await testSetUpWorkspaceStorage(); + }); + + group('Media responses saved as files', () { + test('extracts binary media body to a sibling file with a pointer', + () async { + await workspaceStorage.setRequestModel( + collectionId, + requestId, + requestJsonWith({ + 'statusCode': 200, + 'headers': {'content-type': 'image/png'}, + 'bodyBytes': imageBytes, + }), + saveMediaAsFiles: true, + ); + + final responseJsonPath = + p.join(requestDirPath(), kWorkspaceResponseFile); + final responseJson = jsonDecode( + await File(responseJsonPath).readAsString(), + ) as Map; + + expect(responseJson.containsKey('bodyBytes'), isFalse); + expect(responseJson[kWorkspaceResponseBodyFileKey], 'response_body.png'); + + final bodyFile = File( + p.join(requestDirPath(), 'response_body.png'), + ); + expect(bodyFile.existsSync(), isTrue); + expect(await bodyFile.readAsBytes(), imageBytes); + }); + + test('reads the body file back into bodyBytes on load', () async { + await workspaceStorage.setRequestModel( + collectionId, + requestId, + requestJsonWith({ + 'statusCode': 200, + 'headers': {'content-type': 'image/png'}, + 'bodyBytes': imageBytes, + }), + saveMediaAsFiles: true, + ); + + final loaded = workspaceStorage.getRequestModel(collectionId, requestId); + final response = + loaded!['httpResponseModel'] as Map; + + expect(response.containsKey(kWorkspaceResponseBodyFileKey), isFalse); + expect(response['bodyBytes'], imageBytes); + }); + + test('inlines bytes when the setting is off', () async { + await workspaceStorage.setRequestModel( + collectionId, + requestId, + requestJsonWith({ + 'statusCode': 200, + 'headers': {'content-type': 'image/png'}, + 'bodyBytes': imageBytes, + }), + saveMediaAsFiles: false, + ); + + final responseJson = jsonDecode( + await File(p.join(requestDirPath(), kWorkspaceResponseFile)) + .readAsString(), + ) as Map; + + expect(responseJson['bodyBytes'], imageBytes); + expect(responseJson.containsKey(kWorkspaceResponseBodyFileKey), isFalse); + expect( + File(p.join(requestDirPath(), 'response_body.png')).existsSync(), + isFalse, + ); + }); + + test('does not extract non-media (text) responses even when on', () async { + final textBytes = Uint8List.fromList(utf8.encode('hello world')); + await workspaceStorage.setRequestModel( + collectionId, + requestId, + requestJsonWith({ + 'statusCode': 200, + 'headers': {'content-type': 'text/plain'}, + 'bodyBytes': textBytes, + }), + saveMediaAsFiles: true, + ); + + final responseJson = jsonDecode( + await File(p.join(requestDirPath(), kWorkspaceResponseFile)) + .readAsString(), + ) as Map; + + expect(responseJson['bodyBytes'], textBytes); + expect(responseJson.containsKey(kWorkspaceResponseBodyFileKey), isFalse); + }); + + test('cleans up a stale body file when content type becomes text', + () async { + await workspaceStorage.setRequestModel( + collectionId, + requestId, + requestJsonWith({ + 'statusCode': 200, + 'headers': {'content-type': 'image/png'}, + 'bodyBytes': imageBytes, + }), + saveMediaAsFiles: true, + ); + expect( + File(p.join(requestDirPath(), 'response_body.png')).existsSync(), + isTrue, + ); + + final textBytes = Uint8List.fromList(utf8.encode('now text')); + await workspaceStorage.setRequestModel( + collectionId, + requestId, + requestJsonWith({ + 'statusCode': 200, + 'headers': {'content-type': 'text/plain'}, + 'bodyBytes': textBytes, + }), + saveMediaAsFiles: true, + ); + + expect( + File(p.join(requestDirPath(), 'response_body.png')).existsSync(), + isFalse, + ); + }); + }); +} diff --git a/test/widgets/menu_item_card_test.dart b/test/widgets/menu_item_card_test.dart index d223eeb6ac..d502a3281d 100644 --- a/test/widgets/menu_item_card_test.dart +++ b/test/widgets/menu_item_card_test.dart @@ -59,8 +59,8 @@ void main() { builder: (BuildContext context) { return GestureDetector( onTapUp: (details) { - showItemCardMenu( - context, details, (ItemMenuOption option) {}); + showItemCardMenu(context, details, ItemMenuOption.values, + (ItemMenuOption option) {}); }, child: const Text('Show Menu'), );