From f603824a6c96d56863fae33ff1af96c266d73035 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Thu, 30 Apr 2026 08:03:30 +0300 Subject: [PATCH 01/81] feat(http): scaffold space registration, route and locales --- src/main/i18n/locales/en_US/ui.json | 5 +++++ src/main/i18n/locales/ru_RU/ui.json | 5 +++++ .../providers/markdown/runtime/constants.ts | 2 ++ src/renderer/composables/useCopyTracker.ts | 2 +- src/renderer/ipc/listeners/system.ts | 2 ++ src/renderer/router/index.ts | 6 ++++++ src/renderer/spaceDefinitions.ts | 13 +++++++++++-- src/renderer/views/HttpSpace.vue | 18 ++++++++++++++++++ 8 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 src/renderer/views/HttpSpace.vue diff --git a/src/main/i18n/locales/en_US/ui.json b/src/main/i18n/locales/en_US/ui.json index a234a33d..12e765e1 100644 --- a/src/main/i18n/locales/en_US/ui.json +++ b/src/main/i18n/locales/en_US/ui.json @@ -32,6 +32,11 @@ "untitled": "Untitled", "currencyUnavailable": "Currency rates service unavailable" }, + "http": { + "label": "HTTP", + "tooltip": "HTTP client", + "title": "HTTP Client" + }, "tools": { "label": "Tools", "tooltip": "Developer tools" diff --git a/src/main/i18n/locales/ru_RU/ui.json b/src/main/i18n/locales/ru_RU/ui.json index 5c52d382..b9cd5dce 100644 --- a/src/main/i18n/locales/ru_RU/ui.json +++ b/src/main/i18n/locales/ru_RU/ui.json @@ -32,6 +32,11 @@ "untitled": "Без названия", "currencyUnavailable": "Сервис курсов валют недоступен" }, + "http": { + "label": "HTTP", + "tooltip": "HTTP-клиент", + "title": "HTTP-клиент" + }, "tools": { "label": "Инструменты", "tooltip": "Инструменты разработчика" diff --git a/src/main/storage/providers/markdown/runtime/constants.ts b/src/main/storage/providers/markdown/runtime/constants.ts index 2c32a354..197ba0e8 100644 --- a/src/main/storage/providers/markdown/runtime/constants.ts +++ b/src/main/storage/providers/markdown/runtime/constants.ts @@ -5,6 +5,7 @@ export const TRASH_DIR_NAME = 'trash' export const CODE_SPACE_ID = 'code' export const MATH_SPACE_ID = 'math' export const NOTES_SPACE_ID = 'notes' +export const HTTP_SPACE_ID = 'http' export const META_FILE_NAME = '.meta.yaml' export const SPACE_STATE_FILE_NAME = '.state.yaml' export const LEGACY_FOLDER_META_FILE_NAME = '.masscode-folder.yml' @@ -26,6 +27,7 @@ export const RESERVED_ROOT_NAMES = new Set([ CODE_SPACE_ID, MATH_SPACE_ID, NOTES_SPACE_ID, + HTTP_SPACE_ID, ]) export const NEW_LINE_SPLIT_RE = /\r?\n/ export const SEARCH_DIACRITICS_RE = /[\u0300-\u036F]/g diff --git a/src/renderer/composables/useCopyTracker.ts b/src/renderer/composables/useCopyTracker.ts index a38780fa..fd3f7369 100644 --- a/src/renderer/composables/useCopyTracker.ts +++ b/src/renderer/composables/useCopyTracker.ts @@ -21,7 +21,7 @@ export function useCopyTracker() { lastCopyAt = now const space = getActiveSpaceId() - if (!space) { + if (!space || space === 'http') { return } diff --git a/src/renderer/ipc/listeners/system.ts b/src/renderer/ipc/listeners/system.ts index 8fb83fba..099e14c2 100644 --- a/src/renderer/ipc/listeners/system.ts +++ b/src/renderer/ipc/listeners/system.ts @@ -80,6 +80,8 @@ async function refreshAfterStorageSync() { await getNotesGraph() } break + case 'http': + break case 'tools': break case 'code': diff --git a/src/renderer/router/index.ts b/src/renderer/router/index.ts index 14def757..5e4fb8f6 100644 --- a/src/renderer/router/index.ts +++ b/src/renderer/router/index.ts @@ -30,6 +30,7 @@ export const RouterName = { devtoolsLoremIpsumGenerator: 'devtools/lorem-ipsum-generator', devtoolsJsonDiff: 'devtools/json-diff', mathNotebook: 'math-notebook', + httpSpace: 'http-space', notesSpace: 'notes-space', notesDashboard: 'notes-space/dashboard', notesGraph: 'notes-space/graph', @@ -204,6 +205,11 @@ const routes = [ name: RouterName.mathNotebook, component: () => import('@/views/MathNotebook.vue'), }, + { + path: '/http', + name: RouterName.httpSpace, + component: () => import('@/views/HttpSpace.vue'), + }, { path: '/notes', name: RouterName.notesSpace, diff --git a/src/renderer/spaceDefinitions.ts b/src/renderer/spaceDefinitions.ts index 367bc517..54350d80 100644 --- a/src/renderer/spaceDefinitions.ts +++ b/src/renderer/spaceDefinitions.ts @@ -2,9 +2,9 @@ import type { Component } from 'vue' import type { RouteLocationRaw, RouteRecordName } from 'vue-router' import { i18n, store } from '@/electron' import { router, RouterName } from '@/router' -import { Blocks, Calculator, Code2, Notebook } from 'lucide-vue-next' +import { Blocks, Calculator, Code2, Notebook, Send } from 'lucide-vue-next' -export type SpaceId = 'code' | 'tools' | 'math' | 'notes' +export type SpaceId = 'code' | 'tools' | 'math' | 'notes' | 'http' export interface SpaceDefinition { id: SpaceId @@ -66,6 +66,15 @@ export function getSpaceDefinitions(): SpaceDefinition[] { to: { name: RouterName.mathNotebook }, isActive: routeName => routeName === RouterName.mathNotebook, }, + { + id: 'http', + label: i18n.t('spaces.http.label'), + tooltip: i18n.t('spaces.http.tooltip'), + icon: Send, + to: { name: RouterName.httpSpace }, + isActive: routeName => + isRouteNameInSpace(routeName, RouterName.httpSpace), + }, { id: 'tools', label: i18n.t('spaces.tools.label'), diff --git a/src/renderer/views/HttpSpace.vue b/src/renderer/views/HttpSpace.vue new file mode 100644 index 00000000..e584d31e --- /dev/null +++ b/src/renderer/views/HttpSpace.vue @@ -0,0 +1,18 @@ + + + From 463d50a53a4769500522689fdc5a0d13f10f911a Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Thu, 30 Apr 2026 08:06:40 +0300 Subject: [PATCH 02/81] feat(http): add storage types, paths and contracts --- src/main/storage/contracts.ts | 131 +++++++++++++++ .../markdown/http/runtime/constants.ts | 8 + .../providers/markdown/http/runtime/paths.ts | 14 ++ .../providers/markdown/http/runtime/types.ts | 152 ++++++++++++++++++ 4 files changed, 305 insertions(+) create mode 100644 src/main/storage/providers/markdown/http/runtime/constants.ts create mode 100644 src/main/storage/providers/markdown/http/runtime/paths.ts create mode 100644 src/main/storage/providers/markdown/http/runtime/types.ts diff --git a/src/main/storage/contracts.ts b/src/main/storage/contracts.ts index e85b635f..6faded95 100644 --- a/src/main/storage/contracts.ts +++ b/src/main/storage/contracts.ts @@ -1,3 +1,16 @@ +import type { + HttpAuth, + HttpBodyType, + HttpEnvironmentRecord, + HttpFolderRecord, + HttpFolderTreeRecord, + HttpFormDataEntry, + HttpHeaderEntry, + HttpHistoryRecord, + HttpMethod, + HttpQueryEntry, + HttpRequestRecord, +} from './providers/markdown/http/runtime/types' import type { NotesFolderRecord, NotesFolderTreeRecord, @@ -313,3 +326,121 @@ export interface NoteTagsStorage { getTags: () => NoteTagRecord[] updateTag: (id: number, name: string) => { notFound: boolean } } + +// --- HTTP Space Contracts --- + +export interface HttpFolderCreateInput { + name: string + parentId?: number | null +} + +export interface HttpFolderUpdateInput { + name?: string + parentId?: number | null + isOpen?: number + orderIndex?: number +} + +export interface HttpFolderUpdateResult { + invalidInput: boolean + notFound: boolean +} + +export interface HttpRequestCreateInput { + name: string + folderId?: number | null + method?: HttpMethod + url?: string +} + +export interface HttpRequestUpdateInput { + name?: string + folderId?: number | null + method?: HttpMethod + url?: string + headers?: HttpHeaderEntry[] + query?: HttpQueryEntry[] + bodyType?: HttpBodyType + body?: string | null + formData?: HttpFormDataEntry[] + auth?: HttpAuth + description?: string +} + +export interface HttpRequestUpdateResult { + invalidInput: boolean + notFound: boolean +} + +export interface HttpEnvironmentCreateInput { + name: string + variables?: Record +} + +export interface HttpEnvironmentUpdateInput { + name?: string + variables?: Record +} + +export interface HttpEnvironmentUpdateResult { + invalidInput: boolean + notFound: boolean +} + +export interface HttpHistoryAppendInput { + requestId: number | null + method: HttpMethod + url: string + status: number | null + durationMs: number + sizeBytes: number + requestedAt: number + error?: string +} + +export interface HttpFoldersStorage { + createFolder: (input: HttpFolderCreateInput) => { id: number } + deleteFolder: (id: number) => { deleted: boolean } + getFolders: () => HttpFolderRecord[] + getFoldersTree: () => HttpFolderTreeRecord[] + updateFolder: ( + id: number, + input: HttpFolderUpdateInput, + ) => HttpFolderUpdateResult +} + +export interface HttpRequestsStorage { + createRequest: (input: HttpRequestCreateInput) => { id: number } + deleteRequest: (id: number) => { deleted: boolean } + getRequestById: (id: number) => HttpRequestRecord | null + getRequests: () => HttpRequestRecord[] + updateRequest: ( + id: number, + input: HttpRequestUpdateInput, + ) => HttpRequestUpdateResult +} + +export interface HttpEnvironmentsStorage { + createEnvironment: (input: HttpEnvironmentCreateInput) => { id: number } + deleteEnvironment: (id: number) => { deleted: boolean } + getActiveEnvironmentId: () => number | null + getEnvironments: () => HttpEnvironmentRecord[] + setActiveEnvironment: (id: number | null) => { notFound: boolean } + updateEnvironment: ( + id: number, + input: HttpEnvironmentUpdateInput, + ) => HttpEnvironmentUpdateResult +} + +export interface HttpHistoryStorage { + appendEntry: (input: HttpHistoryAppendInput) => { id: number } + clear: () => void + getEntries: () => HttpHistoryRecord[] +} + +export interface HttpStorageProvider { + environments: HttpEnvironmentsStorage + folders: HttpFoldersStorage + history: HttpHistoryStorage + requests: HttpRequestsStorage +} diff --git a/src/main/storage/providers/markdown/http/runtime/constants.ts b/src/main/storage/providers/markdown/http/runtime/constants.ts new file mode 100644 index 00000000..217b4517 --- /dev/null +++ b/src/main/storage/providers/markdown/http/runtime/constants.ts @@ -0,0 +1,8 @@ +import type { HttpRuntimeCache } from './types' + +export const HTTP_STATE_FILE_NAME = '.state.yaml' +export const HTTP_HISTORY_CAP = 200 + +export const httpRuntimeRef: { cache: HttpRuntimeCache | null } = { + cache: null, +} diff --git a/src/main/storage/providers/markdown/http/runtime/paths.ts b/src/main/storage/providers/markdown/http/runtime/paths.ts new file mode 100644 index 00000000..560bad97 --- /dev/null +++ b/src/main/storage/providers/markdown/http/runtime/paths.ts @@ -0,0 +1,14 @@ +import type { HttpPaths } from './types' +import path from 'node:path' +import { HTTP_SPACE_ID } from '../../runtime/constants' +import { getSpaceDirPath } from '../../runtime/spaces' +import { HTTP_STATE_FILE_NAME } from './constants' + +export function getHttpPaths(vaultPath: string): HttpPaths { + const httpRoot = getSpaceDirPath(vaultPath, HTTP_SPACE_ID) + + return { + httpRoot, + statePath: path.join(httpRoot, HTTP_STATE_FILE_NAME), + } +} diff --git a/src/main/storage/providers/markdown/http/runtime/types.ts b/src/main/storage/providers/markdown/http/runtime/types.ts new file mode 100644 index 00000000..a5620aea --- /dev/null +++ b/src/main/storage/providers/markdown/http/runtime/types.ts @@ -0,0 +1,152 @@ +export type HttpMethod = + | 'GET' + | 'POST' + | 'PUT' + | 'PATCH' + | 'DELETE' + | 'HEAD' + | 'OPTIONS' + +export type HttpBodyType = + | 'none' + | 'json' + | 'text' + | 'form-urlencoded' + | 'multipart' + +export type HttpAuthType = 'none' | 'bearer' | 'basic' + +export interface HttpHeaderEntry { + key: string + value: string +} + +export interface HttpQueryEntry { + key: string + value: string +} + +export interface HttpFormDataEntry { + key: string + type: 'text' | 'file' + value: string +} + +export interface HttpAuth { + type: HttpAuthType + token?: string + username?: string + password?: string +} + +export interface HttpRequestFrontmatter { + id?: number + name?: string + folderId?: number | null + method?: HttpMethod + url?: string + headers?: HttpHeaderEntry[] + query?: HttpQueryEntry[] + bodyType?: HttpBodyType + body?: string | null + formData?: HttpFormDataEntry[] + auth?: HttpAuth + createdAt?: number + updatedAt?: number +} + +export interface HttpFolderRecord { + id: number + name: string + parentId: number | null + isOpen: number + orderIndex: number + createdAt: number + updatedAt: number +} + +export interface HttpFolderTreeRecord extends HttpFolderRecord { + children: HttpFolderTreeRecord[] +} + +export interface HttpRequestIndexItem { + id: number + filePath: string +} + +export interface HttpRequestRecord { + id: number + name: string + folderId: number | null + method: HttpMethod + url: string + headers: HttpHeaderEntry[] + query: HttpQueryEntry[] + bodyType: HttpBodyType + body: string | null + formData: HttpFormDataEntry[] + auth: HttpAuth + description: string + filePath: string + createdAt: number + updatedAt: number +} + +export interface HttpEnvironmentRecord { + id: number + name: string + variables: Record + createdAt: number + updatedAt: number +} + +export interface HttpHistoryRecord { + id: number + requestId: number | null + method: HttpMethod + url: string + status: number | null + durationMs: number + sizeBytes: number + requestedAt: number + error?: string +} + +export interface HttpCounters { + folderId: number + requestId: number + environmentId: number + historyId: number +} + +export interface HttpStateFile { + version?: number + counters?: Partial + folders?: HttpFolderRecord[] + requests?: HttpRequestIndexItem[] + environments?: HttpEnvironmentRecord[] + activeEnvironmentId?: number | null + history?: HttpHistoryRecord[] +} + +export interface HttpState { + version: number + counters: HttpCounters + folders: HttpFolderRecord[] + requests: HttpRequestIndexItem[] + environments: HttpEnvironmentRecord[] + activeEnvironmentId: number | null + history: HttpHistoryRecord[] +} + +export interface HttpPaths { + httpRoot: string + statePath: string +} + +export interface HttpRuntimeCache { + paths: HttpPaths + state: HttpState + requestById: Map + folderById: Map +} From ef372d9ecf60e6e285442094fea98c081be3dd86 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Thu, 30 Apr 2026 08:08:56 +0300 Subject: [PATCH 03/81] feat(http): add state, frontmatter parser and disk sync --- .../providers/markdown/http/runtime/parser.ts | 224 +++++++++++++++ .../providers/markdown/http/runtime/state.ts | 90 ++++++ .../providers/markdown/http/runtime/sync.ts | 267 ++++++++++++++++++ 3 files changed, 581 insertions(+) create mode 100644 src/main/storage/providers/markdown/http/runtime/parser.ts create mode 100644 src/main/storage/providers/markdown/http/runtime/state.ts create mode 100644 src/main/storage/providers/markdown/http/runtime/sync.ts diff --git a/src/main/storage/providers/markdown/http/runtime/parser.ts b/src/main/storage/providers/markdown/http/runtime/parser.ts new file mode 100644 index 00000000..cbc60de8 --- /dev/null +++ b/src/main/storage/providers/markdown/http/runtime/parser.ts @@ -0,0 +1,224 @@ +import type { + HttpAuth, + HttpBodyType, + HttpFormDataEntry, + HttpHeaderEntry, + HttpMethod, + HttpQueryEntry, + HttpRequestFrontmatter, + HttpRequestRecord, +} from './types' +import path from 'node:path' +import fs from 'fs-extra' +import yaml from 'js-yaml' + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/ +const HTTP_METHODS: HttpMethod[] = [ + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'HEAD', + 'OPTIONS', +] +const HTTP_BODY_TYPES: HttpBodyType[] = [ + 'none', + 'json', + 'text', + 'form-urlencoded', + 'multipart', +] + +function splitFrontmatter(source: string): { + body: string + frontmatter: HttpRequestFrontmatter + hasFrontmatter: boolean +} { + const match = source.match(FRONTMATTER_RE) + + if (!match) { + return { body: source, frontmatter: {}, hasFrontmatter: false } + } + + const parsed = yaml.load(match[1]) + return { + body: match[2] || '', + frontmatter: + parsed && typeof parsed === 'object' + ? (parsed as HttpRequestFrontmatter) + : {}, + hasFrontmatter: true, + } +} + +function normalizeMethod(value: unknown): HttpMethod { + if (typeof value !== 'string') { + return 'GET' + } + + const upper = value.toUpperCase() as HttpMethod + return HTTP_METHODS.includes(upper) ? upper : 'GET' +} + +function normalizeBodyType(value: unknown): HttpBodyType { + if (typeof value !== 'string') { + return 'none' + } + + return HTTP_BODY_TYPES.includes(value as HttpBodyType) + ? (value as HttpBodyType) + : 'none' +} + +function normalizeKeyValueEntries( + raw: unknown, + extra?: (entry: Record) => Partial, +): T[] { + if (!Array.isArray(raw)) { + return [] + } + + return raw + .filter((entry): entry is Record => + Boolean(entry && typeof entry === 'object'), + ) + .map((entry) => { + const key = typeof entry.key === 'string' ? entry.key : '' + const value = typeof entry.value === 'string' ? entry.value : '' + return { key, value, ...(extra ? extra(entry) : {}) } as T + }) +} + +function normalizeHeaders(raw: unknown): HttpHeaderEntry[] { + return normalizeKeyValueEntries(raw) +} + +function normalizeQuery(raw: unknown): HttpQueryEntry[] { + return normalizeKeyValueEntries(raw) +} + +function normalizeFormData(raw: unknown): HttpFormDataEntry[] { + return normalizeKeyValueEntries(raw, (entry) => { + const type = entry.type === 'file' ? 'file' : 'text' + return { type } + }) +} + +function normalizeAuth(raw: unknown): HttpAuth { + if (!raw || typeof raw !== 'object') { + return { type: 'none' } + } + + const data = raw as Record + const type + = data.type === 'bearer' || data.type === 'basic' ? data.type : 'none' + + const auth: HttpAuth = { type } + if (typeof data.token === 'string') + auth.token = data.token + if (typeof data.username === 'string') + auth.username = data.username + if (typeof data.password === 'string') + auth.password = data.password + + return auth +} + +export interface ParsedRequestFile { + body: string + description: string + frontmatter: HttpRequestFrontmatter + hasFrontmatter: boolean + normalized: { + method: HttpMethod + url: string + headers: HttpHeaderEntry[] + query: HttpQueryEntry[] + bodyType: HttpBodyType + body: string | null + formData: HttpFormDataEntry[] + auth: HttpAuth + } +} + +export function parseRequestFile(source: string): ParsedRequestFile { + const { body, frontmatter, hasFrontmatter } = splitFrontmatter(source) + const fm = frontmatter + + return { + body, + description: body, + frontmatter: fm, + hasFrontmatter, + normalized: { + method: normalizeMethod(fm.method), + url: typeof fm.url === 'string' ? fm.url : '', + headers: normalizeHeaders(fm.headers), + query: normalizeQuery(fm.query), + bodyType: normalizeBodyType(fm.bodyType), + body: typeof fm.body === 'string' ? fm.body : null, + formData: normalizeFormData(fm.formData), + auth: normalizeAuth(fm.auth), + }, + } +} + +export function serializeRequestFile(record: HttpRequestRecord): string { + const frontmatter: HttpRequestFrontmatter = { + id: record.id, + name: record.name, + folderId: record.folderId, + method: record.method, + url: record.url, + headers: record.headers, + query: record.query, + bodyType: record.bodyType, + body: record.body, + formData: record.formData, + auth: record.auth, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + } + + const text = yaml + .dump(frontmatter, { lineWidth: -1, noRefs: true, sortKeys: false }) + .trim() + + if (!record.description) { + return `---\n${text}\n---\n` + } + + return `---\n${text}\n---\n${record.description}` +} + +export function readRequestFile( + httpRoot: string, + filePath: string, +): ParsedRequestFile | null { + const absolutePath = path.join(httpRoot, filePath) + if (!fs.pathExistsSync(absolutePath)) { + return null + } + + const source = fs.readFileSync(absolutePath, 'utf8') + return parseRequestFile(source) +} + +export function writeRequestFile( + httpRoot: string, + record: HttpRequestRecord, +): void { + const absolutePath = path.join(httpRoot, record.filePath) + const next = serializeRequestFile(record) + + if (fs.pathExistsSync(absolutePath)) { + const current = fs.readFileSync(absolutePath, 'utf8') + if (current === next) { + return + } + } + + fs.ensureDirSync(path.dirname(absolutePath)) + fs.writeFileSync(absolutePath, next, 'utf8') +} diff --git a/src/main/storage/providers/markdown/http/runtime/state.ts b/src/main/storage/providers/markdown/http/runtime/state.ts new file mode 100644 index 00000000..632e41d2 --- /dev/null +++ b/src/main/storage/providers/markdown/http/runtime/state.ts @@ -0,0 +1,90 @@ +import type { + HttpCounters, + HttpPaths, + HttpState, + HttpStateFile, +} from './types' +import fs from 'fs-extra' +import { readSpaceState, writeSpaceState } from '../../runtime/spaceState' +import { HTTP_HISTORY_CAP } from './constants' + +const STATE_VERSION = 1 + +export function createDefaultHttpState(): HttpState { + return { + version: STATE_VERSION, + counters: { + folderId: 0, + requestId: 0, + environmentId: 0, + historyId: 0, + }, + folders: [], + requests: [], + environments: [], + activeEnvironmentId: null, + history: [], + } +} + +function normalizeCounters( + raw: Partial | undefined, +): HttpCounters { + const defaults = createDefaultHttpState().counters + return { + folderId: + typeof raw?.folderId === 'number' ? raw.folderId : defaults.folderId, + requestId: + typeof raw?.requestId === 'number' ? raw.requestId : defaults.requestId, + environmentId: + typeof raw?.environmentId === 'number' + ? raw.environmentId + : defaults.environmentId, + historyId: + typeof raw?.historyId === 'number' ? raw.historyId : defaults.historyId, + } +} + +export function ensureHttpStateFile(paths: HttpPaths): void { + fs.ensureDirSync(paths.httpRoot) + + if (!fs.pathExistsSync(paths.statePath)) { + writeSpaceState(paths.statePath, createDefaultHttpState()) + } +} + +export function loadHttpState(paths: HttpPaths): HttpState { + ensureHttpStateFile(paths) + + const raw = readSpaceState(paths.statePath) + const defaults = createDefaultHttpState() + + if (!raw) { + return defaults + } + + return { + version: typeof raw.version === 'number' ? raw.version : defaults.version, + counters: normalizeCounters(raw.counters), + folders: Array.isArray(raw.folders) ? raw.folders : [], + requests: Array.isArray(raw.requests) ? raw.requests : [], + environments: Array.isArray(raw.environments) ? raw.environments : [], + activeEnvironmentId: + typeof raw.activeEnvironmentId === 'number' + ? raw.activeEnvironmentId + : null, + history: Array.isArray(raw.history) + ? raw.history.slice(-HTTP_HISTORY_CAP) + : [], + } +} + +export function saveHttpState(paths: HttpPaths, state: HttpState): void { + state.version = Math.max(state.version, STATE_VERSION) + + if (state.history.length > HTTP_HISTORY_CAP) { + state.history = state.history.slice(-HTTP_HISTORY_CAP) + } + + writeSpaceState(paths.statePath, state) +} diff --git a/src/main/storage/providers/markdown/http/runtime/sync.ts b/src/main/storage/providers/markdown/http/runtime/sync.ts new file mode 100644 index 00000000..64e283a5 --- /dev/null +++ b/src/main/storage/providers/markdown/http/runtime/sync.ts @@ -0,0 +1,267 @@ +import type { + HttpFolderRecord, + HttpPaths, + HttpRequestRecord, + HttpRuntimeCache, + HttpState, +} from './types' +import path from 'node:path' +import fs from 'fs-extra' +import { toPosixPath } from '../../runtime/shared/path' +import { HTTP_STATE_FILE_NAME, httpRuntimeRef } from './constants' +import { + parseRequestFile, + serializeRequestFile, + writeRequestFile, +} from './parser' +import { ensureHttpStateFile, loadHttpState, saveHttpState } from './state' + +const SKIP_FILES = new Set([HTTP_STATE_FILE_NAME]) + +interface DiskWalkResult { + folderRelativePaths: string[] + requestRelativePaths: string[] +} + +function walkHttpDir(rootPath: string, currentPath = rootPath): DiskWalkResult { + const result: DiskWalkResult = { + folderRelativePaths: [], + requestRelativePaths: [], + } + + if (!fs.pathExistsSync(currentPath)) { + return result + } + + const entries = fs.readdirSync(currentPath, { withFileTypes: true }) + + for (const entry of entries) { + if (entry.name.startsWith('.')) { + continue + } + + const absolutePath = path.join(currentPath, entry.name) + + if (entry.isDirectory()) { + const relativePath = toPosixPath(path.relative(rootPath, absolutePath)) + result.folderRelativePaths.push(relativePath) + const nested = walkHttpDir(rootPath, absolutePath) + result.folderRelativePaths.push(...nested.folderRelativePaths) + result.requestRelativePaths.push(...nested.requestRelativePaths) + continue + } + + if ( + entry.isFile() + && entry.name.endsWith('.md') + && !SKIP_FILES.has(entry.name) + ) { + const relativePath = toPosixPath(path.relative(rootPath, absolutePath)) + result.requestRelativePaths.push(relativePath) + } + } + + return result +} + +function reconcileFolders( + state: HttpState, + folderRelativePaths: string[], +): Map { + const existingByPath = new Map() + const existingPathById = new Map() + + // Build current path → folder map from existing state + function buildPath( + folder: HttpFolderRecord, + lookup: Map, + ): string { + const segments: string[] = [] + let current: HttpFolderRecord | undefined = folder + while (current) { + segments.unshift(current.name) + current + = current.parentId !== null ? lookup.get(current.parentId) : undefined + } + return segments.join('/') + } + + const folderById = new Map( + state.folders.map(folder => [folder.id, folder]), + ) + for (const folder of state.folders) { + const fullPath = buildPath(folder, folderById) + existingByPath.set(fullPath, folder) + existingPathById.set(folder.id, fullPath) + } + + const now = Date.now() + const sortedDiskPaths = [...folderRelativePaths].sort( + (a, b) => a.split('/').length - b.split('/').length, + ) + + const nextFolders: HttpFolderRecord[] = [] + const folderIdByPath = new Map() + + for (const relativePath of sortedDiskPaths) { + const segments = relativePath.split('/') + const name = segments[segments.length - 1] + const parentPath = segments.slice(0, -1).join('/') + const parentId = parentPath + ? (folderIdByPath.get(parentPath) ?? null) + : null + + const existing = existingByPath.get(relativePath) + if (existing) { + const updated: HttpFolderRecord = { + ...existing, + name, + parentId, + } + nextFolders.push(updated) + folderIdByPath.set(relativePath, updated.id) + continue + } + + state.counters.folderId += 1 + const id = state.counters.folderId + nextFolders.push({ + id, + name, + parentId, + isOpen: 0, + orderIndex: nextFolders.length, + createdAt: now, + updatedAt: now, + }) + folderIdByPath.set(relativePath, id) + } + + state.folders = nextFolders + return folderIdByPath +} + +function reconcileRequests( + paths: HttpPaths, + state: HttpState, + requestRelativePaths: string[], + folderIdByPath: Map, +): HttpRequestRecord[] { + const existingByPath = new Map( + state.requests.map(item => [item.filePath, item.id]), + ) + const usedIds = new Set() + const records: HttpRequestRecord[] = [] + const indexEntries: HttpState['requests'] = [] + const now = Date.now() + + for (const relativePath of requestRelativePaths) { + const absolutePath = path.join(paths.httpRoot, relativePath) + const source = fs.readFileSync(absolutePath, 'utf8') + const parsed = parseRequestFile(source) + const fmId = parsed.frontmatter.id + + let id = existingByPath.get(relativePath) + let needsRewrite = !parsed.hasFrontmatter + + if (!id) { + if (typeof fmId === 'number' && fmId > 0 && !usedIds.has(fmId)) { + id = fmId + } + else { + state.counters.requestId += 1 + id = state.counters.requestId + needsRewrite = true + } + } + + if (id > state.counters.requestId) { + state.counters.requestId = id + } + usedIds.add(id) + + const dirPath = path.posix.dirname(relativePath) + const folderId + = dirPath && dirPath !== '.' ? (folderIdByPath.get(dirPath) ?? null) : null + + const fileName = path.posix.basename(relativePath, '.md') + const fmCreatedAt = parsed.frontmatter.createdAt + const fmUpdatedAt = parsed.frontmatter.updatedAt + + const record: HttpRequestRecord = { + id, + name: parsed.frontmatter.name || fileName, + folderId, + method: parsed.normalized.method, + url: parsed.normalized.url, + headers: parsed.normalized.headers, + query: parsed.normalized.query, + bodyType: parsed.normalized.bodyType, + body: parsed.normalized.body, + formData: parsed.normalized.formData, + auth: parsed.normalized.auth, + description: parsed.description, + filePath: relativePath, + createdAt: typeof fmCreatedAt === 'number' ? fmCreatedAt : now, + updatedAt: typeof fmUpdatedAt === 'number' ? fmUpdatedAt : now, + } + + if (needsRewrite || serializeRequestFile(record) !== source) { + writeRequestFile(paths.httpRoot, record) + } + + records.push(record) + indexEntries.push({ id, filePath: relativePath }) + } + + state.requests = indexEntries + return records +} + +function buildRuntimeCache( + paths: HttpPaths, + state: HttpState, + records: HttpRequestRecord[], +): HttpRuntimeCache { + return { + paths, + state, + requestById: new Map(records.map(record => [record.id, record])), + folderById: new Map(state.folders.map(folder => [folder.id, folder])), + } +} + +export function syncHttpRuntimeWithDisk(paths: HttpPaths): HttpRuntimeCache { + ensureHttpStateFile(paths) + const state = loadHttpState(paths) + + const walk = walkHttpDir(paths.httpRoot) + const folderIdByPath = reconcileFolders(state, walk.folderRelativePaths) + const records = reconcileRequests( + paths, + state, + walk.requestRelativePaths, + folderIdByPath, + ) + + saveHttpState(paths, state) + + const cache = buildRuntimeCache(paths, state, records) + httpRuntimeRef.cache = cache + return cache +} + +export function getHttpRuntimeCache(paths: HttpPaths): HttpRuntimeCache { + if ( + httpRuntimeRef.cache + && httpRuntimeRef.cache.paths.httpRoot === paths.httpRoot + ) { + return httpRuntimeRef.cache + } + + return syncHttpRuntimeWithDisk(paths) +} + +export function resetHttpRuntimeCache(): void { + httpRuntimeRef.cache = null +} From 508e091767d59d4c3d9e00813871e53a7b835186 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Thu, 30 Apr 2026 08:15:34 +0300 Subject: [PATCH 04/81] feat(http): add folders and requests storages Implements HttpFoldersStorage (CRUD over folders persisted in state.yaml plus filesystem dirs) and HttpRequestsStorage (CRUD over .md request files with frontmatter). Extends shared validation kinds to cover 'request' for sibling-name conflict messages. --- .../markdown/http/storages/folders.ts | 296 ++++++++++++++++++ .../markdown/http/storages/requests.ts | 289 +++++++++++++++++ .../providers/markdown/runtime/validation.ts | 12 +- 3 files changed, 592 insertions(+), 5 deletions(-) create mode 100644 src/main/storage/providers/markdown/http/storages/folders.ts create mode 100644 src/main/storage/providers/markdown/http/storages/requests.ts diff --git a/src/main/storage/providers/markdown/http/storages/folders.ts b/src/main/storage/providers/markdown/http/storages/folders.ts new file mode 100644 index 00000000..19acf62b --- /dev/null +++ b/src/main/storage/providers/markdown/http/storages/folders.ts @@ -0,0 +1,296 @@ +import type { + HttpFolderCreateInput, + HttpFoldersStorage, + HttpFolderUpdateInput, + HttpFolderUpdateResult, +} from '../../../../contracts' +import type { + HttpFolderRecord, + HttpRequestIndexItem, + HttpRequestRecord, +} from '../runtime/types' +import path from 'node:path' +import { normalizeFlag, normalizeNumber } from '../../runtime/normalizers' +import { getVaultPath } from '../../runtime/paths' +import { + buildFolderPathMap, + collectDescendantIds, + getNextFolderOrder, +} from '../../runtime/shared/folderIndex' +import { + applyFolderParentAndOrder, + assertFolderMoveTargetValid, + createFolderInStateAndDisk, + getFolderPathsByDepth, + getFoldersSortedByCreatedAt, + getFoldersTreeSorted, + moveFolderDirectoryOnDisk, + removeFolderPathsFromDisk, + replaceSubtreePathPrefix, + resolveFolderUpdateTargets, + updateChildEntityPaths, +} from '../../runtime/shared/foldersStorage' +import { + assertDirectoryNameAvailableAtRoot, + assertUniqueSiblingFolderName, + resolveUniqueSiblingFolderName, + throwStorageError, + validateEntryName, +} from '../../runtime/validation' +import { getHttpPaths } from '../runtime/paths' +import { saveHttpState } from '../runtime/state' +import { getHttpRuntimeCache } from '../runtime/sync' + +function findHttpFolderById( + folders: HttpFolderRecord[], + folderId: number, +): HttpFolderRecord | undefined { + return folders.find(folder => folder.id === folderId) +} + +function syncRequestFolderId( + records: HttpRequestRecord[], + indexEntries: HttpRequestIndexItem[], + pathToFolderId: Map, +): void { + for (const record of records) { + const indexEntry = indexEntries.find(entry => entry.id === record.id) + const filePath = indexEntry?.filePath ?? record.filePath + const dirPath = path.posix.dirname(filePath) + const nextFolderId + = dirPath && dirPath !== '.' ? (pathToFolderId.get(dirPath) ?? null) : null + record.folderId = nextFolderId + } +} + +export function createHttpFoldersStorage(): HttpFoldersStorage { + function resolvePaths() { + return getHttpPaths(getVaultPath()) + } + + function getCache() { + return getHttpRuntimeCache(resolvePaths()) + } + + return { + getFolders() { + const { state } = getCache() + return getFoldersSortedByCreatedAt(state.folders) + }, + + getFoldersTree() { + const { state } = getCache() + return getFoldersTreeSorted(state.folders) + }, + + createFolder(input: HttpFolderCreateInput) { + const paths = resolvePaths() + const { state } = getHttpRuntimeCache(paths) + + const name = validateEntryName(input.name, 'folder') + const parentId = input.parentId ?? null + + assertUniqueSiblingFolderName(state, parentId, name) + + if (parentId !== null) { + const parent = findHttpFolderById(state.folders, parentId) + if (!parent) { + throwStorageError('FOLDER_NOT_FOUND', 'Parent folder not found') + } + } + + const { id: folderId } = createFolderInStateAndDisk({ + buildFolderPathMap: state => buildFolderPathMap(state.folders), + createFolder: ({ id, name, now, orderIndex, parentId }) => ({ + createdAt: now, + id, + isOpen: 0, + name, + orderIndex, + parentId, + updatedAt: now, + }), + getNextFolderOrder: (state, parentId) => + getNextFolderOrder(state.folders, parentId), + name, + parentId, + rootPath: paths.httpRoot, + state, + }) + saveHttpState(paths, state) + + return { id: folderId } + }, + + updateFolder( + id: number, + input: HttpFolderUpdateInput, + ): HttpFolderUpdateResult { + const paths = resolvePaths() + const cache = getHttpRuntimeCache(paths) + const { state } = cache + const folder = findHttpFolderById(state.folders, id) + + if (!folder) { + return { invalidInput: false, notFound: true } + } + + if ( + input.name === undefined + && input.parentId === undefined + && input.isOpen === undefined + && input.orderIndex === undefined + ) { + return { invalidInput: true, notFound: false } + } + + const now = Date.now() + let pathChanged = false + + const oldFolderPathMap = buildFolderPathMap(state.folders) + const oldPath = oldFolderPathMap.get(id) + + let targetName + = input.name !== undefined + ? validateEntryName(input.name, 'folder') + : folder.name + + const { targetOrderIndex, targetParentId } = resolveFolderUpdateTargets( + folder, + input, + normalizeNumber, + ) + + if (input.parentId !== undefined) { + assertFolderMoveTargetValid(state.folders, id, targetParentId) + } + + const isParentChanged = targetParentId !== folder.parentId + if (isParentChanged) { + targetName = resolveUniqueSiblingFolderName( + state, + targetParentId, + targetName, + id, + ) + } + else if (targetName !== folder.name) { + assertUniqueSiblingFolderName(state, targetParentId, targetName, id) + } + + if (targetName !== folder.name) { + folder.name = targetName + pathChanged = true + } + + const { parentChanged } = applyFolderParentAndOrder( + state.folders, + folder, + targetParentId, + targetOrderIndex, + ) + + if (parentChanged) { + pathChanged = true + } + + if (input.isOpen !== undefined) { + folder.isOpen = normalizeFlag(input.isOpen) + } + + folder.updatedAt = now + + if (pathChanged) { + const newFolderPathMap = buildFolderPathMap(state.folders) + const newPath = newFolderPathMap.get(id) + + if (oldPath && newPath && oldPath !== newPath) { + const targetParentPath = path.posix.dirname(newPath) + assertDirectoryNameAvailableAtRoot( + paths.httpRoot, + targetParentPath === '.' ? '' : targetParentPath, + path.posix.basename(newPath), + oldPath, + ) + + moveFolderDirectoryOnDisk(paths.httpRoot, oldPath, newPath) + + updateChildEntityPaths({ + entries: [...cache.requestById.values()], + getNextPath: (_, previousPath) => + replaceSubtreePathPrefix(previousPath, oldPath, newPath), + onPathUpdated: (record, _previousPath, nextPath) => { + const indexEntry = state.requests.find(r => r.id === record.id) + if (indexEntry) { + indexEntry.filePath = nextPath + } + }, + }) + + const pathToFolderId = new Map() + newFolderPathMap.forEach((folderPath, folderId) => { + pathToFolderId.set(folderPath, folderId) + }) + syncRequestFolderId( + [...cache.requestById.values()], + state.requests, + pathToFolderId, + ) + } + } + + saveHttpState(paths, state) + return { invalidInput: false, notFound: false } + }, + + deleteFolder(id: number) { + const paths = resolvePaths() + const cache = getHttpRuntimeCache(paths) + const { state } = cache + const folder = findHttpFolderById(state.folders, id) + + if (!folder) { + return { deleted: false } + } + + const descendantIds = collectDescendantIds(state.folders, id) + descendantIds.add(id) + + const folderPathMap = buildFolderPathMap(state.folders) + const folderPathsToDelete = getFolderPathsByDepth( + folderPathMap, + descendantIds, + ) + + removeFolderPathsFromDisk(paths.httpRoot, folderPathsToDelete, { + ignoreErrors: true, + }) + + const removedRequestIds = new Set() + for (const record of cache.requestById.values()) { + if (record.folderId !== null && descendantIds.has(record.folderId)) { + removedRequestIds.add(record.id) + } + } + + if (removedRequestIds.size > 0) { + for (const removedId of removedRequestIds) { + cache.requestById.delete(removedId) + } + state.requests = state.requests.filter( + entry => !removedRequestIds.has(entry.id), + ) + } + + state.folders = state.folders.filter(f => !descendantIds.has(f.id)) + + cache.folderById = new Map( + state.folders.map(folder => [folder.id, folder]), + ) + + saveHttpState(paths, state) + + return { deleted: true } + }, + } +} diff --git a/src/main/storage/providers/markdown/http/storages/requests.ts b/src/main/storage/providers/markdown/http/storages/requests.ts new file mode 100644 index 00000000..073d3832 --- /dev/null +++ b/src/main/storage/providers/markdown/http/storages/requests.ts @@ -0,0 +1,289 @@ +import type { + HttpRequestCreateInput, + HttpRequestsStorage, + HttpRequestUpdateInput, + HttpRequestUpdateResult, +} from '../../../../contracts' +import type { + HttpFolderRecord, + HttpRequestRecord, + HttpState, +} from '../runtime/types' +import path from 'node:path' +import fs from 'fs-extra' +import { getVaultPath } from '../../runtime/paths' +import { buildFolderPathMap } from '../../runtime/shared/folderIndex' +import { + assertUniqueSiblingEntryName, + throwStorageError, + validateEntryName, +} from '../../runtime/validation' +import { writeRequestFile } from '../runtime/parser' +import { getHttpPaths } from '../runtime/paths' +import { saveHttpState } from '../runtime/state' +import { getHttpRuntimeCache } from '../runtime/sync' + +function findFolderById( + folders: HttpFolderRecord[], + folderId: number, +): HttpFolderRecord | undefined { + return folders.find(folder => folder.id === folderId) +} + +function resolveFolderRelativePath( + state: HttpState, + folderId: number | null, +): string { + if (folderId === null) { + return '' + } + + const folderPathMap = buildFolderPathMap(state.folders) + return folderPathMap.get(folderId) ?? '' +} + +function buildRequestFilePath( + state: HttpState, + folderId: number | null, + name: string, +): string { + const folderPath = resolveFolderRelativePath(state, folderId) + const fileName = `${name}.md` + return folderPath ? path.posix.join(folderPath, fileName) : fileName +} + +function removeRequestFile(httpRoot: string, filePath: string): void { + const absolutePath = path.join(httpRoot, filePath) + if (fs.pathExistsSync(absolutePath)) { + fs.removeSync(absolutePath) + } +} + +function moveRequestFile( + httpRoot: string, + oldFilePath: string, + newFilePath: string, +): void { + if (oldFilePath === newFilePath) { + return + } + + const oldAbsolutePath = path.join(httpRoot, oldFilePath) + if (!fs.pathExistsSync(oldAbsolutePath)) { + return + } + + const newAbsolutePath = path.join(httpRoot, newFilePath) + fs.ensureDirSync(path.dirname(newAbsolutePath)) + fs.moveSync(oldAbsolutePath, newAbsolutePath, { overwrite: false }) +} + +export function createHttpRequestsStorage(): HttpRequestsStorage { + function resolvePaths() { + return getHttpPaths(getVaultPath()) + } + + function getCache() { + return getHttpRuntimeCache(resolvePaths()) + } + + return { + getRequests() { + const { requestById } = getCache() + return [...requestById.values()].sort( + (a, b) => b.createdAt - a.createdAt, + ) + }, + + getRequestById(id: number) { + const { requestById } = getCache() + return requestById.get(id) ?? null + }, + + createRequest(input: HttpRequestCreateInput) { + const paths = resolvePaths() + const cache = getHttpRuntimeCache(paths) + const { state } = cache + + const name = validateEntryName(input.name, 'request') + const folderId = input.folderId ?? null + + if (folderId !== null && !findFolderById(state.folders, folderId)) { + throwStorageError('FOLDER_NOT_FOUND', 'Folder not found') + } + + const existingEntries = [...cache.requestById.values()].map(record => ({ + folderId: record.folderId, + id: record.id, + name: record.name, + })) + assertUniqueSiblingEntryName(existingEntries, folderId, name, 'request') + + state.counters.requestId += 1 + const id = state.counters.requestId + const now = Date.now() + const filePath = buildRequestFilePath(state, folderId, name) + + const record: HttpRequestRecord = { + auth: { type: 'none' }, + body: null, + bodyType: 'none', + createdAt: now, + description: '', + filePath, + folderId, + formData: [], + headers: [], + id, + method: input.method ?? 'GET', + name, + query: [], + updatedAt: now, + url: input.url ?? '', + } + + writeRequestFile(paths.httpRoot, record) + state.requests.push({ filePath, id }) + cache.requestById.set(id, record) + + saveHttpState(paths, state) + + return { id } + }, + + updateRequest( + id: number, + input: HttpRequestUpdateInput, + ): HttpRequestUpdateResult { + const paths = resolvePaths() + const cache = getHttpRuntimeCache(paths) + const { state } = cache + const record = cache.requestById.get(id) + + if (!record) { + return { invalidInput: false, notFound: true } + } + + const updatableFields = [ + input.name, + input.folderId, + input.method, + input.url, + input.headers, + input.query, + input.bodyType, + input.body, + input.formData, + input.auth, + input.description, + ] + + if (updatableFields.every(value => value === undefined)) { + return { invalidInput: true, notFound: false } + } + + const previousFilePath = record.filePath + const previousName = record.name + const previousFolderId = record.folderId + + const nextFolderId + = input.folderId !== undefined + ? (input.folderId ?? null) + : record.folderId + if ( + nextFolderId !== null + && nextFolderId !== record.folderId + && !findFolderById(state.folders, nextFolderId) + ) { + throwStorageError('FOLDER_NOT_FOUND', 'Folder not found') + } + + const nextName + = input.name !== undefined + ? validateEntryName(input.name, 'request') + : record.name + + const isFolderChanging = nextFolderId !== record.folderId + const isNameChanging = nextName !== record.name + + if (isFolderChanging || isNameChanging) { + const siblingEntries = [...cache.requestById.values()] + .filter(r => r.id !== id) + .map(r => ({ folderId: r.folderId, id: r.id, name: r.name })) + assertUniqueSiblingEntryName( + siblingEntries, + nextFolderId, + nextName, + 'request', + ) + } + + record.name = nextName + record.folderId = nextFolderId + if (input.method !== undefined) + record.method = input.method + if (input.url !== undefined) + record.url = input.url + if (input.headers !== undefined) + record.headers = input.headers + if (input.query !== undefined) + record.query = input.query + if (input.bodyType !== undefined) + record.bodyType = input.bodyType + if (input.body !== undefined) + record.body = input.body + if (input.formData !== undefined) + record.formData = input.formData + if (input.auth !== undefined) + record.auth = input.auth + if (input.description !== undefined) + record.description = input.description + + record.updatedAt = Date.now() + + let nextFilePath = record.filePath + if ( + previousName !== record.name + || previousFolderId !== record.folderId + ) { + nextFilePath = buildRequestFilePath( + state, + record.folderId, + record.name, + ) + if (nextFilePath !== previousFilePath) { + moveRequestFile(paths.httpRoot, previousFilePath, nextFilePath) + record.filePath = nextFilePath + + const indexEntry = state.requests.find(entry => entry.id === id) + if (indexEntry) { + indexEntry.filePath = nextFilePath + } + } + } + + writeRequestFile(paths.httpRoot, record) + saveHttpState(paths, state) + + return { invalidInput: false, notFound: false } + }, + + deleteRequest(id: number) { + const paths = resolvePaths() + const cache = getHttpRuntimeCache(paths) + const { state } = cache + const record = cache.requestById.get(id) + + if (!record) { + return { deleted: false } + } + + removeRequestFile(paths.httpRoot, record.filePath) + cache.requestById.delete(id) + state.requests = state.requests.filter(entry => entry.id !== id) + + saveHttpState(paths, state) + return { deleted: true } + }, + } +} diff --git a/src/main/storage/providers/markdown/runtime/validation.ts b/src/main/storage/providers/markdown/runtime/validation.ts index dd38f902..3fbfe693 100644 --- a/src/main/storage/providers/markdown/runtime/validation.ts +++ b/src/main/storage/providers/markdown/runtime/validation.ts @@ -41,7 +41,7 @@ function hasInvalidNameChars(name: string): boolean { export function validateEntryName( name: string, - kind: 'folder' | 'note' | 'snippet', + kind: 'folder' | 'note' | 'snippet' | 'request', ): string { const normalized = normalizeName(name) @@ -106,12 +106,12 @@ export function assertUniqueSiblingEntryName( entries: { id: number name: string - isDeleted: number + isDeleted?: number folderId: number | null }[], folderId: number | null, name: string, - kind: 'note' | 'snippet', + kind: 'note' | 'snippet' | 'request', excludeId?: number, ): void { const normalizedName = name.toLowerCase() @@ -119,15 +119,17 @@ export function assertUniqueSiblingEntryName( const hasConflict = entries.some( entry => entry.id !== excludeId - && entry.isDeleted === 0 + && (entry.isDeleted ?? 0) === 0 && entry.folderId === folderId && entry.name.toLowerCase() === normalizedName, ) if (hasConflict) { + const label + = kind === 'note' ? 'Note' : kind === 'snippet' ? 'Snippet' : 'Request' throwStorageError( 'NAME_CONFLICT', - `${kind === 'note' ? 'Note' : 'Snippet'} with this name already exists in this folder`, + `${label} with this name already exists in this folder`, ) } } From 989970e1742661a9a723b2d4832a4810d4acea08 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Thu, 30 Apr 2026 08:21:11 +0300 Subject: [PATCH 05/81] feat(http): wire environments, history, provider and watcher Adds HTTP environments and history storages on top of state.yaml, exposes createHttpStorageProvider via useHttpStorage(), registers HTTP space in PERSISTED_SPACE_IDS and integrates http runtime cache into the markdown watcher so external file changes resync and broadcast storage-synced. --- src/main/storage/index.ts | 12 +- .../storage/providers/markdown/http/index.ts | 8 + .../markdown/http/runtime/constants.ts | 4 + .../providers/markdown/http/runtime/index.ts | 6 + .../markdown/http/storages/environments.ts | 157 ++++++++++++++++++ .../markdown/http/storages/history.ts | 64 +++++++ .../providers/markdown/http/storages/index.ts | 14 ++ .../providers/markdown/runtime/constants.ts | 1 + .../storage/providers/markdown/watcher.ts | 47 +++++- .../providers/markdown/watcherPaths.ts | 5 + 10 files changed, 315 insertions(+), 3 deletions(-) create mode 100644 src/main/storage/providers/markdown/http/index.ts create mode 100644 src/main/storage/providers/markdown/http/runtime/index.ts create mode 100644 src/main/storage/providers/markdown/http/storages/environments.ts create mode 100644 src/main/storage/providers/markdown/http/storages/history.ts create mode 100644 src/main/storage/providers/markdown/http/storages/index.ts diff --git a/src/main/storage/index.ts b/src/main/storage/index.ts index c7775c32..2f0dffbd 100644 --- a/src/main/storage/index.ts +++ b/src/main/storage/index.ts @@ -1,13 +1,19 @@ -import type { NotesStorageProvider, StorageProvider } from './contracts' +import type { + HttpStorageProvider, + NotesStorageProvider, + StorageProvider, +} from './contracts' import { createMarkdownStorageProvider, startMarkdownWatcher, stopMarkdownWatcher, } from './providers/markdown' +import { createHttpStorageProvider } from './providers/markdown/http' import { createNotesStorageProvider } from './providers/markdown/notes' const markdownStorageProvider = createMarkdownStorageProvider() const notesStorageProvider = createNotesStorageProvider() +const httpStorageProvider = createHttpStorageProvider() export function useStorage(): StorageProvider { return markdownStorageProvider @@ -17,4 +23,8 @@ export function useNotesStorage(): NotesStorageProvider { return notesStorageProvider } +export function useHttpStorage(): HttpStorageProvider { + return httpStorageProvider +} + export { startMarkdownWatcher, stopMarkdownWatcher } diff --git a/src/main/storage/providers/markdown/http/index.ts b/src/main/storage/providers/markdown/http/index.ts new file mode 100644 index 00000000..9033f3b4 --- /dev/null +++ b/src/main/storage/providers/markdown/http/index.ts @@ -0,0 +1,8 @@ +export { peekHttpRuntimeCache } from './runtime/constants' +export { getHttpPaths } from './runtime/paths' +export { + getHttpRuntimeCache, + resetHttpRuntimeCache, + syncHttpRuntimeWithDisk, +} from './runtime/sync' +export { createHttpStorageProvider } from './storages' diff --git a/src/main/storage/providers/markdown/http/runtime/constants.ts b/src/main/storage/providers/markdown/http/runtime/constants.ts index 217b4517..25763e4c 100644 --- a/src/main/storage/providers/markdown/http/runtime/constants.ts +++ b/src/main/storage/providers/markdown/http/runtime/constants.ts @@ -6,3 +6,7 @@ export const HTTP_HISTORY_CAP = 200 export const httpRuntimeRef: { cache: HttpRuntimeCache | null } = { cache: null, } + +export function peekHttpRuntimeCache(): HttpRuntimeCache | null { + return httpRuntimeRef.cache +} diff --git a/src/main/storage/providers/markdown/http/runtime/index.ts b/src/main/storage/providers/markdown/http/runtime/index.ts new file mode 100644 index 00000000..67d596c3 --- /dev/null +++ b/src/main/storage/providers/markdown/http/runtime/index.ts @@ -0,0 +1,6 @@ +export * from './constants' +export * from './parser' +export * from './paths' +export * from './state' +export * from './sync' +export * from './types' diff --git a/src/main/storage/providers/markdown/http/storages/environments.ts b/src/main/storage/providers/markdown/http/storages/environments.ts new file mode 100644 index 00000000..2fe7de1e --- /dev/null +++ b/src/main/storage/providers/markdown/http/storages/environments.ts @@ -0,0 +1,157 @@ +import type { + HttpEnvironmentCreateInput, + HttpEnvironmentsStorage, + HttpEnvironmentUpdateInput, + HttpEnvironmentUpdateResult, +} from '../../../../contracts' +import type { HttpEnvironmentRecord } from '../runtime/types' +import { getVaultPath } from '../../runtime/paths' +import { throwStorageError, validateEntryName } from '../../runtime/validation' +import { getHttpPaths } from '../runtime/paths' +import { saveHttpState } from '../runtime/state' +import { getHttpRuntimeCache } from '../runtime/sync' + +function normalizeVariables( + raw: Record | undefined, +): Record { + if (!raw || typeof raw !== 'object') { + return {} + } + + const result: Record = {} + for (const [key, value] of Object.entries(raw)) { + if (typeof key !== 'string' || !key.trim()) { + continue + } + result[key] = typeof value === 'string' ? value : '' + } + return result +} + +export function createHttpEnvironmentsStorage(): HttpEnvironmentsStorage { + function resolvePaths() { + return getHttpPaths(getVaultPath()) + } + + function getCache() { + return getHttpRuntimeCache(resolvePaths()) + } + + return { + getEnvironments() { + const { state } = getCache() + return [...state.environments].sort((a, b) => a.createdAt - b.createdAt) + }, + + getActiveEnvironmentId() { + const { state } = getCache() + return state.activeEnvironmentId + }, + + setActiveEnvironment(id: number | null) { + const paths = resolvePaths() + const { state } = getHttpRuntimeCache(paths) + + if (id !== null) { + const exists = state.environments.some(env => env.id === id) + if (!exists) { + return { notFound: true } + } + } + + state.activeEnvironmentId = id + saveHttpState(paths, state) + return { notFound: false } + }, + + createEnvironment(input: HttpEnvironmentCreateInput) { + const paths = resolvePaths() + const { state } = getHttpRuntimeCache(paths) + + const name = validateEntryName(input.name, 'folder') + const conflict = state.environments.some( + env => env.name.toLowerCase() === name.toLowerCase(), + ) + if (conflict) { + throwStorageError( + 'NAME_CONFLICT', + 'Environment with this name already exists', + ) + } + + state.counters.environmentId += 1 + const id = state.counters.environmentId + const now = Date.now() + const record: HttpEnvironmentRecord = { + createdAt: now, + id, + name, + updatedAt: now, + variables: normalizeVariables(input.variables), + } + state.environments.push(record) + + saveHttpState(paths, state) + return { id } + }, + + updateEnvironment( + id: number, + input: HttpEnvironmentUpdateInput, + ): HttpEnvironmentUpdateResult { + const paths = resolvePaths() + const { state } = getHttpRuntimeCache(paths) + const env = state.environments.find(item => item.id === id) + + if (!env) { + return { invalidInput: false, notFound: true } + } + + if (input.name === undefined && input.variables === undefined) { + return { invalidInput: true, notFound: false } + } + + if (input.name !== undefined) { + const nextName = validateEntryName(input.name, 'folder') + const conflict = state.environments.some( + item => + item.id !== id + && item.name.toLowerCase() === nextName.toLowerCase(), + ) + if (conflict) { + throwStorageError( + 'NAME_CONFLICT', + 'Environment with this name already exists', + ) + } + env.name = nextName + } + + if (input.variables !== undefined) { + env.variables = normalizeVariables(input.variables) + } + + env.updatedAt = Date.now() + saveHttpState(paths, state) + return { invalidInput: false, notFound: false } + }, + + deleteEnvironment(id: number) { + const paths = resolvePaths() + const { state } = getHttpRuntimeCache(paths) + const index = state.environments.findIndex(item => item.id === id) + + if (index === -1) { + return { deleted: false } + } + + state.environments.splice(index, 1) + if (state.activeEnvironmentId === id) { + state.activeEnvironmentId = null + } + + saveHttpState(paths, state) + return { deleted: true } + }, + } +} diff --git a/src/main/storage/providers/markdown/http/storages/history.ts b/src/main/storage/providers/markdown/http/storages/history.ts new file mode 100644 index 00000000..6d7448b6 --- /dev/null +++ b/src/main/storage/providers/markdown/http/storages/history.ts @@ -0,0 +1,64 @@ +import type { + HttpHistoryAppendInput, + HttpHistoryStorage, +} from '../../../../contracts' +import type { HttpHistoryRecord } from '../runtime/types' +import { getVaultPath } from '../../runtime/paths' +import { HTTP_HISTORY_CAP } from '../runtime/constants' +import { getHttpPaths } from '../runtime/paths' +import { saveHttpState } from '../runtime/state' +import { getHttpRuntimeCache } from '../runtime/sync' + +export function createHttpHistoryStorage(): HttpHistoryStorage { + function resolvePaths() { + return getHttpPaths(getVaultPath()) + } + + function getCache() { + return getHttpRuntimeCache(resolvePaths()) + } + + return { + getEntries() { + const { state } = getCache() + return [...state.history].sort((a, b) => b.requestedAt - a.requestedAt) + }, + + appendEntry(input: HttpHistoryAppendInput) { + const paths = resolvePaths() + const { state } = getHttpRuntimeCache(paths) + + state.counters.historyId += 1 + const id = state.counters.historyId + const record: HttpHistoryRecord = { + durationMs: input.durationMs, + id, + method: input.method, + requestedAt: input.requestedAt, + requestId: input.requestId, + sizeBytes: input.sizeBytes, + status: input.status, + url: input.url, + } + + if (input.error) { + record.error = input.error + } + + state.history.push(record) + if (state.history.length > HTTP_HISTORY_CAP) { + state.history = state.history.slice(-HTTP_HISTORY_CAP) + } + + saveHttpState(paths, state) + return { id } + }, + + clear() { + const paths = resolvePaths() + const { state } = getHttpRuntimeCache(paths) + state.history = [] + saveHttpState(paths, state) + }, + } +} diff --git a/src/main/storage/providers/markdown/http/storages/index.ts b/src/main/storage/providers/markdown/http/storages/index.ts new file mode 100644 index 00000000..8eb97339 --- /dev/null +++ b/src/main/storage/providers/markdown/http/storages/index.ts @@ -0,0 +1,14 @@ +import type { HttpStorageProvider } from '../../../../contracts' +import { createHttpEnvironmentsStorage } from './environments' +import { createHttpFoldersStorage } from './folders' +import { createHttpHistoryStorage } from './history' +import { createHttpRequestsStorage } from './requests' + +export function createHttpStorageProvider(): HttpStorageProvider { + return { + environments: createHttpEnvironmentsStorage(), + folders: createHttpFoldersStorage(), + history: createHttpHistoryStorage(), + requests: createHttpRequestsStorage(), + } +} diff --git a/src/main/storage/providers/markdown/runtime/constants.ts b/src/main/storage/providers/markdown/runtime/constants.ts index 197ba0e8..dcdefd61 100644 --- a/src/main/storage/providers/markdown/runtime/constants.ts +++ b/src/main/storage/providers/markdown/runtime/constants.ts @@ -13,6 +13,7 @@ export const PERSISTED_SPACE_IDS = [ CODE_SPACE_ID, MATH_SPACE_ID, NOTES_SPACE_ID, + HTTP_SPACE_ID, ] as const export const SPACE_IDS = new Set(PERSISTED_SPACE_IDS) diff --git a/src/main/storage/providers/markdown/watcher.ts b/src/main/storage/providers/markdown/watcher.ts index 96f6dd15..74bc148f 100644 --- a/src/main/storage/providers/markdown/watcher.ts +++ b/src/main/storage/providers/markdown/watcher.ts @@ -1,6 +1,12 @@ import type { ChokidarOptions, FSWatcher } from 'chokidar' import { BrowserWindow } from 'electron' import { importEsm, log } from '../../../utils' +import { + getHttpPaths, + peekHttpRuntimeCache, + resetHttpRuntimeCache, + syncHttpRuntimeWithDisk, +} from './http' import { getNotesPaths, peekNotesRuntimeCache, @@ -20,6 +26,7 @@ import { import { getWatchPathSpaceId, isCodeWatchPath, + isHttpWatchPath, isMathWatchPath, isNotesWatchPath, normalizeRelativeWatchPath, @@ -34,6 +41,7 @@ let pendingFilePath: string | null = null let hasPendingFullSync = false let hasPendingMathSync = false let hasPendingNotesSync = false +let hasPendingHttpSync = false let watcherStartToken = 0 let chokidarWatchLoader: Promise | null = null @@ -85,6 +93,7 @@ function scheduleStateSync( const changedNotesPath = isNotesWatchPath(changedPath) const changedCodePath = isCodeWatchPath(changedPath) const changedMathPath = isMathWatchPath(changedPath) + const changedHttpPath = isHttpWatchPath(changedPath) const changedCodeRelativePath = changedPath && changedCodePath ? toCodeRelativePath(changedPath) : null @@ -96,17 +105,25 @@ function scheduleStateSync( hasPendingMathSync = true } + if (changedHttpPath) { + hasPendingHttpSync = true + } + if (changedNotesPath) { // Notes space has separate runtime cache sync path. } else if (changedMathPath) { // Math space has no main-process cache to sync; broadcast only. } + else if (changedHttpPath) { + // HTTP space has separate runtime cache sync path. + } else if (forceFullSync || !changedPath) { hasPendingFullSync = true if (forceFullSync && !changedPath) { hasPendingNotesSync = true + hasPendingHttpSync = true } } else if (changedCodeRelativePath) { @@ -131,14 +148,17 @@ function scheduleStateSync( try { const previousCache = peekRuntimeCache() const previousNotesCache = peekNotesRuntimeCache() + const previousHttpCache = peekHttpRuntimeCache() const changedFilePath = hasPendingFullSync ? null : pendingFilePath const shouldNotifyMath = hasPendingMathSync const shouldSyncCode = hasPendingFullSync || changedFilePath !== null const shouldSyncNotes = hasPendingNotesSync + const shouldSyncHttp = hasPendingHttpSync hasPendingFullSync = false hasPendingMathSync = false hasPendingNotesSync = false + hasPendingHttpSync = false pendingFilePath = null let nextCache = previousCache @@ -157,6 +177,9 @@ function scheduleStateSync( const nextNotesCache = shouldSyncNotes ? syncNotesRuntimeWithDisk(getNotesPaths(vaultRootPath)) : previousNotesCache + const nextHttpCache = shouldSyncHttp + ? syncHttpRuntimeWithDisk(getHttpPaths(vaultRootPath)) + : previousHttpCache const hasCodeChanges = shouldSyncCode && (!previousCache || nextCache !== previousCache) @@ -164,8 +187,16 @@ function scheduleStateSync( = shouldSyncNotes && (!previousNotesCache || nextNotesCache !== previousNotesCache) const hasMathChanges = shouldNotifyMath - - if (hasCodeChanges || hasNotesChanges || hasMathChanges) { + const hasHttpChanges + = shouldSyncHttp + && (!previousHttpCache || nextHttpCache !== previousHttpCache) + + if ( + hasCodeChanges + || hasNotesChanges + || hasMathChanges + || hasHttpChanges + ) { BrowserWindow.getAllWindows().forEach((window) => { window.webContents.send('system:storage-synced') }) @@ -195,8 +226,10 @@ export function stopMarkdownWatcher(): void { hasPendingFullSync = false hasPendingMathSync = false hasPendingNotesSync = false + hasPendingHttpSync = false resetRuntimeCache() resetNotesRuntimeCache() + resetHttpRuntimeCache() } export function startMarkdownWatcher(): void { @@ -205,6 +238,8 @@ export function startMarkdownWatcher(): void { const runtimeCache = peekRuntimeCache() const notesPaths = getNotesPaths(vaultRootPath) const notesRuntimeCache = peekNotesRuntimeCache() + const httpPaths = getHttpPaths(vaultRootPath) + const httpRuntimeCache = peekHttpRuntimeCache() if (markdownWatcher && watchedVaultPath === vaultRootPath) { if (!runtimeCache || runtimeCache.paths.vaultPath !== paths.vaultPath) { @@ -219,6 +254,13 @@ export function startMarkdownWatcher(): void { syncNotesRuntimeWithDisk(notesPaths) } + if ( + !httpRuntimeCache + || httpRuntimeCache.paths.httpRoot !== httpPaths.httpRoot + ) { + syncHttpRuntimeWithDisk(httpPaths) + } + return } @@ -226,6 +268,7 @@ export function startMarkdownWatcher(): void { ensureStateFile(paths) syncRuntimeWithDisk(paths) syncNotesRuntimeWithDisk(notesPaths) + syncHttpRuntimeWithDisk(httpPaths) const startToken = ++watcherStartToken diff --git a/src/main/storage/providers/markdown/watcherPaths.ts b/src/main/storage/providers/markdown/watcherPaths.ts index 2eb04a86..d16bc791 100644 --- a/src/main/storage/providers/markdown/watcherPaths.ts +++ b/src/main/storage/providers/markdown/watcherPaths.ts @@ -1,6 +1,7 @@ import path from 'node:path' import { CODE_SPACE_ID, + HTTP_SPACE_ID, INBOX_DIR_NAME, MATH_SPACE_ID, META_DIR_NAME, @@ -99,6 +100,10 @@ export function isMathWatchPath(relativePath: string | null): boolean { return getWatchPathSpaceId(relativePath) === MATH_SPACE_ID } +export function isHttpWatchPath(relativePath: string | null): boolean { + return getWatchPathSpaceId(relativePath) === HTTP_SPACE_ID +} + export function toCodeRelativePath(relativePath: string): string | null { const normalizedRelativePath = relativePath.toLowerCase() From 4140784c5ff07cfe2ccffffb801909087ece4314 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Thu, 30 Apr 2026 08:28:34 +0300 Subject: [PATCH 06/81] feat(http): add API DTOs and routes for folders, requests, environments and history --- src/main/api/dto/http-environments.ts | 44 ++ src/main/api/dto/http-folders.ts | 45 ++ src/main/api/dto/http-history.ts | 33 ++ src/main/api/dto/http-requests.ts | 101 ++++ src/main/api/index.ts | 8 + src/main/api/routes/http-environments.ts | 162 ++++++ src/main/api/routes/http-folders.ts | 165 ++++++ src/main/api/routes/http-history.ts | 38 ++ src/main/api/routes/http-requests.ts | 174 +++++++ src/renderer/services/api/generated/index.ts | 501 ++++++++++++++++++- 10 files changed, 1270 insertions(+), 1 deletion(-) create mode 100644 src/main/api/dto/http-environments.ts create mode 100644 src/main/api/dto/http-folders.ts create mode 100644 src/main/api/dto/http-history.ts create mode 100644 src/main/api/dto/http-requests.ts create mode 100644 src/main/api/routes/http-environments.ts create mode 100644 src/main/api/routes/http-folders.ts create mode 100644 src/main/api/routes/http-history.ts create mode 100644 src/main/api/routes/http-requests.ts diff --git a/src/main/api/dto/http-environments.ts b/src/main/api/dto/http-environments.ts new file mode 100644 index 00000000..5253d21b --- /dev/null +++ b/src/main/api/dto/http-environments.ts @@ -0,0 +1,44 @@ +import Elysia, { t } from 'elysia' + +const httpEnvironmentVariables = t.Record(t.String(), t.String()) + +const httpEnvironmentsAdd = t.Object({ + name: t.String(), + variables: t.Optional(httpEnvironmentVariables), +}) + +const httpEnvironmentsUpdate = t.Object({ + name: t.Optional(t.String()), + variables: t.Optional(httpEnvironmentVariables), +}) + +const httpEnvironmentsSetActive = t.Object({ + id: t.Union([t.Number(), t.Null()]), +}) + +const httpEnvironmentItem = t.Object({ + id: t.Number(), + name: t.String(), + variables: httpEnvironmentVariables, + createdAt: t.Number(), + updatedAt: t.Number(), +}) + +const httpEnvironmentsResponse = t.Object({ + activeId: t.Union([t.Number(), t.Null()]), + items: t.Array(httpEnvironmentItem), +}) + +export const httpEnvironmentsDTO = new Elysia().model({ + httpEnvironmentItemResponse: httpEnvironmentItem, + httpEnvironmentsAdd, + httpEnvironmentsResponse, + httpEnvironmentsSetActive, + httpEnvironmentsUpdate, +}) + +export type HttpEnvironmentsAdd = typeof httpEnvironmentsAdd.static +export type HttpEnvironmentsUpdate = typeof httpEnvironmentsUpdate.static +export type HttpEnvironmentsSetActive = typeof httpEnvironmentsSetActive.static +export type HttpEnvironmentsResponse = typeof httpEnvironmentsResponse.static +export type HttpEnvironmentItemResponse = typeof httpEnvironmentItem.static diff --git a/src/main/api/dto/http-folders.ts b/src/main/api/dto/http-folders.ts new file mode 100644 index 00000000..29088e05 --- /dev/null +++ b/src/main/api/dto/http-folders.ts @@ -0,0 +1,45 @@ +import Elysia, { t } from 'elysia' + +const httpFoldersAdd = t.Object({ + name: t.String(), + parentId: t.Optional(t.Union([t.Number(), t.Null()])), +}) + +const httpFoldersUpdate = t.Object({ + name: t.Optional(t.String()), + parentId: t.Optional(t.Union([t.Number(), t.Null()])), + isOpen: t.Optional(t.Number({ minimum: 0, maximum: 1 })), + orderIndex: t.Optional(t.Number()), +}) + +const httpFoldersItem = t.Object({ + id: t.Number(), + name: t.String(), + createdAt: t.Number(), + updatedAt: t.Number(), + parentId: t.Union([t.Number(), t.Null()]), + isOpen: t.Number(), + orderIndex: t.Number(), +}) + +const httpFoldersItemWithChildren = t.Recursive(This => + t.Object({ + ...httpFoldersItem.properties, + children: t.Array(This), + }), +) + +const httpFoldersResponse = t.Array(httpFoldersItem) +const httpFoldersTreeResponse = t.Array(httpFoldersItemWithChildren) + +export const httpFoldersDTO = new Elysia().model({ + httpFoldersAdd, + httpFoldersResponse, + httpFoldersTreeResponse, + httpFoldersUpdate, +}) + +export type HttpFoldersAdd = typeof httpFoldersAdd.static +export type HttpFoldersResponse = typeof httpFoldersResponse.static +export type HttpFoldersTree = typeof httpFoldersTreeResponse.static +export type HttpFoldersItem = typeof httpFoldersItem.static diff --git a/src/main/api/dto/http-history.ts b/src/main/api/dto/http-history.ts new file mode 100644 index 00000000..7b85b7f6 --- /dev/null +++ b/src/main/api/dto/http-history.ts @@ -0,0 +1,33 @@ +import Elysia, { t } from 'elysia' + +const httpMethod = t.Union([ + t.Literal('GET'), + t.Literal('POST'), + t.Literal('PUT'), + t.Literal('PATCH'), + t.Literal('DELETE'), + t.Literal('HEAD'), + t.Literal('OPTIONS'), +]) + +const httpHistoryItem = t.Object({ + id: t.Number(), + requestId: t.Union([t.Number(), t.Null()]), + method: httpMethod, + url: t.String(), + status: t.Union([t.Number(), t.Null()]), + durationMs: t.Number(), + sizeBytes: t.Number(), + requestedAt: t.Number(), + error: t.Optional(t.String()), +}) + +const httpHistoryResponse = t.Array(httpHistoryItem) + +export const httpHistoryDTO = new Elysia().model({ + httpHistoryItemResponse: httpHistoryItem, + httpHistoryResponse, +}) + +export type HttpHistoryResponse = typeof httpHistoryResponse.static +export type HttpHistoryItemResponse = typeof httpHistoryItem.static diff --git a/src/main/api/dto/http-requests.ts b/src/main/api/dto/http-requests.ts new file mode 100644 index 00000000..34a5fb09 --- /dev/null +++ b/src/main/api/dto/http-requests.ts @@ -0,0 +1,101 @@ +import Elysia, { t } from 'elysia' + +const httpMethod = t.Union([ + t.Literal('GET'), + t.Literal('POST'), + t.Literal('PUT'), + t.Literal('PATCH'), + t.Literal('DELETE'), + t.Literal('HEAD'), + t.Literal('OPTIONS'), +]) + +const httpBodyType = t.Union([ + t.Literal('none'), + t.Literal('json'), + t.Literal('text'), + t.Literal('form-urlencoded'), + t.Literal('multipart'), +]) + +const httpAuthType = t.Union([ + t.Literal('none'), + t.Literal('bearer'), + t.Literal('basic'), +]) + +const httpHeaderEntry = t.Object({ + key: t.String(), + value: t.String(), +}) + +const httpQueryEntry = t.Object({ + key: t.String(), + value: t.String(), +}) + +const httpFormDataEntry = t.Object({ + key: t.String(), + type: t.Union([t.Literal('text'), t.Literal('file')]), + value: t.String(), +}) + +const httpAuth = t.Object({ + type: httpAuthType, + token: t.Optional(t.String()), + username: t.Optional(t.String()), + password: t.Optional(t.String()), +}) + +const httpRequestsAdd = t.Object({ + name: t.String(), + folderId: t.Optional(t.Union([t.Number(), t.Null()])), + method: t.Optional(httpMethod), + url: t.Optional(t.String()), +}) + +const httpRequestsUpdate = t.Object({ + name: t.Optional(t.String()), + folderId: t.Optional(t.Union([t.Number(), t.Null()])), + method: t.Optional(httpMethod), + url: t.Optional(t.String()), + headers: t.Optional(t.Array(httpHeaderEntry)), + query: t.Optional(t.Array(httpQueryEntry)), + bodyType: t.Optional(httpBodyType), + body: t.Optional(t.Union([t.String(), t.Null()])), + formData: t.Optional(t.Array(httpFormDataEntry)), + auth: t.Optional(httpAuth), + description: t.Optional(t.String()), +}) + +const httpRequestItem = t.Object({ + id: t.Number(), + name: t.String(), + folderId: t.Union([t.Number(), t.Null()]), + method: httpMethod, + url: t.String(), + headers: t.Array(httpHeaderEntry), + query: t.Array(httpQueryEntry), + bodyType: httpBodyType, + body: t.Union([t.String(), t.Null()]), + formData: t.Array(httpFormDataEntry), + auth: httpAuth, + description: t.String(), + filePath: t.String(), + createdAt: t.Number(), + updatedAt: t.Number(), +}) + +const httpRequestsResponse = t.Array(httpRequestItem) + +export const httpRequestsDTO = new Elysia().model({ + httpRequestItemResponse: httpRequestItem, + httpRequestsAdd, + httpRequestsResponse, + httpRequestsUpdate, +}) + +export type HttpRequestsAdd = typeof httpRequestsAdd.static +export type HttpRequestsUpdate = typeof httpRequestsUpdate.static +export type HttpRequestsResponse = typeof httpRequestsResponse.static +export type HttpRequestItemResponse = typeof httpRequestItem.static diff --git a/src/main/api/index.ts b/src/main/api/index.ts index c735eff0..ef23ef2c 100644 --- a/src/main/api/index.ts +++ b/src/main/api/index.ts @@ -5,6 +5,10 @@ import { Elysia } from 'elysia' import { store } from '../store' import { importEsm } from '../utils' import folders from './routes/folders' +import httpEnvironments from './routes/http-environments' +import httpFolders from './routes/http-folders' +import httpHistory from './routes/http-history' +import httpRequests from './routes/http-requests' import noteFolders from './routes/note-folders' import noteTags from './routes/note-tags' import notes from './routes/notes' @@ -43,6 +47,10 @@ export async function initApi() { .use(notes) .use(noteFolders) .use(noteTags) + .use(httpFolders) + .use(httpRequests) + .use(httpEnvironments) + .use(httpHistory) .listen(port) // eslint-disable-next-line no-console diff --git a/src/main/api/routes/http-environments.ts b/src/main/api/routes/http-environments.ts new file mode 100644 index 00000000..68469492 --- /dev/null +++ b/src/main/api/routes/http-environments.ts @@ -0,0 +1,162 @@ +import type { HttpEnvironmentsResponse } from '../dto/http-environments' +import { Elysia } from 'elysia' +import { useHttpStorage } from '../../storage' +import { commonAddResponse } from '../dto/common/response' +import { httpEnvironmentsDTO } from '../dto/http-environments' + +const app = new Elysia({ prefix: '/http-environments' }) + +function parseStorageError( + error: unknown, +): { code: string, message: string } | null { + if (!(error instanceof Error)) { + return null + } + + const separatorIndex = error.message.indexOf(':') + if (separatorIndex <= 0) { + return null + } + + return { + code: error.message.slice(0, separatorIndex), + message: error.message.slice(separatorIndex + 1).trim(), + } +} + +function mapStorageError(status: unknown, error: unknown): never { + const setStatus = status as ( + code: number, + payload: { message: string }, + ) => never + const parsedError = parseStorageError(error) + + if (!parsedError) { + return setStatus(500, { message: 'Internal storage error' }) + } + + if (parsedError.code === 'NAME_CONFLICT') { + return setStatus(409, { message: parsedError.message }) + } + + if ( + parsedError.code === 'INVALID_NAME' + || parsedError.code === 'RESERVED_NAME' + ) { + return setStatus(400, { message: parsedError.message }) + } + + return setStatus(500, { + message: parsedError.message || 'Internal storage error', + }) +} + +app + .use(httpEnvironmentsDTO) + .get( + '/', + () => { + const storage = useHttpStorage() + const items = storage.environments.getEnvironments() + const activeId = storage.environments.getActiveEnvironmentId() + + return { activeId, items } as HttpEnvironmentsResponse + }, + { + response: 'httpEnvironmentsResponse', + detail: { + tags: ['HTTP Environments'], + }, + }, + ) + .post( + '/', + ({ body, status }) => { + const storage = useHttpStorage() + try { + const { id } = storage.environments.createEnvironment(body) + + return { id } + } + catch (error) { + return mapStorageError(status, error) + } + }, + { + body: 'httpEnvironmentsAdd', + response: commonAddResponse, + detail: { + tags: ['HTTP Environments'], + }, + }, + ) + .patch( + '/:id', + ({ params, body, status }) => { + const storage = useHttpStorage() + try { + const { invalidInput, notFound } + = storage.environments.updateEnvironment(Number(params.id), body) + + if (invalidInput) { + return status(400, { message: 'Need at least one field to update' }) + } + + if (notFound) { + return status(404, { message: 'Environment not found' }) + } + + return { message: 'Environment updated' } + } + catch (error) { + return mapStorageError(status, error) + } + }, + { + body: 'httpEnvironmentsUpdate', + detail: { + tags: ['HTTP Environments'], + }, + }, + ) + .delete( + '/:id', + ({ params, status }) => { + const storage = useHttpStorage() + const { deleted } = storage.environments.deleteEnvironment( + Number(params.id), + ) + + if (!deleted) { + return status(404, { message: 'Environment not found' }) + } + + return { message: 'Environment deleted' } + }, + { + detail: { + tags: ['HTTP Environments'], + }, + }, + ) + .post( + '/active', + ({ body, status }) => { + const storage = useHttpStorage() + const { notFound } = storage.environments.setActiveEnvironment(body.id) + + if (notFound) { + return status(404, { message: 'Environment not found' }) + } + + return { message: 'Active environment set' } + }, + { + body: 'httpEnvironmentsSetActive', + detail: { + tags: ['HTTP Environments'], + }, + }, + ) + +export default app diff --git a/src/main/api/routes/http-folders.ts b/src/main/api/routes/http-folders.ts new file mode 100644 index 00000000..d169d4e6 --- /dev/null +++ b/src/main/api/routes/http-folders.ts @@ -0,0 +1,165 @@ +import type { HttpFoldersResponse, HttpFoldersTree } from '../dto/http-folders' +import { Elysia } from 'elysia' +import { useHttpStorage } from '../../storage' +import { commonAddResponse } from '../dto/common/response' +import { httpFoldersDTO } from '../dto/http-folders' + +const app = new Elysia({ prefix: '/http-folders' }) + +function parseStorageError( + error: unknown, +): { code: string, message: string } | null { + if (!(error instanceof Error)) { + return null + } + + const separatorIndex = error.message.indexOf(':') + if (separatorIndex <= 0) { + return null + } + + return { + code: error.message.slice(0, separatorIndex), + message: error.message.slice(separatorIndex + 1).trim(), + } +} + +function mapStorageError(status: unknown, error: unknown): never { + const setStatus = status as ( + code: number, + payload: { message: string }, + ) => never + const parsedError = parseStorageError(error) + + if (!parsedError) { + return setStatus(500, { message: 'Internal storage error' }) + } + + if (parsedError.code === 'NAME_CONFLICT') { + return setStatus(409, { message: parsedError.message }) + } + + if (parsedError.code === 'FOLDER_NOT_FOUND') { + return setStatus(404, { message: parsedError.message }) + } + + if ( + parsedError.code === 'INVALID_NAME' + || parsedError.code === 'RESERVED_NAME' + ) { + return setStatus(400, { message: parsedError.message }) + } + + return setStatus(500, { + message: parsedError.message || 'Internal storage error', + }) +} + +app + .use(httpFoldersDTO) + .get( + '/', + () => { + const storage = useHttpStorage() + const result = storage.folders.getFolders() + + return result as HttpFoldersResponse + }, + { + response: 'httpFoldersResponse', + detail: { + tags: ['HTTP Folders'], + }, + }, + ) + .get( + '/tree', + (): any => { + const storage = useHttpStorage() + + return storage.folders.getFoldersTree() as HttpFoldersTree + }, + { + response: 'httpFoldersTreeResponse', + detail: { + tags: ['HTTP Folders'], + }, + }, + ) + .post( + '/', + ({ body, status }) => { + const storage = useHttpStorage() + try { + const { id } = storage.folders.createFolder(body) + + return { id } + } + catch (error) { + return mapStorageError(status, error) + } + }, + { + body: 'httpFoldersAdd', + response: commonAddResponse, + detail: { + tags: ['HTTP Folders'], + }, + }, + ) + .patch( + '/:id', + ({ params, body, status }) => { + const storage = useHttpStorage() + try { + const { invalidInput, notFound } = storage.folders.updateFolder( + Number(params.id), + body, + ) + + if (invalidInput) { + return status(400, { message: 'Need at least one field to update' }) + } + + if (notFound) { + return status(404, { message: 'Folder not found' }) + } + + return { message: 'Folder updated' } + } + catch (error) { + return mapStorageError(status, error) + } + }, + { + body: 'httpFoldersUpdate', + detail: { + tags: ['HTTP Folders'], + }, + }, + ) + .delete( + '/:id', + ({ params, status }) => { + const storage = useHttpStorage() + try { + const { deleted } = storage.folders.deleteFolder(Number(params.id)) + + if (!deleted) { + return status(404, { message: 'Folder not found' }) + } + + return { message: 'Folder deleted' } + } + catch (error) { + return mapStorageError(status, error) + } + }, + { + detail: { + tags: ['HTTP Folders'], + }, + }, + ) + +export default app diff --git a/src/main/api/routes/http-history.ts b/src/main/api/routes/http-history.ts new file mode 100644 index 00000000..bd33c546 --- /dev/null +++ b/src/main/api/routes/http-history.ts @@ -0,0 +1,38 @@ +import type { HttpHistoryResponse } from '../dto/http-history' +import { Elysia } from 'elysia' +import { useHttpStorage } from '../../storage' +import { httpHistoryDTO } from '../dto/http-history' + +const app = new Elysia({ prefix: '/http-history' }) + +app + .use(httpHistoryDTO) + .get( + '/', + () => { + const storage = useHttpStorage() + return storage.history.getEntries() as HttpHistoryResponse + }, + { + response: 'httpHistoryResponse', + detail: { + tags: ['HTTP History'], + }, + }, + ) + .delete( + '/', + () => { + const storage = useHttpStorage() + storage.history.clear() + + return { message: 'History cleared' } + }, + { + detail: { + tags: ['HTTP History'], + }, + }, + ) + +export default app diff --git a/src/main/api/routes/http-requests.ts b/src/main/api/routes/http-requests.ts new file mode 100644 index 00000000..b1dc7272 --- /dev/null +++ b/src/main/api/routes/http-requests.ts @@ -0,0 +1,174 @@ +import type { + HttpRequestItemResponse, + HttpRequestsResponse, +} from '../dto/http-requests' +import { Elysia } from 'elysia' +import { useHttpStorage } from '../../storage' +import { + commonAddResponse, + commonMessageResponse, +} from '../dto/common/response' +import { httpRequestsDTO } from '../dto/http-requests' + +const app = new Elysia({ prefix: '/http-requests' }) + +function parseStorageError( + error: unknown, +): { code: string, message: string } | null { + if (!(error instanceof Error)) { + return null + } + + const separatorIndex = error.message.indexOf(':') + if (separatorIndex <= 0) { + return null + } + + return { + code: error.message.slice(0, separatorIndex), + message: error.message.slice(separatorIndex + 1).trim(), + } +} + +function mapStorageError(status: unknown, error: unknown): never { + const setStatus = status as ( + code: number, + payload: { message: string }, + ) => never + const parsedError = parseStorageError(error) + + if (!parsedError) { + return setStatus(500, { message: 'Internal storage error' }) + } + + if (parsedError.code === 'NAME_CONFLICT') { + return setStatus(409, { message: parsedError.message }) + } + + if (parsedError.code === 'FOLDER_NOT_FOUND') { + return setStatus(404, { message: parsedError.message }) + } + + if ( + parsedError.code === 'INVALID_NAME' + || parsedError.code === 'RESERVED_NAME' + ) { + return setStatus(400, { message: parsedError.message }) + } + + return setStatus(500, { + message: parsedError.message || 'Internal storage error', + }) +} + +app + .use(httpRequestsDTO) + .get( + '/', + () => { + const storage = useHttpStorage() + const result = storage.requests.getRequests() + + return result as HttpRequestsResponse + }, + { + response: 'httpRequestsResponse', + detail: { + tags: ['HTTP Requests'], + }, + }, + ) + .get( + '/:id', + ({ params, status }) => { + const storage = useHttpStorage() + const request = storage.requests.getRequestById(Number(params.id)) + + if (!request) { + return status(404, { message: 'Request not found' }) + } + + return request as HttpRequestItemResponse + }, + { + response: { + 200: 'httpRequestItemResponse', + 404: commonMessageResponse, + }, + detail: { + tags: ['HTTP Requests'], + }, + }, + ) + .post( + '/', + ({ body, status }) => { + const storage = useHttpStorage() + try { + const { id } = storage.requests.createRequest(body) + + return { id } + } + catch (error) { + return mapStorageError(status, error) + } + }, + { + body: 'httpRequestsAdd', + response: commonAddResponse, + detail: { + tags: ['HTTP Requests'], + }, + }, + ) + .patch( + '/:id', + ({ params, body, status }) => { + const storage = useHttpStorage() + try { + const { invalidInput, notFound } = storage.requests.updateRequest( + Number(params.id), + body, + ) + + if (invalidInput) { + return status(400, { message: 'Need at least one field to update' }) + } + + if (notFound) { + return status(404, { message: 'Request not found' }) + } + + return { message: 'Request updated' } + } + catch (error) { + return mapStorageError(status, error) + } + }, + { + body: 'httpRequestsUpdate', + detail: { + tags: ['HTTP Requests'], + }, + }, + ) + .delete( + '/:id', + ({ params, status }) => { + const storage = useHttpStorage() + const { deleted } = storage.requests.deleteRequest(Number(params.id)) + + if (!deleted) { + return status(404, { message: 'Request not found' }) + } + + return { message: 'Request deleted' } + }, + { + detail: { + tags: ['HTTP Requests'], + }, + }, + ) + +export default app diff --git a/src/renderer/services/api/generated/index.ts b/src/renderer/services/api/generated/index.ts index e454fc32..fb9fbe27 100644 --- a/src/renderer/services/api/generated/index.ts +++ b/src/renderer/services/api/generated/index.ts @@ -376,6 +376,202 @@ export interface NoteTagsUpdate { name: string; } +export interface HttpFoldersAdd { + name: string; + parentId?: number | null; +} + +export type HttpFoldersResponse = { + id: number; + name: string; + createdAt: number; + updatedAt: number; + parentId: number | null; + isOpen: number; + orderIndex: number; +}[]; + +export type HttpFoldersTreeResponse = { + id: number; + name: string; + createdAt: number; + updatedAt: number; + parentId: number | null; + isOpen: number; + orderIndex: number; + children: any[]; +}[]; + +export interface HttpFoldersUpdate { + name?: string; + parentId?: number | null; + /** + * @min 0 + * @max 1 + */ + isOpen?: number; + orderIndex?: number; +} + +export interface HttpRequestItemResponse { + id: number; + name: string; + folderId: number | null; + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"; + url: string; + headers: { + key: string; + value: string; + }[]; + query: { + key: string; + value: string; + }[]; + bodyType: "none" | "json" | "text" | "form-urlencoded" | "multipart"; + body: string | null; + formData: { + key: string; + type: "text" | "file"; + value: string; + }[]; + auth: { + type: "none" | "bearer" | "basic"; + token?: string; + username?: string; + password?: string; + }; + description: string; + filePath: string; + createdAt: number; + updatedAt: number; +} + +export interface HttpRequestsAdd { + name: string; + folderId?: number | null; + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"; + url?: string; +} + +export type HttpRequestsResponse = { + id: number; + name: string; + folderId: number | null; + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"; + url: string; + headers: { + key: string; + value: string; + }[]; + query: { + key: string; + value: string; + }[]; + bodyType: "none" | "json" | "text" | "form-urlencoded" | "multipart"; + body: string | null; + formData: { + key: string; + type: "text" | "file"; + value: string; + }[]; + auth: { + type: "none" | "bearer" | "basic"; + token?: string; + username?: string; + password?: string; + }; + description: string; + filePath: string; + createdAt: number; + updatedAt: number; +}[]; + +export interface HttpRequestsUpdate { + name?: string; + folderId?: number | null; + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"; + url?: string; + headers?: { + key: string; + value: string; + }[]; + query?: { + key: string; + value: string; + }[]; + bodyType?: "none" | "json" | "text" | "form-urlencoded" | "multipart"; + body?: string | null; + formData?: { + key: string; + type: "text" | "file"; + value: string; + }[]; + auth?: { + type: "none" | "bearer" | "basic"; + token?: string; + username?: string; + password?: string; + }; + description?: string; +} + +export interface HttpEnvironmentItemResponse { + id: number; + name: string; + variables: object; + createdAt: number; + updatedAt: number; +} + +export interface HttpEnvironmentsAdd { + name: string; + variables?: object; +} + +export interface HttpEnvironmentsResponse { + activeId: number | null; + items: { + id: number; + name: string; + variables: object; + createdAt: number; + updatedAt: number; + }[]; +} + +export interface HttpEnvironmentsSetActive { + id: number | null; +} + +export interface HttpEnvironmentsUpdate { + name?: string; + variables?: object; +} + +export interface HttpHistoryItemResponse { + id: number; + requestId: number | null; + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"; + url: string; + status: number | null; + durationMs: number; + sizeBytes: number; + requestedAt: number; + error?: string; +} + +export type HttpHistoryResponse = { + id: number; + requestId: number | null; + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"; + url: string; + status: number | null; + durationMs: number; + sizeBytes: number; + requestedAt: number; + error?: string; +}[]; + export type QueryParamsType = Record; export type ResponseFormat = keyof Omit; @@ -622,7 +818,7 @@ export class HttpClient { /** * @title massCode API - * @version 5.0.0 + * @version 5.2.0 * * Development documentation */ @@ -1425,4 +1621,307 @@ export class Api< ...params, }), }; + httpFolders = { + /** + * No description + * + * @tags HTTP Folders + * @name GetHttpFolders + * @request GET:/http-folders/ + */ + getHttpFolders: (params: RequestParams = {}) => + this.request({ + path: `/http-folders/`, + method: "GET", + format: "json", + ...params, + }), + + /** + * No description + * + * @tags HTTP Folders + * @name PostHttpFolders + * @request POST:/http-folders/ + */ + postHttpFolders: (data: HttpFoldersAdd, params: RequestParams = {}) => + this.request< + { + id: number | bigint; + }, + any + >({ + path: `/http-folders/`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags HTTP Folders + * @name GetHttpFoldersTree + * @request GET:/http-folders/tree + */ + getHttpFoldersTree: (params: RequestParams = {}) => + this.request({ + path: `/http-folders/tree`, + method: "GET", + format: "json", + ...params, + }), + + /** + * No description + * + * @tags HTTP Folders + * @name PatchHttpFoldersById + * @request PATCH:/http-folders/{id} + */ + patchHttpFoldersById: ( + id: string, + data: HttpFoldersUpdate, + params: RequestParams = {}, + ) => + this.request({ + path: `/http-folders/${id}`, + method: "PATCH", + body: data, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags HTTP Folders + * @name DeleteHttpFoldersById + * @request DELETE:/http-folders/{id} + */ + deleteHttpFoldersById: (id: string, params: RequestParams = {}) => + this.request({ + path: `/http-folders/${id}`, + method: "DELETE", + ...params, + }), + }; + httpRequests = { + /** + * No description + * + * @tags HTTP Requests + * @name GetHttpRequests + * @request GET:/http-requests/ + */ + getHttpRequests: (params: RequestParams = {}) => + this.request({ + path: `/http-requests/`, + method: "GET", + format: "json", + ...params, + }), + + /** + * No description + * + * @tags HTTP Requests + * @name PostHttpRequests + * @request POST:/http-requests/ + */ + postHttpRequests: (data: HttpRequestsAdd, params: RequestParams = {}) => + this.request< + { + id: number | bigint; + }, + any + >({ + path: `/http-requests/`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags HTTP Requests + * @name GetHttpRequestsById + * @request GET:/http-requests/{id} + */ + getHttpRequestsById: (id: string, params: RequestParams = {}) => + this.request< + HttpRequestItemResponse, + { + message: string; + } + >({ + path: `/http-requests/${id}`, + method: "GET", + format: "json", + ...params, + }), + + /** + * No description + * + * @tags HTTP Requests + * @name PatchHttpRequestsById + * @request PATCH:/http-requests/{id} + */ + patchHttpRequestsById: ( + id: string, + data: HttpRequestsUpdate, + params: RequestParams = {}, + ) => + this.request({ + path: `/http-requests/${id}`, + method: "PATCH", + body: data, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags HTTP Requests + * @name DeleteHttpRequestsById + * @request DELETE:/http-requests/{id} + */ + deleteHttpRequestsById: (id: string, params: RequestParams = {}) => + this.request({ + path: `/http-requests/${id}`, + method: "DELETE", + ...params, + }), + }; + httpEnvironments = { + /** + * No description + * + * @tags HTTP Environments + * @name GetHttpEnvironments + * @request GET:/http-environments/ + */ + getHttpEnvironments: (params: RequestParams = {}) => + this.request({ + path: `/http-environments/`, + method: "GET", + format: "json", + ...params, + }), + + /** + * No description + * + * @tags HTTP Environments + * @name PostHttpEnvironments + * @request POST:/http-environments/ + */ + postHttpEnvironments: ( + data: HttpEnvironmentsAdd, + params: RequestParams = {}, + ) => + this.request< + { + id: number | bigint; + }, + any + >({ + path: `/http-environments/`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags HTTP Environments + * @name PatchHttpEnvironmentsById + * @request PATCH:/http-environments/{id} + */ + patchHttpEnvironmentsById: ( + id: string, + data: HttpEnvironmentsUpdate, + params: RequestParams = {}, + ) => + this.request({ + path: `/http-environments/${id}`, + method: "PATCH", + body: data, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags HTTP Environments + * @name DeleteHttpEnvironmentsById + * @request DELETE:/http-environments/{id} + */ + deleteHttpEnvironmentsById: (id: string, params: RequestParams = {}) => + this.request({ + path: `/http-environments/${id}`, + method: "DELETE", + ...params, + }), + + /** + * No description + * + * @tags HTTP Environments + * @name PostHttpEnvironmentsActive + * @request POST:/http-environments/active + */ + postHttpEnvironmentsActive: ( + data: HttpEnvironmentsSetActive, + params: RequestParams = {}, + ) => + this.request({ + path: `/http-environments/active`, + method: "POST", + body: data, + type: ContentType.Json, + ...params, + }), + }; + httpHistory = { + /** + * No description + * + * @tags HTTP History + * @name GetHttpHistory + * @request GET:/http-history/ + */ + getHttpHistory: (params: RequestParams = {}) => + this.request({ + path: `/http-history/`, + method: "GET", + format: "json", + ...params, + }), + + /** + * No description + * + * @tags HTTP History + * @name DeleteHttpHistory + * @request DELETE:/http-history/ + */ + deleteHttpHistory: (params: RequestParams = {}) => + this.request({ + path: `/http-history/`, + method: "DELETE", + ...params, + }), + }; } From ccc817c20439b99728d81083c1bee0def41c5abf Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Thu, 30 Apr 2026 09:35:36 +0300 Subject: [PATCH 07/81] feat(http): add execute IPC handler with undici, env interpolation and history --- package.json | 1 + pnpm-lock.yaml | 3 + src/main/ipc/handlers/http.ts | 446 ++++++++++++++++++++++++++++++++++ src/main/ipc/index.ts | 2 + src/main/types/ipc.ts | 2 +- 5 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 src/main/ipc/handlers/http.ts diff --git a/package.json b/package.json index 10140a69..50fc96ca 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "tailwind-merge": "^3.0.2", "toml": "^3.0.0", "typed.js": "^2.1.0", + "undici": "^6.21.2", "uuid": "^11.1.0", "vue-sonner": "^1.3.0", "vue-virtual-scroller": "2.0.0-beta.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f28b82bc..23e54f7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,6 +191,9 @@ importers: typed.js: specifier: ^2.1.0 version: 2.1.0 + undici: + specifier: ^6.21.2 + version: 6.21.2 uuid: specifier: ^11.1.0 version: 11.1.0 diff --git a/src/main/ipc/handlers/http.ts b/src/main/ipc/handlers/http.ts new file mode 100644 index 00000000..09526e9b --- /dev/null +++ b/src/main/ipc/handlers/http.ts @@ -0,0 +1,446 @@ +import type { IncomingHttpHeaders } from 'node:http' +import type { Dispatcher } from 'undici' +import type { + HttpAuth, + HttpBodyType, + HttpFormDataEntry, + HttpHeaderEntry, + HttpMethod, + HttpQueryEntry, +} from '../../storage/providers/markdown/http/runtime/types' +import { Buffer } from 'node:buffer' +import { readFileSync } from 'node:fs' +import { basename } from 'node:path' +import { ipcMain } from 'electron' +import { request as undiciRequest } from 'undici' +import { useHttpStorage } from '../../storage' + +export interface HttpExecuteRequest { + method: HttpMethod + url: string + headers: HttpHeaderEntry[] + query: HttpQueryEntry[] + bodyType: HttpBodyType + body: string | null + formData: HttpFormDataEntry[] + auth: HttpAuth +} + +export interface HttpExecutePayload { + request: HttpExecuteRequest + requestId: number | null + environmentId: number | null + timeoutMs?: number +} + +export type HttpResponseBodyKind = 'text' | 'json' | 'binary' + +export interface HttpExecuteResult { + status: number | null + statusText: string + headers: HttpHeaderEntry[] + body: string + bodyKind: HttpResponseBodyKind + durationMs: number + sizeBytes: number + truncated: boolean + error?: string +} + +const RESPONSE_BODY_CAP_BYTES = 10 * 1024 * 1024 +const DEFAULT_TIMEOUT_MS = 30_000 +const VAR_PATTERN = /\{\{\s*([\w.-]+)\s*\}\}/g + +export function interpolate( + template: string, + variables: Record, +): string { + return template.replace(VAR_PATTERN, (match, key: string) => { + return Object.prototype.hasOwnProperty.call(variables, key) + ? variables[key] + : match + }) +} + +function interpolateAuth( + auth: HttpAuth, + variables: Record, +): HttpAuth { + return { + type: auth.type, + token: + auth.token !== undefined + ? interpolate(auth.token, variables) + : auth.token, + username: + auth.username !== undefined + ? interpolate(auth.username, variables) + : auth.username, + password: + auth.password !== undefined + ? interpolate(auth.password, variables) + : auth.password, + } +} + +function interpolateRequest( + request: HttpExecuteRequest, + variables: Record, +): HttpExecuteRequest { + return { + method: request.method, + url: interpolate(request.url, variables), + headers: request.headers.map(h => ({ + key: h.key, + value: interpolate(h.value, variables), + })), + query: request.query.map(q => ({ + key: q.key, + value: interpolate(q.value, variables), + })), + bodyType: request.bodyType, + body: + request.body !== null + ? interpolate(request.body, variables) + : request.body, + formData: request.formData.map(entry => ({ + key: entry.key, + type: entry.type, + value: + entry.type === 'text' + ? interpolate(entry.value, variables) + : entry.value, + })), + auth: interpolateAuth(request.auth, variables), + } +} + +export function applyAuth( + auth: HttpAuth, + headers: HttpHeaderEntry[], +): HttpHeaderEntry[] { + if (auth.type === 'bearer' && auth.token) { + return [ + ...headers, + { key: 'Authorization', value: `Bearer ${auth.token}` }, + ] + } + + if (auth.type === 'basic' && auth.username !== undefined) { + const credentials = Buffer.from( + `${auth.username}:${auth.password ?? ''}`, + ).toString('base64') + return [ + ...headers, + { key: 'Authorization', value: `Basic ${credentials}` }, + ] + } + + return headers +} + +function buildUrl(rawUrl: string, query: HttpQueryEntry[]): string { + const url = new URL(rawUrl) + for (const { key, value } of query) { + if (key) { + url.searchParams.append(key, value) + } + } + return url.toString() +} + +function toHeadersObject(entries: HttpHeaderEntry[]): Record { + const obj: Record = {} + for (const { key, value } of entries) { + if (key) { + obj[key] = value + } + } + return obj +} + +interface BuiltBody { + body: Dispatcher.DispatchOptions['body'] | FormData + contentType?: string +} + +export function buildBody( + bodyType: HttpBodyType, + body: string | null, + formData: HttpFormDataEntry[], +): BuiltBody { + switch (bodyType) { + case 'none': + return { body: undefined } + case 'json': + return { body: body ?? '', contentType: 'application/json' } + case 'text': + return { body: body ?? '', contentType: 'text/plain' } + case 'form-urlencoded': + return { + body: body ?? '', + contentType: 'application/x-www-form-urlencoded', + } + case 'multipart': { + const fd = new FormData() + for (const entry of formData) { + if (!entry.key) + continue + if (entry.type === 'file' && entry.value) { + const buffer = readFileSync(entry.value) + const blob = new Blob([buffer]) + fd.append(entry.key, blob, basename(entry.value)) + } + else { + fd.append(entry.key, entry.value ?? '') + } + } + return { body: fd } + } + } +} + +function detectBodyKind(contentType: string | undefined): HttpResponseBodyKind { + const ct = (contentType ?? '').toLowerCase() + if (ct.includes('json')) + return 'json' + if ( + ct.startsWith('text/') + || ct.includes('xml') + || ct.includes('javascript') + || ct.includes('html') + || ct.includes('x-www-form-urlencoded') + ) { + return 'text' + } + return 'binary' +} + +function toHeaderEntries(headers: IncomingHttpHeaders): HttpHeaderEntry[] { + const entries: HttpHeaderEntry[] = [] + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) + continue + if (Array.isArray(value)) { + for (const v of value) { + entries.push({ key, value: v }) + } + } + else { + entries.push({ key, value }) + } + } + return entries +} + +function getContentType(headers: IncomingHttpHeaders): string | undefined { + const value = headers['content-type'] + if (Array.isArray(value)) + return value[0] + return value +} + +async function readBodyCapped( + body: NodeJS.ReadableStream, + cap: number, +): Promise<{ buffer: Buffer, sizeBytes: number, truncated: boolean }> { + const chunks: Buffer[] = [] + let received = 0 + let truncated = false + + for await (const chunk of body as AsyncIterable) { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + if (received + buf.length > cap) { + const remaining = cap - received + if (remaining > 0) { + chunks.push(buf.subarray(0, remaining)) + received += remaining + } + truncated = true + break + } + chunks.push(buf) + received += buf.length + } + + if (truncated) { + const maybeDestroyable = body as unknown as { destroy?: () => void } + if (typeof maybeDestroyable.destroy === 'function') { + maybeDestroyable.destroy() + } + } + + return { + buffer: Buffer.concat(chunks), + sizeBytes: received, + truncated, + } +} + +function resolveEnvironmentVariables( + environmentId: number | null, +): Record { + if (environmentId === null) + return {} + const storage = useHttpStorage() + const env = storage.environments + .getEnvironments() + .find(e => e.id === environmentId) + return env?.variables ?? {} +} + +async function executeHttpRequest( + payload: HttpExecutePayload, +): Promise { + const variables = resolveEnvironmentVariables(payload.environmentId) + const interpolated = interpolateRequest(payload.request, variables) + + const headersWithAuth = applyAuth(interpolated.auth, interpolated.headers) + const headersObj = toHeadersObject(headersWithAuth) + const built = buildBody( + interpolated.bodyType, + interpolated.body, + interpolated.formData, + ) + + const hasContentType = Object.keys(headersObj).some( + k => k.toLowerCase() === 'content-type', + ) + if (!hasContentType && built.contentType) { + headersObj['Content-Type'] = built.contentType + } + + let finalUrl: string + try { + finalUrl = buildUrl(interpolated.url, interpolated.query) + } + catch (error) { + return { + status: null, + statusText: '', + headers: [], + body: '', + bodyKind: 'text', + durationMs: 0, + sizeBytes: 0, + truncated: false, + error: error instanceof Error ? error.message : 'Invalid URL', + } + } + + const timeoutMs = payload.timeoutMs ?? DEFAULT_TIMEOUT_MS + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + const startedAt = Date.now() + const startedAtPerf = performance.now() + + try { + const response = await undiciRequest(finalUrl, { + method: interpolated.method, + headers: headersObj, + body: built.body as Dispatcher.DispatchOptions['body'], + signal: controller.signal, + maxRedirections: 5, + }) + + const { buffer, sizeBytes, truncated } = await readBodyCapped( + response.body as unknown as NodeJS.ReadableStream, + RESPONSE_BODY_CAP_BYTES, + ) + + const durationMs = Math.round(performance.now() - startedAtPerf) + const headerEntries = toHeaderEntries(response.headers) + const contentType = getContentType(response.headers) + const bodyKind = detectBodyKind(contentType) + const text = bodyKind === 'binary' ? '' : buffer.toString('utf-8') + + const result: HttpExecuteResult = { + status: response.statusCode, + statusText: '', + headers: headerEntries, + body: text, + bodyKind, + durationMs, + sizeBytes, + truncated, + } + + appendHistory( + payload, + finalUrl, + interpolated.method, + response.statusCode, + durationMs, + sizeBytes, + startedAt, + ) + + return result + } + catch (error) { + const durationMs = Math.round(performance.now() - startedAtPerf) + const message = error instanceof Error ? error.message : String(error) + const isAbort = error instanceof Error && error.name === 'AbortError' + + appendHistory( + payload, + finalUrl, + interpolated.method, + null, + durationMs, + 0, + startedAt, + isAbort ? `Timeout after ${timeoutMs}ms` : message, + ) + + return { + status: null, + statusText: '', + headers: [], + body: '', + bodyKind: 'text', + durationMs, + sizeBytes: 0, + truncated: false, + error: isAbort ? `Timeout after ${timeoutMs}ms` : message, + } + } + finally { + clearTimeout(timer) + } +} + +function appendHistory( + payload: HttpExecutePayload, + url: string, + method: HttpMethod, + status: number | null, + durationMs: number, + sizeBytes: number, + requestedAt: number, + error?: string, +): void { + try { + const storage = useHttpStorage() + storage.history.appendEntry({ + requestId: payload.requestId, + method, + url, + status, + durationMs, + sizeBytes, + requestedAt, + ...(error ? { error } : {}), + }) + } + catch { + // history is best-effort; never fail the response + } +} + +export function registerHttpHandlers(): void { + ipcMain.handle( + 'spaces:http:execute', + async (_, payload: HttpExecutePayload) => executeHttpRequest(payload), + ) +} diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 1a1e4d90..da985569 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -7,6 +7,7 @@ import { store } from '../store' import { isSqliteFile } from '../utils' import { registerDialogHandlers } from './handlers/dialog' import { registerFsHandlers } from './handlers/fs' +import { registerHttpHandlers } from './handlers/http' import { registerPrettierHandlers } from './handlers/prettier' import { registerSpacesHandlers } from './handlers/spaces' import { registerSystemHandlers } from './handlers/system' @@ -25,6 +26,7 @@ export function registerIPC() { registerFsHandlers() registerThemeHandlers() registerSpacesHandlers() + registerHttpHandlers() ipcMain.on('main-menu:update-context', (_, payload: MainMenuContext) => { updateMainMenu(payload) diff --git a/src/main/types/ipc.ts b/src/main/types/ipc.ts index 6c48f3fc..c940ae0f 100644 --- a/src/main/types/ipc.ts +++ b/src/main/types/ipc.ts @@ -55,7 +55,7 @@ type SystemAction = type PrettierAction = 'format' type FsAction = 'assets' | 'notes-asset' type ThemeAction = 'list' | 'get' | 'open-dir' | 'create-template' | 'changed' -type SpacesAction = 'math:read' | 'math:write' +type SpacesAction = 'math:read' | 'math:write' | 'http:execute' export type MainMenuChannel = CombineWith export type DBChannel = CombineWith From 3927eddf158be006806a5eabd36519c322029386 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Thu, 30 Apr 2026 09:49:08 +0300 Subject: [PATCH 08/81] refactor(http): extract shared types to src/main/types/http.ts Single source of truth for HTTP primitives shared between main, IPC and renderer (renderer tsconfig already includes src/main/types/**). Removes duplicate definitions from runtime/types.ts and inline declarations in the IPC handler. --- src/main/ipc/handlers/http.ts | 38 ++-------- .../providers/markdown/http/runtime/types.ts | 58 +++++---------- src/main/types/http.ts | 72 +++++++++++++++++++ 3 files changed, 95 insertions(+), 73 deletions(-) create mode 100644 src/main/types/http.ts diff --git a/src/main/ipc/handlers/http.ts b/src/main/ipc/handlers/http.ts index 09526e9b..016f3bd4 100644 --- a/src/main/ipc/handlers/http.ts +++ b/src/main/ipc/handlers/http.ts @@ -3,11 +3,15 @@ import type { Dispatcher } from 'undici' import type { HttpAuth, HttpBodyType, + HttpExecutePayload, + HttpExecuteRequest, + HttpExecuteResult, HttpFormDataEntry, HttpHeaderEntry, HttpMethod, HttpQueryEntry, -} from '../../storage/providers/markdown/http/runtime/types' + HttpResponseBodyKind, +} from '../../types/http' import { Buffer } from 'node:buffer' import { readFileSync } from 'node:fs' import { basename } from 'node:path' @@ -15,38 +19,6 @@ import { ipcMain } from 'electron' import { request as undiciRequest } from 'undici' import { useHttpStorage } from '../../storage' -export interface HttpExecuteRequest { - method: HttpMethod - url: string - headers: HttpHeaderEntry[] - query: HttpQueryEntry[] - bodyType: HttpBodyType - body: string | null - formData: HttpFormDataEntry[] - auth: HttpAuth -} - -export interface HttpExecutePayload { - request: HttpExecuteRequest - requestId: number | null - environmentId: number | null - timeoutMs?: number -} - -export type HttpResponseBodyKind = 'text' | 'json' | 'binary' - -export interface HttpExecuteResult { - status: number | null - statusText: string - headers: HttpHeaderEntry[] - body: string - bodyKind: HttpResponseBodyKind - durationMs: number - sizeBytes: number - truncated: boolean - error?: string -} - const RESPONSE_BODY_CAP_BYTES = 10 * 1024 * 1024 const DEFAULT_TIMEOUT_MS = 30_000 const VAR_PATTERN = /\{\{\s*([\w.-]+)\s*\}\}/g diff --git a/src/main/storage/providers/markdown/http/runtime/types.ts b/src/main/storage/providers/markdown/http/runtime/types.ts index a5620aea..f86ac7b9 100644 --- a/src/main/storage/providers/markdown/http/runtime/types.ts +++ b/src/main/storage/providers/markdown/http/runtime/types.ts @@ -1,43 +1,21 @@ -export type HttpMethod = - | 'GET' - | 'POST' - | 'PUT' - | 'PATCH' - | 'DELETE' - | 'HEAD' - | 'OPTIONS' - -export type HttpBodyType = - | 'none' - | 'json' - | 'text' - | 'form-urlencoded' - | 'multipart' - -export type HttpAuthType = 'none' | 'bearer' | 'basic' - -export interface HttpHeaderEntry { - key: string - value: string -} - -export interface HttpQueryEntry { - key: string - value: string -} - -export interface HttpFormDataEntry { - key: string - type: 'text' | 'file' - value: string -} - -export interface HttpAuth { - type: HttpAuthType - token?: string - username?: string - password?: string -} +import type { + HttpAuth, + HttpBodyType, + HttpFormDataEntry, + HttpHeaderEntry, + HttpMethod, + HttpQueryEntry, +} from '../../../../../types/http' + +export type { + HttpAuth, + HttpAuthType, + HttpBodyType, + HttpFormDataEntry, + HttpHeaderEntry, + HttpMethod, + HttpQueryEntry, +} from '../../../../../types/http' export interface HttpRequestFrontmatter { id?: number diff --git a/src/main/types/http.ts b/src/main/types/http.ts new file mode 100644 index 00000000..5cc5aa86 --- /dev/null +++ b/src/main/types/http.ts @@ -0,0 +1,72 @@ +export type HttpMethod = + | 'GET' + | 'POST' + | 'PUT' + | 'PATCH' + | 'DELETE' + | 'HEAD' + | 'OPTIONS' + +export type HttpBodyType = + | 'none' + | 'json' + | 'text' + | 'form-urlencoded' + | 'multipart' + +export type HttpAuthType = 'none' | 'bearer' | 'basic' + +export interface HttpHeaderEntry { + key: string + value: string +} + +export interface HttpQueryEntry { + key: string + value: string +} + +export interface HttpFormDataEntry { + key: string + type: 'text' | 'file' + value: string +} + +export interface HttpAuth { + type: HttpAuthType + token?: string + username?: string + password?: string +} + +export interface HttpExecuteRequest { + method: HttpMethod + url: string + headers: HttpHeaderEntry[] + query: HttpQueryEntry[] + bodyType: HttpBodyType + body: string | null + formData: HttpFormDataEntry[] + auth: HttpAuth +} + +export interface HttpExecutePayload { + request: HttpExecuteRequest + requestId: number | null + environmentId: number | null + timeoutMs?: number +} + +export type HttpResponseBodyKind = 'text' | 'json' | 'binary' + +export interface HttpExecuteResult { + status: number | null + statusText: string + headers: HttpHeaderEntry[] + body: string + bodyKind: HttpResponseBodyKind + durationMs: number + sizeBytes: number + truncated: boolean + error?: string +} From d91d9b19c931abe86d4391007c78e22e2b81e87d Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Thu, 30 Apr 2026 09:49:23 +0300 Subject: [PATCH 09/81] feat(http): add renderer composables for folders, requests, environments, history and execute Module-level reactive state for the HTTP space. Mirrors the notes-space pattern: useHttpFolders/useHttpRequests/useHttpEnvironments/useHttpHistory talk to the REST API, useHttpExecute wraps the spaces:http:execute IPC, useHttpApp owns the cross-composable folderId/requestId selection. useHttpRequests tracks a draft snapshot against the loaded request so the UI can detect dirty state and offer save/discard. Adds the spaces.http.untitledRequest i18n key for default request names. --- src/main/i18n/locales/en_US/ui.json | 3 +- src/main/i18n/locales/ru_RU/ui.json | 3 +- src/renderer/composables/index.ts | 1 + src/renderer/composables/spaces/http/index.ts | 6 + .../composables/spaces/http/useHttpApp.ts | 15 + .../spaces/http/useHttpEnvironments.ts | 106 +++++++ .../composables/spaces/http/useHttpExecute.ts | 87 ++++++ .../composables/spaces/http/useHttpFolders.ts | 146 ++++++++++ .../composables/spaces/http/useHttpHistory.ts | 41 +++ .../spaces/http/useHttpRequests.ts | 263 ++++++++++++++++++ 10 files changed, 669 insertions(+), 2 deletions(-) create mode 100644 src/renderer/composables/spaces/http/index.ts create mode 100644 src/renderer/composables/spaces/http/useHttpApp.ts create mode 100644 src/renderer/composables/spaces/http/useHttpEnvironments.ts create mode 100644 src/renderer/composables/spaces/http/useHttpExecute.ts create mode 100644 src/renderer/composables/spaces/http/useHttpFolders.ts create mode 100644 src/renderer/composables/spaces/http/useHttpHistory.ts create mode 100644 src/renderer/composables/spaces/http/useHttpRequests.ts diff --git a/src/main/i18n/locales/en_US/ui.json b/src/main/i18n/locales/en_US/ui.json index 12e765e1..1cd8311c 100644 --- a/src/main/i18n/locales/en_US/ui.json +++ b/src/main/i18n/locales/en_US/ui.json @@ -35,7 +35,8 @@ "http": { "label": "HTTP", "tooltip": "HTTP client", - "title": "HTTP Client" + "title": "HTTP Client", + "untitledRequest": "Untitled request" }, "tools": { "label": "Tools", diff --git a/src/main/i18n/locales/ru_RU/ui.json b/src/main/i18n/locales/ru_RU/ui.json index b9cd5dce..ff2c6acb 100644 --- a/src/main/i18n/locales/ru_RU/ui.json +++ b/src/main/i18n/locales/ru_RU/ui.json @@ -35,7 +35,8 @@ "http": { "label": "HTTP", "tooltip": "HTTP-клиент", - "title": "HTTP-клиент" + "title": "HTTP-клиент", + "untitledRequest": "Безымянный запрос" }, "tools": { "label": "Инструменты", diff --git a/src/renderer/composables/index.ts b/src/renderer/composables/index.ts index a8048a88..4e2c19ee 100644 --- a/src/renderer/composables/index.ts +++ b/src/renderer/composables/index.ts @@ -1,4 +1,5 @@ export * from './math-notebook' +export * from './spaces/http' export * from './spaces/notes' export * from './useActivityTracker' export * from './useApp' diff --git a/src/renderer/composables/spaces/http/index.ts b/src/renderer/composables/spaces/http/index.ts new file mode 100644 index 00000000..39113e99 --- /dev/null +++ b/src/renderer/composables/spaces/http/index.ts @@ -0,0 +1,6 @@ +export { useHttpApp } from './useHttpApp' +export { useHttpEnvironments } from './useHttpEnvironments' +export { useHttpExecute } from './useHttpExecute' +export { useHttpFolders } from './useHttpFolders' +export { useHttpHistory } from './useHttpHistory' +export { useHttpRequests } from './useHttpRequests' diff --git a/src/renderer/composables/spaces/http/useHttpApp.ts b/src/renderer/composables/spaces/http/useHttpApp.ts new file mode 100644 index 00000000..84af7080 --- /dev/null +++ b/src/renderer/composables/spaces/http/useHttpApp.ts @@ -0,0 +1,15 @@ +export interface HttpSpaceState { + folderId?: number + requestId?: number +} + +const httpState = reactive({}) + +const isHttpSpaceInitialized = ref(false) + +export function useHttpApp() { + return { + httpState, + isHttpSpaceInitialized, + } +} diff --git a/src/renderer/composables/spaces/http/useHttpEnvironments.ts b/src/renderer/composables/spaces/http/useHttpEnvironments.ts new file mode 100644 index 00000000..471d9b77 --- /dev/null +++ b/src/renderer/composables/spaces/http/useHttpEnvironments.ts @@ -0,0 +1,106 @@ +import type { + HttpEnvironmentsAdd, + HttpEnvironmentsResponse, + HttpEnvironmentsUpdate, +} from '@/services/api/generated' +import { markPersistedStorageMutation } from '@/composables/useStorageMutation' +import { api } from '@/services/api' + +export type HttpEnvironment = HttpEnvironmentsResponse['items'][number] + +const environments = shallowRef([]) +const activeEnvironmentId = ref(null) + +const activeEnvironment = computed(() => { + if (activeEnvironmentId.value === null) + return null + return ( + environments.value.find(env => env.id === activeEnvironmentId.value) + ?? null + ) +}) + +async function getHttpEnvironments() { + try { + const { data } = await api.httpEnvironments.getHttpEnvironments() + environments.value = data.items + activeEnvironmentId.value = data.activeId + } + catch (error) { + console.error(error) + } +} + +async function createHttpEnvironment(payload: HttpEnvironmentsAdd) { + try { + markPersistedStorageMutation() + const { data } = await api.httpEnvironments.postHttpEnvironments(payload) + await getHttpEnvironments() + return Number(data.id) + } + catch (error) { + console.error(error) + } +} + +async function updateHttpEnvironment( + environmentId: number, + data: HttpEnvironmentsUpdate, +) { + try { + markPersistedStorageMutation() + await api.httpEnvironments.patchHttpEnvironmentsById( + String(environmentId), + data, + ) + await getHttpEnvironments() + } + catch (error) { + console.error(error) + } +} + +async function deleteHttpEnvironment(environmentId: number) { + try { + markPersistedStorageMutation() + await api.httpEnvironments.deleteHttpEnvironmentsById( + String(environmentId), + ) + await getHttpEnvironments() + } + catch (error) { + console.error(error) + } +} + +async function setActiveHttpEnvironment(environmentId: number | null) { + try { + markPersistedStorageMutation() + await api.httpEnvironments.postHttpEnvironmentsActive({ + id: environmentId, + }) + activeEnvironmentId.value = environmentId + } + catch (error) { + console.error(error) + } +} + +function resetHttpEnvironmentsState() { + environments.value = [] + activeEnvironmentId.value = null +} + +export function useHttpEnvironments() { + return { + activeEnvironment, + activeEnvironmentId, + createHttpEnvironment, + deleteHttpEnvironment, + environments, + getHttpEnvironments, + resetHttpEnvironmentsState, + setActiveHttpEnvironment, + updateHttpEnvironment, + } +} diff --git a/src/renderer/composables/spaces/http/useHttpExecute.ts b/src/renderer/composables/spaces/http/useHttpExecute.ts new file mode 100644 index 00000000..f84bbd05 --- /dev/null +++ b/src/renderer/composables/spaces/http/useHttpExecute.ts @@ -0,0 +1,87 @@ +import type { + HttpExecutePayload, + HttpExecuteRequest, + HttpExecuteResult, +} from '~/main/types/http' +import { markPersistedStorageMutation } from '@/composables/useStorageMutation' +import { ipc } from '@/electron' +import { useHttpEnvironments } from './useHttpEnvironments' +import { useHttpRequests } from './useHttpRequests' + +export type HttpResponse = HttpExecuteResult + +const isExecuting = ref(false) +const lastResponse = shallowRef(null) +const lastError = ref(null) + +const { currentDraft, currentRequest } = useHttpRequests() +const { activeEnvironmentId } = useHttpEnvironments() + +function buildExecuteRequest(): HttpExecuteRequest | null { + const draft = currentDraft.value + if (!draft) + return null + + return { + method: draft.method, + url: draft.url, + headers: draft.headers.map(h => ({ ...h })), + query: draft.query.map(q => ({ ...q })), + bodyType: draft.bodyType, + body: draft.body, + formData: draft.formData.map(f => ({ ...f })), + auth: { ...draft.auth }, + } +} + +async function executeCurrentRequest(): Promise { + const request = buildExecuteRequest() + if (!request) + return null + + const payload: HttpExecutePayload = { + request, + requestId: currentRequest.value?.id ?? null, + environmentId: activeEnvironmentId.value, + } + + isExecuting.value = true + lastError.value = null + + try { + markPersistedStorageMutation() + const response = (await ipc.invoke( + 'spaces:http:execute', + payload, + )) as HttpResponse + lastResponse.value = response + if (response.error) { + lastError.value = response.error + } + return response + } + catch (error) { + const message = error instanceof Error ? error.message : String(error) + lastError.value = message + return null + } + finally { + isExecuting.value = false + } +} + +function resetHttpExecuteState() { + isExecuting.value = false + lastResponse.value = null + lastError.value = null +} + +export function useHttpExecute() { + return { + executeCurrentRequest, + isExecuting, + lastError, + lastResponse, + resetHttpExecuteState, + } +} diff --git a/src/renderer/composables/spaces/http/useHttpFolders.ts b/src/renderer/composables/spaces/http/useHttpFolders.ts new file mode 100644 index 00000000..0404b70e --- /dev/null +++ b/src/renderer/composables/spaces/http/useHttpFolders.ts @@ -0,0 +1,146 @@ +import type { + HttpFoldersResponse, + HttpFoldersTreeResponse, + HttpFoldersUpdate, +} from '@/services/api/generated' +import { markPersistedStorageMutation } from '@/composables/useStorageMutation' +import { i18n } from '@/electron' +import { api } from '@/services/api' +import { useHttpApp } from './useHttpApp' + +export type HttpFolderItem = HttpFoldersResponse[number] +export type HttpFolderTreeItem = HttpFoldersTreeResponse[number] + +const folders = shallowRef([]) +const folderTree = shallowRef([]) + +const renameFolderId = ref(null) + +const { httpState } = useHttpApp() + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function getNextIndexedName(baseName: string, existingNames: string[]): string { + const normalizedBase = baseName.trim() + const indexedNameRe = new RegExp( + `^${escapeRegExp(normalizedBase)}(?:\\s+(\\d+))?$`, + 'i', + ) + + let maxIndex = 0 + existingNames.forEach((name) => { + const match = name.trim().match(indexedNameRe) + if (!match) + return + const index = match[1] ? Number(match[1]) : 0 + if (Number.isFinite(index)) { + maxIndex = Math.max(maxIndex, index) + } + }) + return `${normalizedBase} ${maxIndex + 1}` +} + +function getNextUntitledFolderName(parentId: number | null): string { + const siblingNames = folders.value + .filter(folder => (folder.parentId ?? null) === parentId) + .map(folder => folder.name) + + return getNextIndexedName(i18n.t('folder.untitled'), siblingNames) +} + +async function getHttpFolders() { + try { + const [list, tree] = await Promise.all([ + api.httpFolders.getHttpFolders(), + api.httpFolders.getHttpFoldersTree(), + ]) + folders.value = list.data + folderTree.value = tree.data + } + catch (error) { + console.error(error) + } +} + +async function createHttpFolder(parentId?: number) { + try { + const name = getNextUntitledFolderName(parentId ?? null) + markPersistedStorageMutation() + const { data } = await api.httpFolders.postHttpFolders({ + name, + ...(parentId !== undefined && { parentId }), + }) + + if (parentId) { + await updateHttpFolder(parentId, { isOpen: 1 }) + } + + await getHttpFolders() + + return Number(data.id) + } + catch (error) { + console.error(error) + } +} + +async function createHttpFolderAndSelect(parentId?: number) { + const id = await createHttpFolder(parentId) + if (id) { + httpState.folderId = id + renameFolderId.value = id + } +} + +async function updateHttpFolder(folderId: number, data: HttpFoldersUpdate) { + try { + markPersistedStorageMutation() + await api.httpFolders.patchHttpFoldersById(String(folderId), data) + await getHttpFolders() + } + catch (error) { + console.error(error) + } +} + +async function deleteHttpFolder(folderId: number) { + try { + markPersistedStorageMutation() + await api.httpFolders.deleteHttpFoldersById(String(folderId)) + if (httpState.folderId === folderId) { + httpState.folderId = undefined + } + await getHttpFolders() + } + catch (error) { + console.error(error) + } +} + +function selectHttpFolder(folderId: number | undefined) { + httpState.folderId = folderId +} + +function resetHttpFoldersState() { + folders.value = [] + folderTree.value = [] + renameFolderId.value = null + httpState.folderId = undefined +} + +export function useHttpFolders() { + return { + createHttpFolder, + createHttpFolderAndSelect, + deleteHttpFolder, + folderTree, + folders, + getHttpFolders, + renameFolderId, + resetHttpFoldersState, + selectHttpFolder, + updateHttpFolder, + } +} diff --git a/src/renderer/composables/spaces/http/useHttpHistory.ts b/src/renderer/composables/spaces/http/useHttpHistory.ts new file mode 100644 index 00000000..3c69bcb8 --- /dev/null +++ b/src/renderer/composables/spaces/http/useHttpHistory.ts @@ -0,0 +1,41 @@ +import type { HttpHistoryResponse } from '@/services/api/generated' +import { markPersistedStorageMutation } from '@/composables/useStorageMutation' +import { api } from '@/services/api' + +export type HttpHistoryItem = HttpHistoryResponse[number] + +const history = shallowRef([]) + +async function getHttpHistory() { + try { + const { data } = await api.httpHistory.getHttpHistory() + history.value = data + } + catch (error) { + console.error(error) + } +} + +async function clearHttpHistory() { + try { + markPersistedStorageMutation() + await api.httpHistory.deleteHttpHistory() + history.value = [] + } + catch (error) { + console.error(error) + } +} + +function resetHttpHistoryState() { + history.value = [] +} + +export function useHttpHistory() { + return { + clearHttpHistory, + getHttpHistory, + history, + resetHttpHistoryState, + } +} diff --git a/src/renderer/composables/spaces/http/useHttpRequests.ts b/src/renderer/composables/spaces/http/useHttpRequests.ts new file mode 100644 index 00000000..481ef0e1 --- /dev/null +++ b/src/renderer/composables/spaces/http/useHttpRequests.ts @@ -0,0 +1,263 @@ +import type { + HttpRequestItemResponse, + HttpRequestsAdd, + HttpRequestsResponse, + HttpRequestsUpdate, +} from '@/services/api/generated' +import { markPersistedStorageMutation } from '@/composables/useStorageMutation' +import { i18n } from '@/electron' +import { api } from '@/services/api' +import { useHttpApp } from './useHttpApp' + +export type HttpRequestListItem = HttpRequestsResponse[number] +export type HttpRequest = HttpRequestItemResponse + +export type HttpRequestDraft = Pick< + HttpRequest, + | 'name' + | 'folderId' + | 'method' + | 'url' + | 'headers' + | 'query' + | 'bodyType' + | 'body' + | 'formData' + | 'auth' + | 'description' +> + +const requests = shallowRef([]) +const currentRequest = shallowRef(null) +const currentDraft = ref(null) + +const renameRequestId = ref(null) + +const { httpState } = useHttpApp() + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function getNextIndexedName(baseName: string, existingNames: string[]): string { + const normalizedBase = baseName.trim() + const indexedNameRe = new RegExp( + `^${escapeRegExp(normalizedBase)}(?:\\s+(\\d+))?$`, + 'i', + ) + + let maxIndex = 0 + existingNames.forEach((name) => { + const match = name.trim().match(indexedNameRe) + if (!match) + return + const index = match[1] ? Number(match[1]) : 0 + if (Number.isFinite(index)) { + maxIndex = Math.max(maxIndex, index) + } + }) + return `${normalizedBase} ${maxIndex + 1}` +} + +function getNextUntitledRequestName(folderId: number | null): string { + const siblingNames = requests.value + .filter(request => (request.folderId ?? null) === folderId) + .map(request => request.name) + + return getNextIndexedName( + i18n.t('spaces.http.untitledRequest'), + siblingNames, + ) +} + +function toDraft(request: HttpRequest): HttpRequestDraft { + return { + name: request.name, + folderId: request.folderId, + method: request.method, + url: request.url, + headers: request.headers.map(h => ({ ...h })), + query: request.query.map(q => ({ ...q })), + bodyType: request.bodyType, + body: request.body, + formData: request.formData.map(f => ({ ...f })), + auth: { ...request.auth }, + description: request.description, + } +} + +const isCurrentRequestDirty = computed(() => { + if (!currentRequest.value || !currentDraft.value) + return false + return ( + JSON.stringify(toDraft(currentRequest.value)) + !== JSON.stringify(currentDraft.value) + ) +}) + +async function getHttpRequests() { + try { + const { data } = await api.httpRequests.getHttpRequests() + requests.value = data + } + catch (error) { + console.error(error) + } +} + +async function getHttpRequestById( + requestId: number, +): Promise { + try { + const { data } = await api.httpRequests.getHttpRequestsById( + String(requestId), + ) + return data + } + catch (error) { + console.error(error) + return null + } +} + +async function createHttpRequest(payload?: Partial) { + try { + const folderId = payload?.folderId ?? null + const name = payload?.name?.trim() || getNextUntitledRequestName(folderId) + + markPersistedStorageMutation() + const { data } = await api.httpRequests.postHttpRequests({ + name, + folderId, + ...(payload?.method && { method: payload.method }), + ...(payload?.url !== undefined && { url: payload.url }), + }) + + await getHttpRequests() + + return Number(data.id) + } + catch (error) { + console.error(error) + } +} + +async function createHttpRequestAndSelect(payload?: Partial) { + const id = await createHttpRequest(payload) + if (id) { + await selectHttpRequest(id) + renameRequestId.value = id + } +} + +async function updateHttpRequest(requestId: number, data: HttpRequestsUpdate) { + try { + markPersistedStorageMutation() + await api.httpRequests.patchHttpRequestsById(String(requestId), data) + await getHttpRequests() + if (currentRequest.value?.id === requestId) { + const fresh = await getHttpRequestById(requestId) + if (fresh) { + currentRequest.value = fresh + currentDraft.value = toDraft(fresh) + } + } + } + catch (error) { + console.error(error) + } +} + +async function deleteHttpRequest(requestId: number) { + try { + markPersistedStorageMutation() + await api.httpRequests.deleteHttpRequestsById(String(requestId)) + if (httpState.requestId === requestId) { + httpState.requestId = undefined + currentRequest.value = null + currentDraft.value = null + } + await getHttpRequests() + } + catch (error) { + console.error(error) + } +} + +async function selectHttpRequest(requestId: number | undefined) { + httpState.requestId = requestId + + if (requestId === undefined) { + currentRequest.value = null + currentDraft.value = null + return + } + + const fresh = await getHttpRequestById(requestId) + if (fresh) { + currentRequest.value = fresh + currentDraft.value = toDraft(fresh) + } + else { + currentRequest.value = null + currentDraft.value = null + } +} + +async function saveCurrentRequest() { + if (!currentRequest.value || !currentDraft.value) + return + if (!isCurrentRequestDirty.value) + return + + const draft = currentDraft.value + const update: HttpRequestsUpdate = { + name: draft.name, + folderId: draft.folderId, + method: draft.method, + url: draft.url, + headers: draft.headers, + query: draft.query, + bodyType: draft.bodyType, + body: draft.body, + formData: draft.formData, + auth: draft.auth, + description: draft.description, + } + + await updateHttpRequest(currentRequest.value.id, update) +} + +function discardCurrentRequestChanges() { + if (!currentRequest.value) + return + currentDraft.value = toDraft(currentRequest.value) +} + +function resetHttpRequestsState() { + requests.value = [] + currentRequest.value = null + currentDraft.value = null + renameRequestId.value = null + httpState.requestId = undefined +} + +export function useHttpRequests() { + return { + createHttpRequest, + createHttpRequestAndSelect, + currentDraft, + currentRequest, + deleteHttpRequest, + discardCurrentRequestChanges, + getHttpRequestById, + getHttpRequests, + isCurrentRequestDirty, + renameRequestId, + requests, + resetHttpRequestsState, + saveCurrentRequest, + selectHttpRequest, + updateHttpRequest, + } +} From 624ea4c90d37d357dc3c5c040936b718b5196124 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Thu, 30 Apr 2026 09:55:35 +0300 Subject: [PATCH 10/81] feat(http): scaffold 3-column space layout with sidebar/editor/response HttpSpace view wires LayoutThreeColumn with HttpSidebar, RequestEditor and ResponsePanel placeholders. useHttpSpaceInit kicks off folders/requests/ environments/history fetches once on mount. Real component bodies follow in Phase 6.2-6.5. --- src/renderer/components/http/HttpSidebar.vue | 20 +++++++++ .../components/http/RequestEditor.vue | 26 +++++++++++ .../components/http/ResponsePanel.vue | 26 +++++++++++ src/renderer/composables/spaces/http/index.ts | 1 + .../spaces/http/useHttpSpaceInit.ts | 43 +++++++++++++++++++ src/renderer/views/HttpSpace.vue | 25 ++++++++--- 6 files changed, 134 insertions(+), 7 deletions(-) create mode 100644 src/renderer/components/http/HttpSidebar.vue create mode 100644 src/renderer/components/http/RequestEditor.vue create mode 100644 src/renderer/components/http/ResponsePanel.vue create mode 100644 src/renderer/composables/spaces/http/useHttpSpaceInit.ts diff --git a/src/renderer/components/http/HttpSidebar.vue b/src/renderer/components/http/HttpSidebar.vue new file mode 100644 index 00000000..4e73dbdf --- /dev/null +++ b/src/renderer/components/http/HttpSidebar.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/renderer/components/http/RequestEditor.vue b/src/renderer/components/http/RequestEditor.vue new file mode 100644 index 00000000..2cea49c4 --- /dev/null +++ b/src/renderer/components/http/RequestEditor.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/renderer/components/http/ResponsePanel.vue b/src/renderer/components/http/ResponsePanel.vue new file mode 100644 index 00000000..56ffcd89 --- /dev/null +++ b/src/renderer/components/http/ResponsePanel.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/renderer/composables/spaces/http/index.ts b/src/renderer/composables/spaces/http/index.ts index 39113e99..af27f56b 100644 --- a/src/renderer/composables/spaces/http/index.ts +++ b/src/renderer/composables/spaces/http/index.ts @@ -4,3 +4,4 @@ export { useHttpExecute } from './useHttpExecute' export { useHttpFolders } from './useHttpFolders' export { useHttpHistory } from './useHttpHistory' export { useHttpRequests } from './useHttpRequests' +export { resetHttpSpaceInit, useHttpSpaceInit } from './useHttpSpaceInit' diff --git a/src/renderer/composables/spaces/http/useHttpSpaceInit.ts b/src/renderer/composables/spaces/http/useHttpSpaceInit.ts new file mode 100644 index 00000000..a66190fb --- /dev/null +++ b/src/renderer/composables/spaces/http/useHttpSpaceInit.ts @@ -0,0 +1,43 @@ +import { useHttpApp } from './useHttpApp' +import { useHttpEnvironments } from './useHttpEnvironments' +import { useHttpFolders } from './useHttpFolders' +import { useHttpHistory } from './useHttpHistory' +import { useHttpRequests } from './useHttpRequests' + +const { isHttpSpaceInitialized } = useHttpApp() +const { getHttpFolders } = useHttpFolders() +const { getHttpRequests } = useHttpRequests() +const { getHttpEnvironments } = useHttpEnvironments() +const { getHttpHistory } = useHttpHistory() + +export function resetHttpSpaceInit() { + isHttpSpaceInitialized.value = false +} + +async function initHttpSpace() { + if (isHttpSpaceInitialized.value) + return + + const results = await Promise.allSettled([ + getHttpFolders(), + getHttpRequests(), + getHttpEnvironments(), + getHttpHistory(), + ]) + + results.forEach((result) => { + if (result.status === 'rejected') { + console.error('HTTP space init error:', result.reason) + } + }) + + isHttpSpaceInitialized.value = results.every( + result => result.status === 'fulfilled', + ) +} + +export function useHttpSpaceInit() { + return { + initHttpSpace, + } +} diff --git a/src/renderer/views/HttpSpace.vue b/src/renderer/views/HttpSpace.vue index e584d31e..786709e1 100644 --- a/src/renderer/views/HttpSpace.vue +++ b/src/renderer/views/HttpSpace.vue @@ -1,8 +1,10 @@ From 25ed720fb3fa67f93139d2bc79b42ff5eb898456 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Thu, 30 Apr 2026 10:00:42 +0300 Subject: [PATCH 11/81] feat(http): render sidebar tree with folders, requests and context menus --- src/main/i18n/locales/en_US/ui.json | 6 +- src/main/i18n/locales/ru_RU/ui.json | 6 +- .../components/http/HttpMethodBadge.vue | 47 ++++++ src/renderer/components/http/HttpSidebar.vue | 72 +++++++-- .../components/http/HttpSidebarFolderItem.vue | 146 ++++++++++++++++++ .../http/HttpSidebarRequestItem.vue | 107 +++++++++++++ 6 files changed, 371 insertions(+), 13 deletions(-) create mode 100644 src/renderer/components/http/HttpMethodBadge.vue create mode 100644 src/renderer/components/http/HttpSidebarFolderItem.vue create mode 100644 src/renderer/components/http/HttpSidebarRequestItem.vue diff --git a/src/main/i18n/locales/en_US/ui.json b/src/main/i18n/locales/en_US/ui.json index 1cd8311c..85d57381 100644 --- a/src/main/i18n/locales/en_US/ui.json +++ b/src/main/i18n/locales/en_US/ui.json @@ -36,7 +36,11 @@ "label": "HTTP", "tooltip": "HTTP client", "title": "HTTP Client", - "untitledRequest": "Untitled request" + "untitledRequest": "Untitled request", + "empty": "No requests yet", + "action": { + "newRequest": "New request" + } }, "tools": { "label": "Tools", diff --git a/src/main/i18n/locales/ru_RU/ui.json b/src/main/i18n/locales/ru_RU/ui.json index ff2c6acb..e5d85d46 100644 --- a/src/main/i18n/locales/ru_RU/ui.json +++ b/src/main/i18n/locales/ru_RU/ui.json @@ -36,7 +36,11 @@ "label": "HTTP", "tooltip": "HTTP-клиент", "title": "HTTP-клиент", - "untitledRequest": "Безымянный запрос" + "untitledRequest": "Безымянный запрос", + "empty": "Пока нет запросов", + "action": { + "newRequest": "Новый запрос" + } }, "tools": { "label": "Инструменты", diff --git a/src/renderer/components/http/HttpMethodBadge.vue b/src/renderer/components/http/HttpMethodBadge.vue new file mode 100644 index 00000000..a1ddabb6 --- /dev/null +++ b/src/renderer/components/http/HttpMethodBadge.vue @@ -0,0 +1,47 @@ + + + diff --git a/src/renderer/components/http/HttpSidebar.vue b/src/renderer/components/http/HttpSidebar.vue index 4e73dbdf..9f32484f 100644 --- a/src/renderer/components/http/HttpSidebar.vue +++ b/src/renderer/components/http/HttpSidebar.vue @@ -1,20 +1,70 @@ diff --git a/src/renderer/components/http/HttpSidebarFolderItem.vue b/src/renderer/components/http/HttpSidebarFolderItem.vue new file mode 100644 index 00000000..3a470c04 --- /dev/null +++ b/src/renderer/components/http/HttpSidebarFolderItem.vue @@ -0,0 +1,146 @@ + + + diff --git a/src/renderer/components/http/HttpSidebarRequestItem.vue b/src/renderer/components/http/HttpSidebarRequestItem.vue new file mode 100644 index 00000000..c02f0ec4 --- /dev/null +++ b/src/renderer/components/http/HttpSidebarRequestItem.vue @@ -0,0 +1,107 @@ + + + From a8b1f9b8cc87675ee57fc5a06edbe65872086a48 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Thu, 30 Apr 2026 10:08:30 +0300 Subject: [PATCH 12/81] feat(http): split layout into folders sidebar, requests list and editor pane --- src/main/i18n/locales/en_US/ui.json | 20 +++ src/main/i18n/locales/ru_RU/ui.json | 20 +++ src/renderer/components/http/HttpSidebar.vue | 79 +++------ .../components/http/HttpSidebarFolderItem.vue | 42 ++--- .../http/HttpSidebarRequestItem.vue | 107 ------------ .../components/http/KeyValueTable.vue | 71 ++++++++ .../components/http/RequestEditor.vue | 156 ++++++++++++++++-- .../components/http/RequestEditorPane.vue | 19 +++ src/renderer/components/http/RequestsList.vue | 36 ++++ .../components/http/RequestsListHeader.vue | 43 +++++ .../components/http/RequestsListItem.vue | 113 +++++++++++++ src/renderer/views/HttpSpace.vue | 4 +- 12 files changed, 512 insertions(+), 198 deletions(-) delete mode 100644 src/renderer/components/http/HttpSidebarRequestItem.vue create mode 100644 src/renderer/components/http/KeyValueTable.vue create mode 100644 src/renderer/components/http/RequestEditorPane.vue create mode 100644 src/renderer/components/http/RequestsList.vue create mode 100644 src/renderer/components/http/RequestsListHeader.vue create mode 100644 src/renderer/components/http/RequestsListItem.vue diff --git a/src/main/i18n/locales/en_US/ui.json b/src/main/i18n/locales/en_US/ui.json index 85d57381..f5a34f94 100644 --- a/src/main/i18n/locales/en_US/ui.json +++ b/src/main/i18n/locales/en_US/ui.json @@ -38,8 +38,28 @@ "title": "HTTP Client", "untitledRequest": "Untitled request", "empty": "No requests yet", + "allRequests": "All Requests", "action": { "newRequest": "New request" + }, + "editor": { + "noSelected": "No request selected", + "urlPlaceholder": "Enter request URL", + "send": "Send", + "save": "Save", + "tabs": { + "params": "Params", + "headers": "Headers", + "body": "Body", + "auth": "Auth", + "description": "Description" + }, + "keyValue": { + "key": "Key", + "value": "Value", + "addRow": "Add row", + "empty": "No entries" + } } }, "tools": { diff --git a/src/main/i18n/locales/ru_RU/ui.json b/src/main/i18n/locales/ru_RU/ui.json index e5d85d46..8e700320 100644 --- a/src/main/i18n/locales/ru_RU/ui.json +++ b/src/main/i18n/locales/ru_RU/ui.json @@ -38,8 +38,28 @@ "title": "HTTP-клиент", "untitledRequest": "Безымянный запрос", "empty": "Пока нет запросов", + "allRequests": "Все запросы", "action": { "newRequest": "Новый запрос" + }, + "editor": { + "noSelected": "Запрос не выбран", + "urlPlaceholder": "Введите URL запроса", + "send": "Отправить", + "save": "Сохранить", + "tabs": { + "params": "Параметры", + "headers": "Заголовки", + "body": "Тело", + "auth": "Авторизация", + "description": "Описание" + }, + "keyValue": { + "key": "Ключ", + "value": "Значение", + "addRow": "Добавить строку", + "empty": "Нет записей" + } } }, "tools": { diff --git a/src/renderer/components/http/HttpSidebar.vue b/src/renderer/components/http/HttpSidebar.vue index 9f32484f..2be0d885 100644 --- a/src/renderer/components/http/HttpSidebar.vue +++ b/src/renderer/components/http/HttpSidebar.vue @@ -1,70 +1,43 @@ diff --git a/src/renderer/components/http/HttpSidebarFolderItem.vue b/src/renderer/components/http/HttpSidebarFolderItem.vue index 3a470c04..57c952ef 100644 --- a/src/renderer/components/http/HttpSidebarFolderItem.vue +++ b/src/renderer/components/http/HttpSidebarFolderItem.vue @@ -1,8 +1,7 @@ - - diff --git a/src/renderer/components/http/KeyValueTable.vue b/src/renderer/components/http/KeyValueTable.vue new file mode 100644 index 00000000..8a05a7f8 --- /dev/null +++ b/src/renderer/components/http/KeyValueTable.vue @@ -0,0 +1,71 @@ + + + diff --git a/src/renderer/components/http/RequestEditor.vue b/src/renderer/components/http/RequestEditor.vue index 2cea49c4..a617d3b8 100644 --- a/src/renderer/components/http/RequestEditor.vue +++ b/src/renderer/components/http/RequestEditor.vue @@ -1,26 +1,148 @@ diff --git a/src/renderer/components/http/RequestEditorPane.vue b/src/renderer/components/http/RequestEditorPane.vue new file mode 100644 index 00000000..1c29da72 --- /dev/null +++ b/src/renderer/components/http/RequestEditorPane.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/renderer/components/http/RequestsList.vue b/src/renderer/components/http/RequestsList.vue new file mode 100644 index 00000000..5a20822f --- /dev/null +++ b/src/renderer/components/http/RequestsList.vue @@ -0,0 +1,36 @@ + + + diff --git a/src/renderer/components/http/RequestsListHeader.vue b/src/renderer/components/http/RequestsListHeader.vue new file mode 100644 index 00000000..c0a60394 --- /dev/null +++ b/src/renderer/components/http/RequestsListHeader.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/renderer/components/http/RequestsListItem.vue b/src/renderer/components/http/RequestsListItem.vue new file mode 100644 index 00000000..1d46342e --- /dev/null +++ b/src/renderer/components/http/RequestsListItem.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/src/renderer/views/HttpSpace.vue b/src/renderer/views/HttpSpace.vue index 786709e1..d24de897 100644 --- a/src/renderer/views/HttpSpace.vue +++ b/src/renderer/views/HttpSpace.vue @@ -20,10 +20,10 @@ onMounted(() => { From 028d0aa0cc1cc0fe8ef563fad7c0aead0ae52b0f Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Thu, 30 Apr 2026 10:10:56 +0300 Subject: [PATCH 13/81] fix(http): use registered Http* component names and add search input --- .../components/http/RequestEditor.vue | 4 +-- .../components/http/RequestEditorPane.vue | 4 +-- src/renderer/components/http/RequestsList.vue | 18 ++++++++---- .../components/http/RequestsListHeader.vue | 28 +++++++++++++++++-- src/renderer/views/HttpSpace.vue | 4 +-- 5 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/renderer/components/http/RequestEditor.vue b/src/renderer/components/http/RequestEditor.vue index a617d3b8..ad4eb297 100644 --- a/src/renderer/components/http/RequestEditor.vue +++ b/src/renderer/components/http/RequestEditor.vue @@ -122,10 +122,10 @@ async function onSave() {
- + - + diff --git a/src/renderer/components/http/RequestEditorPane.vue b/src/renderer/components/http/RequestEditorPane.vue index 1c29da72..3d9cb372 100644 --- a/src/renderer/components/http/RequestEditorPane.vue +++ b/src/renderer/components/http/RequestEditorPane.vue @@ -7,13 +7,13 @@ const { currentRequest } = useHttpRequests() diff --git a/src/renderer/components/http/RequestsList.vue b/src/renderer/components/http/RequestsList.vue index 5a20822f..da4d6ca3 100644 --- a/src/renderer/components/http/RequestsList.vue +++ b/src/renderer/components/http/RequestsList.vue @@ -5,17 +5,25 @@ import { i18n } from '@/electron' const { httpState } = useHttpApp() const { requests } = useHttpRequests() +const searchQuery = ref('') + const displayedRequests = computed(() => { - if (httpState.folderId === undefined) { - return requests.value + const folderFiltered + = httpState.folderId === undefined + ? requests.value + : requests.value.filter(r => r.folderId === httpState.folderId) + + const term = searchQuery.value.trim().toLowerCase() + if (!term) { + return folderFiltered } - return requests.value.filter(r => r.folderId === httpState.folderId) + return folderFiltered.filter(r => r.name.toLowerCase().includes(term)) }) From 3377808f1e7af4b1f2cc0e5734d7240a2b02fea4 Mon Sep 17 00:00:00 2001 From: Anton Reshetov Date: Thu, 30 Apr 2026 11:17:52 +0300 Subject: [PATCH 14/81] fix(http): match notes list header layout (single search row with + button) --- .../components/http/RequestsListHeader.vue | 34 +++++-------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/src/renderer/components/http/RequestsListHeader.vue b/src/renderer/components/http/RequestsListHeader.vue index a95201a9..875e439d 100644 --- a/src/renderer/components/http/RequestsListHeader.vue +++ b/src/renderer/components/http/RequestsListHeader.vue @@ -1,27 +1,14 @@