apigrip 0.6.2 → 0.6.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -2
- package/cli/commands/last.js +32 -0
- package/cli/commands/serve.js +13 -0
- package/cli/index.js +2 -0
- package/client/dist/assets/index-BNWZuYOS.css +1 -0
- package/client/dist/assets/index-gg39Ts4w.js +75 -0
- package/client/dist/index.html +2 -2
- package/core/history-store.js +138 -0
- package/core/project-config.js +108 -0
- package/core/spec-discovery.js +17 -1
- package/lib/index.js +27 -0
- package/mcp/server.js +21 -0
- package/package.json +40 -2
- package/server/routes/project.js +4 -0
- package/server/routes/requests.js +50 -0
- package/server/spec-watcher.js +34 -3
- package/client/dist/assets/index-CRa1JtiS.css +0 -1
- package/client/dist/assets/index-Iwhkggco.js +0 -75
package/client/dist/index.html
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
if (t) document.documentElement.setAttribute('data-theme', t);
|
|
11
11
|
} catch (e) {}
|
|
12
12
|
</script>
|
|
13
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-gg39Ts4w.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BNWZuYOS.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getConfigDir, getProjectHash } from './env-resolver.js';
|
|
4
|
+
|
|
5
|
+
const MAX_ENTRIES = 50;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Get the history file path for a project.
|
|
9
|
+
*/
|
|
10
|
+
function getHistoryFilePath(projectDir) {
|
|
11
|
+
const configDir = getConfigDir();
|
|
12
|
+
const hash = getProjectHash(projectDir);
|
|
13
|
+
return path.join(configDir, 'history', `${hash}.json`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read and parse the history file.
|
|
18
|
+
* Returns an empty object if the file doesn't exist or is corrupt.
|
|
19
|
+
*/
|
|
20
|
+
function readHistoryFile(filePath) {
|
|
21
|
+
try {
|
|
22
|
+
if (!fs.existsSync(filePath)) {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
26
|
+
const parsed = JSON.parse(raw);
|
|
27
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
28
|
+
console.warn(`[history-store] Corrupt history file at ${filePath}, returning defaults`);
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
return parsed;
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.warn(`[history-store] Failed to read history file at ${filePath}: ${err.message}`);
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Write the full history object to the history file.
|
|
40
|
+
*/
|
|
41
|
+
function writeHistoryFile(filePath, data) {
|
|
42
|
+
const dir = path.dirname(filePath);
|
|
43
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
44
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Append a response to the history for a specific endpoint.
|
|
49
|
+
* Prepends to the array (newest first), caps at MAX_ENTRIES.
|
|
50
|
+
* @param {string} projectDir - Project directory path
|
|
51
|
+
* @param {string} method - HTTP method
|
|
52
|
+
* @param {string} endpointPath - Endpoint path
|
|
53
|
+
* @param {object} response - Response object to save
|
|
54
|
+
*/
|
|
55
|
+
export function appendHistory(projectDir, method, endpointPath, response) {
|
|
56
|
+
const filePath = getHistoryFilePath(projectDir);
|
|
57
|
+
const all = readHistoryFile(filePath);
|
|
58
|
+
const key = `${method.toUpperCase()} ${endpointPath}`;
|
|
59
|
+
|
|
60
|
+
if (!Array.isArray(all[key])) {
|
|
61
|
+
all[key] = [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const entry = {
|
|
65
|
+
...response,
|
|
66
|
+
timestamp: new Date().toISOString(),
|
|
67
|
+
index: all[key].length > 0 ? (all[key][0].index || 0) + 1 : 0,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
all[key].unshift(entry);
|
|
71
|
+
|
|
72
|
+
// Cap at MAX_ENTRIES
|
|
73
|
+
if (all[key].length > MAX_ENTRIES) {
|
|
74
|
+
all[key] = all[key].slice(0, MAX_ENTRIES);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
writeHistoryFile(filePath, all);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Load history for a specific endpoint.
|
|
82
|
+
* @param {string} projectDir - Project directory path
|
|
83
|
+
* @param {string} method - HTTP method
|
|
84
|
+
* @param {string} endpointPath - Endpoint path
|
|
85
|
+
* @param {number} [limit] - Max entries to return (default: all)
|
|
86
|
+
* @returns {Array} Array of history entries (newest first)
|
|
87
|
+
*/
|
|
88
|
+
export function loadHistory(projectDir, method, endpointPath, limit) {
|
|
89
|
+
const filePath = getHistoryFilePath(projectDir);
|
|
90
|
+
const all = readHistoryFile(filePath);
|
|
91
|
+
const key = `${method.toUpperCase()} ${endpointPath}`;
|
|
92
|
+
const entries = all[key] || [];
|
|
93
|
+
if (limit && limit > 0) {
|
|
94
|
+
return entries.slice(0, limit);
|
|
95
|
+
}
|
|
96
|
+
return entries;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Load a single history entry by index.
|
|
101
|
+
* @param {string} projectDir - Project directory path
|
|
102
|
+
* @param {string} method - HTTP method
|
|
103
|
+
* @param {string} endpointPath - Endpoint path
|
|
104
|
+
* @param {number} index - Index value of the entry
|
|
105
|
+
* @returns {object|null} The history entry or null
|
|
106
|
+
*/
|
|
107
|
+
export function loadHistoryEntry(projectDir, method, endpointPath, index) {
|
|
108
|
+
const entries = loadHistory(projectDir, method, endpointPath);
|
|
109
|
+
return entries.find((e) => e.index === index) || null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Clear history for a specific endpoint.
|
|
114
|
+
* @param {string} projectDir - Project directory path
|
|
115
|
+
* @param {string} method - HTTP method
|
|
116
|
+
* @param {string} endpointPath - Endpoint path
|
|
117
|
+
* @returns {boolean} True if history was cleared, false if none existed
|
|
118
|
+
*/
|
|
119
|
+
export function clearHistory(projectDir, method, endpointPath) {
|
|
120
|
+
const filePath = getHistoryFilePath(projectDir);
|
|
121
|
+
const all = readHistoryFile(filePath);
|
|
122
|
+
const key = `${method.toUpperCase()} ${endpointPath}`;
|
|
123
|
+
if (key in all) {
|
|
124
|
+
delete all[key];
|
|
125
|
+
writeHistoryFile(filePath, all);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Clear all history for a project.
|
|
133
|
+
* @param {string} projectDir - Project directory path
|
|
134
|
+
*/
|
|
135
|
+
export function clearAllHistory(projectDir) {
|
|
136
|
+
const filePath = getHistoryFilePath(projectDir);
|
|
137
|
+
writeHistoryFile(filePath, {});
|
|
138
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* project-config.js — Load and apply .apigrip.json project config files.
|
|
3
|
+
*
|
|
4
|
+
* .apigrip.json is an optional, checked-in file that seeds environments
|
|
5
|
+
* and provides a spec path override. It never overwrites existing user config.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { loadEnvironments, saveEnvironments } from './env-resolver.js';
|
|
11
|
+
|
|
12
|
+
const CONFIG_FILENAME = '.apigrip.json';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Load and parse the .apigrip.json file from a project directory.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} projectDir - Absolute path to the project directory
|
|
18
|
+
* @returns {{ spec?: string, active_environment?: string, environments?: object } | null}
|
|
19
|
+
* Parsed config object, or null if file is missing/invalid.
|
|
20
|
+
*/
|
|
21
|
+
export function loadProjectConfig(projectDir) {
|
|
22
|
+
const configPath = path.join(projectDir, CONFIG_FILENAME);
|
|
23
|
+
try {
|
|
24
|
+
if (!fs.existsSync(configPath)) return null;
|
|
25
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
26
|
+
const parsed = JSON.parse(raw);
|
|
27
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return parsed;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Apply .apigrip.json config to the user's XDG environment store.
|
|
38
|
+
*
|
|
39
|
+
* Seed algorithm:
|
|
40
|
+
* - "base" in config: only seed if user's XDG base is empty (no keys)
|
|
41
|
+
* - named envs: only add environment names not already in user's XDG config
|
|
42
|
+
* - "active_environment": only apply if user has no active env set
|
|
43
|
+
*
|
|
44
|
+
* @param {string} projectDir - Absolute path to the project directory
|
|
45
|
+
* @returns {{ seeded: string[], active_applied: boolean }}
|
|
46
|
+
*/
|
|
47
|
+
export function applyProjectConfig(projectDir) {
|
|
48
|
+
const result = { seeded: [], active_applied: false };
|
|
49
|
+
|
|
50
|
+
const config = loadProjectConfig(projectDir);
|
|
51
|
+
if (!config) return result;
|
|
52
|
+
|
|
53
|
+
const envData = loadEnvironments(projectDir);
|
|
54
|
+
let changed = false;
|
|
55
|
+
|
|
56
|
+
// Seed base environment if user's base is empty
|
|
57
|
+
if (config.environments?.base && typeof config.environments.base === 'object') {
|
|
58
|
+
if (Object.keys(envData.base).length === 0) {
|
|
59
|
+
envData.base = { ...config.environments.base };
|
|
60
|
+
result.seeded.push('base');
|
|
61
|
+
changed = true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Seed named environments (only add new names, never overwrite)
|
|
66
|
+
if (config.environments && typeof config.environments === 'object') {
|
|
67
|
+
for (const [name, vars] of Object.entries(config.environments)) {
|
|
68
|
+
if (name === 'base') continue;
|
|
69
|
+
if (!(name in envData.environments)) {
|
|
70
|
+
envData.environments[name] = { ...vars };
|
|
71
|
+
result.seeded.push(name);
|
|
72
|
+
changed = true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Apply active_environment if user has none set
|
|
78
|
+
if (config.active_environment && envData.active == null) {
|
|
79
|
+
// Only set if the environment actually exists (either already existed or was just seeded)
|
|
80
|
+
if (envData.environments[config.active_environment]) {
|
|
81
|
+
envData.active = config.active_environment;
|
|
82
|
+
result.active_applied = true;
|
|
83
|
+
changed = true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (changed) {
|
|
88
|
+
saveEnvironments(projectDir, envData);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get the spec path override from .apigrip.json.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} projectDir - Absolute path to the project directory
|
|
98
|
+
* @returns {string | null} Absolute spec path if valid, or null
|
|
99
|
+
*/
|
|
100
|
+
export function getSpecOverride(projectDir) {
|
|
101
|
+
const config = loadProjectConfig(projectDir);
|
|
102
|
+
if (!config?.spec || typeof config.spec !== 'string') return null;
|
|
103
|
+
|
|
104
|
+
const resolved = path.resolve(projectDir, config.spec);
|
|
105
|
+
if (!fs.existsSync(resolved)) return null;
|
|
106
|
+
|
|
107
|
+
return resolved;
|
|
108
|
+
}
|
package/core/spec-discovery.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
-
import { join, extname } from 'node:path';
|
|
2
|
+
import { join, resolve, extname } from 'node:path';
|
|
3
3
|
import yaml from 'js-yaml';
|
|
4
4
|
|
|
5
5
|
const EXCLUDED_DIRS = new Set([
|
|
@@ -57,6 +57,22 @@ function isExcludedDir(name) {
|
|
|
57
57
|
* @returns {Promise<{specPath: string, specFile: string} | null>}
|
|
58
58
|
*/
|
|
59
59
|
export async function discoverSpec(projectDir) {
|
|
60
|
+
// Phase 0: Check .apigrip.json for spec override
|
|
61
|
+
try {
|
|
62
|
+
const configPath = join(projectDir, '.apigrip.json');
|
|
63
|
+
const configRaw = await readFile(configPath, 'utf-8');
|
|
64
|
+
const config = JSON.parse(configRaw);
|
|
65
|
+
if (config && typeof config === 'object' && typeof config.spec === 'string') {
|
|
66
|
+
const specPath = resolve(projectDir, config.spec);
|
|
67
|
+
if (await isSpecFile(specPath)) {
|
|
68
|
+
const relative = specPath.slice(projectDir.length + 1);
|
|
69
|
+
return { specPath, specFile: relative };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
// No .apigrip.json or invalid — fall through to normal discovery
|
|
74
|
+
}
|
|
75
|
+
|
|
60
76
|
// Phase 1: Check root priority files
|
|
61
77
|
for (const fileName of ROOT_PRIORITY) {
|
|
62
78
|
const filePath = join(projectDir, fileName);
|
package/lib/index.js
CHANGED
|
@@ -84,10 +84,22 @@ import {
|
|
|
84
84
|
import { loadPreferences, savePreferences } from '../core/preferences-store.js';
|
|
85
85
|
import { getGitInfo } from '../core/git-info.js';
|
|
86
86
|
import { saveLastResponse, loadLastResponse } from '../core/response-store.js';
|
|
87
|
+
import {
|
|
88
|
+
appendHistory,
|
|
89
|
+
loadHistory,
|
|
90
|
+
loadHistoryEntry,
|
|
91
|
+
clearHistory,
|
|
92
|
+
clearAllHistory,
|
|
93
|
+
} from '../core/history-store.js';
|
|
87
94
|
|
|
88
95
|
export {
|
|
89
96
|
saveLastResponse,
|
|
90
97
|
loadLastResponse,
|
|
98
|
+
appendHistory,
|
|
99
|
+
loadHistory,
|
|
100
|
+
loadHistoryEntry,
|
|
101
|
+
clearHistory,
|
|
102
|
+
clearAllHistory,
|
|
91
103
|
loadParams,
|
|
92
104
|
saveParams,
|
|
93
105
|
loadProjects,
|
|
@@ -98,6 +110,16 @@ export {
|
|
|
98
110
|
getGitInfo,
|
|
99
111
|
};
|
|
100
112
|
|
|
113
|
+
// ---- Project config -------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
import {
|
|
116
|
+
loadProjectConfig,
|
|
117
|
+
applyProjectConfig,
|
|
118
|
+
getSpecOverride,
|
|
119
|
+
} from '../core/project-config.js';
|
|
120
|
+
|
|
121
|
+
export { loadProjectConfig, applyProjectConfig, getSpecOverride };
|
|
122
|
+
|
|
101
123
|
// ---- High-level convenience -----------------------------------------------
|
|
102
124
|
|
|
103
125
|
/**
|
|
@@ -288,6 +310,11 @@ export async function send(specOrPath, method, endpointPath, options = {}) {
|
|
|
288
310
|
} catch {
|
|
289
311
|
// Non-critical
|
|
290
312
|
}
|
|
313
|
+
try {
|
|
314
|
+
appendHistory(projectDir, method, endpointPath, result);
|
|
315
|
+
} catch {
|
|
316
|
+
// Non-critical
|
|
317
|
+
}
|
|
291
318
|
}
|
|
292
319
|
|
|
293
320
|
return result;
|
package/mcp/server.js
CHANGED
|
@@ -10,6 +10,7 @@ import { validateBody, validateResponse } from '../core/schema-validator.js';
|
|
|
10
10
|
import { buildCurlArgs, buildUrl } from '../core/curl-builder.js';
|
|
11
11
|
import { executeCurl } from '../core/curl-executor.js';
|
|
12
12
|
import { saveLastResponse, loadLastResponse } from '../core/response-store.js';
|
|
13
|
+
import { appendHistory, loadHistory } from '../core/history-store.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Create and configure the MCP server.
|
|
@@ -224,6 +225,13 @@ export async function createMcpServer(state) {
|
|
|
224
225
|
// Non-critical
|
|
225
226
|
}
|
|
226
227
|
|
|
228
|
+
// Append to history
|
|
229
|
+
try {
|
|
230
|
+
appendHistory(state.projectDir, upperMethod, endpointPath, response);
|
|
231
|
+
} catch {
|
|
232
|
+
// Non-critical
|
|
233
|
+
}
|
|
234
|
+
|
|
227
235
|
return { content: [{ type: 'text', text: JSON.stringify(response, null, 2) }] };
|
|
228
236
|
} catch (err) {
|
|
229
237
|
return {
|
|
@@ -248,5 +256,18 @@ export async function createMcpServer(state) {
|
|
|
248
256
|
}
|
|
249
257
|
);
|
|
250
258
|
|
|
259
|
+
server.tool('get_history', 'Get request history for an endpoint',
|
|
260
|
+
{ method: z.string(), path: z.string(), limit: z.number().optional() },
|
|
261
|
+
async ({ method, path: endpointPath, limit }) => {
|
|
262
|
+
const entries = loadHistory(state.projectDir, method.toUpperCase(), endpointPath, limit);
|
|
263
|
+
if (entries.length === 0) {
|
|
264
|
+
return {
|
|
265
|
+
content: [{ type: 'text', text: `No history for ${method} ${endpointPath}` }],
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
return { content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }] };
|
|
269
|
+
}
|
|
270
|
+
);
|
|
271
|
+
|
|
251
272
|
return server;
|
|
252
273
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apigrip",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.7",
|
|
4
4
|
"description": "A spec-first, read-only OpenAPI client for developers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.cjs",
|
|
@@ -30,8 +30,13 @@
|
|
|
30
30
|
"build": "vite build --config client/vite.config.js",
|
|
31
31
|
"test": "node --test $(find test -name '*.test.js')",
|
|
32
32
|
"test:client": "vitest run --config client/vitest.config.js",
|
|
33
|
+
"test:e2e": "npx playwright test --config e2e/playwright.config.js",
|
|
34
|
+
"test:e2e:headed": "npx playwright test --config e2e/playwright.config.js --headed",
|
|
35
|
+
"test:e2e:ui": "npx playwright test --config e2e/playwright.config.js --ui",
|
|
33
36
|
"prepare": "test -d client/src && npm run build || true",
|
|
34
|
-
"prepublishOnly": "npm run build"
|
|
37
|
+
"prepublishOnly": "npm run build",
|
|
38
|
+
"electron:dev": "electron electron/main.js",
|
|
39
|
+
"electron:build": "npm run build && electron-builder"
|
|
35
40
|
},
|
|
36
41
|
"keywords": [
|
|
37
42
|
"openapi",
|
|
@@ -61,14 +66,47 @@
|
|
|
61
66
|
"yargs": "^18.0.0"
|
|
62
67
|
},
|
|
63
68
|
"devDependencies": {
|
|
69
|
+
"@playwright/test": "^1.58.2",
|
|
64
70
|
"@testing-library/jest-dom": "^6.9.1",
|
|
65
71
|
"@testing-library/react": "^16.3.2",
|
|
66
72
|
"@testing-library/user-event": "^14.6.1",
|
|
67
73
|
"@vitejs/plugin-react": "^5.1.4",
|
|
74
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
68
75
|
"jsdom": "^28.1.0",
|
|
69
76
|
"react": "^19.2.4",
|
|
70
77
|
"react-dom": "^19.2.4",
|
|
78
|
+
"electron": "^35.1.2",
|
|
79
|
+
"electron-builder": "^26.0.12",
|
|
71
80
|
"vite": "^7.3.1",
|
|
72
81
|
"vitest": "^4.0.18"
|
|
82
|
+
},
|
|
83
|
+
"build": {
|
|
84
|
+
"appId": "com.apigrip.app",
|
|
85
|
+
"productName": "Apigrip",
|
|
86
|
+
"extraMetadata": {
|
|
87
|
+
"main": "electron/main.js"
|
|
88
|
+
},
|
|
89
|
+
"files": [
|
|
90
|
+
"cli/",
|
|
91
|
+
"core/",
|
|
92
|
+
"lib/",
|
|
93
|
+
"mcp/",
|
|
94
|
+
"server/",
|
|
95
|
+
"electron/",
|
|
96
|
+
"client/dist/",
|
|
97
|
+
"package.json"
|
|
98
|
+
],
|
|
99
|
+
"mac": {
|
|
100
|
+
"target": "dmg"
|
|
101
|
+
},
|
|
102
|
+
"win": {
|
|
103
|
+
"target": "nsis"
|
|
104
|
+
},
|
|
105
|
+
"linux": {
|
|
106
|
+
"target": "AppImage"
|
|
107
|
+
},
|
|
108
|
+
"directories": {
|
|
109
|
+
"output": "dist-electron"
|
|
110
|
+
}
|
|
73
111
|
}
|
|
74
112
|
}
|
package/server/routes/project.js
CHANGED
|
@@ -3,6 +3,7 @@ import { getGitInfo } from '../../core/git-info.js';
|
|
|
3
3
|
import { parseSpec } from '../../core/spec-parser.js';
|
|
4
4
|
import { discoverSpec } from '../../core/spec-discovery.js';
|
|
5
5
|
import { addProject } from '../../core/projects-store.js';
|
|
6
|
+
import { applyProjectConfig } from '../../core/project-config.js';
|
|
6
7
|
import { broadcast } from './events.js';
|
|
7
8
|
import path from 'path';
|
|
8
9
|
import fs from 'fs';
|
|
@@ -79,6 +80,9 @@ export function createProjectRoutes(state) {
|
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
// Apply .apigrip.json project config (seeds environments)
|
|
84
|
+
try { applyProjectConfig(newPath); } catch { /* non-critical */ }
|
|
85
|
+
|
|
82
86
|
const git = await getGitInfo(state.projectDir);
|
|
83
87
|
const projectInfo = {
|
|
84
88
|
path: state.projectDir,
|
|
@@ -6,6 +6,7 @@ import { buildCurlArgs, buildUrl, shellQuote } from '../../core/curl-builder.js'
|
|
|
6
6
|
import { executeCurl } from '../../core/curl-executor.js';
|
|
7
7
|
import { loadEnvironments, resolveEnvironment, resolveAllParams } from '../../core/env-resolver.js';
|
|
8
8
|
import { saveLastResponse, loadLastResponse } from '../../core/response-store.js';
|
|
9
|
+
import { appendHistory, loadHistory, loadHistoryEntry, clearHistory } from '../../core/history-store.js';
|
|
9
10
|
|
|
10
11
|
export function createRequestRoutes(state) {
|
|
11
12
|
const router = Router();
|
|
@@ -109,6 +110,13 @@ export function createRequestRoutes(state) {
|
|
|
109
110
|
// Non-critical — don't fail the request if caching fails
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
// Append to history
|
|
114
|
+
try {
|
|
115
|
+
appendHistory(state.projectDir, method, endpointPath, responseData);
|
|
116
|
+
} catch {
|
|
117
|
+
// Non-critical
|
|
118
|
+
}
|
|
119
|
+
|
|
112
120
|
res.json(responseData);
|
|
113
121
|
} catch (err) {
|
|
114
122
|
res.status(502).json({
|
|
@@ -197,5 +205,47 @@ export function createRequestRoutes(state) {
|
|
|
197
205
|
res.json(result);
|
|
198
206
|
});
|
|
199
207
|
|
|
208
|
+
// GET /api/project/request/history — get history for an endpoint
|
|
209
|
+
router.get('/project/request/history', (req, res) => {
|
|
210
|
+
if (!state.projectDir) {
|
|
211
|
+
return res.status(404).json({ error: 'No active project' });
|
|
212
|
+
}
|
|
213
|
+
const { method, path: endpointPath, limit } = req.query;
|
|
214
|
+
if (!method || !endpointPath) {
|
|
215
|
+
return res.status(400).json({ error: 'Missing required query params: method, path' });
|
|
216
|
+
}
|
|
217
|
+
const entries = loadHistory(state.projectDir, method, endpointPath, limit ? Number(limit) : undefined);
|
|
218
|
+
res.json(entries);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// GET /api/project/request/history/entry — get single history entry by index
|
|
222
|
+
router.get('/project/request/history/entry', (req, res) => {
|
|
223
|
+
if (!state.projectDir) {
|
|
224
|
+
return res.status(404).json({ error: 'No active project' });
|
|
225
|
+
}
|
|
226
|
+
const { method, path: endpointPath, index } = req.query;
|
|
227
|
+
if (!method || !endpointPath || index == null) {
|
|
228
|
+
return res.status(400).json({ error: 'Missing required query params: method, path, index' });
|
|
229
|
+
}
|
|
230
|
+
const entry = loadHistoryEntry(state.projectDir, method, endpointPath, Number(index));
|
|
231
|
+
if (!entry) {
|
|
232
|
+
return res.status(404).json({ error: `No history entry with index ${index} for ${method} ${endpointPath}` });
|
|
233
|
+
}
|
|
234
|
+
res.json(entry);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// DELETE /api/project/request/history — clear history for an endpoint
|
|
238
|
+
router.delete('/project/request/history', (req, res) => {
|
|
239
|
+
if (!state.projectDir) {
|
|
240
|
+
return res.status(404).json({ error: 'No active project' });
|
|
241
|
+
}
|
|
242
|
+
const { method, path: endpointPath } = req.query;
|
|
243
|
+
if (!method || !endpointPath) {
|
|
244
|
+
return res.status(400).json({ error: 'Missing required query params: method, path' });
|
|
245
|
+
}
|
|
246
|
+
clearHistory(state.projectDir, method, endpointPath);
|
|
247
|
+
res.json({ cleared: true });
|
|
248
|
+
});
|
|
249
|
+
|
|
200
250
|
return router;
|
|
201
251
|
}
|
package/server/spec-watcher.js
CHANGED
|
@@ -13,6 +13,7 @@ import path from 'node:path';
|
|
|
13
13
|
|
|
14
14
|
import { parseSpec, extractEndpoints, getRefDeps } from '../core/spec-parser.js';
|
|
15
15
|
import { getGitInfo } from '../core/git-info.js';
|
|
16
|
+
import { applyProjectConfig, getSpecOverride } from '../core/project-config.js';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Debounce a function. Returns a wrapper that delays invocation until
|
|
@@ -104,6 +105,9 @@ export async function createSpecWatcher(state, broadcast) {
|
|
|
104
105
|
// If we can't resolve deps, just watch the main file
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
// .apigrip.json config file
|
|
109
|
+
const configPath = path.join(state.projectDir, '.apigrip.json');
|
|
110
|
+
|
|
107
111
|
// Git metadata files
|
|
108
112
|
const gitDir = path.join(state.projectDir, '.git');
|
|
109
113
|
const gitPaths = [
|
|
@@ -113,7 +117,7 @@ export async function createSpecWatcher(state, broadcast) {
|
|
|
113
117
|
];
|
|
114
118
|
|
|
115
119
|
// Create watcher
|
|
116
|
-
const watcher = chokidar.watch([...filesToWatch, ...gitPaths], {
|
|
120
|
+
const watcher = chokidar.watch([...filesToWatch, configPath, ...gitPaths], {
|
|
117
121
|
ignoreInitial: true,
|
|
118
122
|
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 10 },
|
|
119
123
|
});
|
|
@@ -162,6 +166,29 @@ export async function createSpecWatcher(state, broadcast) {
|
|
|
162
166
|
}
|
|
163
167
|
}, 100);
|
|
164
168
|
|
|
169
|
+
// Debounced config change handler (.apigrip.json)
|
|
170
|
+
const resolvedConfigPath = path.resolve(configPath);
|
|
171
|
+
const handleConfigChange = debounce(async () => {
|
|
172
|
+
try {
|
|
173
|
+
applyProjectConfig(state.projectDir);
|
|
174
|
+
} catch {
|
|
175
|
+
// ignore config apply errors
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Check if spec override changed
|
|
179
|
+
try {
|
|
180
|
+
const newSpecPath = getSpecOverride(state.projectDir);
|
|
181
|
+
if (newSpecPath && newSpecPath !== path.resolve(state.specPath)) {
|
|
182
|
+
state.specPath = newSpecPath;
|
|
183
|
+
specFiles.add(path.resolve(newSpecPath));
|
|
184
|
+
watcher.add(newSpecPath);
|
|
185
|
+
handleSpecChange();
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
// ignore
|
|
189
|
+
}
|
|
190
|
+
}, 100);
|
|
191
|
+
|
|
165
192
|
// Debounced git change handler
|
|
166
193
|
const handleGitChange = debounce(async () => {
|
|
167
194
|
try {
|
|
@@ -178,7 +205,9 @@ export async function createSpecWatcher(state, broadcast) {
|
|
|
178
205
|
|
|
179
206
|
watcher.on('change', (changedPath) => {
|
|
180
207
|
const resolved = path.resolve(changedPath);
|
|
181
|
-
if (
|
|
208
|
+
if (resolved === resolvedConfigPath) {
|
|
209
|
+
handleConfigChange();
|
|
210
|
+
} else if (specFiles.has(resolved)) {
|
|
182
211
|
handleSpecChange();
|
|
183
212
|
} else {
|
|
184
213
|
// Assume it's a git-related file
|
|
@@ -189,7 +218,9 @@ export async function createSpecWatcher(state, broadcast) {
|
|
|
189
218
|
// Also handle add events (new $ref files, etc.)
|
|
190
219
|
watcher.on('add', (changedPath) => {
|
|
191
220
|
const resolved = path.resolve(changedPath);
|
|
192
|
-
if (
|
|
221
|
+
if (resolved === resolvedConfigPath) {
|
|
222
|
+
handleConfigChange();
|
|
223
|
+
} else if (specFiles.has(resolved)) {
|
|
193
224
|
handleSpecChange();
|
|
194
225
|
} else {
|
|
195
226
|
handleGitChange();
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
:root{--bg: #282a36;--bg-secondary: #44475a;--bg-input: #1e1f29;--fg: #f8f8f2;--fg-muted: #6272a4;--fg-faint: #44475a;--border: #44475a;--accent: #bd93f9;--accent-hover: #caa8ff;--method-get: #50fa7b;--method-post: #bd93f9;--method-put: #ffb86c;--method-delete: #ff5555;--method-patch: #f1fa8c;--status-2xx: #50fa7b;--status-3xx: #f1fa8c;--status-4xx: #ffb86c;--status-5xx: #ff5555;--font-mono: "SF Mono", "Fira Code", "Cascadia Code", monospace;--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;--radius: 6px;--transition: .15s ease}[data-theme=light]{--bg: #f8f8f2;--bg-secondary: #e8e8e2;--bg-input: #ffffff;--fg: #282a36;--fg-muted: #6272a4;--fg-faint: #c0c0c0;--border: #d0d0d0;--accent: #7c5cbf;--accent-hover: #6a4daa}*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}body{background:var(--bg);color:var(--fg);font-family:var(--font-sans);font-size:14px;line-height:1.5;height:100vh;overflow:hidden}#root{display:flex;flex-direction:column;height:100%}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--bg)}::-webkit-scrollbar-thumb{background:var(--bg-secondary);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:var(--fg-muted)}*{scrollbar-width:thin;scrollbar-color:var(--bg-secondary) var(--bg)}::selection{background:var(--accent);color:var(--bg)}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}h1{font-size:1.5rem;font-weight:700;line-height:1.3}h2{font-size:1.25rem;font-weight:600;line-height:1.3}h3{font-size:1rem;font-weight:600;line-height:1.4}h4{font-size:.875rem;font-weight:600;line-height:1.4}p{margin-bottom:.5em}code{font-family:var(--font-mono);font-size:.9em;background:var(--bg-secondary);padding:.15em .35em;border-radius:3px}pre{font-family:var(--font-mono);font-size:.85rem;line-height:1.5;white-space:pre-wrap;word-wrap:break-word}.app{display:flex;flex-direction:column;height:100%}.app__body{display:flex;flex:1;min-height:0;overflow:hidden}.app__sidebar{width:280px;min-width:200px;border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}.app__main{flex:1;display:flex;min-width:0;overflow:hidden}.app__request-panel{flex:1;min-width:0;overflow-y:auto;border-right:1px solid var(--border);padding:16px}.app__response-panel{flex:1;min-width:0;overflow-y:auto;padding:16px}.topbar{display:flex;align-items:center;gap:12px;padding:8px 16px;background:var(--bg-secondary);border-bottom:1px solid var(--border);min-height:44px;flex-shrink:0}.topbar__section{display:flex;align-items:center;gap:8px}.topbar__section--right{margin-left:auto}.topbar__label{font-size:12px;color:var(--fg-muted);text-transform:uppercase;letter-spacing:.05em}.topbar__select{background:var(--bg-input);color:var(--fg);border:1px solid var(--border);border-radius:var(--radius);padding:4px 8px;font-size:13px;font-family:var(--font-sans);cursor:pointer}.topbar__select:hover{border-color:var(--accent)}.topbar__btn{background:none;border:1px solid var(--border);border-radius:var(--radius);color:var(--fg);padding:4px 10px;font-size:12px;cursor:pointer;transition:background var(--transition)}.topbar__btn:hover{background:var(--bg)}.topbar__git{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--fg-muted);font-family:var(--font-mono)}.topbar__git-branch{color:var(--accent)}.topbar__git-commit{color:var(--fg-muted)}.topbar__git-dirty{color:var(--method-put)}.topbar__toast{font-size:12px;color:var(--method-get);animation:topbar-toast-fade 3s ease forwards}@keyframes topbar-toast-fade{0%{opacity:1}70%{opacity:1}to{opacity:0}}.endpoint-list{display:flex;flex-direction:column;height:100%}.endpoint-list__search{padding:8px;border-bottom:1px solid var(--border)}.endpoint-list__search-input{width:100%;background:var(--bg-input);color:var(--fg);border:1px solid var(--border);border-radius:var(--radius);padding:6px 10px;font-size:13px;font-family:var(--font-sans)}.endpoint-list__search-input::placeholder{color:var(--fg-muted)}.endpoint-list__toolbar{display:flex;align-items:center;justify-content:flex-end;padding:4px 8px;border-bottom:1px solid var(--border)}.endpoint-list__view-btn{background:none;border:none;color:var(--fg-muted);cursor:pointer;padding:2px 6px;font-size:12px}.endpoint-list__view-btn--active{color:var(--accent)}.endpoint-list__items{flex:1;overflow-y:auto;padding:4px 0}.endpoint-list__tag-header{padding:8px 12px 4px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--fg-muted)}.endpoint-list__tree-folder{padding:4px 12px;font-size:12px;color:var(--fg-muted);cursor:pointer;display:flex;align-items:center;gap:4px;-webkit-user-select:none;user-select:none}.endpoint-list__tree-folder:hover{color:var(--fg)}.endpoint-list__tree-children{padding-left:16px}.endpoint-item{display:flex;align-items:center;gap:8px;padding:5px 12px;cursor:pointer;transition:background var(--transition);border-left:3px solid transparent}.endpoint-item:hover{background:var(--bg-secondary)}.endpoint-item--selected{background:var(--bg-secondary);border-left-color:var(--accent)}.endpoint-item__method{font-size:10px;font-weight:700;font-family:var(--font-mono);text-transform:uppercase;min-width:52px;text-align:center;padding:2px 6px;border-radius:3px;letter-spacing:.02em}.endpoint-item__method--get{color:var(--method-get);background:#50fa7b1a}.endpoint-item__method--post{color:var(--method-post);background:#bd93f91a}.endpoint-item__method--put{color:var(--method-put);background:#ffb86c1a}.endpoint-item__method--delete{color:var(--method-delete);background:#ff55551a}.endpoint-item__method--patch{color:var(--method-patch);background:#f1fa8c1a}.endpoint-item__path{font-family:var(--font-mono);font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.endpoint-item__summary{font-size:11px;color:var(--fg-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-left:auto}.request-panel__section{margin-bottom:16px}.request-panel__section-title{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--fg-muted);margin-bottom:8px}.request-panel__server-select{width:100%;background:var(--bg-input);color:var(--fg);border:1px solid var(--border);border-radius:var(--radius);padding:6px 10px;font-family:var(--font-mono);font-size:13px}.request-panel__server-vars{display:flex;flex-direction:column;gap:6px;margin-top:8px}.request-panel__send-bar{display:flex;align-items:center;gap:8px;margin-top:16px;margin-bottom:24px}.request-panel__send-btn{background:var(--accent);color:var(--bg);border:none;border-radius:var(--radius);padding:8px 24px;font-size:14px;font-weight:600;cursor:pointer;transition:background var(--transition)}.request-panel__send-btn:hover{background:var(--accent-hover)}.request-panel__send-btn:disabled{opacity:.5;cursor:not-allowed}.request-panel__spinner{display:inline-block;width:16px;height:16px;border:2px solid var(--fg-muted);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.param-form{display:flex;flex-direction:column;gap:10px}.param-form__field{display:flex;flex-direction:column;gap:3px}.param-form__label{display:flex;align-items:center;gap:4px;font-size:12px;font-weight:600}.param-form__required{color:var(--method-delete)}.param-form__desc{font-size:11px;color:var(--fg-muted)}.param-form__input{background:var(--bg-input);color:var(--fg);border:1px solid var(--border);border-radius:var(--radius);padding:6px 10px;font-family:var(--font-mono);font-size:13px}.param-form__input:focus{border-color:var(--accent)}.param-form__resolved{font-size:11px;color:var(--fg-muted);font-family:var(--font-mono)}.hl-wrap{position:relative}.hl-backdrop{position:absolute;inset:0;padding:6px 10px;font-family:var(--font-mono);font-size:13px;line-height:normal;color:transparent;white-space:pre;overflow:hidden;pointer-events:none;border:1px solid transparent;border-radius:var(--radius)}.hl-input{background:transparent!important;position:relative;caret-color:var(--fg)}.hl-wrap .param-form__input{background:transparent}.hl-wrap:before{content:"";position:absolute;inset:0;background:var(--bg-input);border-radius:var(--radius);z-index:-1}.hl-var{background:color-mix(in srgb,var(--method-get) 20%,transparent);color:var(--method-get);border-radius:3px;padding:0 1px}.body-editor__toolbar{display:flex;align-items:center;gap:8px;margin-bottom:8px}.body-editor__content-type{background:var(--bg-input);color:var(--fg);border:1px solid var(--border);border-radius:var(--radius);padding:4px 8px;font-size:13px}.body-editor__sample-btn{background:none;border:1px solid var(--border);border-radius:var(--radius);color:var(--fg);padding:4px 10px;font-size:12px;cursor:pointer}.body-editor__sample-btn:hover{border-color:var(--accent);color:var(--accent)}.body-editor__textarea{width:100%;min-height:200px;background:var(--bg-input);color:var(--fg);border:1px solid var(--border);border-radius:var(--radius);padding:10px;font-family:var(--font-mono);font-size:13px;line-height:1.5;resize:vertical}.body-editor__kv-table{width:100%;border-collapse:collapse}.body-editor__kv-table th{text-align:left;font-size:11px;font-weight:600;color:var(--fg-muted);padding:4px 8px;border-bottom:1px solid var(--border)}.body-editor__kv-table td{padding:4px 8px}.body-editor__kv-input{width:100%;background:var(--bg-input);color:var(--fg);border:1px solid var(--border);border-radius:3px;padding:4px 8px;font-family:var(--font-mono);font-size:13px}.body-editor__kv-remove{background:none;border:none;color:var(--method-delete);cursor:pointer;font-size:16px;padding:2px 6px}.body-editor__kv-add{background:none;border:1px dashed var(--border);border-radius:var(--radius);color:var(--fg-muted);padding:4px 12px;font-size:12px;cursor:pointer;margin-top:6px}.body-editor__kv-add:hover{border-color:var(--accent);color:var(--accent)}.body-editor__validation{margin-top:6px;font-size:12px;display:flex;align-items:center;gap:4px}.body-editor__validation--valid{color:var(--method-get)}.body-editor__validation--invalid{color:var(--method-delete)}.body-editor__validation--none{color:var(--fg-muted)}.response-panel__empty{display:flex;align-items:center;justify-content:center;height:100%;color:var(--fg-muted);font-size:14px}.response-panel__loading{display:flex;align-items:center;justify-content:center;height:100%;gap:10px;color:var(--fg-muted)}.response-panel__header{display:flex;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap}.response-panel__status{font-weight:700;font-family:var(--font-mono);font-size:14px;padding:3px 10px;border-radius:var(--radius)}.response-panel__status--2xx{color:var(--status-2xx);background:#50fa7b1a}.response-panel__status--3xx{color:var(--status-3xx);background:#f1fa8c1a}.response-panel__status--4xx{color:var(--status-4xx);background:#ffb86c1a}.response-panel__status--5xx{color:var(--status-5xx);background:#ff55551a}.response-panel__meta{font-size:12px;color:var(--fg-muted)}.response-panel__tabs{display:flex;border-bottom:1px solid var(--border);margin-bottom:12px}.response-panel__tab{background:none;border:none;border-bottom:2px solid transparent;color:var(--fg-muted);padding:6px 16px;font-size:13px;cursor:pointer;transition:color var(--transition),border-color var(--transition)}.response-panel__tab:hover{color:var(--fg)}.response-panel__tab--active{color:var(--accent);border-bottom-color:var(--accent)}.body-view__toolbar{display:flex;align-items:center;gap:8px;margin-bottom:8px}.body-view__btn{background:none;border:1px solid var(--border);border-radius:var(--radius);color:var(--fg);padding:3px 10px;font-size:12px;cursor:pointer}.body-view__btn:hover,.body-view__btn--active{border-color:var(--accent);color:var(--accent)}.body-view__pre{background:var(--bg-input);border-radius:var(--radius);padding:12px;overflow:auto;max-height:calc(100vh - 260px);font-size:13px;line-height:1.5}.body-view__pre code{background:none;padding:0;border-radius:0}.body-view__pre .hljs-attr{color:#50fa7b}.body-view__pre .hljs-string{color:#f1fa8c}.body-view__pre .hljs-number{color:#bd93f9}.body-view__pre .hljs-literal,.body-view__pre .hljs-keyword,.body-view__pre .hljs-tag{color:#ff79c6}.body-view__pre .hljs-name{color:#50fa7b}.body-view__pre .hljs-selector-tag{color:#ff79c6}.body-view__pre .hljs-punctuation{color:var(--fg)}.body-view__pre--wrap{white-space:pre-wrap;word-wrap:break-word}.body-view__pre--nowrap{white-space:pre}.body-view__filter-group{display:flex;align-items:center;flex:1;position:relative}.body-view__filter{width:100%;background:var(--bg-input);border:1px solid var(--border);border-radius:var(--radius);color:var(--fg);padding:3px 26px 3px 10px;font-size:12px;font-family:inherit}.body-view__filter::placeholder{color:var(--fg-muted)}.body-view__filter:focus{outline:none;border-color:var(--accent)}.body-view__filter--active{border-color:var(--method-get)}.body-view__filter--error{border-color:var(--method-delete)}.body-view__filter-clear{position:absolute;right:4px;background:none;border:none;color:var(--fg-muted);cursor:pointer;font-size:12px;padding:2px 6px;line-height:1}.body-view__filter-clear:hover{color:var(--fg)}.body-view__filter-error{color:var(--method-delete);font-size:11px;margin-bottom:6px;padding:2px 0}.headers-view__table{width:100%;border-collapse:collapse}.headers-view__table th{text-align:left;font-size:11px;font-weight:600;color:var(--fg-muted);padding:6px 10px;border-bottom:1px solid var(--border)}.headers-view__table td{padding:5px 10px;font-family:var(--font-mono);font-size:13px;border-bottom:1px solid var(--fg-faint)}.headers-view__table td:first-child{color:var(--accent);white-space:nowrap}.headers-view__section-title{font-size:12px;font-weight:600;color:var(--fg-muted);margin:12px 0 6px;cursor:pointer;-webkit-user-select:none;user-select:none}.verbose-view{background:var(--bg-input);border-radius:var(--radius);padding:12px;overflow:auto;max-height:calc(100vh - 220px);font-family:var(--font-mono);font-size:13px;line-height:1.6;white-space:pre-wrap;word-wrap:break-word}.verbose-view__line--comment{color:#6272a4}.verbose-view__line--request{color:#8be9fd}.verbose-view__line--response{color:#50fa7b}.verbose-view__line--other{color:#f8f8f2}.curl-view{position:relative}.curl-view__pre{background:var(--bg-input);border-radius:var(--radius);padding:12px;font-family:var(--font-mono);font-size:13px;line-height:1.5;white-space:pre-wrap;word-wrap:break-word;overflow:auto;max-height:calc(100vh - 220px)}.curl-view__copy-btn{position:absolute;top:8px;right:8px}.env-editor{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center}.env-editor__backdrop{position:absolute;inset:0;background:#0009}.env-editor__dialog{position:relative;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);width:640px;max-width:90vw;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 8px 32px #0006}.env-editor__header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border)}.env-editor__title{font-size:16px;font-weight:600}.env-editor__close{background:none;border:none;color:var(--fg-muted);font-size:20px;cursor:pointer;padding:2px 6px}.env-editor__close:hover{color:var(--fg)}.env-editor__tabs{display:flex;border-bottom:1px solid var(--border);padding:0 16px;overflow-x:auto}.env-editor__tab{background:none;border:none;border-bottom:2px solid transparent;color:var(--fg-muted);padding:8px 14px;font-size:13px;cursor:pointer;white-space:nowrap}.env-editor__tab:hover{color:var(--fg)}.env-editor__tab--active{color:var(--accent);border-bottom-color:var(--accent)}.env-editor__body{flex:1;overflow-y:auto;padding:16px}.env-editor__new-env{display:flex;align-items:center;gap:8px;margin-bottom:12px}.env-editor__new-env-input{flex:1;background:var(--bg-input);color:var(--fg);border:1px solid var(--border);border-radius:var(--radius);padding:6px 10px;font-size:13px}.env-editor__footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 16px;border-top:1px solid var(--border)}.env-editor__save-btn{background:var(--accent);color:var(--bg);border:none;border-radius:var(--radius);padding:6px 20px;font-size:13px;font-weight:600;cursor:pointer}.env-editor__save-btn:hover{background:var(--accent-hover)}.env-editor__delete-btn{background:none;border:1px solid var(--method-delete);border-radius:var(--radius);color:var(--method-delete);padding:6px 14px;font-size:12px;cursor:pointer}.env-editor__delete-btn:hover{background:#ff55551a}.command-palette{position:fixed;inset:0;z-index:2000;display:flex;justify-content:center;padding-top:15vh}.command-palette__backdrop{position:absolute;inset:0;background:#00000080}.command-palette__dialog{position:relative;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);width:520px;max-width:90vw;max-height:60vh;display:flex;flex-direction:column;box-shadow:0 8px 32px #0006}.command-palette__input{background:var(--bg-input);color:var(--fg);border:none;border-bottom:1px solid var(--border);padding:14px 16px;font-size:15px;font-family:var(--font-sans);border-radius:var(--radius) var(--radius) 0 0}.command-palette__input::placeholder{color:var(--fg-muted)}.command-palette__results{flex:1;overflow-y:auto;padding:4px 0}.command-palette__section-title{padding:8px 16px 4px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--fg-muted)}.command-palette__item{display:flex;align-items:center;gap:10px;padding:8px 16px;cursor:pointer;transition:background var(--transition)}.command-palette__item:hover,.command-palette__item--active{background:var(--bg-secondary)}.command-palette__item-label{font-size:14px}.command-palette__item-detail{font-size:12px;color:var(--fg-muted);margin-left:auto}.command-palette__item-shortcut{margin-left:auto;font-family:var(--font-mono);font-size:11px;color:var(--fg-muted);background:var(--bg-input);border:1px solid var(--border);border-radius:3px;padding:1px 6px;white-space:nowrap}.endpoint-doc{border:1px solid var(--border);border-radius:var(--radius);margin-bottom:16px}.endpoint-doc__toggle{display:flex;align-items:center;justify-content:space-between;width:100%;background:none;border:none;color:var(--fg);padding:10px 24px;font-size:14px;font-weight:600;cursor:pointer;text-align:left}.endpoint-doc__toggle:hover{background:var(--bg-secondary)}.endpoint-doc__toggle-icon{color:var(--fg-muted);font-size:12px;transition:transform var(--transition)}.endpoint-doc__toggle-icon--expanded{transform:rotate(90deg)}.endpoint-doc__body{padding:0 24px 14px;font-size:13px;line-height:1.6}.endpoint-doc__deprecated{background:#ff55551a;color:var(--method-delete);padding:6px 10px;border-radius:var(--radius);font-size:12px;font-weight:600;margin-bottom:8px}.endpoint-doc__body h1,.endpoint-doc__body h2,.endpoint-doc__body h3{margin-top:.8em;margin-bottom:.4em}.endpoint-doc__body p{margin-bottom:.5em}.endpoint-doc__body code{font-size:.9em}.body-editor__schema{margin-top:8px}.body-editor__schema-toggle{display:flex;align-items:center;justify-content:space-between;width:100%;background:var(--bg-elevated);border:1px solid var(--border);border-radius:var(--radius);padding:6px 12px;color:var(--fg-muted);font-size:12px;cursor:pointer}.body-editor__schema-toggle:hover{color:var(--fg)}.body-editor__schema-body{padding:10px 12px;border:1px solid var(--border);border-top:none;border-radius:0 0 var(--radius) var(--radius);font-size:12px;font-family:var(--font-mono);max-height:300px;overflow-y:auto}.schema-view__prop{padding:3px 0;display:flex;align-items:baseline;flex-wrap:wrap;gap:6px}.schema-view__name{color:var(--accent);font-weight:600}.schema-view__type{color:var(--fg-muted);font-size:11px}.schema-view__required{color:var(--method-delete);font-size:10px;font-weight:600}.schema-view__desc{color:var(--fg-muted);font-style:italic;font-family:var(--font-sans);font-size:11px}.schema-view__enum{color:var(--fg-muted);font-size:10px}.validation-details{display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:13px}.validation-details__indicator--valid{color:var(--method-get)}.validation-details__indicator--invalid{color:var(--method-delete)}.validation-details__indicator--none{color:var(--fg-muted)}.validation-details__errors{margin-top:8px;font-size:12px}.validation-details__error{padding:6px 10px;border-left:3px solid var(--method-delete);background:#ff55550d;margin-bottom:4px;border-radius:0 var(--radius) var(--radius) 0}.validation-details__error-path{font-family:var(--font-mono);color:var(--accent);font-size:11px}.validation-details__error-msg{color:var(--fg)}.validation-details__error-detail{color:var(--fg-muted);font-size:11px}.copy-btn{background:none;border:1px solid var(--border);border-radius:var(--radius);color:var(--fg);padding:3px 10px;font-size:12px;cursor:pointer}.copy-btn:hover{border-color:var(--accent);color:var(--accent)}[data-theme=light] .body-view__pre .hljs-attr{color:#1a8a3f}[data-theme=light] .body-view__pre .hljs-string{color:#b35c00}[data-theme=light] .body-view__pre .hljs-number{color:#7c3aed}[data-theme=light] .body-view__pre .hljs-literal,[data-theme=light] .body-view__pre .hljs-keyword,[data-theme=light] .body-view__pre .hljs-tag{color:#d6336c}[data-theme=light] .body-view__pre .hljs-name{color:#1a8a3f}[data-theme=light] .body-view__pre .hljs-selector-tag{color:#d6336c}[data-theme=light] .body-view__pre .hljs-punctuation{color:var(--fg)}.resize-handle{flex-shrink:0;background:var(--border);transition:background .15s}.resize-handle--horizontal{width:4px;cursor:col-resize}.resize-handle--vertical{height:4px;cursor:row-resize}.resize-handle:hover,.resize-handle:active{background:var(--accent)}
|