diff --git a/package.json b/package.json index 406ee74..dfafd76 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "java-runner-client", - "version": "2.1.3", + "version": "2.1.4", "description": "Run and manage Java processes with profiles, console I/O, and system tray support", "main": "dist/main/main.js", "scripts": { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 2c6e703..49e47ec 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,10 +1,11 @@ +import React from 'react'; import { HashRouter, Navigate, Route, Routes } from 'react-router-dom'; -import { TitleBar } from './components/common/TitleBar'; -import { DevModeGate } from './components/developer/DevModeGate'; -import { MainLayout } from './components/MainLayout'; import { AppProvider } from './store/AppStore'; +import { TitleBar } from './components/layout/TitleBar'; +import { MainLayout } from './components/MainLayout'; +import { DevModeGate } from './components/developer/DevModeGate'; -function JavaRunnerFallback() { +function Fallback() { return (
!
@@ -24,22 +25,21 @@ function JavaRunnerFallback() { } export default function App() { - if (!window.api) return ; + if (!window.api) return ; return ( - -
+ + {/* Root: full viewport, flex column, no overflow */} +
- - } /> - } /> - + {/* Content area: takes remaining height, no overflow — children manage their own */} +
+ + } /> + } /> + +
diff --git a/src/renderer/components/MainLayout.tsx b/src/renderer/components/MainLayout.tsx index 97b1829..b458e53 100644 --- a/src/renderer/components/MainLayout.tsx +++ b/src/renderer/components/MainLayout.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom'; import { ProfileSidebar } from './profiles/ProfileSidebar'; import { ConsoleTab } from './console/ConsoleTab'; @@ -8,25 +8,16 @@ import { SettingsTab } from './settings/SettingsTab'; import { UtilitiesTab } from './utils/UtilitiesTab'; import { FaqPanel } from './faq/FaqPanel'; import { DeveloperTab } from './developer/DeveloperTab'; +import { PanelHeader } from './layout/PanelHeader'; import { useApp } from '../store/AppStore'; import { useDevMode } from '../hooks/useDevMode'; import { VscTerminal, VscAccount } from 'react-icons/vsc'; import { LuList } from 'react-icons/lu'; -import { JRCEnvironment } from 'src/main/shared/types/App.types'; - -const MAIN_TABS = [ - { path: 'console', label: 'Console', Icon: VscTerminal }, - { path: 'config', label: 'Configure', Icon: LuList }, - { path: 'profile', label: 'Profile', Icon: VscAccount }, -] as const; +// Panels rendered in the side-panel view (replace main tabs area) const SIDE_PANELS = ['settings', 'faq', 'utilities', 'developer'] as const; type SidePanel = (typeof SIDE_PANELS)[number]; -function isSidePanel(seg: string): seg is SidePanel { - return (SIDE_PANELS as readonly string[]).includes(seg); -} - const PANEL_LABELS: Record = { settings: 'Application Settings', faq: 'FAQ', @@ -34,8 +25,19 @@ const PANEL_LABELS: Record = { developer: 'Developer', }; +function isSidePanel(seg: string): seg is SidePanel { + return (SIDE_PANELS as readonly string[]).includes(seg); +} + +// Profile-specific tabs shown in the tab bar +const PROFILE_TABS = [ + { path: 'console', label: 'Console', Icon: VscTerminal }, + { path: 'config', label: 'Configure', Icon: LuList }, + { path: 'profile', label: 'Profile', Icon: VscAccount }, +] as const; + export function MainLayout() { - const { state, activeProfile, isRunning, setActiveProfile } = useApp(); + const { state, activeProfile, isRunning } = useApp(); const devMode = useDevMode(); const navigate = useNavigate(); const location = useLocation(); @@ -48,89 +50,51 @@ export function MainLayout() { const color = activeProfile?.color ?? '#4ade80'; const running = activeProfile ? isRunning(activeProfile.id) : false; - // Redirect away from developer panel if dev mode is turned off + // Redirect away from developer panel when dev mode is disabled useEffect(() => { if (!devMode && activePanel === 'developer') { - navigate('console', { replace: true }); + navigate('/console', { replace: true }); } }, [devMode, activePanel, navigate]); - // When profile changes, go to console + // Navigate to console when active profile changes const prevIdRef = React.useRef(state.activeProfileId); useEffect(() => { if (state.activeProfileId !== prevIdRef.current) { prevIdRef.current = state.activeProfileId; - if (!activePanel) navigate('console', { replace: true }); + if (!activePanel) navigate('/console', { replace: true }); } }, [state.activeProfileId, activePanel, navigate]); - const openPanel = (panel: SidePanel) => { - navigate(activePanel === panel ? 'console' : panel); - }; - - const handleProfileClick = () => { - if (activePanel) navigate('console'); - }; - return ( -
- openPanel('settings')} - onOpenFaq={() => openPanel('faq')} - onOpenUtilities={() => openPanel('utilities')} - onOpenDeveloper={() => openPanel('developer')} - onProfileClick={handleProfileClick} - activeSidePanel={activePanel} - /> +
+ -
+
{activePanel ? ( + // Side panel view <> -
-
- -
- - {PANEL_LABELS[activePanel]} - -
-
-
- -
+ +
} /> } /> } /> } /> + } />
) : ( + // Profile tab view <> -
- {MAIN_TABS.map((tab) => { +
+ {PROFILE_TABS.map((tab) => { const isActive = activeTab === tab.path; return (
-
+
} /> } /> } /> - } /> + } />
diff --git a/src/renderer/components/common/ContextMenu.tsx b/src/renderer/components/common/ContextMenu.tsx index 60f53a4..f42d3d2 100644 --- a/src/renderer/components/common/ContextMenu.tsx +++ b/src/renderer/components/common/ContextMenu.tsx @@ -21,21 +21,13 @@ export function ContextMenu({ x, y, items, onClose }: Props) { useEffect(() => { const handleClick = (e: MouseEvent) => { - if (!ref.current) return; - - // only close if clicking OUTSIDE - if (!ref.current.contains(e.target as Node)) { - onClose(); - } + if (!ref.current?.contains(e.target as Node)) onClose(); }; - const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; - document.addEventListener('click', handleClick); document.addEventListener('keydown', handleKey); - return () => { document.removeEventListener('click', handleClick); document.removeEventListener('keydown', handleKey); @@ -43,10 +35,8 @@ export function ContextMenu({ x, y, items, onClose }: Props) { }, [onClose]); const style: React.CSSProperties = { position: 'fixed', zIndex: 1000 }; - if (x + 180 > window.innerWidth) style.right = window.innerWidth - x; else style.left = x; - if (y + items.length * 32 > window.innerHeight) style.bottom = window.innerHeight - y; else style.top = y; @@ -57,12 +47,11 @@ export function ContextMenu({ x, y, items, onClose }: Props) { if (item.type === 'separator') { return
; } - return (
- - {/* Scrollable body */} -
{children}
+
{children}
); diff --git a/src/renderer/components/common/PropList.tsx b/src/renderer/components/common/PropList.tsx index 474b0ff..d261638 100644 --- a/src/renderer/components/common/PropList.tsx +++ b/src/renderer/components/common/PropList.tsx @@ -19,6 +19,7 @@ export function PropList({ items, onChange, onPendingChange }: Props) { const notify = (k: string, v: string) => onPendingChange?.(k.trim().length > 0 || v.trim().length > 0); + const setKey = (v: string) => { setDraftKey(v); notify(v, draftValue); @@ -44,6 +45,12 @@ export function PropList({ items, onChange, onPendingChange }: Props) { const editValue = (i: number, value: string) => onChange(items.map((it, idx) => (idx === i ? { ...it, value } : it))); + const inputCls = (disabled?: boolean) => + [ + 'flex-1 bg-base-900 border border-surface-border rounded-md px-2.5 py-1.5 text-xs font-mono text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent/40 transition-colors', + disabled ? 'opacity-40' : '', + ].join(' '); + return (
{items.length > 0 && ( @@ -61,7 +68,6 @@ export function PropList({ items, onChange, onPendingChange }: Props) {
+ + +
+ ); +} diff --git a/src/renderer/components/console/ConsoleTab.tsx b/src/renderer/components/console/ConsoleTab.tsx index a7faded..ac5e9f7 100644 --- a/src/renderer/components/console/ConsoleTab.tsx +++ b/src/renderer/components/console/ConsoleTab.tsx @@ -1,8 +1,10 @@ -import React, { useRef, useEffect, useState, useCallback, useMemo, KeyboardEvent } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { useApp } from '../../store/AppStore'; -import { Button } from '../common/Button'; -import { VscSearch, VscChevronUp, VscChevronDown, VscClose } from 'react-icons/vsc'; -import { ConsoleLine } from '../../../main/shared/types/Process.types'; +import { ConsoleToolbar } from './ConsoleToolbar'; +import { ConsoleSearch } from './ConsoleSearch'; +import { ConsoleOutput } from './ConsoleOutput'; +import { ConsoleInput } from './ConsoleInput'; +import { VscClose } from 'react-icons/vsc'; export function ConsoleTab() { const { state, activeProfile, startProcess, stopProcess, sendInput, clearConsole, isRunning } = @@ -14,71 +16,44 @@ export function ConsoleTab() { const settings = state.settings; const color = activeProfile?.color ?? '#4ade80'; const processState = state.processStates.find((s) => s.profileId === profileId); - const pid = processState?.pid; - const [inputValue, setInputValue] = useState(''); - const [historyIdx, setHistoryIdx] = useState(-1); - const [cmdHistory, setCmdHistory] = useState([]); - const [autoScroll, setAutoScroll] = useState(true); const [starting, setStarting] = useState(false); const [errorMsg, setErrorMsg] = useState(null); + const [cmdHistory, setCmdHistory] = useState([]); + + // Search state const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [searchIdx, setSearchIdx] = useState(0); - const scrollRef = useRef(null); - const inputRef = useRef(null); - const searchRef = useRef(null); - const bottomRef = useRef(null); - const matchRefs = useRef<(HTMLDivElement | null)[]>([]); + // Auto-scroll state (owned here, passed down) + const [autoScroll, setAutoScroll] = useState(true); + // Reset on profile change useEffect(() => { - setInputValue(''); - setHistoryIdx(-1); setErrorMsg(null); setSearchOpen(false); setSearchQuery(''); setSearchIdx(0); + setAutoScroll(true); }, [profileId]); + // Global keyboard shortcuts useEffect(() => { - if (autoScroll && !searchOpen) bottomRef.current?.scrollIntoView({ behavior: 'instant' }); - }, [lines.length, autoScroll, searchOpen]); - - const handleScroll = useCallback(() => { - const el = scrollRef.current; - if (!el) return; - setAutoScroll(el.scrollHeight - el.scrollTop - el.clientHeight < 40); - }, []); - - const searchTerm = searchQuery.trim().toLowerCase(); - - const matchIndices = useMemo(() => { - if (!searchTerm) return []; - return lines.reduce((acc, line, i) => { - if (line.text.toLowerCase().includes(searchTerm)) acc.push(i); - return acc; - }, []); - }, [lines, searchTerm]); - - const clampedIdx = - matchIndices.length > 0 - ? ((searchIdx % matchIndices.length) + matchIndices.length) % matchIndices.length - : 0; - - const scrollToMatch = useCallback((idx: number) => { - const el = matchRefs.current[idx]; - if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }, []); - - useEffect(() => { - if (matchIndices.length > 0) scrollToMatch(clampedIdx); - }, [clampedIdx, matchIndices, scrollToMatch]); + const handler = (e: KeyboardEvent) => { + if (e.ctrlKey && e.key === 'f') { + e.preventDefault(); + openSearch(); + } + if (e.key === 'Escape' && searchOpen) closeSearch(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [searchOpen]); const openSearch = useCallback(() => { setSearchOpen(true); setAutoScroll(false); - setTimeout(() => searchRef.current?.focus(), 50); }, []); const closeSearch = useCallback(() => { @@ -88,9 +63,6 @@ export function ConsoleTab() { setAutoScroll(true); }, []); - const goNext = useCallback(() => setSearchIdx((i) => i + 1), []); - const goPrev = useCallback(() => setSearchIdx((i) => i - 1), []); - const handleToggle = useCallback(async () => { if (!activeProfile) return; setErrorMsg(null); @@ -108,65 +80,17 @@ export function ConsoleTab() { } }, [activeProfile, running, profileId, stopProcess, startProcess]); - const handleSend = useCallback(async () => { - const cmd = inputValue.trim(); - if (!cmd || !running) return; - await sendInput(profileId, cmd); - setCmdHistory((prev) => - [cmd, ...prev.filter((c) => c !== cmd)].slice(0, settings?.consoleHistorySize ?? 200) - ); - setInputValue(''); - setHistoryIdx(-1); - }, [inputValue, running, profileId, sendInput, settings]); - - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleSend(); - return; - } - if (e.key === 'ArrowUp') { - e.preventDefault(); - const n = Math.min(historyIdx + 1, cmdHistory.length - 1); - setHistoryIdx(n); - setInputValue(cmdHistory[n] ?? ''); - return; - } - if (e.key === 'ArrowDown') { - e.preventDefault(); - const n = Math.max(historyIdx - 1, -1); - setHistoryIdx(n); - setInputValue(n === -1 ? '' : (cmdHistory[n] ?? '')); - return; - } - if (e.key === 'l' && e.ctrlKey) { - e.preventDefault(); - clearConsole(profileId); - } - if (e.key === 'f' && e.ctrlKey) { - e.preventDefault(); - openSearch(); - } + const handleSend = useCallback( + async (cmd: string) => { + await sendInput(profileId, cmd); + setCmdHistory((prev) => + [cmd, ...prev.filter((c) => c !== cmd)].slice(0, settings?.consoleHistorySize ?? 200) + ); }, - [handleSend, historyIdx, cmdHistory, clearConsole, profileId, openSearch] + [profileId, sendInput, settings] ); - useEffect(() => { - const handler = (e: globalThis.KeyboardEvent) => { - if (e.ctrlKey && e.key === 'f') { - e.preventDefault(); - openSearch(); - } - if (e.key === 'Escape' && searchOpen) closeSearch(); - }; - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); - }, [openSearch, closeSearch, searchOpen]); - - const fontSize = settings?.consoleFontSize ?? 13; - const wordWrap = settings?.consoleWordWrap ?? false; - const lineNums = settings?.consoleLineNumbers ?? false; + const handleClear = useCallback(() => clearConsole(profileId), [clearConsole, profileId]); if (!activeProfile) { return ( @@ -176,119 +100,45 @@ export function ConsoleTab() { ); } - matchRefs.current = new Array(matchIndices.length).fill(null); - - return ( -
-
- - - {running && pid && ( - - - PID {pid} - - )} - -
- - {!autoScroll && !searchOpen && ( - - )} - - + const fontSize = settings?.consoleFontSize ?? 13; + const wordWrap = settings?.consoleWordWrap ?? false; + const lineNumbers = settings?.consoleLineNumbers ?? false; - + // Calculate match count for search display + const searchTerm = searchQuery.trim().toLowerCase(); + const matchCount = searchTerm + ? lines.filter((l) => l.text.toLowerCase().includes(searchTerm)).length + : 0; + const clampedIdx = matchCount > 0 ? ((searchIdx % matchCount) + matchCount) % matchCount : 0; - - {lines.length.toLocaleString()} lines - -
+ return ( +
+ setAutoScroll(true)} + /> {searchOpen && ( -
- { - setSearchQuery(e.target.value); - setSearchIdx(0); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - e.shiftKey ? goPrev() : goNext(); - } - if (e.key === 'Escape') closeSearch(); - }} - placeholder="Search console... (Enter next, Shift+Enter prev)" - className="flex-1 bg-base-950 border border-surface-border rounded px-2.5 py-1 - text-xs font-mono text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent/40" - /> - {searchTerm && ( - - {matchIndices.length === 0 - ? 'No matches' - : `${clampedIdx + 1} / ${matchIndices.length}`} - - )} - - - -
+ { + setSearchQuery(q); + setSearchIdx(0); + }} + onNext={() => setSearchIdx((i) => i + 1)} + onPrev={() => setSearchIdx((i) => i - 1)} + onClose={closeSearch} + /> )} {errorMsg && ( @@ -300,149 +150,27 @@ export function ConsoleTab() {
)} -
!searchOpen && inputRef.current?.focus()} - className="flex-1 overflow-y-auto overflow-x-auto bg-base-950 select-text" - style={{ fontSize, lineHeight: 1.6, fontFamily: 'monospace' }} - > -
- {lines.length === 0 && ( -
- {running ? 'Waiting for output...' : 'Process not running. Press Run to start.'} -
- )} - {lines.map((line, i) => { - const matchPos = matchIndices.indexOf(i); - const isCurrentMatch = matchPos === clampedIdx && matchPos !== -1; - const isAnyMatch = matchPos !== -1; - - return ( - { - matchRefs.current[matchPos] = el; - } - : undefined - } - /> - ); - })} -
-
-
- -
- - setInputValue(e.target.value)} - onKeyDown={handleKeyDown} - disabled={!running} - placeholder={ - running - ? 'Send command... (up/down history, Ctrl+L clear, Ctrl+F search)' - : 'Start the process to send commands' - } - className="flex-1 bg-transparent text-xs font-mono text-text-primary placeholder:text-text-muted focus:outline-none disabled:opacity-40" - style={{ fontSize }} - /> -
-
- ); -} - -const LINE_COLORS: Record = { - stdout: 'text-text-primary', - stderr: 'text-console-error', - input: 'text-console-input', - system: 'text-text-muted', -}; - -const ConsoleLineRow = React.forwardRef< - HTMLDivElement, - { - line: ConsoleLine; - lineNum: number; - showLineNum: boolean; - wordWrap: boolean; - searchTerm: string; - isCurrentMatch: boolean; - isAnyMatch: boolean; - } ->(({ line, lineNum, showLineNum, wordWrap, searchTerm, isCurrentMatch, isAnyMatch }, ref) => { - const text = line.text || ' '; - - const content = - searchTerm && isAnyMatch ? renderHighlighted(text, searchTerm, isCurrentMatch) : text; - - return ( -
- {showLineNum && ( - - {lineNum} - - )} - - {content} - + + +
); -}); -ConsoleLineRow.displayName = 'ConsoleLineRow'; - -function renderHighlighted(text: string, term: string, isCurrent: boolean): React.ReactNode { - const parts: React.ReactNode[] = []; - const lower = text.toLowerCase(); - let last = 0; - let idx = lower.indexOf(term); - let key = 0; - - while (idx !== -1) { - if (idx > last) parts.push(text.slice(last, idx)); - parts.push( - - {text.slice(idx, idx + term.length)} - - ); - last = idx + term.length; - idx = lower.indexOf(term, last); - } - if (last < text.length) parts.push(text.slice(last)); - return parts; } diff --git a/src/renderer/components/console/ConsoleToolbar.tsx b/src/renderer/components/console/ConsoleToolbar.tsx new file mode 100644 index 0000000..7dbffa8 --- /dev/null +++ b/src/renderer/components/console/ConsoleToolbar.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { VscSearch } from 'react-icons/vsc'; +import { Button } from '../common/Button'; + +interface Props { + running: boolean; + starting: boolean; + pid?: number; + color: string; + lineCount: number; + autoScroll: boolean; + onToggle: () => void; + onClear: () => void; + onOpenSearch: () => void; + onScrollToBottom: () => void; +} + +export function ConsoleToolbar({ + running, + starting, + pid, + color, + lineCount, + autoScroll, + onToggle, + onClear, + onOpenSearch, + onScrollToBottom, +}: Props) { + return ( +
+ + + {running && pid && ( + + + PID {pid} + + )} + +
+ + {!autoScroll && ( + + )} + + + + + + + {lineCount.toLocaleString()} lines + +
+ ); +} diff --git a/src/renderer/components/developer/DevApiExplorer.tsx b/src/renderer/components/developer/DevApiExplorer.tsx index e00cdd6..fd57fef 100644 --- a/src/renderer/components/developer/DevApiExplorer.tsx +++ b/src/renderer/components/developer/DevApiExplorer.tsx @@ -1,9 +1,12 @@ -import { useState, useCallback } from 'react'; +import React, { useState, useCallback } from 'react'; import { VscCheck, VscCopy, VscPlay, VscEdit, VscCode } from 'react-icons/vsc'; -import { routeConfig, RouteDefinition } from '../../../main/shared/config/API.config'; +import { + routeConfig, + RouteDefinition, + REST_API_CONFIG, +} from '../../../main/shared/config/API.config'; import { useApp } from '../../store/AppStore'; import { Button } from '../common/Button'; -import { REST_API_CONFIG } from '../../../main/shared/config/API.config'; import { ContextMenu, ContextMenuItem } from '../common/ContextMenu'; const METHOD_COLORS: Record = { @@ -13,33 +16,24 @@ const METHOD_COLORS: Record = { DELETE: 'text-red-400 border-red-400/30 bg-red-400/10', }; -// ─── JSON Syntax Highlighter ──────────────────────────────────────────────── +// ─── JSON syntax highlighter ───────────────────────────────────────────────── -type Token = - | { type: 'key'; value: string } - | { type: 'string'; value: string } - | { type: 'number'; value: string } - | { type: 'boolean'; value: string } - | { type: 'null'; value: string } - | { type: 'punct'; value: string } - | { type: 'plain'; value: string }; +type Token = { + type: 'key' | 'string' | 'number' | 'boolean' | 'null' | 'punct' | 'plain'; + value: string; +}; function tokenizeJson(text: string): Token[] { const tokens: Token[] = []; let i = 0; - while (i < text.length) { const ws = text.slice(i).match(/^[\s,:\[\]{}]+/); if (ws) { - const chunk = ws[0]; - for (const ch of chunk) { - if ('{}[],:'.includes(ch)) tokens.push({ type: 'punct', value: ch }); - else tokens.push({ type: 'plain', value: ch }); - } - i += chunk.length; + for (const ch of ws[0]) + tokens.push({ type: '{}[],:'.includes(ch) ? 'punct' : 'plain', value: ch }); + i += ws[0].length; continue; } - if (text[i] === '"') { let j = i + 1; while (j < text.length) { @@ -53,31 +47,28 @@ function tokenizeJson(text: string): Token[] { } j++; } - const raw = text.slice(i, j); - const afterStr = text.slice(j).match(/^\s*:/); - tokens.push({ type: afterStr ? 'key' : 'string', value: raw }); + tokens.push({ + type: text.slice(j).match(/^\s*:/) ? 'key' : 'string', + value: text.slice(i, j), + }); i = j; continue; } - const num = text.slice(i).match(/^-?\d+(\.\d+)?([eE][+-]?\d+)?/); if (num) { tokens.push({ type: 'number', value: num[0] }); i += num[0].length; continue; } - - const keyword = text.slice(i).match(/^(true|false|null)/); - if (keyword) { - tokens.push({ type: keyword[0] === 'null' ? 'null' : 'boolean', value: keyword[0] }); - i += keyword[0].length; + const kw = text.slice(i).match(/^(true|false|null)/); + if (kw) { + tokens.push({ type: kw[0] === 'null' ? 'null' : 'boolean', value: kw[0] }); + i += kw[0].length; continue; } - tokens.push({ type: 'plain', value: text[i] }); i++; } - return tokens; } @@ -97,16 +88,13 @@ function JsonHighlight({ text }: { text: string }) { JSON.parse(text); isJson = true; } catch { - /* not JSON */ + /* not json */ } - if (!isJson) return {text}; - - const tokens = tokenizeJson(text); return ( <> - {tokens.map((tok, idx) => ( - + {tokenizeJson(text).map((tok, i) => ( + {tok.value} ))} @@ -118,66 +106,35 @@ function JsonHighlight({ text }: { text: string }) { export function DevApiExplorer() { const { state } = useApp(); - const [selected, setSelected] = useState(null); const [pathParams, setPathParams] = useState>({}); const [body, setBody] = useState(''); const [response, setResponse] = useState(null); const [loading, setLoading] = useState(false); - const [urlCopied, setUrlCopied] = useState(false); const [responseCopied, setResponseCopied] = useState(false); const [isEditing, setIsEditing] = useState(false); - - const [ctxMenu, setCtxMenu] = useState<{ - x: number; - y: number; - items: ContextMenuItem[]; - } | null>(null); + const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>( + null + ); const port = state.settings?.restApiPort ?? 4444; const restEnabled = state.settings?.restApiEnabled ?? false; - // ── Helpers ────────────────────────────────────────────────────────────── - - const handleContextMenu = useCallback( - (e: React.MouseEvent, extraItems: ContextMenuItem[] = []) => { - const selection = window.getSelection()?.toString() ?? ''; - - const items: ContextMenuItem[] = [ - { - label: 'Copy', - icon: , - disabled: !selection, - onClick: () => navigator.clipboard.writeText(selection), - }, - ...extraItems, - ]; - - e.preventDefault(); - setCtxMenu({ x: e.clientX, y: e.clientY, items }); - }, - [] - ); - const handleSelect = (route: RouteDefinition) => { setSelected(route); setResponse(null); setIsEditing(false); setBody(route.bodyTemplate ?? ''); - const params: Record = {}; - const matches = route.path.matchAll(/:([a-zA-Z]+)/g); - for (const m of matches) params[m[1]] = ''; + for (const m of route.path.matchAll(/:([a-zA-Z]+)/g)) params[m[1]] = ''; setPathParams(params); }; const buildUrl = () => { if (!selected) return ''; let path = selected.path; - for (const [k, v] of Object.entries(pathParams)) { - path = path.replace(`:${k}`, v || `:${k}`); - } + for (const [k, v] of Object.entries(pathParams)) path = path.replace(`:${k}`, v || `:${k}`); return `http://${REST_API_CONFIG.host}:${port}${path}`; }; @@ -186,19 +143,14 @@ export function DevApiExplorer() { setLoading(true); setResponse(null); setIsEditing(false); - try { - const url = buildUrl(); const opts: RequestInit = { method: selected.method }; - if (body.trim() && ['POST', 'PUT', 'PATCH'].includes(selected.method)) { opts.headers = { 'Content-Type': 'application/json' }; opts.body = body; } - - const res = await fetch(url, opts); + const res = await fetch(buildUrl(), opts); const text = await res.text(); - try { setResponse(JSON.stringify(JSON.parse(text), null, 2)); } catch { @@ -207,7 +159,6 @@ export function DevApiExplorer() { } catch (err) { setResponse(`Error: ${err instanceof Error ? err.message : String(err)}`); } - setLoading(false); }; @@ -216,7 +167,6 @@ export function DevApiExplorer() { setUrlCopied(true); setTimeout(() => setUrlCopied(false), 1500); }; - const copyResponse = useCallback(() => { if (!response) return; navigator.clipboard.writeText(response); @@ -224,9 +174,23 @@ export function DevApiExplorer() { setTimeout(() => setResponseCopied(false), 1500); }, [response]); - const toggleEdit = () => setIsEditing((v) => !v); - - // ── Response context menu items (extend here in the future) ────────────── + const handleContextMenu = useCallback((e: React.MouseEvent, extra: ContextMenuItem[] = []) => { + const sel = window.getSelection()?.toString() ?? ''; + e.preventDefault(); + setCtxMenu({ + x: e.clientX, + y: e.clientY, + items: [ + { + label: 'Copy', + icon: , + disabled: !sel, + onClick: () => navigator.clipboard.writeText(sel), + }, + ...extra, + ], + }); + }, []); const responseCtxItems = useCallback( (): ContextMenuItem[] => @@ -243,18 +207,15 @@ export function DevApiExplorer() { [response] ); - // ───────────────────────────────────────────────────────────────────────── - return ( -
- {/* ── Route list ──────────────────────────────────────────────────── */} +
+ {/* Route list */}
{!restEnabled && (
REST API disabled in Settings
)} - {Object.entries(routeConfig).map(([key, route]) => ( -
- - {/* Path params */} {Object.keys(pathParams).length > 0 && (
{Object.entries(pathParams).map(([k, v]) => ( @@ -345,10 +300,9 @@ export function DevApiExplorer() { )}
-
- {/* Body */} +
{['POST', 'PUT', 'PATCH'].includes(selected.method) && ( -
+
Request Body (JSON)
@@ -356,22 +310,17 @@ export function DevApiExplorer() { value={body} onChange={(e) => setBody(e.target.value)} spellCheck={false} - className="flex-1 bg-base-950 text-xs font-mono text-text-primary px-3 py-2 resize-none focus:outline-none select-text" + className="flex-1 bg-base-950 text-xs font-mono text-text-primary px-3 py-2 resize-none focus:outline-none select-text min-h-0" />
)} - - {/* ── Response panel ────────────────────────────────────── */} -
- {/* Titlebar */} +
Response - {response && (
-
)}
- - {/* Highlighted view or editable textarea */} {isEditing ? (