@kyro-cms/admin 0.9.5 → 0.9.6

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.
@@ -2,26 +2,26 @@ import React, {
2
2
  useState,
3
3
  useCallback,
4
4
  useEffect,
5
- useMemo,
5
+ useRef,
6
6
  Suspense,
7
7
  lazy,
8
8
  } from "react";
9
9
  import {
10
10
  Book,
11
- Send,
12
11
  Trash2,
13
12
  Copy,
14
13
  RefreshCw,
15
- Settings,
16
- Maximize2,
17
14
  ChevronRight,
18
- ChevronDown,
19
- Search,
20
- Type,
21
15
  Activity,
22
16
  Zap,
23
17
  Info,
24
- X
18
+ X,
19
+ Download,
20
+ Check,
21
+ Code2,
22
+ Play,
23
+ Clock,
24
+ Terminal,
25
25
  } from "./ui/icons";
26
26
 
27
27
  interface GraphQLPlaygroundProps {
@@ -33,25 +33,47 @@ interface GraphQLPlaygroundProps {
33
33
 
34
34
  interface QueryTab {
35
35
  id: string;
36
- name: string;
37
36
  query: string;
38
37
  variables: string;
39
38
  headers: string;
40
39
  }
41
40
 
42
- // Lazy-load CodeMirror
41
+ interface HistoryEntry {
42
+ id: string;
43
+ query: string;
44
+ variables: string;
45
+ response: string;
46
+ timestamp: number;
47
+ duration: number;
48
+ statusCode: number;
49
+ }
50
+
43
51
  const CodeMirrorEditor = lazy(() =>
44
52
  import("@uiw/react-codemirror").then((mod) => ({ default: mod.default })),
45
53
  );
46
54
  import { javascript } from "@codemirror/lang-javascript";
47
- import { githubLight } from "@uiw/codemirror-theme-github";
48
55
  import { aura } from "@uiw/codemirror-theme-aura";
49
- import "graphiql/graphiql.css";
56
+
57
+ function prettifyQuery(query: string): string {
58
+ let indent = 0;
59
+ let result = "";
60
+ const lines = query.split("\n");
61
+ for (const line of lines) {
62
+ const trimmed = line.trim();
63
+ if (!trimmed) continue;
64
+ if (trimmed.startsWith("}") || trimmed.startsWith("]")) indent = Math.max(0, indent - 1);
65
+ result += " ".repeat(indent) + trimmed + "\n";
66
+ if (trimmed.endsWith("{") || trimmed.endsWith("[")) indent++;
67
+ const opens = (trimmed.match(/\{/g) || []).length;
68
+ const closes = (trimmed.match(/\}/g) || []).length;
69
+ indent += opens - closes;
70
+ }
71
+ return result.trim();
72
+ }
50
73
 
51
74
  const DEFAULT_QUERY = `# Welcome to Kyro CMS GraphQL Playground
52
- # Try these example queries:
75
+ # Cmd+Enter to run, Cmd+Shift+P to prettify
53
76
 
54
- # 1. Introspection - Discover the schema
55
77
  {
56
78
  __schema {
57
79
  types {
@@ -65,21 +87,15 @@ const DEFAULT_QUERY = `# Welcome to Kyro CMS GraphQL Playground
65
87
  }
66
88
  }
67
89
 
68
- # 2. List all collections
69
- {
70
- collections {
71
- slug
72
- name
73
- }
74
- }
75
-
76
- # 3. Ping the API
77
- {
78
- ping
79
- }
90
+ # Example: fetch all posts (uncomment to use)
91
+ # {
92
+ # posts(page: 1, limit: 10) {
93
+ # docs { id title slug status }
94
+ # totalDocs
95
+ # }
96
+ # }
80
97
  `;
81
98
 
82
- // --- Docs Types ---
83
99
  interface TypeInfo {
84
100
  name: string;
85
101
  kind: string;
@@ -120,38 +136,39 @@ export function GraphQLPlayground({
120
136
  initialShowDocs = false,
121
137
  }: GraphQLPlaygroundProps) {
122
138
  const [token, setToken] = useState<string>("");
139
+ const [showToken, setShowToken] = useState(false);
123
140
  const [isConnected, setIsConnected] = useState(false);
124
- const [tabs, setTabs] = useState<QueryTab[]>([
125
- {
126
- id: "default",
127
- name: "Query",
128
- query: initialQuery || DEFAULT_QUERY,
129
- variables: initialVariables || "{}",
130
- headers: "",
131
- },
132
- ]);
133
- const [currentTabId, setCurrentTabId] = useState("default");
141
+ const [tab, setTab] = useState<QueryTab>({
142
+ id: "default",
143
+ query: initialQuery || DEFAULT_QUERY,
144
+ variables: initialVariables || "{}",
145
+ headers: "",
146
+ });
134
147
  const [response, setResponse] = useState<string>("");
135
148
  const [isLoading, setIsLoading] = useState(false);
136
149
  const [error, setError] = useState<string | null>(null);
137
- const [activeTab, setActiveTab] = useState<"query" | "variables" | "headers">(
138
- "query",
139
- );
150
+ const [activeEditorTab, setActiveEditorTab] = useState<"query" | "variables" | "headers">("query");
140
151
  const [isMounted, setIsMounted] = useState(false);
141
152
  const [showDocs, setShowDocs] = useState(initialShowDocs);
142
153
  const [schema, setSchema] = useState<SchemaInfo | null>(null);
143
154
  const [loadingSchema, setLoadingSchema] = useState(false);
144
155
  const [selectedType, setSelectedType] = useState<TypeInfo | null>(null);
156
+ const [rightTab, setRightTab] = useState<"response" | "docs" | "history">(
157
+ initialShowDocs ? "docs" : "response",
158
+ );
159
+ const [history, setHistory] = useState<HistoryEntry[]>([]);
160
+ const [lastDuration, setLastDuration] = useState<number>(0);
161
+ const [lastStatus, setLastStatus] = useState<number>(0);
162
+ const [copied, setCopied] = useState(false);
163
+ const [splitPos, setSplitPos] = useState(50);
164
+ const [isDragging, setIsDragging] = useState(false);
165
+ const containerRef = useRef<HTMLDivElement>(null);
166
+ const cursorPos = useRef({ line: 1, col: 1 });
145
167
 
146
168
  useEffect(() => {
147
169
  setIsMounted(true);
148
170
  }, []);
149
171
 
150
- const currentTab = useMemo(
151
- () => tabs.find((t) => t.id === currentTabId) || tabs[0],
152
- [tabs, currentTabId],
153
- );
154
-
155
172
  const fetchSchema = useCallback(async () => {
156
173
  setLoadingSchema(true);
157
174
  try {
@@ -212,55 +229,110 @@ export function GraphQLPlayground({
212
229
  }, [endpoint]);
213
230
 
214
231
  useEffect(() => {
215
- if (showDocs && !schema) {
216
- fetchSchema();
217
- }
232
+ if (showDocs && !schema) fetchSchema();
218
233
  }, [showDocs, schema, fetchSchema]);
219
234
 
220
- const handleRun = async () => {
235
+ const handleRun = useCallback(async () => {
221
236
  setIsLoading(true);
222
237
  setError(null);
238
+ const startTime = performance.now();
223
239
  try {
224
240
  const headers: Record<string, string> = {
225
241
  "Content-Type": "application/json",
226
242
  };
227
243
  if (token) headers["Authorization"] = `Bearer ${token}`;
228
-
229
- // Add custom headers from tab
230
- if (currentTab.headers) {
244
+ if (tab.headers) {
231
245
  try {
232
- const customHeaders = JSON.parse(currentTab.headers);
246
+ const customHeaders = JSON.parse(tab.headers);
233
247
  Object.assign(headers, customHeaders);
234
- } catch (e) {
235
- console.warn("Invalid custom headers JSON");
236
- }
248
+ } catch { /* ignore invalid */ }
237
249
  }
238
250
 
239
251
  const res = await fetch(endpoint, {
240
252
  method: "POST",
241
253
  headers,
242
254
  body: JSON.stringify({
243
- query: currentTab.query,
244
- variables: currentTab.variables ? JSON.parse(currentTab.variables) : {},
255
+ query: tab.query,
256
+ variables: tab.variables ? JSON.parse(tab.variables) : {},
245
257
  }),
246
258
  });
247
259
 
260
+ const endTime = performance.now();
261
+ const duration = Math.round(endTime - startTime);
262
+ setLastDuration(duration);
263
+ setLastStatus(res.status);
264
+
248
265
  const data = await res.json();
249
- setResponse(JSON.stringify(data, null, 2));
266
+ const formatted = JSON.stringify(data, null, 2);
267
+ setResponse(formatted);
250
268
  if (data.errors) setError("Query returned errors");
269
+
270
+ setHistory((prev) => [
271
+ {
272
+ id: Date.now().toString(),
273
+ query: tab.query,
274
+ variables: tab.variables,
275
+ response: formatted,
276
+ timestamp: Date.now(),
277
+ duration,
278
+ statusCode: res.status,
279
+ },
280
+ ...prev.slice(0, 49),
281
+ ]);
251
282
  } catch (err: unknown) {
252
283
  const message = err instanceof Error ? err.message : "Request failed";
253
- setError(message || "Request failed");
284
+ setError(message);
254
285
  setResponse(JSON.stringify({ error: message }, null, 2));
255
286
  } finally {
256
287
  setIsLoading(false);
257
288
  }
289
+ }, [endpoint, token, tab]);
290
+
291
+ useEffect(() => {
292
+ const handler = (e: KeyboardEvent) => {
293
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
294
+ e.preventDefault();
295
+ handleRun();
296
+ }
297
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === "p") {
298
+ e.preventDefault();
299
+ handlePrettify();
300
+ }
301
+ };
302
+ window.addEventListener("keydown", handler);
303
+ return () => window.removeEventListener("keydown", handler);
304
+ }, [handleRun]);
305
+
306
+ const updateTab = (key: "query" | "variables" | "headers", value: string) => {
307
+ setTab((prev) => ({ ...prev, [key]: value }));
258
308
  };
259
309
 
260
- const updateTab = (key: keyof QueryTab, value: string) => {
261
- setTabs((prev) =>
262
- prev.map((t) => (t.id === currentTabId ? { ...t, [key]: value } : t)),
263
- );
310
+ const handlePrettify = () => {
311
+ const pretty = prettifyQuery(tab.query);
312
+ setTab((prev) => ({ ...prev, query: pretty }));
313
+ };
314
+
315
+ const handleCopyResponse = async () => {
316
+ if (response) {
317
+ await navigator.clipboard.writeText(response);
318
+ setCopied(true);
319
+ setTimeout(() => setCopied(false), 2000);
320
+ }
321
+ };
322
+
323
+ const handleDownloadResponse = () => {
324
+ if (!response) return;
325
+ const blob = new Blob([response], { type: "application/json" });
326
+ const url = URL.createObjectURL(blob);
327
+ const a = document.createElement("a");
328
+ a.href = url;
329
+ a.download = "graphql-response.json";
330
+ a.click();
331
+ URL.revokeObjectURL(url);
332
+ };
333
+
334
+ const handleClearEditor = () => {
335
+ setTab((prev) => ({ ...prev, query: "" }));
264
336
  };
265
337
 
266
338
  const extensions = [javascript()];
@@ -268,218 +340,382 @@ export function GraphQLPlayground({
268
340
 
269
341
  const renderType = (type: Record<string, unknown>): string => {
270
342
  if (!type) return "Unknown";
271
- if (type.name) return type.name;
343
+ if (type.name) return type.name as string;
272
344
  if (type.ofType) {
273
- if (type.kind === "NON_NULL") return `${renderType(type.ofType)}!`;
274
- if (type.kind === "LIST") return `[${renderType(type.ofType)}]`;
275
- return renderType(type.ofType);
345
+ if (type.kind === "NON_NULL") return `${renderType(type.ofType as Record<string, unknown>)}!`;
346
+ if (type.kind === "LIST") return `[${renderType(type.ofType as Record<string, unknown>)}]`;
347
+ return renderType(type.ofType as Record<string, unknown>);
276
348
  }
277
349
  return "Unknown";
278
350
  };
279
351
 
352
+ const startDrag = useCallback((e: React.MouseEvent) => {
353
+ e.preventDefault();
354
+ setIsDragging(true);
355
+ }, []);
356
+
357
+ useEffect(() => {
358
+ if (!isDragging) return;
359
+ const onMove = (e: MouseEvent) => {
360
+ if (!containerRef.current) return;
361
+ const rect = containerRef.current.getBoundingClientRect();
362
+ const pct = ((e.clientX - rect.left) / rect.width) * 100;
363
+ setSplitPos(Math.max(20, Math.min(80, pct)));
364
+ };
365
+ const onUp = () => setIsDragging(false);
366
+ window.addEventListener("mousemove", onMove);
367
+ window.addEventListener("mouseup", onUp);
368
+ return () => {
369
+ window.removeEventListener("mousemove", onMove);
370
+ window.removeEventListener("mouseup", onUp);
371
+ };
372
+ }, [isDragging]);
373
+
374
+ const editorPills = [
375
+ { key: "query" as const, label: "Query", shortcut: "" },
376
+ { key: "variables" as const, label: "Variables", shortcut: "" },
377
+ { key: "headers" as const, label: "Headers", shortcut: "" },
378
+ ];
379
+
380
+ const rightPills = [
381
+ { key: "response" as const, label: "Response", badge: lastStatus ? `${lastStatus}ms` : undefined },
382
+ { key: "docs" as const, label: "Docs" },
383
+ { key: "history" as const, label: `History${history.length ? ` (${history.length})` : ""}` },
384
+ ];
385
+
386
+ const editorValue =
387
+ activeEditorTab === "query"
388
+ ? tab.query
389
+ : activeEditorTab === "variables"
390
+ ? tab.variables
391
+ : tab.headers;
392
+
280
393
  return (
281
- <div className="h-full flex flex-col bg-[var(--kyro-bg)] overflow-hidden rounded-lg border border-[var(--kyro-border)]">
282
- {/* Header */}
283
- <div className="flex items-center justify-between px-4 py-3 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
284
- <div className="flex items-center gap-4">
285
- <div className="flex items-center gap-2">
286
- <div className="w-8 h-8 rounded-lg bg-pink-500/10 flex items-center justify-center text-pink-500">
287
- <Zap className="w-4 h-4" />
288
- </div>
289
- <span className="font-bold text-sm tracking-tight">GraphQL Playground</span>
394
+ <div ref={containerRef} className="h-full flex flex-col bg-[var(--kyro-bg)] overflow-hidden rounded-lg border border-[var(--kyro-border)]">
395
+ {/* Compact top bar */}
396
+ <div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)] shrink-0">
397
+ <div className="flex items-center gap-1.5 min-w-0">
398
+ <div className="w-7 h-7 rounded-lg bg-[var(--kyro-primary)]/10 flex items-center justify-center text-[var(--kyro-primary)] shrink-0">
399
+ <Zap className="w-3.5 h-3.5" />
290
400
  </div>
291
- <div className="h-4 w-px bg-[var(--kyro-border)]" />
401
+ <span className="text-xs font-semibold hidden sm:inline">GraphQL</span>
402
+ </div>
403
+ <div className="flex items-center gap-1.5 text-[10px] text-[var(--kyro-text-muted)]">
404
+ <div className={`w-1.5 h-1.5 rounded-full ${isConnected ? "bg-[var(--kyro-success)]" : "bg-[var(--kyro-danger)]"}`} />
405
+ <span className="hidden sm:inline">{isConnected ? "Connected" : "Disconnected"}</span>
406
+ </div>
407
+ <div className="h-4 w-px bg-[var(--kyro-border)]" />
408
+ {token ? (
409
+ <div className="flex items-center gap-1 px-2 py-1 rounded-md bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] text-[10px] font-mono text-[var(--kyro-text-muted)]">
410
+ <span className="w-2 h-2 rounded-full bg-[var(--kyro-success)]" />
411
+ Bearer ••••{token.slice(-4)}
412
+ <button onClick={() => { setToken(""); setShowToken(false); }} className="ml-1 hover:text-[var(--kyro-danger)]">
413
+ <X className="w-3 h-3" />
414
+ </button>
415
+ </div>
416
+ ) : (
292
417
  <div className="flex items-center gap-1">
293
- <div className={`w-2 h-2 rounded-full ${isConnected ? "bg-green-500" : "bg-red-500"} animate-pulse`} />
294
- <span className="text-[10px] font-bold tracking-widest text-[var(--kyro-text-muted)]">
295
- {isConnected ? "Connected" : "Disconnected"}
296
- </span>
418
+ <input
419
+ type={showToken ? "text" : "password"}
420
+ value={token}
421
+ onChange={(e) => setToken(e.target.value)}
422
+ placeholder="Bearer token"
423
+ className="w-24 px-2 py-1 text-[10px] font-mono bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-md text-[var(--kyro-text-primary)] placeholder:text-[var(--kyro-text-muted)] focus:outline-none focus:border-[var(--kyro-primary)]"
424
+ />
425
+ <button onClick={() => setShowToken(!showToken)} className="p-1 text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)]">
426
+ {showToken ? <X className="w-3 h-3" /> : <Info className="w-3 h-3" />}
427
+ </button>
297
428
  </div>
298
- </div>
299
- <div className="flex items-center gap-2">
300
- <button
301
- onClick={() => setShowDocs(!showDocs)}
302
- className={`kyro-btn kyro-btn-md flex items-center gap-2 ${showDocs
303
- ? "bg-pink-500 text-white border-pink-500 hover:bg-pink-600 hover:border-pink-600 shadow-[0_0_15px_rgba(236,72,153,0.3)]"
304
- : "kyro-btn-ghost"
305
- }`}
306
- >
429
+ )}
430
+ <div className="ml-auto flex items-center gap-1">
431
+ <button onClick={() => { setShowDocs(!showDocs); setRightTab("docs"); }} className={`p-1.5 rounded-lg transition-all ${rightTab === "docs" && showDocs ? "bg-[var(--kyro-primary)] text-white" : "text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]"}`} title="Schema docs">
307
432
  <Book className="w-3.5 h-3.5" />
308
- Documentation
309
433
  </button>
434
+ <button onClick={() => setRightTab("history")} className={`p-1.5 rounded-lg transition-all ${rightTab === "history" ? "bg-[var(--kyro-primary)] text-white" : "text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]"}`} title="History">
435
+ <Clock className="w-3.5 h-3.5" />
436
+ </button>
437
+ <button onClick={handlePrettify} className="p-1.5 rounded-lg text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]" title="Prettify (Cmd+Shift+P)">
438
+ <Code2 className="w-3.5 h-3.5" />
439
+ </button>
440
+ <button onClick={handleCopyResponse} className="p-1.5 rounded-lg text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]" title="Copy response">
441
+ {copied ? <Check className="w-3.5 h-3.5 text-[var(--kyro-success)]" /> : <Copy className="w-3.5 h-3.5" />}
442
+ </button>
443
+ <button onClick={handleDownloadResponse} className="p-1.5 rounded-lg text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]" title="Download response">
444
+ <Download className="w-3.5 h-3.5" />
445
+ </button>
446
+ <button onClick={handleClearEditor} className="p-1.5 rounded-lg text-[var(--kyro-text-muted)] hover:text-[var(--kyro-danger)] hover:bg-[var(--kyro-danger-bg)]" title="Clear editor">
447
+ <Trash2 className="w-3.5 h-3.5" />
448
+ </button>
449
+ <div className="h-4 w-px bg-[var(--kyro-border)] mx-1" />
310
450
  <button
311
451
  onClick={handleRun}
312
452
  disabled={isLoading}
313
- className="kyro-btn kyro-btn-md kyro-btn-primary flex items-center gap-2"
453
+ className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-[var(--kyro-primary)] text-white text-xs font-semibold hover:opacity-90 disabled:opacity-50 transition-all shadow-sm"
314
454
  >
315
- <Send className="w-3.5 h-3.5" />
316
- {isLoading ? "Running..." : "Run Query"}
455
+ <Play className="w-3 h-3" />
456
+ {isLoading ? "..." : "Run"}
317
457
  </button>
318
458
  </div>
319
459
  </div>
320
460
 
321
- <div className="flex-1 flex overflow-hidden">
322
- {/* Left: Editor and Schema */}
323
- <div className={`flex flex-col border-r border-[var(--kyro-border)] transition-all duration-300 ${showDocs ? "w-1/2" : "w-1/2"}`}>
324
- {/* Tabs for Query/Vars/Headers */}
325
- <div className="flex border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
326
- {(["query", "variables", "headers"] as const).map((tab) => (
461
+ {/* Main split area */}
462
+ <div className="flex-1 flex overflow-hidden relative">
463
+ {/* Left: editor */}
464
+ <div className="flex flex-col overflow-hidden border-r border-[var(--kyro-border)]" style={{ width: `${splitPos}%` }}>
465
+ {/* Editor pills */}
466
+ <div className="flex gap-0.5 px-3 py-1.5 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
467
+ {editorPills.map((p) => (
327
468
  <button
328
- key={tab}
329
- onClick={() => setActiveTab(tab)}
330
- className={`px-4 py-2 text-xs font-bold tracking-widest transition-all ${activeTab === tab
331
- ? "text-pink-500 border-b-2 border-pink-500"
332
- : "text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)]"
333
- }`}
469
+ key={p.key}
470
+ onClick={() => setActiveEditorTab(p.key)}
471
+ className={`px-2.5 py-1 text-[10px] font-semibold rounded-md transition-all ${
472
+ activeEditorTab === p.key
473
+ ? "bg-[var(--kyro-primary)] text-white"
474
+ : "text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]"
475
+ }`}
334
476
  >
335
- {tab}
477
+ {p.label}
336
478
  </button>
337
479
  ))}
338
480
  </div>
339
481
 
482
+ {/* CodeMirror */}
340
483
  <div className="flex-1 overflow-hidden relative bg-[var(--kyro-bg)]">
341
- <Suspense fallback={<div className="p-4 text-xs">Loading editor...</div>}>
484
+ <Suspense fallback={<div className="p-3 text-[10px] text-[var(--kyro-text-muted)]">Loading editor...</div>}>
342
485
  {isMounted && (
343
486
  <CodeMirrorEditor
344
- value={activeTab === "query" ? currentTab.query : activeTab === "variables" ? currentTab.variables : currentTab.headers}
487
+ value={editorValue}
345
488
  height="100%"
346
489
  extensions={extensions}
347
490
  theme={theme}
348
- onChange={(val) => updateTab(activeTab, val)}
349
- basicSetup={true}
491
+ onChange={(val) => updateTab(activeEditorTab, val)}
492
+ basicSetup={{ lineNumbers: true, foldGutter: true, highlightActiveLine: true }}
350
493
  style={{
351
494
  height: "100%",
352
- fontSize: "13px",
495
+ fontSize: "12px",
353
496
  fontFamily: "'Fira Code', monospace",
354
497
  }}
355
498
  />
356
499
  )}
357
500
  </Suspense>
358
501
  </div>
502
+
503
+ {/* Status bar */}
504
+ <div className="flex items-center justify-between px-3 py-1 border-t border-[var(--kyro-border)] bg-[var(--kyro-surface)] text-[9px] text-[var(--kyro-text-muted)] font-mono">
505
+ <span>Ln {cursorPos.current.line}, Col {cursorPos.current.col}</span>
506
+ <span>{tab.query.length} chars</span>
507
+ </div>
359
508
  </div>
360
509
 
361
- {/* Center/Right: Response or Docs */}
362
- <div className="flex-1 flex flex-col min-w-0">
363
- {showDocs ? (
364
- <div className="flex-1 flex flex-col overflow-hidden bg-[var(--kyro-surface)]">
365
- <div className="flex items-center justify-between px-4 py-2 border-b border-[var(--kyro-border)]">
366
- <span className="text-xs font-bold tracking-widest text-[var(--kyro-text-secondary)]">Schema Explorer</span>
367
- <button onClick={() => setShowDocs(false)} className="text-[var(--kyro-text-muted)] hover:text-red-500">
368
- <X className="w-4 h-4" />
369
- </button>
370
- </div>
371
- <div className="flex-1 overflow-y-auto p-4 space-y-4">
372
- {loadingSchema ? (
373
- <div className="flex items-center justify-center h-40">
374
- <RefreshCw className="w-6 h-6 animate-spin text-pink-500" />
375
- </div>
376
- ) : schema ? (
377
- <div className="space-y-6">
378
- {selectedType ? (
379
- <div className="space-y-4">
380
- <button
381
- onClick={() => setSelectedType(null)}
382
- className="flex items-center gap-1 text-xs text-pink-500 font-bold hover:underline"
383
- >
384
- ← Back to types
385
- </button>
386
- <div>
387
- <h3 className="text-lg font-bold text-[var(--kyro-text-primary)]">{selectedType.name}</h3>
388
- <p className="text-xs text-[var(--kyro-text-muted)] italic">{selectedType.kind}</p>
389
- {selectedType.description && (
390
- <p className="mt-2 text-sm text-[var(--kyro-text-secondary)] leading-relaxed">{selectedType.description}</p>
510
+ {/* Drag handle */}
511
+ <div
512
+ className="absolute top-0 bottom-0 z-10 w-1.5 cursor-col-resize group"
513
+ style={{ left: `calc(${splitPos}% - 3px)` }}
514
+ onMouseDown={startDrag}
515
+ >
516
+ <div className="w-0.5 h-full mx-auto bg-transparent group-hover:bg-[var(--kyro-primary)] group-hover:opacity-40 transition-all" />
517
+ </div>
518
+
519
+ {/* Right panel */}
520
+ <div className="flex-1 flex flex-col overflow-hidden min-w-0" style={{ width: `${100 - splitPos}%` }}>
521
+ {/* Right pills */}
522
+ <div className="flex gap-0.5 px-3 py-1.5 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
523
+ {rightPills.map((p) => (
524
+ <button
525
+ key={p.key}
526
+ onClick={() => { setRightTab(p.key); if (p.key === "docs") setShowDocs(true); }}
527
+ className={`px-2.5 py-1 text-[10px] font-semibold rounded-md transition-all ${
528
+ rightTab === p.key
529
+ ? "bg-[var(--kyro-primary)] text-white"
530
+ : "text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]"
531
+ }`}
532
+ >
533
+ {p.label}
534
+ </button>
535
+ ))}
536
+ </div>
537
+
538
+ <div className="flex-1 overflow-hidden">
539
+ {rightTab === "docs" ? (
540
+ <div className="h-full flex flex-col overflow-hidden bg-[var(--kyro-surface)]">
541
+ <div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--kyro-border)]">
542
+ <span className="text-[10px] font-semibold text-[var(--kyro-text-secondary)]">Schema Explorer</span>
543
+ <button onClick={() => setShowDocs(false)} className="text-[var(--kyro-text-muted)] hover:text-[var(--kyro-text-primary)]">
544
+ <X className="w-3.5 h-3.5" />
545
+ </button>
546
+ </div>
547
+ <div className="flex-1 overflow-y-auto p-3 space-y-3">
548
+ {loadingSchema ? (
549
+ <div className="flex items-center justify-center h-32">
550
+ <RefreshCw className="w-5 h-5 animate-spin text-[var(--kyro-primary)]" />
551
+ </div>
552
+ ) : schema ? (
553
+ <div className="space-y-4">
554
+ {selectedType ? (
555
+ <div className="space-y-3">
556
+ <button
557
+ onClick={() => setSelectedType(null)}
558
+ className="flex items-center gap-1 text-[10px] text-[var(--kyro-primary)] font-semibold hover:underline"
559
+ >
560
+ ← Back to types
561
+ </button>
562
+ <div>
563
+ <h3 className="text-sm font-bold text-[var(--kyro-text-primary)]">{selectedType.name}</h3>
564
+ <p className="text-[10px] text-[var(--kyro-text-muted)] italic">{selectedType.kind}</p>
565
+ {selectedType.description && (
566
+ <p className="mt-1.5 text-[11px] text-[var(--kyro-text-secondary)] leading-relaxed">{selectedType.description}</p>
567
+ )}
568
+ </div>
569
+ {selectedType.fields && (
570
+ <div className="space-y-2">
571
+ <h4 className="text-[10px] font-semibold tracking-wider text-[var(--kyro-text-muted)] pt-3">Fields</h4>
572
+ {selectedType.fields.map(f => (
573
+ <div key={f.name} className="p-2.5 bg-[var(--kyro-surface-accent)] rounded-lg border border-[var(--kyro-border)]">
574
+ <div className="flex items-center justify-between gap-2">
575
+ <span className="font-semibold text-[11px] text-[var(--kyro-text-primary)]">{f.name}</span>
576
+ <span className="text-[9px] font-mono text-[var(--kyro-primary)] bg-[var(--kyro-primary)]/10 px-1.5 py-0.5 rounded">{renderType(f.type)}</span>
577
+ </div>
578
+ {f.description && <p className="text-[10px] text-[var(--kyro-text-secondary)] mt-1">{f.description}</p>}
579
+ {f.args && f.args.length > 0 && (
580
+ <div className="mt-1.5 pl-3 border-l-2 border-[var(--kyro-border)] space-y-0.5">
581
+ {f.args.map(a => (
582
+ <div key={a.name} className="text-[9px]">
583
+ <span className="text-[var(--kyro-text-muted)]">{a.name}:</span>{" "}
584
+ <span className="text-[var(--kyro-primary)]">{renderType(a.type)}</span>
585
+ </div>
586
+ ))}
587
+ </div>
588
+ )}
589
+ </div>
590
+ ))}
591
+ </div>
391
592
  )}
392
593
  </div>
393
- {selectedType.fields && (
394
- <div className="space-y-3">
395
- <h4 className="text-xs font-bold tracking-widest text-[var(--kyro-text-muted)] pt-4">Fields</h4>
396
- {selectedType.fields.map(f => (
397
- <div key={f.name} className="p-3 bg-[var(--kyro-surface-accent)] rounded-lg border border-[var(--kyro-border)]">
398
- <div className="flex items-center justify-between gap-2">
399
- <span className="font-bold text-sm text-[var(--kyro-text-primary)]">{f.name}</span>
400
- <span className="text-[10px] font-mono text-pink-500 bg-pink-500/10 px-1.5 py-0.5 rounded">{renderType(f.type)}</span>
401
- </div>
402
- {f.description && <p className="text-xs text-[var(--kyro-text-secondary)] mt-1">{f.description}</p>}
403
- {f.args && f.args.length > 0 && (
404
- <div className="mt-2 pl-4 border-l-2 border-[var(--kyro-border)] space-y-1">
405
- {f.args.map(a => (
406
- <div key={a.name} className="text-[10px]">
407
- <span className="text-[var(--kyro-text-muted)]">{a.name}:</span> <span className="text-pink-400">{renderType(a.type)}</span>
408
- </div>
409
- ))}
594
+ ) : (
595
+ <div className="space-y-3">
596
+ <div className="grid grid-cols-2 gap-2">
597
+ {["Query", "Mutation"].map(t => {
598
+ const found = schema.types.find(type => type.name === t);
599
+ if (!found) return null;
600
+ return (
601
+ <button
602
+ key={t}
603
+ onClick={() => setSelectedType(found)}
604
+ className="flex items-center justify-between p-3 bg-[var(--kyro-surface-accent)] rounded-xl border border-[var(--kyro-border)] hover:border-[var(--kyro-primary)] transition-all text-left group"
605
+ >
606
+ <div>
607
+ <span className="text-[10px] font-semibold text-[var(--kyro-text-muted)] block">{t}</span>
608
+ <span className="text-[11px] font-bold text-[var(--kyro-text-primary)]">Root Operations</span>
410
609
  </div>
411
- )}
412
- </div>
413
- ))}
610
+ <ChevronRight className="w-3.5 h-3.5 text-[var(--kyro-primary)] group-hover:translate-x-1 transition-transform" />
611
+ </button>
612
+ );
613
+ })}
414
614
  </div>
415
- )}
416
- </div>
417
- ) : (
418
- <div className="space-y-4">
419
- <div className="grid grid-cols-2 gap-2">
420
- {["Query", "Mutation"].map(t => {
421
- const found = schema.types.find(type => type.name === t);
422
- if (!found) return null;
423
- return (
615
+ <div className="space-y-1.5">
616
+ <h4 className="text-[10px] font-semibold tracking-wider text-[var(--kyro-text-muted)] pt-3">All Types</h4>
617
+ {schema.types.filter(t => !t.name.startsWith("__") && t.kind === "OBJECT").map(t => (
424
618
  <button
425
- key={t}
426
- onClick={() => setSelectedType(found)}
427
- className="flex items-center justify-between p-4 bg-[var(--kyro-surface-accent)] rounded-xl border border-[var(--kyro-border)] hover:border-pink-500 transition-all text-left group"
619
+ key={t.name}
620
+ onClick={() => setSelectedType(t)}
621
+ className="w-full flex items-center justify-between px-3 py-1.5 hover:bg-[var(--kyro-surface-accent)] rounded-lg transition-all text-left"
428
622
  >
429
- <div>
430
- <span className="text-xs font-bold text-[var(--kyro-text-muted)] block">{t}</span>
431
- <span className="text-sm font-bold text-[var(--kyro-text-primary)]">Root Operations</span>
432
- </div>
433
- <ChevronRight className="w-4 h-4 text-pink-500 group-hover:translate-x-1 transition-transform" />
623
+ <span className="text-[11px] font-medium text-[var(--kyro-text-primary)]">{t.name}</span>
624
+ <ChevronRight className="w-3 h-3 opacity-30" />
434
625
  </button>
435
- );
436
- })}
437
- </div>
438
- <div className="space-y-2">
439
- <h4 className="text-xs font-bold tracking-widest text-[var(--kyro-text-muted)] pt-4">All Types</h4>
440
- {schema.types.filter(t => !t.name.startsWith("__") && t.kind === "OBJECT").map(t => (
441
- <button
442
- key={t.name}
443
- onClick={() => setSelectedType(t)}
444
- className="w-full flex items-center justify-between px-4 py-2 hover:bg-[var(--kyro-surface-accent)] rounded-lg transition-all text-left"
445
- >
446
- <span className="text-sm font-medium text-[var(--kyro-text-primary)]">{t.name}</span>
447
- <ChevronRight className="w-3.5 h-3.5 opacity-30" />
448
- </button>
449
- ))}
626
+ ))}
627
+ </div>
450
628
  </div>
451
- </div>
452
- )}
453
- </div>
454
- ) : (
455
- <p className="text-xs text-[var(--kyro-text-muted)]">No schema loaded.</p>
456
- )}
629
+ )}
630
+ </div>
631
+ ) : (
632
+ <p className="text-[10px] text-[var(--kyro-text-muted)]">No schema loaded.</p>
633
+ )}
634
+ </div>
457
635
  </div>
458
- </div>
459
- ) : (
460
- <div className="flex-1 flex flex-col overflow-hidden">
461
- <div className="flex items-center px-4 py-2 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
462
- <span className="text-xs font-bold tracking-widest text-[var(--kyro-text-secondary)]">Response</span>
636
+ ) : rightTab === "history" ? (
637
+ <div className="h-full flex flex-col overflow-hidden bg-[var(--kyro-surface)]">
638
+ <div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--kyro-border)]">
639
+ <span className="text-[10px] font-semibold text-[var(--kyro-text-secondary)]">Query History</span>
640
+ {history.length > 0 && (
641
+ <button onClick={() => setHistory([])} className="text-[9px] text-[var(--kyro-text-muted)] hover:text-[var(--kyro-danger)]">Clear</button>
642
+ )}
643
+ </div>
644
+ <div className="flex-1 overflow-y-auto">
645
+ {history.length === 0 ? (
646
+ <div className="flex flex-col items-center justify-center h-32 text-[var(--kyro-text-muted)]">
647
+ <Terminal className="w-8 h-8 mb-2 opacity-30" />
648
+ <p className="text-[10px]">No queries executed yet</p>
649
+ </div>
650
+ ) : (
651
+ <div className="p-2 space-y-1">
652
+ {history.map((entry) => (
653
+ <button
654
+ key={entry.id}
655
+ onClick={() => {
656
+ setTab((prev) => ({ ...prev, query: entry.query, variables: entry.variables }));
657
+ setResponse(entry.response);
658
+ }}
659
+ className="w-full text-left p-2 rounded-lg hover:bg-[var(--kyro-surface-accent)] border border-transparent hover:border-[var(--kyro-border)] transition-all"
660
+ >
661
+ <div className="text-[10px] font-mono text-[var(--kyro-text-primary)] truncate leading-relaxed">
662
+ {entry.query.split("\n").slice(0, 3).join(" ").substring(0, 80)}
663
+ </div>
664
+ <div className="flex items-center gap-2 mt-1 text-[9px] text-[var(--kyro-text-muted)]">
665
+ <span>{new Date(entry.timestamp).toLocaleTimeString()}</span>
666
+ <span>{entry.duration}ms</span>
667
+ <span className={`${entry.statusCode < 400 ? "text-[var(--kyro-success)]" : "text-[var(--kyro-danger)]"}`}>
668
+ {entry.statusCode}
669
+ </span>
670
+ </div>
671
+ </button>
672
+ ))}
673
+ </div>
674
+ )}
675
+ </div>
463
676
  </div>
464
- <div className="flex-1 overflow-auto p-4 bg-[var(--kyro-bg-secondary)]">
465
- {isLoading ? (
466
- <div className="flex flex-col items-center justify-center h-full gap-4">
467
- <RefreshCw className="w-8 h-8 animate-spin text-pink-500" />
468
- <span className="text-xs font-bold text-[var(--kyro-text-muted)]">Running Query...</span>
469
- </div>
470
- ) : response ? (
471
- <pre className="text-[13px] font-mono text-[var(--kyro-text-primary)] whitespace-pre-wrap selection:bg-pink-500/20">
472
- {response}
473
- </pre>
474
- ) : (
475
- <div className="flex flex-col items-center justify-center h-full opacity-30">
476
- <Activity className="w-16 h-16 mb-4" />
477
- <p className="text-sm font-bold">Query output will appear here</p>
478
- </div>
479
- )}
677
+ ) : (
678
+ <div className="h-full flex flex-col overflow-hidden">
679
+ <div className="flex items-center gap-2 px-3 py-1.5 border-b border-[var(--kyro-border)] bg-[var(--kyro-surface)]">
680
+ <span className="text-[10px] font-semibold text-[var(--kyro-text-secondary)]">Response</span>
681
+ {lastDuration > 0 && (
682
+ <span className="text-[9px] font-mono text-[var(--kyro-text-muted)]">
683
+ {lastDuration}ms
684
+ </span>
685
+ )}
686
+ {lastStatus > 0 && (
687
+ <span className={`text-[9px] font-semibold px-1.5 py-0.5 rounded ${
688
+ lastStatus < 400 ? "bg-[var(--kyro-success-bg)] text-[var(--kyro-success)]" : "bg-[var(--kyro-danger-bg)] text-[var(--kyro-danger)]"
689
+ }`}>
690
+ {lastStatus}
691
+ </span>
692
+ )}
693
+ </div>
694
+ <div className="flex-1 overflow-auto bg-[var(--kyro-bg-secondary)]">
695
+ {isLoading ? (
696
+ <div className="flex flex-col items-center justify-center h-full gap-3">
697
+ <RefreshCw className="w-6 h-6 animate-spin text-[var(--kyro-primary)]" />
698
+ <span className="text-[10px] font-semibold text-[var(--kyro-text-muted)]">Running Query...</span>
699
+ </div>
700
+ ) : response ? (
701
+ <pre className="text-[11px] font-mono text-[var(--kyro-text-primary)] whitespace-pre-wrap p-3 selection:bg-[var(--kyro-primary)]/20">
702
+ {error && (
703
+ <div className="mb-2 p-2 rounded bg-[var(--kyro-danger-bg)] border border-[var(--kyro-danger)]/20 text-[10px] text-[var(--kyro-danger)] font-medium">
704
+ ⚠ {error}
705
+ </div>
706
+ )}
707
+ {response}
708
+ </pre>
709
+ ) : (
710
+ <div className="flex flex-col items-center justify-center h-full opacity-30">
711
+ <Activity className="w-12 h-12 mb-3" />
712
+ <p className="text-[11px] font-bold">Press Run to execute</p>
713
+ </div>
714
+ )}
715
+ </div>
480
716
  </div>
481
- </div>
482
- )}
717
+ )}
718
+ </div>
483
719
  </div>
484
720
  </div>
485
721
  </div>