diff --git a/.gitignore b/.gitignore index d666f1064..1466d3834 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,17 @@ tmp .zed codebook.toml target + +# Devenv +.devenv* +devenv.local.nix +devenv.local.yaml + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml + +.envrc + diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 000000000..82102d1ff --- /dev/null +++ b/devenv.nix @@ -0,0 +1,44 @@ +{ pkgs, lib, config, inputs, ... }: + +{ + + # https://devenv.sh/packages/ + packages = with pkgs; [ + git + lld + ]; + + languages.rust.enable = true; + + languages.javascript = { + enable = true; + package = pkgs.nodejs_24; + npm.enable = true; + }; + + scripts.setup.exec = '' + echo "🚀 Setting up Yaak development environment..." + npm install + npm run bootstrap + echo "✅ Setup complete! Run 'npm start' to begin development." + ''; + + enterShell = '' + echo "📦 Yaak Development Environment" + echo "================================" + echo "Node.js: $(node --version)" + echo "NPM: $(npm --version)" + echo "Rust: $(rustc --version)" + ''; + + enterTest = '' + echo "Running tests" + node --version | grep --color=auto "v20" + rustc --version | grep --color=auto "rustc" + ''; + + # https://devenv.sh/git-hooks/ + # git-hooks.hooks.shellcheck.enable = true; + + # See full reference at https://devenv.sh/reference/options/ +} diff --git a/packages/plugin-runtime-types/src/bindings/gen_models.ts b/packages/plugin-runtime-types/src/bindings/gen_models.ts index ba67aba38..ec85aa89a 100644 --- a/packages/plugin-runtime-types/src/bindings/gen_models.ts +++ b/packages/plugin-runtime-types/src/bindings/gen_models.ts @@ -18,9 +18,22 @@ export type EncryptedKey = { encryptedKey: string, }; export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array, color: string | null, sortPriority: number, }; -export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; - -export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, sortPriority: number, }; +export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string }; + +export type Folder = { + model: "folder"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + folderId: string | null; + authentication: Record; + authenticationType: string | null; + description: string; + headers: Array; + name: string; + sortPriority: number; +}; export type GraphQlIntrospection = { model: "graphql_introspection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, }; @@ -34,11 +47,51 @@ export type GrpcEventType = "info" | "error" | "client_message" | "server_messag export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record, description: string, message: string, metadata: Array, method: string | null, name: string, service: string | null, sortPriority: number, url: string, }; -export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, body: Record, bodyType: string | null, description: string, headers: Array, method: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; - -export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; - -export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; +export type HttpRequest = { + model: "http_request"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + folderId: string | null; + authentication: Record; + authenticationType: string | null; + body: Record; + bodyType: string | null; + description: string; + headers: Array; + method: string; + name: string; + sortPriority: number; + url: string; + urlParameters: Array; +}; + +export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string }; + +export type HttpResponse = { + model: "http_response"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + requestId: string; + bodyPath: string | null; + contentLength: number | null; + contentLengthCompressed: number | null; + elapsed: number; + elapsedHeaders: number; + error: string | null; + headers: Array; + remoteAddr: string | null; + requestContentLength: number | null; + requestHeaders: Array; + status: number; + statusReason: string | null; + state: HttpResponseState; + url: string; + version: string | null; +}; export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, }; @@ -53,7 +106,7 @@ export type HttpResponseHeader = { name: string, value: string, }; export type HttpResponseState = "initialized" | "connected" | "closed"; -export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id?: string, }; +export type HttpUrlParameter = { enabled?: boolean; name: string; value: string; id?: string }; export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, }; @@ -77,6 +130,6 @@ export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, message: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; -export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; +export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number; settingMaxFilterHistory: number; }; export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, }; diff --git a/packages/plugin-runtime/src/PluginInstance.ts b/packages/plugin-runtime/src/PluginInstance.ts index 990a9b434..dc4d634ce 100644 --- a/packages/plugin-runtime/src/PluginInstance.ts +++ b/packages/plugin-runtime/src/PluginInstance.ts @@ -308,7 +308,7 @@ export class PluginInstance { payload.values = applyFormInputDefaults(args, payload.values); const resolvedArgs = await applyDynamicFormInput(ctx, args, payload); const resolvedActions: HttpAuthenticationAction[] = []; - for (const { onSelect, ...action } of actions ?? []) { + for (const { onSelect: _onSelect, ...action } of actions ?? []) { resolvedActions.push(action); } diff --git a/src-tauri/yaak-git/bindings/gen_models.ts b/src-tauri/yaak-git/bindings/gen_models.ts index 84a0f770c..440351f25 100644 --- a/src-tauri/yaak-git/bindings/gen_models.ts +++ b/src-tauri/yaak-git/bindings/gen_models.ts @@ -1,21 +1,119 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array, color: string | null, sortPriority: number, }; +export type Environment = { + model: "environment"; + id: string; + workspaceId: string; + createdAt: string; + updatedAt: string; + name: string; + public: boolean; + parentModel: string; + parentId: string | null; + variables: Array; + color: string | null; + sortPriority: number; +}; -export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; +export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string }; -export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, sortPriority: number, }; +export type Folder = { + model: "folder"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + folderId: string | null; + authentication: Record; + authenticationType: string | null; + description: string; + headers: Array; + name: string; + sortPriority: number; +}; -export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record, description: string, message: string, metadata: Array, method: string | null, name: string, service: string | null, sortPriority: number, url: string, }; +export type GrpcRequest = { + model: "grpc_request"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + folderId: string | null; + authenticationType: string | null; + authentication: Record; + description: string; + message: string; + metadata: Array; + method: string | null; + name: string; + service: string | null; + sortPriority: number; + url: string; +}; -export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, body: Record, bodyType: string | null, description: string, headers: Array, method: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; +export type HttpRequest = { + model: "http_request"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + folderId: string | null; + authentication: Record; + authenticationType: string | null; + body: Record; + bodyType: string | null; + description: string; + headers: Array; + method: string; + name: string; + sortPriority: number; + url: string; + urlParameters: Array; +}; -export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; +export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string }; -export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id?: string, }; +export type HttpUrlParameter = { enabled?: boolean; name: string; value: string; id?: string }; -export type SyncModel = { "type": "workspace" } & Workspace | { "type": "environment" } & Environment | { "type": "folder" } & Folder | { "type": "http_request" } & HttpRequest | { "type": "grpc_request" } & GrpcRequest | { "type": "websocket_request" } & WebsocketRequest; +export type SyncModel = + | ({ type: "workspace" } & Workspace) + | ({ type: "environment" } & Environment) + | ({ type: "folder" } & Folder) + | ({ type: "http_request" } & HttpRequest) + | ({ type: "grpc_request" } & GrpcRequest) + | ({ type: "websocket_request" } & WebsocketRequest); -export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, message: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; +export type WebsocketRequest = { + model: "websocket_request"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + folderId: string | null; + authentication: Record; + authenticationType: string | null; + description: string; + headers: Array; + message: string; + name: string; + sortPriority: number; + url: string; + urlParameters: Array; +}; -export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; +export type Workspace = { + model: "workspace"; + id: string; + createdAt: string; + updatedAt: string; + authentication: Record; + authenticationType: string | null; + description: string; + headers: Array; + name: string; + encryptionKeyChallenge: string | null; + settingValidateCertificates: boolean; + settingFollowRedirects: boolean; + settingRequestTimeout: number; + settingMaxFilterHistory: number; +}; diff --git a/src-tauri/yaak-models/bindings/gen_models.ts b/src-tauri/yaak-models/bindings/gen_models.ts index 844c0deb0..829a58728 100644 --- a/src-tauri/yaak-models/bindings/gen_models.ts +++ b/src-tauri/yaak-models/bindings/gen_models.ts @@ -1,96 +1,423 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta; - -export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, }; - -export type Cookie = { raw_cookie: string, domain: CookieDomain, expires: CookieExpires, path: [string, boolean], }; - -export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty"; - -export type CookieExpires = { "AtUtc": string } | "SessionEnd"; - -export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array, name: string, }; +export type AnyModel = + | CookieJar + | Environment + | Folder + | GraphQlIntrospection + | GrpcConnection + | GrpcEvent + | GrpcRequest + | HttpRequest + | HttpResponse + | HttpResponseEvent + | KeyValue + | Plugin + | Settings + | SyncState + | WebsocketConnection + | WebsocketEvent + | WebsocketRequest + | Workspace + | WorkspaceMeta; + +export type ClientCertificate = { + host: string; + port: number | null; + crtFile: string | null; + keyFile: string | null; + pfxFile: string | null; + passphrase: string | null; + enabled?: boolean; +}; + +export type Cookie = { + raw_cookie: string; + domain: CookieDomain; + expires: CookieExpires; + path: [string, boolean]; +}; + +export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty"; + +export type CookieExpires = { AtUtc: string } | "SessionEnd"; + +export type CookieJar = { + model: "cookie_jar"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + cookies: Array; + name: string; +}; export type EditorKeymap = "default" | "vim" | "vscode" | "emacs"; -export type EncryptedKey = { encryptedKey: string, }; - -export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array, color: string | null, sortPriority: number, }; - -export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; - -export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, sortPriority: number, }; - -export type GraphQlIntrospection = { model: "graphql_introspection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, }; - -export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, }; +export type EncryptedKey = { encryptedKey: string }; + +export type Environment = { + model: "environment"; + id: string; + workspaceId: string; + createdAt: string; + updatedAt: string; + name: string; + public: boolean; + parentModel: string; + parentId: string | null; + variables: Array; + color: string | null; + sortPriority: number; +}; + +export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string }; + +export type Folder = { + model: "folder"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + folderId: string | null; + authentication: Record; + authenticationType: string | null; + description: string; + headers: Array; + name: string; + sortPriority: number; +}; + +export type GraphQlIntrospection = { + model: "graphql_introspection"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + requestId: string; + content: string | null; +}; + +export type GrpcConnection = { + model: "grpc_connection"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + requestId: string; + elapsed: number; + error: string | null; + method: string; + service: string; + status: number; + state: GrpcConnectionState; + trailers: { [key in string]?: string }; + url: string; +}; export type GrpcConnectionState = "initialized" | "connected" | "closed"; -export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, content: string, error: string | null, eventType: GrpcEventType, metadata: { [key in string]?: string }, status: number | null, }; - -export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end"; - -export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record, description: string, message: string, metadata: Array, method: string | null, name: string, service: string | null, sortPriority: number, url: string, }; - -export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, body: Record, bodyType: string | null, description: string, headers: Array, method: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; - -export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; - -export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; - -export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, }; +export type GrpcEvent = { + model: "grpc_event"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + requestId: string; + connectionId: string; + content: string; + error: string | null; + eventType: GrpcEventType; + metadata: { [key in string]?: string }; + status: number | null; +}; + +export type GrpcEventType = + | "info" + | "error" + | "client_message" + | "server_message" + | "connection_start" + | "connection_end"; + +export type GrpcRequest = { + model: "grpc_request"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + folderId: string | null; + authenticationType: string | null; + authentication: Record; + description: string; + message: string; + metadata: Array; + method: string | null; + name: string; + service: string | null; + sortPriority: number; + url: string; +}; + +export type HttpRequest = { + model: "http_request"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + folderId: string | null; + authentication: Record; + authenticationType: string | null; + body: Record; + bodyType: string | null; + description: string; + headers: Array; + method: string; + name: string; + sortPriority: number; + url: string; + urlParameters: Array; +}; + +export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string }; + +export type HttpResponse = { + model: "http_response"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + requestId: string; + bodyPath: string | null; + contentLength: number | null; + contentLengthCompressed: number | null; + elapsed: number; + elapsedHeaders: number; + error: string | null; + headers: Array; + remoteAddr: string | null; + requestContentLength: number | null; + requestHeaders: Array; + status: number; + statusReason: string | null; + state: HttpResponseState; + url: string; + version: string | null; +}; + +export type HttpResponseEvent = { + model: "http_response_event"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + responseId: string; + event: HttpResponseEventData; +}; /** * Serializable representation of HTTP response events for DB storage. * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. * The `From` impl is in yaak-http to avoid circular dependencies. */ -export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, }; - -export type HttpResponseHeader = { name: string, value: string, }; +export type HttpResponseEventData = + | { type: "setting"; name: string; value: string } + | { type: "info"; message: string } + | { type: "redirect"; url: string; status: number; behavior: string } + | { type: "send_url"; method: string; path: string } + | { type: "receive_url"; version: string; status: string } + | { type: "header_up"; name: string; value: string } + | { type: "header_down"; name: string; value: string } + | { type: "chunk_sent"; bytes: number } + | { type: "chunk_received"; bytes: number }; + +export type HttpResponseHeader = { name: string; value: string }; export type HttpResponseState = "initialized" | "connected" | "closed"; -export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id?: string, }; - -export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, }; - -export type ModelChangeEvent = { "type": "upsert", created: boolean, } | { "type": "delete" }; - -export type ModelPayload = { model: AnyModel, updateSource: UpdateSource, change: ModelChangeEvent, }; - -export type ParentAuthentication = { authentication: Record, authenticationType: string | null, }; - -export type ParentHeaders = { headers: Array, }; - -export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, }; - -export type PluginKeyValue = { model: "plugin_key_value", createdAt: string, updatedAt: string, pluginName: string, key: string, value: string, }; - -export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" }; - -export type ProxySettingAuth = { user: string, password: string, }; - -export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, }; - -export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, }; - -export type UpdateSource = { "type": "background" } | { "type": "import" } | { "type": "plugin" } | { "type": "sync" } | { "type": "window", label: string, }; - -export type WebsocketConnection = { model: "websocket_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, headers: Array, state: WebsocketConnectionState, status: number, url: string, }; +export type HttpUrlParameter = { enabled?: boolean; name: string; value: string; id?: string }; + +export type KeyValue = { + model: "key_value"; + id: string; + createdAt: string; + updatedAt: string; + key: string; + namespace: string; + value: string; +}; + +export type ModelChangeEvent = { type: "upsert"; created: boolean } | { type: "delete" }; + +export type ModelPayload = { + model: AnyModel; + updateSource: UpdateSource; + change: ModelChangeEvent; +}; + +export type ParentAuthentication = { + authentication: Record; + authenticationType: string | null; +}; + +export type ParentHeaders = { headers: Array }; + +export type Plugin = { + model: "plugin"; + id: string; + createdAt: string; + updatedAt: string; + checkedAt: string | null; + directory: string; + enabled: boolean; + url: string | null; +}; + +export type PluginKeyValue = { + model: "plugin_key_value"; + createdAt: string; + updatedAt: string; + pluginName: string; + key: string; + value: string; +}; + +export type ProxySetting = + | { + type: "enabled"; + http: string; + https: string; + auth: ProxySettingAuth | null; + bypass: string; + disabled: boolean; + } + | { type: "disabled" }; + +export type ProxySettingAuth = { user: string; password: string }; + +export type Settings = { + model: "settings"; + id: string; + createdAt: string; + updatedAt: string; + appearance: string; + clientCertificates: Array; + coloredMethods: boolean; + editorFont: string | null; + editorFontSize: number; + editorKeymap: EditorKeymap; + editorSoftWrap: boolean; + hideWindowControls: boolean; + useNativeTitlebar: boolean; + interfaceFont: string | null; + interfaceFontSize: number; + interfaceScale: number; + openWorkspaceNewWindow: boolean | null; + proxy: ProxySetting | null; + themeDark: string; + themeLight: string; + updateChannel: string; + hideLicenseBadge: boolean; + autoupdate: boolean; + autoDownloadUpdates: boolean; + checkNotifications: boolean; +}; + +export type SyncState = { + model: "sync_state"; + id: string; + workspaceId: string; + createdAt: string; + updatedAt: string; + flushedAt: string; + modelId: string; + checksum: string; + relPath: string; + syncDir: string; +}; + +export type UpdateSource = + | { type: "background" } + | { type: "import" } + | { type: "plugin" } + | { type: "sync" } + | { type: "window"; label: string }; + +export type WebsocketConnection = { + model: "websocket_connection"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + requestId: string; + elapsed: number; + error: string | null; + headers: Array; + state: WebsocketConnectionState; + status: number; + url: string; +}; export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed"; -export type WebsocketEvent = { model: "websocket_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, isServer: boolean, message: Array, messageType: WebsocketEventType, }; +export type WebsocketEvent = { + model: "websocket_event"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + requestId: string; + connectionId: string; + isServer: boolean; + message: Array; + messageType: WebsocketEventType; +}; export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text"; export type WebsocketMessageType = "text" | "binary"; -export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, message: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; - -export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; - -export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, }; +export type WebsocketRequest = { + model: "websocket_request"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + folderId: string | null; + authentication: Record; + authenticationType: string | null; + description: string; + headers: Array; + message: string; + name: string; + sortPriority: number; + url: string; + urlParameters: Array; +}; + +export type Workspace = { + model: "workspace"; + id: string; + createdAt: string; + updatedAt: string; + authentication: Record; + authenticationType: string | null; + description: string; + headers: Array; + name: string; + encryptionKeyChallenge: string | null; + settingValidateCertificates: boolean; + settingFollowRedirects: boolean; + settingRequestTimeout: number; + settingMaxFilterHistory: number; +}; + +export type WorkspaceMeta = { + model: "workspace_meta"; + id: string; + workspaceId: string; + createdAt: string; + updatedAt: string; + encryptionKey: EncryptedKey | null; + settingSyncDir: string | null; +}; diff --git a/src-tauri/yaak-models/migrations/20260101000000_max-filter-history-setting.sql b/src-tauri/yaak-models/migrations/20260101000000_max-filter-history-setting.sql new file mode 100644 index 000000000..b70adcd0d --- /dev/null +++ b/src-tauri/yaak-models/migrations/20260101000000_max-filter-history-setting.sql @@ -0,0 +1,2 @@ +-- Add setting for maximum filter history items +ALTER TABLE workspaces ADD COLUMN setting_max_filter_history INTEGER NOT NULL DEFAULT 20; diff --git a/src-tauri/yaak-models/src/models.rs b/src-tauri/yaak-models/src/models.rs index 634188968..8cb833950 100644 --- a/src-tauri/yaak-models/src/models.rs +++ b/src-tauri/yaak-models/src/models.rs @@ -296,6 +296,12 @@ pub struct Workspace { #[serde(default = "default_true")] pub setting_follow_redirects: bool, pub setting_request_timeout: i32, + #[serde(default = "default_history_limit")] + pub setting_max_filter_history: i32, +} + +fn default_history_limit() -> i32 { + 20 } impl UpsertModelInfo for Workspace { @@ -336,6 +342,7 @@ impl UpsertModelInfo for Workspace { (SettingFollowRedirects, self.setting_follow_redirects.into()), (SettingRequestTimeout, self.setting_request_timeout.into()), (SettingValidateCertificates, self.setting_validate_certificates.into()), + (SettingMaxFilterHistory, self.setting_max_filter_history.into()), ]) } @@ -352,6 +359,7 @@ impl UpsertModelInfo for Workspace { WorkspaceIden::SettingFollowRedirects, WorkspaceIden::SettingRequestTimeout, WorkspaceIden::SettingValidateCertificates, + WorkspaceIden::SettingMaxFilterHistory, ] } @@ -375,6 +383,7 @@ impl UpsertModelInfo for Workspace { setting_follow_redirects: row.get("setting_follow_redirects")?, setting_request_timeout: row.get("setting_request_timeout")?, setting_validate_certificates: row.get("setting_validate_certificates")?, + setting_max_filter_history: row.get("setting_max_filter_history").unwrap_or(20), }) } } diff --git a/src-tauri/yaak-models/src/queries/grpc_requests.rs b/src-tauri/yaak-models/src/queries/grpc_requests.rs index 10289b44d..53aca78aa 100644 --- a/src-tauri/yaak-models/src/queries/grpc_requests.rs +++ b/src-tauri/yaak-models/src/queries/grpc_requests.rs @@ -20,6 +20,7 @@ impl<'a> DbContext<'a> { source: &UpdateSource, ) -> Result { self.delete_all_grpc_connections_for_request(m.id.as_str(), source)?; + self.delete_filter_history_for_request(m.id.as_str(), source)?; self.delete(m, source) } diff --git a/src-tauri/yaak-models/src/queries/http_requests.rs b/src-tauri/yaak-models/src/queries/http_requests.rs index a4d6fe21e..511416a99 100644 --- a/src-tauri/yaak-models/src/queries/http_requests.rs +++ b/src-tauri/yaak-models/src/queries/http_requests.rs @@ -20,6 +20,7 @@ impl<'a> DbContext<'a> { source: &UpdateSource, ) -> Result { self.delete_all_http_responses_for_request(m.id.as_str(), source)?; + self.delete_filter_history_for_request(m.id.as_str(), source)?; self.delete(m, source) } diff --git a/src-tauri/yaak-models/src/queries/key_values.rs b/src-tauri/yaak-models/src/queries/key_values.rs index 038627d16..44d74417d 100644 --- a/src-tauri/yaak-models/src/queries/key_values.rs +++ b/src-tauri/yaak-models/src/queries/key_values.rs @@ -6,6 +6,7 @@ use chrono::NaiveDateTime; use log::error; use sea_query::{Asterisk, Cond, Expr, Query, SqliteQueryBuilder}; use sea_query_rusqlite::RusqliteBinder; +use std::collections::HashMap; impl<'a> DbContext<'a> { pub fn list_key_values(&self) -> Result> { @@ -173,4 +174,38 @@ impl<'a> DbContext<'a> { self.delete(&kv, source)?; Ok(()) } + + /// Delete filter history for a request + pub fn delete_filter_history_for_request( + &self, + request_id: &str, + source: &UpdateSource, + ) -> Result<()> { + // Use the per-request pattern: request.{requestId}.filter% + let key_prefix = format!("request.{}.filter", request_id); + let search_pattern = format!("{}%", key_prefix); + + let (sql, params) = Query::select() + .from(KeyValueIden::Table) + .column(Asterisk) + .cond_where( + Cond::all() + .add(Expr::col(KeyValueIden::Namespace).eq("no_sync")) + .add(Expr::col(KeyValueIden::Key).like(search_pattern)), + ) + .build_rusqlite(SqliteQueryBuilder); + + let mut stmt = self.conn.prepare(sql.as_str())?; + let items: Vec = stmt + .query_map(&*params.as_params(), KeyValue::from_row)? + .filter_map(|v| v.ok()) + .collect(); + + // Delete each entry + for kv in items { + self.delete(&kv, source)?; + } + + Ok(()) + } } diff --git a/src-tauri/yaak-models/src/queries/websocket_requests.rs b/src-tauri/yaak-models/src/queries/websocket_requests.rs index c45498a92..521c299c3 100644 --- a/src-tauri/yaak-models/src/queries/websocket_requests.rs +++ b/src-tauri/yaak-models/src/queries/websocket_requests.rs @@ -20,6 +20,7 @@ impl<'a> DbContext<'a> { source: &UpdateSource, ) -> Result { self.delete_all_websocket_connections_for_request(websocket_request.id.as_str(), source)?; + self.delete_filter_history_for_request(websocket_request.id.as_str(), source)?; self.delete(websocket_request, source) } diff --git a/src-tauri/yaak-plugins/bindings/gen_models.ts b/src-tauri/yaak-plugins/bindings/gen_models.ts index ba67aba38..ec85aa89a 100644 --- a/src-tauri/yaak-plugins/bindings/gen_models.ts +++ b/src-tauri/yaak-plugins/bindings/gen_models.ts @@ -18,9 +18,22 @@ export type EncryptedKey = { encryptedKey: string, }; export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array, color: string | null, sortPriority: number, }; -export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; - -export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, sortPriority: number, }; +export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string }; + +export type Folder = { + model: "folder"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + folderId: string | null; + authentication: Record; + authenticationType: string | null; + description: string; + headers: Array; + name: string; + sortPriority: number; +}; export type GraphQlIntrospection = { model: "graphql_introspection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, }; @@ -34,11 +47,51 @@ export type GrpcEventType = "info" | "error" | "client_message" | "server_messag export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record, description: string, message: string, metadata: Array, method: string | null, name: string, service: string | null, sortPriority: number, url: string, }; -export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, body: Record, bodyType: string | null, description: string, headers: Array, method: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; - -export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; - -export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, }; +export type HttpRequest = { + model: "http_request"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + folderId: string | null; + authentication: Record; + authenticationType: string | null; + body: Record; + bodyType: string | null; + description: string; + headers: Array; + method: string; + name: string; + sortPriority: number; + url: string; + urlParameters: Array; +}; + +export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string }; + +export type HttpResponse = { + model: "http_response"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + requestId: string; + bodyPath: string | null; + contentLength: number | null; + contentLengthCompressed: number | null; + elapsed: number; + elapsedHeaders: number; + error: string | null; + headers: Array; + remoteAddr: string | null; + requestContentLength: number | null; + requestHeaders: Array; + status: number; + statusReason: string | null; + state: HttpResponseState; + url: string; + version: string | null; +}; export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, }; @@ -53,7 +106,7 @@ export type HttpResponseHeader = { name: string, value: string, }; export type HttpResponseState = "initialized" | "connected" | "closed"; -export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id?: string, }; +export type HttpUrlParameter = { enabled?: boolean; name: string; value: string; id?: string }; export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, }; @@ -77,6 +130,6 @@ export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, message: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; -export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; +export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number; settingMaxFilterHistory: number; }; export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, }; diff --git a/src-tauri/yaak-sync/bindings/gen_models.ts b/src-tauri/yaak-sync/bindings/gen_models.ts index 6a397665b..d1c51b664 100644 --- a/src-tauri/yaak-sync/bindings/gen_models.ts +++ b/src-tauri/yaak-sync/bindings/gen_models.ts @@ -1,23 +1,132 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array, color: string | null, sortPriority: number, }; +export type Environment = { + model: "environment"; + id: string; + workspaceId: string; + createdAt: string; + updatedAt: string; + name: string; + public: boolean; + parentModel: string; + parentId: string | null; + variables: Array; + color: string | null; + sortPriority: number; +}; -export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, }; +export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string }; -export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, sortPriority: number, }; +export type Folder = { + model: "folder"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + folderId: string | null; + authentication: Record; + authenticationType: string | null; + description: string; + headers: Array; + name: string; + sortPriority: number; +}; -export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record, description: string, message: string, metadata: Array, method: string | null, name: string, service: string | null, sortPriority: number, url: string, }; +export type GrpcRequest = { + model: "grpc_request"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + folderId: string | null; + authenticationType: string | null; + authentication: Record; + description: string; + message: string; + metadata: Array; + method: string | null; + name: string; + service: string | null; + sortPriority: number; + url: string; +}; -export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, body: Record, bodyType: string | null, description: string, headers: Array, method: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; +export type HttpRequest = { + model: "http_request"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + folderId: string | null; + authentication: Record; + authenticationType: string | null; + body: Record; + bodyType: string | null; + description: string; + headers: Array; + method: string; + name: string; + sortPriority: number; + url: string; + urlParameters: Array; +}; -export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; +export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string }; -export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id?: string, }; +export type HttpUrlParameter = { enabled?: boolean; name: string; value: string; id?: string }; -export type SyncModel = { "type": "workspace" } & Workspace | { "type": "environment" } & Environment | { "type": "folder" } & Folder | { "type": "http_request" } & HttpRequest | { "type": "grpc_request" } & GrpcRequest | { "type": "websocket_request" } & WebsocketRequest; +export type SyncModel = + | ({ type: "workspace" } & Workspace) + | ({ type: "environment" } & Environment) + | ({ type: "folder" } & Folder) + | ({ type: "http_request" } & HttpRequest) + | ({ type: "grpc_request" } & GrpcRequest) + | ({ type: "websocket_request" } & WebsocketRequest); -export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, }; +export type SyncState = { + model: "sync_state"; + id: string; + workspaceId: string; + createdAt: string; + updatedAt: string; + flushedAt: string; + modelId: string; + checksum: string; + relPath: string; + syncDir: string; +}; -export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, description: string, headers: Array, message: string, name: string, sortPriority: number, url: string, urlParameters: Array, }; +export type WebsocketRequest = { + model: "websocket_request"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + folderId: string | null; + authentication: Record; + authenticationType: string | null; + description: string; + headers: Array; + message: string; + name: string; + sortPriority: number; + url: string; + urlParameters: Array; +}; -export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record, authenticationType: string | null, description: string, headers: Array, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, }; +export type Workspace = { + model: "workspace"; + id: string; + createdAt: string; + updatedAt: string; + authentication: Record; + authenticationType: string | null; + description: string; + headers: Array; + name: string; + encryptionKeyChallenge: string | null; + settingValidateCertificates: boolean; + settingFollowRedirects: boolean; + settingRequestTimeout: number; + settingMaxFilterHistory: number; +}; diff --git a/src-tauri/yaak-templates/pkg/yaak_templates.d.ts b/src-tauri/yaak-templates/pkg/yaak_templates.d.ts index c881c571c..df962dbc0 100644 --- a/src-tauri/yaak-templates/pkg/yaak_templates.d.ts +++ b/src-tauri/yaak-templates/pkg/yaak_templates.d.ts @@ -1,5 +1,5 @@ /* tslint:disable */ /* eslint-disable */ -export function parse_template(template: string): any; export function escape_template(template: string): any; +export function parse_template(template: string): any; export function unescape_template(template: string): any; diff --git a/src-tauri/yaak-templates/pkg/yaak_templates_bg.js b/src-tauri/yaak-templates/pkg/yaak_templates_bg.js index 75a386480..900d46859 100644 --- a/src-tauri/yaak-templates/pkg/yaak_templates_bg.js +++ b/src-tauri/yaak-templates/pkg/yaak_templates_bg.js @@ -165,10 +165,10 @@ function takeFromExternrefTable0(idx) { * @param {string} template * @returns {any} */ -export function parse_template(template) { +export function escape_template(template) { const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); const len0 = WASM_VECTOR_LEN; - const ret = wasm.parse_template(ptr0, len0); + const ret = wasm.escape_template(ptr0, len0); if (ret[2]) { throw takeFromExternrefTable0(ret[1]); } @@ -179,10 +179,10 @@ export function parse_template(template) { * @param {string} template * @returns {any} */ -export function escape_template(template) { +export function parse_template(template) { const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); const len0 = WASM_VECTOR_LEN; - const ret = wasm.escape_template(ptr0, len0); + const ret = wasm.parse_template(ptr0, len0); if (ret[2]) { throw takeFromExternrefTable0(ret[1]); } diff --git a/src-tauri/yaak-templates/pkg/yaak_templates_bg.wasm b/src-tauri/yaak-templates/pkg/yaak_templates_bg.wasm index bc426c25b..e5203a182 100644 Binary files a/src-tauri/yaak-templates/pkg/yaak_templates_bg.wasm and b/src-tauri/yaak-templates/pkg/yaak_templates_bg.wasm differ diff --git a/src-web/components/Settings/SettingsGeneral.tsx b/src-web/components/Settings/SettingsGeneral.tsx index 0a6af1a9e..8a91d4324 100644 --- a/src-web/components/Settings/SettingsGeneral.tsx +++ b/src-web/components/Settings/SettingsGeneral.tsx @@ -4,6 +4,7 @@ import { useAtomValue } from 'jotai'; import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace'; import { useCheckForUpdates } from '../../hooks/useCheckForUpdates'; import { appInfo } from '../../lib/appInfo'; +import { HISTORY_LIMITS } from '../../lib/historyConstants'; import { revealInFinderText } from '../../lib/reveal'; import { CargoFeature } from '../CargoFeature'; import { Checkbox } from '../core/Checkbox'; @@ -119,6 +120,28 @@ export function SettingsGeneral() { type="number" /> + { + const num = Number.parseInt(value, 10); + return num >= HISTORY_LIMITS.MIN_MAX_ITEMS && num <= HISTORY_LIMITS.MAX_MAX_ITEMS; + }} + help={`Number of filter expressions to remember (${HISTORY_LIMITS.MIN_MAX_ITEMS}-${HISTORY_LIMITS.MAX_MAX_ITEMS}). Applies to JSONPath/XPath response filters.`} + onChange={(v) => + patchModel(workspace, { + settingMaxFilterHistory: Number.parseInt(v, 10) || HISTORY_LIMITS.DEFAULT_MAX_ITEMS, + }) + } + type="number" + /> + void; + onRemove: (value: string) => void; + onClearAll: () => void; + onTogglePin: (value: string) => void; +} + +export function FilterHistoryDropdown({ + history, + currentValue, + onSelect, + onRemove, + onClearAll, + onTogglePin, +}: Props) { + const groupedHistory = useMemo(() => { + const now = Date.now(); + const oneDayMs = 24 * 60 * 60 * 1000; + + const pinned: typeof history = []; + const today: typeof history = []; + const yesterday: typeof history = []; + const older: typeof history = []; + + for (const item of history) { + if (item.pinned) { + pinned.push(item); + continue; + } + + const age = now - item.timestamp; + if (age < oneDayMs) { + today.push(item); + } else if (age < 2 * oneDayMs) { + yesterday.push(item); + } else { + older.push(item); + } + } + + return { pinned, today, yesterday, older }; + }, [history]); + + const historyItems = useMemo(() => { + const items: DropdownItem[] = []; + + if (history.length === 0) { + return items; + } + + // Actions + items.push({ + type: 'default', + label: 'Clear All History', + leftSlot: , + color: 'danger', + onSelect: onClearAll, + }); + + // Helper to create history item + const createHistoryItem = (item: (typeof history)[0], showDate = false) => { + const dateStr = showDate + ? new Date(item.timestamp).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: + new Date(item.timestamp).getFullYear() !== new Date().getFullYear() + ? 'numeric' + : undefined, + }) + : null; + + const isSelected = item.value === currentValue; + + // Truncate long filter expressions + const MAX_DISPLAY_LENGTH = 80; + const displayValue = + item.value.length > MAX_DISPLAY_LENGTH + ? `${item.value.slice(0, MAX_DISPLAY_LENGTH)}...` + : item.value; + + return { + type: 'default' as const, + label: ( +
+
+
+ {displayValue} +
+ {dateStr &&
{dateStr}
} +
+
+ ), + leftSlot: isSelected ? : , + rightSlot: ( +
+ {/* biome-ignore lint/a11y/useSemanticElements: Cannot use button as parent dropdown item is already a button */} + { + e.stopPropagation(); + onTogglePin(item.value); + }} + > + + + {/* biome-ignore lint/a11y/useSemanticElements: Cannot use button as parent dropdown item is already a button */} + { + e.stopPropagation(); + onRemove(item.value); + }} + > + + +
+ ), + onSelect: () => onSelect(item.value), + }; + }; + + // Add grouped items + const groups = [ + { label: 'Pinned', items: groupedHistory.pinned, showDate: false }, + { label: 'Today', items: groupedHistory.today, showDate: false }, + { label: 'Yesterday', items: groupedHistory.yesterday, showDate: false }, + { label: 'Older', items: groupedHistory.older, showDate: true }, + ]; + + for (const group of groups) { + if (group.items.length > 0) { + items.push({ type: 'separator', label: group.label }); + for (const item of group.items) { + items.push(createHistoryItem(item, group.showDate)); + } + } + } + + return items; + }, [history, currentValue, onSelect, onRemove, onClearAll, onTogglePin, groupedHistory]); + + if (history.length === 0) { + return null; + } + + return ( + +
+ + +
+
+ ); +} diff --git a/src-web/components/responseViewers/HTMLOrTextViewer.tsx b/src-web/components/responseViewers/HTMLOrTextViewer.tsx index f1bb5a920..edcbdf8f5 100644 --- a/src-web/components/responseViewers/HTMLOrTextViewer.tsx +++ b/src-web/components/responseViewers/HTMLOrTextViewer.tsx @@ -2,6 +2,7 @@ import type { HttpResponse } from '@yaakapp-internal/models'; import { useMemo, useState } from 'react'; import { useResponseBodyText } from '../../hooks/useResponseBodyText'; import { languageFromContentType } from '../../lib/contentType'; +import { getFilterType } from '../../lib/filterType'; import { getContentTypeFromHeaders } from '../../lib/model_util'; import type { EditorProps } from '../core/Editor/Editor'; import { EmptyStateText } from '../EmptyStateText'; @@ -64,11 +65,15 @@ function HttpTextViewer({ response, text, language, pretty, className }: HttpTex [filteredBody], ); + // Compute history key per request with filter type based on language + const historyStateKey = `request.${response.requestId}.filter.${getFilterType(language)}`; + return ( { @@ -27,10 +31,19 @@ interface Props { const useFilterText = createGlobalState>({}); -export function TextViewer({ language, text, stateKey, pretty, className, onFilter }: Props) { +export function TextViewer({ + language, + text, + stateKey, + historyStateKey, + pretty, + className, + onFilter, +}: Props) { const [filterTextMap, setFilterTextMap] = useFilterText(); const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null; const debouncedFilterText = useDebouncedValue(filterText); + const setFilterText = useCallback( (v: string | null) => { if (!stateKey) return; @@ -39,6 +52,10 @@ export function TextViewer({ language, text, stateKey, pretty, className, onFilt [setFilterTextMap, stateKey], ); + const { history, addToHistory, clearHistory, removeFromHistory, togglePin } = useInputHistory({ + stateKey: historyStateKey ?? null, + }); + const isSearching = filterText != null; const filteredResponse = onFilter && debouncedFilterText @@ -55,6 +72,26 @@ export function TextViewer({ language, text, stateKey, pretty, className, onFilt const canFilter = onFilter && (language === 'json' || language === 'xml' || language === 'html'); + const handleFilterCommit = useCallback( + (value: string) => { + if (value.trim() && !filteredResponse.error) { + addToHistory(value); + } + }, + [addToHistory, filteredResponse.error], + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + toggleSearch(); + } else if (e.key === 'Enter' && filterText) { + handleFilterCommit(filterText); + } + }, + [toggleSearch, filterText, handleFilterCommit], + ); + const actions = useMemo(() => { const nodes: ReactNode[] = []; @@ -63,21 +100,31 @@ export function TextViewer({ language, text, stateKey, pretty, className, onFilt if (isSearching) { nodes.push(
- e.key === 'Escape' && toggleSearch()} - onChange={setFilterText} - stateKey={stateKey ? `filter.${stateKey}` : null} - /> + + + +
, ); } @@ -103,8 +150,14 @@ export function TextViewer({ language, text, stateKey, pretty, className, onFilt isSearching, language, stateKey, + historyStateKey, setFilterText, toggleSearch, + history, + removeFromHistory, + clearHistory, + togglePin, + handleKeyDown, ]); const formattedBody = useFormatText({ text, language, pretty: pretty ?? false }); diff --git a/src-web/hooks/useInputHistory.ts b/src-web/hooks/useInputHistory.ts new file mode 100644 index 000000000..1babca001 --- /dev/null +++ b/src-web/hooks/useInputHistory.ts @@ -0,0 +1,150 @@ +import { useAtomValue } from 'jotai'; +import { useCallback, useMemo } from 'react'; +import { HISTORY_LIMITS } from '../lib/historyConstants'; +import { prepareHistoryInput } from '../lib/sanitizeInput'; +import { showToast } from '../lib/toast'; +import { activeWorkspaceAtom } from './useActiveWorkspace'; +import { useKeyValue } from './useKeyValue'; + +export interface HistoryItem { + value: string; + timestamp: number; + pinned?: boolean; +} + +interface UseInputHistoryOptions { + stateKey: string | null; + maxItems?: number; +} + +export function useInputHistory({ stateKey, maxItems }: UseInputHistoryOptions) { + const workspace = useAtomValue(activeWorkspaceAtom); + const defaultMaxItems = workspace?.settingMaxFilterHistory ?? HISTORY_LIMITS.DEFAULT_MAX_ITEMS; + const effectiveMaxItems = maxItems ?? defaultMaxItems; + + const { value: history, set: setHistory } = useKeyValue({ + namespace: 'no_sync', + key: stateKey ?? 'noop', + fallback: [], + }); + + const addToHistory = useCallback( + async (value: string) => { + if (!stateKey) return; + + try { + const sanitized = prepareHistoryInput(value); + if (!sanitized) return; + + // Check if item already exists and is pinned - if so, skip silently + const existingItem = (history ?? []).find((item) => item.value === sanitized); + if (existingItem?.pinned) { + return; + } + + // Create new history item + const newItem: HistoryItem = { + value: sanitized, + timestamp: Date.now(), + }; + + const filteredHistory = (history ?? []).filter((item) => item.value !== sanitized); + + // Separate pinned and unpinned items + const pinnedItems = filteredHistory.filter((item) => item.pinned); + const unpinnedItems = filteredHistory.filter((item) => !item.pinned); + + // Add new item to unpinned and apply limit only to unpinned items + const limitedUnpinned = [newItem, ...unpinnedItems].slice(0, effectiveMaxItems); + + // Combine: pinned items always stay, unpinned items are limited + const updated = [...pinnedItems, ...limitedUnpinned]; + + await setHistory(updated); + + await setHistory(updated); + } catch (error) { + console.error('Failed to add to history:', error); + showToast({ + message: 'Failed to save filter to history', + color: 'danger', + }); + } + }, + [history, stateKey, effectiveMaxItems, setHistory], + ); + + const clearHistory = useCallback(async () => { + if (!stateKey) return; + + try { + await setHistory([]); + showToast({ + message: 'Filter history cleared', + color: 'success', + }); + } catch (error) { + console.error('Failed to clear history:', error); + showToast({ + message: 'Failed to clear filter history', + color: 'danger', + }); + } + }, [stateKey, setHistory]); + + const removeFromHistory = useCallback( + async (value: string) => { + if (!stateKey) return; + + try { + const updated = (history ?? []).filter((item) => item.value !== value); + + await setHistory(updated); + } catch (error) { + console.error('Failed to remove from history:', error); + showToast({ + message: 'Failed to remove from history', + color: 'danger', + }); + } + }, + [history, stateKey, setHistory], + ); + + const togglePin = useCallback( + async (value: string) => { + if (!stateKey) return; + + try { + const updated = (history ?? []).map((item) => + item.value === value ? { ...item, pinned: !item.pinned } : item, + ); + + await setHistory(updated); + + const pinnedItem = updated.find((i) => i.value === value); + showToast({ + message: pinnedItem?.pinned ? 'Filter pinned' : 'Filter unpinned', + color: 'success', + }); + } catch (error) { + console.error('Failed to toggle pin:', error); + showToast({ + message: 'Failed to toggle pin', + color: 'danger', + }); + } + }, + [history, stateKey, setHistory], + ); + + const historyItems = useMemo(() => history ?? [], [history]); + + return { + history: historyItems, + addToHistory, + clearHistory, + removeFromHistory, + togglePin, + }; +} diff --git a/src-web/hooks/useTrackFilterHistory.ts b/src-web/hooks/useTrackFilterHistory.ts new file mode 100644 index 000000000..f88058032 --- /dev/null +++ b/src-web/hooks/useTrackFilterHistory.ts @@ -0,0 +1,84 @@ +import { useEffect } from 'react'; +import { useKeyValue } from './useKeyValue'; + +/** + * Track filter history keys associated with a request for cleanup purposes + * Stores a registry of: requestId -> historyStateKeys[] + */ +export function useTrackFilterHistoryKeys( + requestId: string | null, + historyStateKey: string | null, +) { + const { value: registry, set: setRegistry } = useKeyValue>({ + namespace: 'no_sync', + key: 'filter_history_registry', + fallback: {}, + }); + + useEffect(() => { + if (!requestId || !historyStateKey) return; + + const currentKeys = registry?.[requestId] ?? []; + + // Only update if this key isn't already tracked + if (!currentKeys.includes(historyStateKey)) { + setRegistry({ + ...registry, + [requestId]: [...currentKeys, historyStateKey], + }); + } + }, [requestId, historyStateKey, registry, setRegistry]); +} + +/** + * Get all filter history keys for a request + */ +export async function getFilterHistoryKeysForRequest(requestId: string): Promise { + const { getKeyValue } = await import('../lib/keyValueStore'); + + const registry = getKeyValue>({ + namespace: 'no_sync', + key: 'filter_history_registry', + fallback: {}, + }); + + return registry[requestId] ?? []; +} + +/** + * Clean up filter history for deleted requests + */ +export async function cleanupFilterHistoryForRequest(requestId: string): Promise { + const { setKeyValue } = await import('../lib/keyValueStore'); + + const historyKeys = await getFilterHistoryKeysForRequest(requestId); + + // Clear each history key + for (const key of historyKeys) { + try { + await setKeyValue({ + namespace: 'no_sync', + key: `input_history.${key}`, + value: [], + }); + } catch (error) { + console.error(`Failed to cleanup history for key ${key}:`, error); + } + } + + // Remove from registry + const registry = (await import('../lib/keyValueStore')).getKeyValue>({ + namespace: 'no_sync', + key: 'filter_history_registry', + fallback: {}, + }); + + const updatedRegistry = { ...registry }; + delete updatedRegistry[requestId]; + + await setKeyValue({ + namespace: 'no_sync', + key: 'filter_history_registry', + value: updatedRegistry, + }); +} diff --git a/src-web/lib/filterType.ts b/src-web/lib/filterType.ts new file mode 100644 index 000000000..ba5ece8ea --- /dev/null +++ b/src-web/lib/filterType.ts @@ -0,0 +1,9 @@ +import type { EditorProps } from '../components/core/Editor/Editor'; + +export type FilterType = 'jsonpath' | 'xpath' | 'generic'; + +export function getFilterType(language: EditorProps['language']): FilterType { + if (language === 'json') return 'jsonpath'; + if (language === 'xml' || language === 'html') return 'xpath'; + return 'generic'; +} diff --git a/src-web/lib/historyCleanup.ts b/src-web/lib/historyCleanup.ts new file mode 100644 index 000000000..6ca9dd99f --- /dev/null +++ b/src-web/lib/historyCleanup.ts @@ -0,0 +1,56 @@ +import { keyValuesAtom } from '@yaakapp-internal/models'; +import { jotaiStore } from './jotai'; +import { buildKeyValueKey, setKeyValue } from './keyValueStore'; + +/** + * Clean up history entries for a deleted request/resource + */ +export async function cleanupHistoryForKey(stateKey: string): Promise { + const historyKey = `input_history.filter.${stateKey}`; + + try { + await setKeyValue({ + namespace: 'no_sync', + key: historyKey, + value: [], + }); + } catch (error) { + console.error('Failed to cleanup history:', error); + } +} + +/** + * Copy history from one request to another (for duplication) + */ +export async function copyHistoryForKey(fromStateKey: string, toStateKey: string): Promise { + const fromKey = `input_history.filter.${fromStateKey}`; + const toKey = `input_history.filter.${toStateKey}`; + + try { + const keyValues = jotaiStore.get(keyValuesAtom); + const fromKeyValue = keyValues?.find( + (kv) => buildKeyValueKey(kv.key) === buildKeyValueKey(fromKey), + ); + + if (fromKeyValue?.value) { + const history = JSON.parse(fromKeyValue.value); + await setKeyValue({ + namespace: 'no_sync', + key: toKey, + value: history, + }); + } + } catch (error) { + console.error('Failed to copy history:', error); + } +} + +/** + * Get all history keys for cleanup purposes + */ +export function getAllHistoryKeys(): string[] { + const keyValues = jotaiStore.get(keyValuesAtom); + if (!keyValues) return []; + + return keyValues.filter((kv) => kv.key.startsWith('input_history.')).map((kv) => kv.key); +} diff --git a/src-web/lib/historyConstants.ts b/src-web/lib/historyConstants.ts new file mode 100644 index 000000000..057d73779 --- /dev/null +++ b/src-web/lib/historyConstants.ts @@ -0,0 +1,10 @@ +export const HISTORY_LIMITS = { + DEFAULT_MAX_ITEMS: 20, + MIN_MAX_ITEMS: 10, + MAX_MAX_ITEMS: 200, +} as const; + +export const HISTORY_TYPES = { + FILTER: 'filter', + URL: 'url', +} as const; diff --git a/src-web/lib/sanitizeInput.ts b/src-web/lib/sanitizeInput.ts new file mode 100644 index 000000000..5785ddee1 --- /dev/null +++ b/src-web/lib/sanitizeInput.ts @@ -0,0 +1,34 @@ +/** + * For filter expressions, we use minimal processing + * React automatically escapes rendered content, so XSS is not a concern + */ +export function sanitizeInput(input: string): string { + return input; +} + +/** + * Basic validation for history input + * Note: Validation is minimal since React escapes all rendered content + * and these expressions are not executed as code + */ +export function isValidHistoryInput(input: string): boolean { + // Just ensure it's not empty or only whitespace + return input.trim().length > 0; +} + +/** + * Prepare input for history storage + * Minimal processing - just trim and limit length + */ +export function prepareHistoryInput(input: string): string | null { + const trimmed = input.trim(); + + if (!trimmed) { + return null; + } + + // Limit length to prevent storage abuse (1000 chars is plenty for filters) + const maxLength = 1000; + + return trimmed.slice(0, maxLength); +}