peep-proxy 0.1.0
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/dist/app.d.ts +9 -0
- package/dist/app.js +162 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +98 -0
- package/dist/components/BorderedBox.d.ts +10 -0
- package/dist/components/BorderedBox.js +13 -0
- package/dist/components/DetailPanel.d.ts +13 -0
- package/dist/components/DetailPanel.js +8 -0
- package/dist/components/DetailView.d.ts +12 -0
- package/dist/components/DetailView.js +151 -0
- package/dist/components/DomainSidebar.d.ts +12 -0
- package/dist/components/DomainSidebar.js +36 -0
- package/dist/components/RequestList.d.ts +22 -0
- package/dist/components/RequestList.js +30 -0
- package/dist/components/RequestRow.d.ts +15 -0
- package/dist/components/RequestRow.js +84 -0
- package/dist/components/SortModal.d.ts +8 -0
- package/dist/components/SortModal.js +56 -0
- package/dist/components/SpinnerContext.d.ts +5 -0
- package/dist/components/SpinnerContext.js +20 -0
- package/dist/components/StatusBar.d.ts +11 -0
- package/dist/components/StatusBar.js +21 -0
- package/dist/hooks/useActivePanel.d.ts +11 -0
- package/dist/hooks/useActivePanel.js +36 -0
- package/dist/hooks/useDetailScroll.d.ts +11 -0
- package/dist/hooks/useDetailScroll.js +59 -0
- package/dist/hooks/useDetailTabs.d.ts +14 -0
- package/dist/hooks/useDetailTabs.js +50 -0
- package/dist/hooks/useDomainFilter.d.ts +30 -0
- package/dist/hooks/useDomainFilter.js +103 -0
- package/dist/hooks/useListNavigation.d.ts +12 -0
- package/dist/hooks/useListNavigation.js +105 -0
- package/dist/hooks/useSorting.d.ts +20 -0
- package/dist/hooks/useSorting.js +71 -0
- package/dist/hooks/useTerminalDimensions.d.ts +6 -0
- package/dist/hooks/useTerminalDimensions.js +25 -0
- package/dist/hooks/useTrafficEntries.d.ts +2 -0
- package/dist/hooks/useTrafficEntries.js +16 -0
- package/dist/proxy/ca.d.ts +4 -0
- package/dist/proxy/ca.js +72 -0
- package/dist/proxy/cert-trust.d.ts +20 -0
- package/dist/proxy/cert-trust.js +181 -0
- package/dist/proxy/index.d.ts +3 -0
- package/dist/proxy/index.js +2 -0
- package/dist/proxy/proxy-server.d.ts +9 -0
- package/dist/proxy/proxy-server.js +262 -0
- package/dist/proxy/system-proxy.d.ts +2 -0
- package/dist/proxy/system-proxy.js +64 -0
- package/dist/proxy/types.d.ts +45 -0
- package/dist/proxy/types.js +1 -0
- package/dist/store/index.d.ts +2 -0
- package/dist/store/index.js +1 -0
- package/dist/store/traffic-store.d.ts +14 -0
- package/dist/store/traffic-store.js +87 -0
- package/dist/store/types.d.ts +14 -0
- package/dist/store/types.js +1 -0
- package/dist/theme.d.ts +1 -0
- package/dist/theme.js +1 -0
- package/dist/utils/contentType.d.ts +7 -0
- package/dist/utils/contentType.js +46 -0
- package/dist/utils/copyToClipboard.d.ts +1 -0
- package/dist/utils/copyToClipboard.js +21 -0
- package/dist/utils/decompress.d.ts +2 -0
- package/dist/utils/decompress.js +25 -0
- package/dist/utils/formatBytes.d.ts +1 -0
- package/dist/utils/formatBytes.js +14 -0
- package/dist/utils/getTabText.d.ts +3 -0
- package/dist/utils/getTabText.js +96 -0
- package/dist/utils/highlightBody.d.ts +3 -0
- package/dist/utils/highlightBody.js +43 -0
- package/package.json +57 -0
- package/readme.md +73 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
import { PRIMARY_COLOR } from "../theme.js";
|
|
4
|
+
const METHOD_COLORS = {
|
|
5
|
+
GET: "green",
|
|
6
|
+
POST: "yellow",
|
|
7
|
+
PUT: "blue",
|
|
8
|
+
DELETE: "red",
|
|
9
|
+
PATCH: "magenta",
|
|
10
|
+
};
|
|
11
|
+
function formatMethod(method, width) {
|
|
12
|
+
return method.slice(0, width).padEnd(width);
|
|
13
|
+
}
|
|
14
|
+
function formatStatus(entry, width) {
|
|
15
|
+
if (entry.state === "pending")
|
|
16
|
+
return "---".padEnd(width);
|
|
17
|
+
if (entry.state === "error")
|
|
18
|
+
return "ERR".padEnd(width);
|
|
19
|
+
const code = String(entry.response?.statusCode ?? "---");
|
|
20
|
+
return code.slice(0, width).padEnd(width);
|
|
21
|
+
}
|
|
22
|
+
function getStatusColor(entry) {
|
|
23
|
+
if (entry.state === "pending")
|
|
24
|
+
return undefined;
|
|
25
|
+
if (entry.state === "error")
|
|
26
|
+
return "red";
|
|
27
|
+
const code = entry.response?.statusCode;
|
|
28
|
+
if (!code)
|
|
29
|
+
return undefined;
|
|
30
|
+
if (code < 300)
|
|
31
|
+
return "green";
|
|
32
|
+
if (code < 400)
|
|
33
|
+
return "cyan";
|
|
34
|
+
if (code < 500)
|
|
35
|
+
return "yellow";
|
|
36
|
+
return "red";
|
|
37
|
+
}
|
|
38
|
+
function formatDuration(entry, width) {
|
|
39
|
+
if (entry.state === "pending")
|
|
40
|
+
return "...".padEnd(width);
|
|
41
|
+
const ms = entry.response?.duration;
|
|
42
|
+
if (ms === undefined)
|
|
43
|
+
return "---".padEnd(width);
|
|
44
|
+
const text = ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`;
|
|
45
|
+
return text.slice(0, width).padEnd(width);
|
|
46
|
+
}
|
|
47
|
+
function formatSize(entry, width) {
|
|
48
|
+
if (entry.state === "pending" || !entry.response)
|
|
49
|
+
return "...".padEnd(width);
|
|
50
|
+
const bytes = entry.response.body.length;
|
|
51
|
+
let text;
|
|
52
|
+
if (bytes >= 1000000) {
|
|
53
|
+
text = `${(bytes / 1000000).toFixed(1)}M`;
|
|
54
|
+
}
|
|
55
|
+
else if (bytes >= 1000) {
|
|
56
|
+
text = `${(bytes / 1000).toFixed(1)}K`;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
text = `${bytes}B`;
|
|
60
|
+
}
|
|
61
|
+
return text.slice(0, width).padEnd(width);
|
|
62
|
+
}
|
|
63
|
+
function truncate(text, maxLen) {
|
|
64
|
+
if (text.length <= maxLen)
|
|
65
|
+
return text.padEnd(maxLen);
|
|
66
|
+
return `${text.slice(0, maxLen - 1)}…`;
|
|
67
|
+
}
|
|
68
|
+
function formatSeq(seq, width) {
|
|
69
|
+
return String(seq).slice(0, width).padStart(width);
|
|
70
|
+
}
|
|
71
|
+
export function RequestRow({ entry, isSelected, columnWidths }) {
|
|
72
|
+
const id = formatSeq(entry.seq, columnWidths.id);
|
|
73
|
+
const method = formatMethod(entry.request.method, columnWidths.method);
|
|
74
|
+
const url = truncate(entry.request.path, columnWidths.url);
|
|
75
|
+
const status = formatStatus(entry, columnWidths.status);
|
|
76
|
+
const duration = formatDuration(entry, columnWidths.duration);
|
|
77
|
+
const size = formatSize(entry, columnWidths.size);
|
|
78
|
+
const methodColor = METHOD_COLORS[entry.request.method] ?? "white";
|
|
79
|
+
const statusColor = getStatusColor(entry);
|
|
80
|
+
if (isSelected) {
|
|
81
|
+
return (_jsxs(Text, { backgroundColor: PRIMARY_COLOR, color: "black", children: [_jsx(Text, { children: " " }), _jsx(Text, { children: id }), _jsx(Text, { children: " " }), _jsx(Text, { children: method }), _jsx(Text, { children: " " }), _jsx(Text, { children: url }), _jsx(Text, { children: " " }), _jsx(Text, { children: status }), _jsx(Text, { children: " " }), _jsx(Text, { children: duration }), _jsx(Text, { children: " " }), _jsx(Text, { children: size })] }));
|
|
82
|
+
}
|
|
83
|
+
return (_jsxs(Text, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: id }), _jsx(Text, { children: " " }), _jsx(Text, { color: methodColor, children: method }), _jsx(Text, { children: " " }), _jsx(Text, { children: url }), _jsx(Text, { children: " " }), _jsx(Text, { color: statusColor, dimColor: entry.state === "pending", children: status }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: entry.state === "pending", children: duration }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: entry.state === "pending", children: size })] }));
|
|
84
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SortColumn, SortConfig } from "../hooks/useSorting.js";
|
|
2
|
+
type Props = {
|
|
3
|
+
sortConfig: SortConfig | null;
|
|
4
|
+
onSelect: (column: SortColumn) => void;
|
|
5
|
+
onClose: () => void;
|
|
6
|
+
};
|
|
7
|
+
export declare function SortModal({ sortConfig, onSelect, onClose }: Props): import("react/jsx-runtime").JSX.Element;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { PRIMARY_COLOR } from "../theme.js";
|
|
5
|
+
const COLUMNS = [
|
|
6
|
+
{ column: "id", label: "#" },
|
|
7
|
+
{ column: "method", label: "Method" },
|
|
8
|
+
{ column: "url", label: "URL" },
|
|
9
|
+
{ column: "status", label: "Status" },
|
|
10
|
+
{ column: "duration", label: "Time" },
|
|
11
|
+
{ column: "size", label: "Size" },
|
|
12
|
+
];
|
|
13
|
+
export function SortModal({ sortConfig, onSelect, onClose }) {
|
|
14
|
+
const activeIndex = sortConfig
|
|
15
|
+
? COLUMNS.findIndex((c) => c.column === sortConfig.column)
|
|
16
|
+
: -1;
|
|
17
|
+
const [selectedIndex, setSelectedIndex] = useState(activeIndex >= 0 ? activeIndex : 0);
|
|
18
|
+
useInput((input, key) => {
|
|
19
|
+
if (key.escape) {
|
|
20
|
+
onClose();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (key.return) {
|
|
24
|
+
const item = COLUMNS[selectedIndex];
|
|
25
|
+
if (item)
|
|
26
|
+
onSelect(item.column);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (input === "j" || key.downArrow) {
|
|
30
|
+
setSelectedIndex((i) => Math.min(i + 1, COLUMNS.length - 1));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (input === "k" || key.upArrow) {
|
|
34
|
+
setSelectedIndex((i) => Math.max(i - 1, 0));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
const innerWidth = 20;
|
|
38
|
+
const title = " Sort by ";
|
|
39
|
+
const remaining = Math.max(0, innerWidth - title.length - 1);
|
|
40
|
+
const topLine = `┌─${title}${"─".repeat(remaining)}┐`;
|
|
41
|
+
const bottomLine = `└${"─".repeat(innerWidth)}┘`;
|
|
42
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: PRIMARY_COLOR, children: topLine }), COLUMNS.map(({ column, label }, i) => {
|
|
43
|
+
const isActive = sortConfig?.column === column;
|
|
44
|
+
const arrow = isActive
|
|
45
|
+
? sortConfig.direction === "asc"
|
|
46
|
+
? " ▲"
|
|
47
|
+
: " ▼"
|
|
48
|
+
: "";
|
|
49
|
+
const text = ` ${label}${arrow}`;
|
|
50
|
+
const padded = text.padEnd(innerWidth);
|
|
51
|
+
if (i === selectedIndex) {
|
|
52
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: PRIMARY_COLOR, children: "\u2502" }), _jsx(Text, { backgroundColor: PRIMARY_COLOR, color: "black", bold: true, children: padded }), _jsx(Text, { color: PRIMARY_COLOR, children: "\u2502" })] }, column));
|
|
53
|
+
}
|
|
54
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: PRIMARY_COLOR, children: "\u2502" }), _jsx(Text, { bold: isActive, children: padded }), _jsx(Text, { color: PRIMARY_COLOR, children: "\u2502" })] }, column));
|
|
55
|
+
}), _jsx(Text, { bold: true, color: PRIMARY_COLOR, children: bottomLine })] }));
|
|
56
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
import { createContext, useContext, useEffect, useState } from "react";
|
|
4
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
5
|
+
const INTERVAL = 80;
|
|
6
|
+
const SpinnerFrameContext = createContext(0);
|
|
7
|
+
export function SpinnerProvider({ children }) {
|
|
8
|
+
const [frame, setFrame] = useState(0);
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const timer = setInterval(() => {
|
|
11
|
+
setFrame((prev) => (prev + 1) % FRAMES.length);
|
|
12
|
+
}, INTERVAL);
|
|
13
|
+
return () => clearInterval(timer);
|
|
14
|
+
}, []);
|
|
15
|
+
return (_jsx(SpinnerFrameContext.Provider, { value: frame, children: children }));
|
|
16
|
+
}
|
|
17
|
+
export function Spinner() {
|
|
18
|
+
const frame = useContext(SpinnerFrameContext);
|
|
19
|
+
return _jsx(Text, { children: FRAMES[frame] });
|
|
20
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Panel } from "../hooks/useActivePanel.js";
|
|
2
|
+
type Props = {
|
|
3
|
+
port: number;
|
|
4
|
+
requestCount: number;
|
|
5
|
+
selectedIndex: number;
|
|
6
|
+
columns: number;
|
|
7
|
+
activePanel: Panel;
|
|
8
|
+
notification?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function StatusBar({ port, requestCount, selectedIndex, columns, activePanel, notification, }: Props): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
const HINTS = {
|
|
4
|
+
sidebar: "j/k:scroll h/l:group Enter:select q:quit",
|
|
5
|
+
list: "j/k:scroll Enter:detail Esc:sidebar s:sort q:quit",
|
|
6
|
+
request: "j/k:scroll h/l:tab y:copy Tab:response Esc:list q:quit",
|
|
7
|
+
response: "j/k:scroll h/l:tab y:copy Tab:request Esc:list q:quit",
|
|
8
|
+
};
|
|
9
|
+
export function StatusBar({ port, requestCount, selectedIndex, columns, activePanel, notification, }) {
|
|
10
|
+
const left = notification
|
|
11
|
+
? `✓ ${notification}`
|
|
12
|
+
: `Proxy :${port} | ${requestCount} request${requestCount !== 1 ? "s" : ""}`;
|
|
13
|
+
const position = !notification && requestCount > 0
|
|
14
|
+
? ` [${selectedIndex + 1}/${requestCount}]`
|
|
15
|
+
: "";
|
|
16
|
+
const right = HINTS[activePanel];
|
|
17
|
+
const leftFull = left + position;
|
|
18
|
+
const content = ` ${leftFull}${" ".repeat(Math.max(1, columns - leftFull.length - right.length - 2))}${right} `;
|
|
19
|
+
const line = content.slice(0, columns);
|
|
20
|
+
return (_jsx(Box, { children: _jsx(Text, { dimColor: true, inverse: true, children: line }) }));
|
|
21
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type Panel = "sidebar" | "list" | "request" | "response";
|
|
2
|
+
type Options = {
|
|
3
|
+
hasSelection: boolean;
|
|
4
|
+
awaitingColumn: boolean;
|
|
5
|
+
};
|
|
6
|
+
type Result = {
|
|
7
|
+
activePanel: Panel;
|
|
8
|
+
setActivePanel: (panel: Panel) => void;
|
|
9
|
+
};
|
|
10
|
+
export declare function useActivePanel({ hasSelection, awaitingColumn, }: Options): Result;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useInput } from "ink";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
export function useActivePanel({ hasSelection, awaitingColumn, }) {
|
|
4
|
+
const [activePanel, setActivePanel] = useState("sidebar");
|
|
5
|
+
// Reset to list when selection is lost (but keep sidebar accessible)
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
if (!hasSelection &&
|
|
8
|
+
(activePanel === "request" || activePanel === "response")) {
|
|
9
|
+
setActivePanel("list");
|
|
10
|
+
}
|
|
11
|
+
}, [hasSelection, activePanel]);
|
|
12
|
+
useInput((_input, key) => {
|
|
13
|
+
if (awaitingColumn)
|
|
14
|
+
return;
|
|
15
|
+
// Esc: detail → list, list → sidebar
|
|
16
|
+
if (key.escape) {
|
|
17
|
+
if (activePanel === "request" || activePanel === "response") {
|
|
18
|
+
setActivePanel("list");
|
|
19
|
+
}
|
|
20
|
+
else if (activePanel === "list") {
|
|
21
|
+
setActivePanel("sidebar");
|
|
22
|
+
}
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
// Tab toggles request ↔ response in detail view
|
|
26
|
+
if (key.tab) {
|
|
27
|
+
if (activePanel === "request") {
|
|
28
|
+
setActivePanel("response");
|
|
29
|
+
}
|
|
30
|
+
else if (activePanel === "response") {
|
|
31
|
+
setActivePanel("request");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}, { isActive: activePanel !== "sidebar" });
|
|
35
|
+
return { activePanel, setActivePanel };
|
|
36
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type Options = {
|
|
2
|
+
contentHeight: number;
|
|
3
|
+
viewportHeight: number;
|
|
4
|
+
isActive: boolean;
|
|
5
|
+
};
|
|
6
|
+
type Result = {
|
|
7
|
+
scrollOffset: number;
|
|
8
|
+
resetScroll: () => void;
|
|
9
|
+
};
|
|
10
|
+
export declare function useDetailScroll({ contentHeight, viewportHeight, isActive, }: Options): Result;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useInput } from "ink";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
const GG_TIMEOUT = 500;
|
|
4
|
+
export function useDetailScroll({ contentHeight, viewportHeight, isActive, }) {
|
|
5
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
6
|
+
const gPressedAt = useRef(null);
|
|
7
|
+
const maxOffset = Math.max(0, contentHeight - viewportHeight);
|
|
8
|
+
// Clamp scroll when content shrinks
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
setScrollOffset((prev) => Math.min(prev, maxOffset));
|
|
11
|
+
}, [maxOffset]);
|
|
12
|
+
const clamp = useCallback((offset) => Math.max(0, Math.min(offset, maxOffset)), [maxOffset]);
|
|
13
|
+
const resetScroll = useCallback(() => {
|
|
14
|
+
setScrollOffset(0);
|
|
15
|
+
}, []);
|
|
16
|
+
useInput((input, key) => {
|
|
17
|
+
// j / ↓ — scroll down
|
|
18
|
+
if (input === "j" || key.downArrow) {
|
|
19
|
+
setScrollOffset((o) => clamp(o + 1));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
// k / ↑ — scroll up
|
|
23
|
+
if (input === "k" || key.upArrow) {
|
|
24
|
+
setScrollOffset((o) => clamp(o - 1));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
// G — jump to bottom
|
|
28
|
+
if (input === "G") {
|
|
29
|
+
setScrollOffset(maxOffset);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
// g — gg detection
|
|
33
|
+
if (input === "g") {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
if (gPressedAt.current !== null &&
|
|
36
|
+
now - gPressedAt.current < GG_TIMEOUT) {
|
|
37
|
+
setScrollOffset(0);
|
|
38
|
+
gPressedAt.current = null;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
gPressedAt.current = now;
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
// Ctrl+d — half page down
|
|
46
|
+
if (key.ctrl && input === "d") {
|
|
47
|
+
setScrollOffset((o) => clamp(o + Math.floor(viewportHeight / 2)));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// Ctrl+u — half page up
|
|
51
|
+
if (key.ctrl && input === "u") {
|
|
52
|
+
setScrollOffset((o) => clamp(o - Math.floor(viewportHeight / 2)));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// Any other key resets gg state
|
|
56
|
+
gPressedAt.current = null;
|
|
57
|
+
}, { isActive });
|
|
58
|
+
return { scrollOffset, resetScroll };
|
|
59
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { TrafficEntry } from "../store/index.js";
|
|
2
|
+
import type { Panel } from "./useActivePanel.js";
|
|
3
|
+
export type DetailTab = "headers" | "body" | "raw";
|
|
4
|
+
type Options = {
|
|
5
|
+
activePanel: Panel;
|
|
6
|
+
selectedEntry?: TrafficEntry;
|
|
7
|
+
};
|
|
8
|
+
type Result = {
|
|
9
|
+
requestTab: DetailTab;
|
|
10
|
+
responseTab: DetailTab;
|
|
11
|
+
notification: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function useDetailTabs({ activePanel, selectedEntry }: Options): Result;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useInput } from "ink";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { copyToClipboard } from "../utils/copyToClipboard.js";
|
|
4
|
+
import { getTabText } from "../utils/getTabText.js";
|
|
5
|
+
const TABS = ["body", "headers", "raw"];
|
|
6
|
+
export function useDetailTabs({ activePanel, selectedEntry }) {
|
|
7
|
+
const [requestTab, setRequestTab] = useState("body");
|
|
8
|
+
const [responseTab, setResponseTab] = useState("body");
|
|
9
|
+
const [notification, setNotification] = useState("");
|
|
10
|
+
const timerRef = useRef();
|
|
11
|
+
const clearNotification = useCallback(() => {
|
|
12
|
+
setNotification("");
|
|
13
|
+
}, []);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
return () => {
|
|
16
|
+
if (timerRef.current)
|
|
17
|
+
clearTimeout(timerRef.current);
|
|
18
|
+
};
|
|
19
|
+
}, []);
|
|
20
|
+
useInput((input) => {
|
|
21
|
+
const isDetail = activePanel === "request" || activePanel === "response";
|
|
22
|
+
if (!isDetail)
|
|
23
|
+
return;
|
|
24
|
+
if (input === "y" && selectedEntry) {
|
|
25
|
+
const side = activePanel;
|
|
26
|
+
const tab = side === "request" ? requestTab : responseTab;
|
|
27
|
+
const text = getTabText(selectedEntry, side, tab);
|
|
28
|
+
copyToClipboard(text);
|
|
29
|
+
setNotification("Copied!");
|
|
30
|
+
if (timerRef.current)
|
|
31
|
+
clearTimeout(timerRef.current);
|
|
32
|
+
timerRef.current = setTimeout(clearNotification, 2000);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const setter = activePanel === "request" ? setRequestTab : setResponseTab;
|
|
36
|
+
const current = activePanel === "request" ? requestTab : responseTab;
|
|
37
|
+
const idx = TABS.indexOf(current);
|
|
38
|
+
if (input === "l") {
|
|
39
|
+
const next = TABS[idx + 1];
|
|
40
|
+
if (next)
|
|
41
|
+
setter(next);
|
|
42
|
+
}
|
|
43
|
+
else if (input === "h") {
|
|
44
|
+
const prev = TABS[idx - 1];
|
|
45
|
+
if (prev)
|
|
46
|
+
setter(prev);
|
|
47
|
+
}
|
|
48
|
+
}, { isActive: activePanel === "request" || activePanel === "response" });
|
|
49
|
+
return { requestTab, responseTab, notification };
|
|
50
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { TrafficEntry } from "../store/index.js";
|
|
2
|
+
export type SidebarItem = {
|
|
3
|
+
type: "all";
|
|
4
|
+
count: number;
|
|
5
|
+
} | {
|
|
6
|
+
type: "group";
|
|
7
|
+
baseDomain: string;
|
|
8
|
+
count: number;
|
|
9
|
+
expanded: boolean;
|
|
10
|
+
} | {
|
|
11
|
+
type: "domain";
|
|
12
|
+
host: string;
|
|
13
|
+
count: number;
|
|
14
|
+
grouped: boolean;
|
|
15
|
+
};
|
|
16
|
+
export type DomainGroup = {
|
|
17
|
+
baseDomain: string;
|
|
18
|
+
domains: {
|
|
19
|
+
host: string;
|
|
20
|
+
count: number;
|
|
21
|
+
}[];
|
|
22
|
+
totalCount: number;
|
|
23
|
+
};
|
|
24
|
+
export declare function useDomainGroups(entries: TrafficEntry[]): {
|
|
25
|
+
visibleItems: SidebarItem[];
|
|
26
|
+
groups: DomainGroup[];
|
|
27
|
+
toggleAtIndex: (index: number) => void;
|
|
28
|
+
expandAtIndex: (index: number) => void;
|
|
29
|
+
collapseAtIndex: (index: number) => void;
|
|
30
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from "react";
|
|
2
|
+
function getBaseDomain(host) {
|
|
3
|
+
const parts = host.split(".");
|
|
4
|
+
if (parts.length <= 2)
|
|
5
|
+
return host;
|
|
6
|
+
return parts.slice(-2).join(".");
|
|
7
|
+
}
|
|
8
|
+
export function useDomainGroups(entries) {
|
|
9
|
+
const [expandedGroups, setExpandedGroups] = useState(new Set());
|
|
10
|
+
const groups = useMemo(() => {
|
|
11
|
+
const counts = new Map();
|
|
12
|
+
for (const entry of entries) {
|
|
13
|
+
const host = entry.request.host;
|
|
14
|
+
counts.set(host, (counts.get(host) ?? 0) + 1);
|
|
15
|
+
}
|
|
16
|
+
const groupMap = new Map();
|
|
17
|
+
for (const [host, count] of counts) {
|
|
18
|
+
const base = getBaseDomain(host);
|
|
19
|
+
if (!groupMap.has(base))
|
|
20
|
+
groupMap.set(base, []);
|
|
21
|
+
groupMap.get(base)?.push({ host, count });
|
|
22
|
+
}
|
|
23
|
+
const result = [];
|
|
24
|
+
for (const [baseDomain, domains] of groupMap) {
|
|
25
|
+
domains.sort((a, b) => a.host.localeCompare(b.host));
|
|
26
|
+
const totalCount = domains.reduce((sum, d) => sum + d.count, 0);
|
|
27
|
+
result.push({ baseDomain, domains, totalCount });
|
|
28
|
+
}
|
|
29
|
+
result.sort((a, b) => a.baseDomain.localeCompare(b.baseDomain));
|
|
30
|
+
return result;
|
|
31
|
+
}, [entries]);
|
|
32
|
+
const visibleItems = useMemo(() => {
|
|
33
|
+
const items = [{ type: "all", count: entries.length }];
|
|
34
|
+
for (const group of groups) {
|
|
35
|
+
if (group.domains.length === 1) {
|
|
36
|
+
items.push({
|
|
37
|
+
type: "domain",
|
|
38
|
+
host: group.domains[0]?.host ?? "",
|
|
39
|
+
count: group.domains[0]?.count ?? 0,
|
|
40
|
+
grouped: false,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
const expanded = expandedGroups.has(group.baseDomain);
|
|
45
|
+
items.push({
|
|
46
|
+
type: "group",
|
|
47
|
+
baseDomain: group.baseDomain,
|
|
48
|
+
count: group.totalCount,
|
|
49
|
+
expanded,
|
|
50
|
+
});
|
|
51
|
+
if (expanded) {
|
|
52
|
+
for (const domain of group.domains) {
|
|
53
|
+
items.push({
|
|
54
|
+
type: "domain",
|
|
55
|
+
host: domain.host,
|
|
56
|
+
count: domain.count,
|
|
57
|
+
grouped: true,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return items;
|
|
64
|
+
}, [groups, expandedGroups, entries.length]);
|
|
65
|
+
const toggleAtIndex = useCallback((index) => {
|
|
66
|
+
const item = visibleItems[index];
|
|
67
|
+
if (!item || item.type !== "group")
|
|
68
|
+
return;
|
|
69
|
+
setExpandedGroups((prev) => {
|
|
70
|
+
const next = new Set(prev);
|
|
71
|
+
if (next.has(item.baseDomain)) {
|
|
72
|
+
next.delete(item.baseDomain);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
next.add(item.baseDomain);
|
|
76
|
+
}
|
|
77
|
+
return next;
|
|
78
|
+
});
|
|
79
|
+
}, [visibleItems]);
|
|
80
|
+
const expandAtIndex = useCallback((index) => {
|
|
81
|
+
const item = visibleItems[index];
|
|
82
|
+
if (!item || item.type !== "group" || item.expanded)
|
|
83
|
+
return;
|
|
84
|
+
setExpandedGroups((prev) => new Set([...prev, item.baseDomain]));
|
|
85
|
+
}, [visibleItems]);
|
|
86
|
+
const collapseAtIndex = useCallback((index) => {
|
|
87
|
+
const item = visibleItems[index];
|
|
88
|
+
if (!item || item.type !== "group" || !item.expanded)
|
|
89
|
+
return;
|
|
90
|
+
setExpandedGroups((prev) => {
|
|
91
|
+
const next = new Set(prev);
|
|
92
|
+
next.delete(item.baseDomain);
|
|
93
|
+
return next;
|
|
94
|
+
});
|
|
95
|
+
}, [visibleItems]);
|
|
96
|
+
return {
|
|
97
|
+
visibleItems,
|
|
98
|
+
groups,
|
|
99
|
+
toggleAtIndex,
|
|
100
|
+
expandAtIndex,
|
|
101
|
+
collapseAtIndex,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type Options = {
|
|
2
|
+
itemCount: number;
|
|
3
|
+
viewportHeight: number;
|
|
4
|
+
isActive?: boolean;
|
|
5
|
+
keys?: string[];
|
|
6
|
+
};
|
|
7
|
+
type Result = {
|
|
8
|
+
selectedIndex: number;
|
|
9
|
+
scrollOffset: number;
|
|
10
|
+
};
|
|
11
|
+
export declare function useListNavigation({ itemCount, viewportHeight, isActive, keys, }: Options): Result;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useInput } from "ink";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
const GG_TIMEOUT = 500;
|
|
4
|
+
export function useListNavigation({ itemCount, viewportHeight, isActive = true, keys, }) {
|
|
5
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
6
|
+
const gPressedAt = useRef(null);
|
|
7
|
+
const prevItemCount = useRef(itemCount);
|
|
8
|
+
const prevKeysRef = useRef(keys);
|
|
9
|
+
const selectedRef = useRef(selectedIndex);
|
|
10
|
+
selectedRef.current = selectedIndex;
|
|
11
|
+
// Key-based stabilization: when keys change, find the previously selected
|
|
12
|
+
// key in the new array and move the index to match
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const prev = prevKeysRef.current;
|
|
15
|
+
prevKeysRef.current = keys;
|
|
16
|
+
if (!keys || !prev)
|
|
17
|
+
return;
|
|
18
|
+
const prevKey = prev[selectedRef.current];
|
|
19
|
+
if (!prevKey)
|
|
20
|
+
return;
|
|
21
|
+
const newIdx = keys.indexOf(prevKey);
|
|
22
|
+
if (newIdx !== -1 && newIdx !== selectedRef.current) {
|
|
23
|
+
setSelectedIndex(newIdx);
|
|
24
|
+
}
|
|
25
|
+
}, [keys]);
|
|
26
|
+
// Auto-follow: if user was at the last item and a new one arrives, stay at bottom
|
|
27
|
+
// Disabled when keys are provided (keyed lists stabilize by identity instead)
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (keys) {
|
|
30
|
+
prevItemCount.current = itemCount;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const wasAtEnd = selectedIndex === prevItemCount.current - 1;
|
|
34
|
+
prevItemCount.current = itemCount;
|
|
35
|
+
if (wasAtEnd && itemCount > 0) {
|
|
36
|
+
setSelectedIndex(itemCount - 1);
|
|
37
|
+
}
|
|
38
|
+
}, [itemCount, selectedIndex, keys]);
|
|
39
|
+
// Clamp selectedIndex if itemCount shrinks
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (itemCount === 0) {
|
|
42
|
+
setSelectedIndex(0);
|
|
43
|
+
}
|
|
44
|
+
else if (selectedIndex >= itemCount) {
|
|
45
|
+
setSelectedIndex(itemCount - 1);
|
|
46
|
+
}
|
|
47
|
+
}, [itemCount, selectedIndex]);
|
|
48
|
+
const clamp = useCallback((index) => Math.max(0, Math.min(index, itemCount - 1)), [itemCount]);
|
|
49
|
+
useInput((input, key) => {
|
|
50
|
+
if (itemCount === 0)
|
|
51
|
+
return;
|
|
52
|
+
// j / ↓ — move down
|
|
53
|
+
if (input === "j" || key.downArrow) {
|
|
54
|
+
setSelectedIndex((i) => clamp(i + 1));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// k / ↑ — move up
|
|
58
|
+
if (input === "k" || key.upArrow) {
|
|
59
|
+
setSelectedIndex((i) => clamp(i - 1));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// G — jump to bottom
|
|
63
|
+
if (input === "G") {
|
|
64
|
+
setSelectedIndex(itemCount - 1);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// g — first press starts gg detection
|
|
68
|
+
if (input === "g") {
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
if (gPressedAt.current !== null &&
|
|
71
|
+
now - gPressedAt.current < GG_TIMEOUT) {
|
|
72
|
+
// gg — jump to top
|
|
73
|
+
setSelectedIndex(0);
|
|
74
|
+
gPressedAt.current = null;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
gPressedAt.current = now;
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// Ctrl+d — page down
|
|
82
|
+
if (key.ctrl && input === "d") {
|
|
83
|
+
setSelectedIndex((i) => clamp(i + Math.floor(viewportHeight / 2)));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// Ctrl+u — page up
|
|
87
|
+
if (key.ctrl && input === "u") {
|
|
88
|
+
setSelectedIndex((i) => clamp(i - Math.floor(viewportHeight / 2)));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// Any other key resets gg state
|
|
92
|
+
gPressedAt.current = null;
|
|
93
|
+
}, { isActive });
|
|
94
|
+
// Derive scroll offset to keep selectedIndex visible
|
|
95
|
+
const scrollOffset = deriveScrollOffset(selectedIndex, viewportHeight, itemCount);
|
|
96
|
+
return { selectedIndex, scrollOffset };
|
|
97
|
+
}
|
|
98
|
+
function deriveScrollOffset(selectedIndex, viewportHeight, itemCount) {
|
|
99
|
+
if (itemCount <= viewportHeight)
|
|
100
|
+
return 0;
|
|
101
|
+
let offset = selectedIndex - Math.floor(viewportHeight / 2);
|
|
102
|
+
offset = Math.max(0, offset);
|
|
103
|
+
offset = Math.min(offset, itemCount - viewportHeight);
|
|
104
|
+
return offset;
|
|
105
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { TrafficEntry } from "../store/index.js";
|
|
2
|
+
export type SortColumn = "id" | "method" | "url" | "status" | "duration" | "size";
|
|
3
|
+
export type SortDirection = "asc" | "desc";
|
|
4
|
+
export type SortConfig = {
|
|
5
|
+
column: SortColumn;
|
|
6
|
+
direction: SortDirection;
|
|
7
|
+
};
|
|
8
|
+
type Options = {
|
|
9
|
+
entries: TrafficEntry[];
|
|
10
|
+
isActive?: boolean;
|
|
11
|
+
};
|
|
12
|
+
type Result = {
|
|
13
|
+
sortedEntries: TrafficEntry[];
|
|
14
|
+
sortConfig: SortConfig | null;
|
|
15
|
+
modalOpen: boolean;
|
|
16
|
+
selectColumn: (column: SortColumn) => void;
|
|
17
|
+
closeModal: () => void;
|
|
18
|
+
};
|
|
19
|
+
export declare function useSorting({ entries, isActive }: Options): Result;
|
|
20
|
+
export {};
|