@hienlh/ppm 0.17.37 → 0.17.38
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/CHANGELOG.md +11 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{ai-resource-editor-DAIaB4mX.js → ai-resource-editor-ChT9FzxT.js} +1 -1
- package/dist/web/assets/{audio-preview-Df6_blMs.js → audio-preview-CSK6nxNy.js} +1 -1
- package/dist/web/assets/chat-tab-DGM7js5J.js +13 -0
- package/dist/web/assets/code-editor-BvLxm1bf.js +10 -0
- package/dist/web/assets/{conflict-editor-BqMdj782.js → conflict-editor-Bc063EfD.js} +3 -3
- package/dist/web/assets/context-menu-DIiZdfbE.js +1 -0
- package/dist/web/assets/{csv-preview-BC_BuOzK.js → csv-preview-ft27dubA.js} +1 -1
- package/dist/web/assets/{database-viewer-DZQDQOye.js → database-viewer-Bdj1ferk.js} +1 -1
- package/dist/web/assets/{diff-viewer-CaKD5pR5.js → diff-viewer-C46_ZovZ.js} +1 -1
- package/dist/web/assets/{docx-preview-MBY4op8y.js → docx-preview-CLDLTD8N.js} +1 -1
- package/dist/web/assets/{extension-webview-CMtucoeg.js → extension-webview-BSvKnN1E.js} +1 -1
- package/dist/web/assets/{git-log-panel-DWZUIupS.js → git-log-panel-GP8XaTlA.js} +1 -1
- package/dist/web/assets/{glide-data-grid-SLs8Xp6G.js → glide-data-grid-On_pS9Y7.js} +4 -4
- package/dist/web/assets/group-chat-tab-DmYt9GfT.js +1 -0
- package/dist/web/assets/{image-preview-lRcpEoEk.js → image-preview-BWNTrufr.js} +1 -1
- package/dist/web/assets/index-Crnizc26.js +50 -0
- package/dist/web/assets/index-opWDpE6A.css +2 -0
- package/dist/web/assets/keybindings-store-B4EWnplE.js +1 -0
- package/dist/web/assets/{markdown-renderer-DkYo6o9f.js → markdown-renderer-Bhv5w9Op.js} +1 -1
- package/dist/web/assets/{markdown-renderer-D9r_4-qe.js → markdown-renderer-suZuQvya.js} +2 -2
- package/dist/web/assets/notification-store-B3zEJT3q.js +1 -0
- package/dist/web/assets/{pdf-preview-D17y9l8U.js → pdf-preview-DcP6Tj7N.js} +1 -1
- package/dist/web/assets/{postgres-viewer-DgpX_Kyl.js → postgres-viewer-BOlXoo4R.js} +2 -2
- package/dist/web/assets/settings-tab-BqJJqL1l.js +1 -0
- package/dist/web/assets/{sql-query-editor-BIeAQego.js → sql-query-editor-h4zuID_d.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-SipxlqWM.js → sqlite-viewer-D5X8iWUw.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-Cp-rP5HZ.js → system-monitor-tab-DL-viZmD.js} +1 -1
- package/dist/web/assets/terminal-tab-BVW9MfC3.js +3 -0
- package/dist/web/assets/tool-cards-zK7OP3DS.js +7 -0
- package/dist/web/assets/{use-monaco-theme-Bd1GBgB3.js → use-monaco-theme-Bk-dETGV.js} +1 -1
- package/dist/web/assets/{video-preview-DT8B2-re.js → video-preview-C6bKCBvz.js} +1 -1
- package/dist/web/index.html +3 -3
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/web/app.tsx +6 -2
- package/src/web/components/layout/mobile-nav.tsx +6 -3
- package/src/web/components/terminal/terminal-links-sheet.tsx +66 -0
- package/src/web/components/terminal/terminal-mobile-toolbar.tsx +107 -0
- package/src/web/components/terminal/terminal-tab.tsx +57 -86
- package/src/web/components/ui/mobile-bottom-sheet.tsx +20 -3
- package/src/web/hooks/use-terminal-touch-selection.ts +87 -0
- package/src/web/hooks/use-terminal.ts +53 -1
- package/dist/web/assets/chat-tab-yPXWmX01.js +0 -13
- package/dist/web/assets/code-editor-CSDB2W8N.js +0 -10
- package/dist/web/assets/group-chat-tab-Celp6ihW.js +0 -1
- package/dist/web/assets/index-BkH2b_6W.js +0 -50
- package/dist/web/assets/index-eW0VEaRF.css +0 -2
- package/dist/web/assets/keybindings-store-CtfEQL3U.js +0 -1
- package/dist/web/assets/mobile-bottom-sheet-BwPBKZON.js +0 -1
- package/dist/web/assets/notification-store-OF6L8lWu.js +0 -1
- package/dist/web/assets/settings-tab-Cou9P1gO.js +0 -1
- package/dist/web/assets/terminal-tab-C9T_X4oo.js +0 -3
- package/dist/web/assets/tool-cards-BFVmoF_j.js +0 -7
|
@@ -1,51 +1,32 @@
|
|
|
1
1
|
import { useRef, useEffect, useState, useCallback, memo } from "react";
|
|
2
2
|
import { useTerminal } from "@/hooks/use-terminal";
|
|
3
|
+
import { useTerminalTouchSelection } from "@/hooks/use-terminal-touch-selection";
|
|
3
4
|
import { cn } from "@/lib/utils";
|
|
4
5
|
import { copyToClipboard } from "@/lib/clipboard";
|
|
5
|
-
import {
|
|
6
|
+
import { RotateCcw, MessageSquare } from "lucide-react";
|
|
6
7
|
import "@xterm/xterm/css/xterm.css";
|
|
7
8
|
import { toast } from "sonner";
|
|
8
9
|
|
|
9
10
|
import { usePanelStore } from "@/stores/panel-store";
|
|
11
|
+
import { TerminalMobileToolbar } from "./terminal-mobile-toolbar";
|
|
12
|
+
import { TerminalLinksSheet } from "./terminal-links-sheet";
|
|
10
13
|
|
|
11
14
|
interface TerminalTabProps {
|
|
12
15
|
metadata?: Record<string, unknown>;
|
|
13
16
|
tabId?: string;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
|
-
const MOBILE_KEYS = [
|
|
17
|
-
{ label: "Tab", value: "\t" },
|
|
18
|
-
{ label: "Esc", value: "\x1b" },
|
|
19
|
-
{ label: "Ctrl", value: null, isModifier: true },
|
|
20
|
-
{ label: "\u2191", value: "\x1b[A" },
|
|
21
|
-
{ label: "\u2193", value: "\x1b[B" },
|
|
22
|
-
{ label: "\u2190", value: "\x1b[D" },
|
|
23
|
-
{ label: "\u2192", value: "\x1b[C" },
|
|
24
|
-
{ label: "^C", value: "\x03" },
|
|
25
|
-
] as const;
|
|
26
|
-
|
|
27
19
|
export const TerminalTab = memo(function TerminalTab({ metadata, tabId }: TerminalTabProps) {
|
|
28
20
|
const sessionId = (metadata?.sessionId as string) ?? "new";
|
|
29
21
|
const projectName = metadata?.projectName as string | undefined;
|
|
30
22
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
31
23
|
|
|
32
|
-
const { connected, reconnecting, exited, sendData, getSelection, getLastCommandOutput, restart } = useTerminal({ sessionId, projectName, containerRef, tabId });
|
|
24
|
+
const { connected, reconnecting, exited, sendData, getSelection, getLastCommandOutput, getBufferUrls, restart } = useTerminal({ sessionId, projectName, containerRef, tabId });
|
|
33
25
|
const [ctrlMode, setCtrlMode] = useState(false);
|
|
34
|
-
const [
|
|
35
|
-
|
|
36
|
-
// Adjust height when mobile keyboard opens
|
|
37
|
-
useEffect(() => {
|
|
38
|
-
const vv = window.visualViewport;
|
|
39
|
-
if (!vv) return;
|
|
40
|
-
|
|
41
|
-
function handleResize() {
|
|
42
|
-
if (!vv) return;
|
|
43
|
-
setViewportHeight(vv.height);
|
|
44
|
-
}
|
|
26
|
+
const [selectMode, setSelectMode] = useState(false);
|
|
27
|
+
const [linksOpen, setLinksOpen] = useState(false);
|
|
45
28
|
|
|
46
|
-
|
|
47
|
-
return () => vv.removeEventListener("resize", handleResize);
|
|
48
|
-
}, []);
|
|
29
|
+
useTerminalTouchSelection(containerRef, selectMode);
|
|
49
30
|
|
|
50
31
|
const focusTerminal = useCallback(() => {
|
|
51
32
|
const termElement = containerRef.current?.querySelector(
|
|
@@ -54,18 +35,19 @@ export const TerminalTab = memo(function TerminalTab({ metadata, tabId }: Termin
|
|
|
54
35
|
termElement?.focus();
|
|
55
36
|
}, []);
|
|
56
37
|
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
38
|
+
// Raising the soft keyboard needs focus() to run inside a gesture the browser
|
|
39
|
+
// recognises, and xterm's own mousedown handling does not reliably qualify.
|
|
40
|
+
// Capture-phase `touchend` runs before xterm's handlers and is unambiguously a
|
|
41
|
+
// gesture, so the keyboard opens even when the click path is unreliable.
|
|
42
|
+
// Skipped in select mode: a drag there is a selection gesture, and raising the
|
|
43
|
+
// keyboard would both shrink the viewport mid-drag and clear the selection.
|
|
62
44
|
useEffect(() => {
|
|
63
45
|
const container = containerRef.current;
|
|
64
|
-
if (!container) return;
|
|
46
|
+
if (!container || selectMode) return;
|
|
65
47
|
const onTouchEnd = () => focusTerminal();
|
|
66
48
|
container.addEventListener("touchend", onTouchEnd, { capture: true });
|
|
67
49
|
return () => container.removeEventListener("touchend", onTouchEnd, { capture: true });
|
|
68
|
-
}, [focusTerminal]);
|
|
50
|
+
}, [focusTerminal, selectMode]);
|
|
69
51
|
|
|
70
52
|
const sendKey = useCallback(
|
|
71
53
|
(value: string) => {
|
|
@@ -86,12 +68,22 @@ export const TerminalTab = memo(function TerminalTab({ metadata, tabId }: Termin
|
|
|
86
68
|
[ctrlMode, sendData, focusTerminal],
|
|
87
69
|
);
|
|
88
70
|
|
|
71
|
+
// xterm starts a selection from mousedown and has no touch equivalent, so on a
|
|
72
|
+
// phone getSelection() is always empty. Falling back to the last command's
|
|
73
|
+
// output keeps the button useful instead of silently doing nothing.
|
|
89
74
|
const handleCopy = useCallback(async () => {
|
|
90
75
|
const selection = getSelection();
|
|
91
|
-
|
|
92
|
-
|
|
76
|
+
const text = selection || getLastCommandOutput();
|
|
77
|
+
if (!text.trim()) {
|
|
78
|
+
toast("Nothing to copy", { duration: 1500 });
|
|
79
|
+
return;
|
|
93
80
|
}
|
|
94
|
-
|
|
81
|
+
const ok = await copyToClipboard(text);
|
|
82
|
+
toast[ok ? "success" : "error"](
|
|
83
|
+
ok ? (selection ? "Selection copied" : "Last output copied") : "Could not copy",
|
|
84
|
+
{ duration: 1500 },
|
|
85
|
+
);
|
|
86
|
+
}, [getSelection, getLastCommandOutput]);
|
|
95
87
|
|
|
96
88
|
const handlePaste = useCallback(async () => {
|
|
97
89
|
try {
|
|
@@ -101,7 +93,10 @@ export const TerminalTab = memo(function TerminalTab({ metadata, tabId }: Termin
|
|
|
101
93
|
focusTerminal();
|
|
102
94
|
}
|
|
103
95
|
} catch {
|
|
104
|
-
//
|
|
96
|
+
// readText() needs a secure context and an explicit permission grant, and
|
|
97
|
+
// Safari rejects it outright when the tap is not treated as a gesture.
|
|
98
|
+
// Say so — a silent catch reads as a dead button.
|
|
99
|
+
toast.error("Clipboard blocked — paste with the keyboard instead", { duration: 2500 });
|
|
105
100
|
}
|
|
106
101
|
}, [sendData, focusTerminal]);
|
|
107
102
|
|
|
@@ -136,10 +131,7 @@ export const TerminalTab = memo(function TerminalTab({ metadata, tabId }: Termin
|
|
|
136
131
|
const isMobile = typeof window !== "undefined" && "ontouchstart" in window;
|
|
137
132
|
|
|
138
133
|
return (
|
|
139
|
-
<div
|
|
140
|
-
className="flex flex-col h-full"
|
|
141
|
-
style={viewportHeight ? { maxHeight: `${viewportHeight}px` } : undefined}
|
|
142
|
-
>
|
|
134
|
+
<div className="flex flex-col h-full min-h-0">
|
|
143
135
|
{/* Status bar */}
|
|
144
136
|
<div className="flex items-center gap-2 px-3 py-1 bg-surface border-b border-border text-xs">
|
|
145
137
|
<span
|
|
@@ -180,52 +172,31 @@ export const TerminalTab = memo(function TerminalTab({ metadata, tabId }: Termin
|
|
|
180
172
|
{/* Terminal container. onClick focuses xterm's hidden textarea — on mobile
|
|
181
173
|
the soft keyboard only opens when focus() runs inside a recognized tap
|
|
182
174
|
gesture, which xterm's internal mousedown handling doesn't reliably do. */}
|
|
183
|
-
<div
|
|
175
|
+
<div
|
|
176
|
+
ref={containerRef}
|
|
177
|
+
onClick={selectMode ? undefined : focusTerminal}
|
|
178
|
+
className={cn("flex-1 min-h-0 bg-background p-1", selectMode && "touch-none")}
|
|
179
|
+
/>
|
|
184
180
|
|
|
185
|
-
{/* Mobile toolbar */}
|
|
186
181
|
{isMobile && (
|
|
187
|
-
|
|
188
|
-
<
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
<MessageSquare size={14} />
|
|
206
|
-
</button>
|
|
207
|
-
<div className="w-px h-5 bg-border mx-0.5" />
|
|
208
|
-
{MOBILE_KEYS.map((key) => (
|
|
209
|
-
<button
|
|
210
|
-
key={key.label}
|
|
211
|
-
onClick={() => {
|
|
212
|
-
if (key.label === "Ctrl") {
|
|
213
|
-
setCtrlMode(!ctrlMode);
|
|
214
|
-
} else if (key.value) {
|
|
215
|
-
sendKey(key.value);
|
|
216
|
-
}
|
|
217
|
-
}}
|
|
218
|
-
className={cn(
|
|
219
|
-
"px-3 py-1.5 rounded text-xs font-mono min-w-[36px] min-h-[32px]",
|
|
220
|
-
"bg-surface-elevated text-text-primary active:bg-primary active:text-primary-foreground",
|
|
221
|
-
"transition-colors select-none",
|
|
222
|
-
key.label === "Ctrl" && ctrlMode && "bg-primary text-primary-foreground",
|
|
223
|
-
)}
|
|
224
|
-
>
|
|
225
|
-
{key.label}
|
|
226
|
-
</button>
|
|
227
|
-
))}
|
|
228
|
-
</div>
|
|
182
|
+
<>
|
|
183
|
+
<TerminalMobileToolbar
|
|
184
|
+
ctrlMode={ctrlMode}
|
|
185
|
+
onToggleCtrl={() => setCtrlMode(!ctrlMode)}
|
|
186
|
+
selectMode={selectMode}
|
|
187
|
+
onToggleSelect={() => setSelectMode(!selectMode)}
|
|
188
|
+
onKey={sendKey}
|
|
189
|
+
onCopy={() => void handleCopy()}
|
|
190
|
+
onPaste={() => void handlePaste()}
|
|
191
|
+
onSendToChat={handleSendToChat}
|
|
192
|
+
onOpenLinks={() => setLinksOpen(true)}
|
|
193
|
+
/>
|
|
194
|
+
<TerminalLinksSheet
|
|
195
|
+
open={linksOpen}
|
|
196
|
+
onClose={() => setLinksOpen(false)}
|
|
197
|
+
urls={linksOpen ? getBufferUrls() : []}
|
|
198
|
+
/>
|
|
199
|
+
</>
|
|
229
200
|
)}
|
|
230
201
|
</div>
|
|
231
202
|
);
|
|
@@ -78,17 +78,34 @@ export function BottomSheet({ open, onClose, children, className, zIndex = 50 }:
|
|
|
78
78
|
onClick={(e) => e.stopPropagation()}
|
|
79
79
|
{...swipeHandlers}
|
|
80
80
|
>
|
|
81
|
-
{/* Drag handle
|
|
82
|
-
|
|
81
|
+
{/* Drag handle. shrink-0 so a flex-column sheet gives it its own height
|
|
82
|
+
instead of letting `h-full`/`flex-1` content overflow past it. */}
|
|
83
|
+
<div className="flex justify-center pt-3 pb-1 shrink-0">
|
|
83
84
|
<div className="w-10 h-1 rounded-full bg-border" />
|
|
84
85
|
</div>
|
|
85
86
|
{children}
|
|
86
87
|
</div>
|
|
87
88
|
</div>,
|
|
88
|
-
|
|
89
|
+
sheetPortalTarget(),
|
|
89
90
|
);
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Portal target for every sheet: the React root, never `document.body`.
|
|
95
|
+
*
|
|
96
|
+
* TabPool reparents live tab DOM into panel slots with `appendChild`, and on
|
|
97
|
+
* mobile the dock's slot lives inside a sheet. A body-level portal would put
|
|
98
|
+
* that node outside the root container React binds its delegated listeners to,
|
|
99
|
+
* so React resolves the node's root container (`#root`) against the container
|
|
100
|
+
* the event fired on (`body`), finds no match, and drops the event — every
|
|
101
|
+
* button inside a dock tab goes dead while native listeners still fire.
|
|
102
|
+
* `#root` carries no styles, so keeping the portal inside it leaves stacking
|
|
103
|
+
* order untouched.
|
|
104
|
+
*/
|
|
105
|
+
function sheetPortalTarget(): HTMLElement {
|
|
106
|
+
return document.getElementById("root") ?? document.body;
|
|
107
|
+
}
|
|
108
|
+
|
|
92
109
|
/* ------------------------------------------------------------------ */
|
|
93
110
|
/* Context-menu-specific helpers (used by adaptive-context-menu) */
|
|
94
111
|
/* ------------------------------------------------------------------ */
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drag-to-select for xterm on touch devices.
|
|
3
|
+
*
|
|
4
|
+
* xterm has no touch selection at all: `SelectionService` is driven by a
|
|
5
|
+
* `mousedown` on the `.xterm` element followed by `mousemove`/`mouseup` on the
|
|
6
|
+
* owning document, and nothing translates touch into those. This hook feeds it
|
|
7
|
+
* synthetic mouse events from a one-finger drag, so selection, its highlight and
|
|
8
|
+
* `term.getSelection()` all behave exactly as they do with a mouse.
|
|
9
|
+
*
|
|
10
|
+
* Only active while the caller has select mode switched on — the same gesture
|
|
11
|
+
* otherwise scrolls the viewport, and silently stealing it would strand the user
|
|
12
|
+
* with no way to scroll back.
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, type RefObject } from "react";
|
|
15
|
+
|
|
16
|
+
function mouseEventFrom(type: string, touch: Touch, buttons: number): MouseEvent {
|
|
17
|
+
return new MouseEvent(type, {
|
|
18
|
+
bubbles: true,
|
|
19
|
+
cancelable: true,
|
|
20
|
+
view: window,
|
|
21
|
+
clientX: touch.clientX,
|
|
22
|
+
clientY: touch.clientY,
|
|
23
|
+
screenX: touch.screenX,
|
|
24
|
+
screenY: touch.screenY,
|
|
25
|
+
button: 0,
|
|
26
|
+
buttons,
|
|
27
|
+
// Click count. xterm branches on it to pick single/word/line selection and
|
|
28
|
+
// ignores the event entirely at the default of 0, so a drag would start no
|
|
29
|
+
// selection at all without this.
|
|
30
|
+
detail: 1,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useTerminalTouchSelection(
|
|
35
|
+
containerRef: RefObject<HTMLDivElement | null>,
|
|
36
|
+
enabled: boolean,
|
|
37
|
+
) {
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const container = containerRef.current;
|
|
40
|
+
if (!enabled || !container) return;
|
|
41
|
+
|
|
42
|
+
// `.xterm` is where SelectionService listens for mousedown; dispatching on
|
|
43
|
+
// the screen element would work too but this keeps the target explicit.
|
|
44
|
+
const xterm = container.querySelector(".xterm");
|
|
45
|
+
if (!xterm) return;
|
|
46
|
+
|
|
47
|
+
// Capture phase so these run before xterm's own document-level touch
|
|
48
|
+
// gesture handler, and stopPropagation keeps that handler from scrolling
|
|
49
|
+
// the viewport out from under the drag.
|
|
50
|
+
const onTouchStart = (e: TouchEvent) => {
|
|
51
|
+
const touch = e.touches[0];
|
|
52
|
+
if (!touch || e.touches.length > 1) return;
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
e.stopPropagation();
|
|
55
|
+
xterm.dispatchEvent(mouseEventFrom("mousedown", touch, 1));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const onTouchMove = (e: TouchEvent) => {
|
|
59
|
+
const touch = e.touches[0];
|
|
60
|
+
if (!touch) return;
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
e.stopPropagation();
|
|
63
|
+
document.dispatchEvent(mouseEventFrom("mousemove", touch, 1));
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const onTouchEnd = (e: TouchEvent) => {
|
|
67
|
+
const touch = e.changedTouches[0];
|
|
68
|
+
if (!touch) return;
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
e.stopPropagation();
|
|
71
|
+
document.dispatchEvent(mouseEventFrom("mouseup", touch, 0));
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const opts = { capture: true, passive: false } as const;
|
|
75
|
+
container.addEventListener("touchstart", onTouchStart, opts);
|
|
76
|
+
container.addEventListener("touchmove", onTouchMove, opts);
|
|
77
|
+
container.addEventListener("touchend", onTouchEnd, opts);
|
|
78
|
+
container.addEventListener("touchcancel", onTouchEnd, opts);
|
|
79
|
+
|
|
80
|
+
return () => {
|
|
81
|
+
container.removeEventListener("touchstart", onTouchStart, opts);
|
|
82
|
+
container.removeEventListener("touchmove", onTouchMove, opts);
|
|
83
|
+
container.removeEventListener("touchend", onTouchEnd, opts);
|
|
84
|
+
container.removeEventListener("touchcancel", onTouchEnd, opts);
|
|
85
|
+
};
|
|
86
|
+
}, [containerRef, enabled]);
|
|
87
|
+
}
|
|
@@ -34,9 +34,17 @@ interface UseTerminalReturn {
|
|
|
34
34
|
getSelection: () => string;
|
|
35
35
|
/** Read buffer from last command start to current cursor (for "Send to Chat"). */
|
|
36
36
|
getLastCommandOutput: () => string;
|
|
37
|
+
/** URLs present in the scrollback, most recent first. */
|
|
38
|
+
getBufferUrls: () => string[];
|
|
37
39
|
restart: () => void;
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
/** Trailing punctuation that ends a sentence rather than the URL itself. */
|
|
43
|
+
const URL_TRAILING_PUNCT = /[).,;:'"\]}>]+$/;
|
|
44
|
+
const URL_PATTERN = /\bhttps?:\/\/[^\s<>"'`]+/g;
|
|
45
|
+
/** Cap the scan so a 50k-line scrollback never blocks the tap that triggered it. */
|
|
46
|
+
const URL_SCAN_MAX_ROWS = 5000;
|
|
47
|
+
|
|
40
48
|
const RESIZE_PREFIX = "\x01RESIZE:";
|
|
41
49
|
const PING_MSG = "\x01PING";
|
|
42
50
|
const PONG_MSG = "\x01PONG";
|
|
@@ -101,6 +109,50 @@ export function useTerminal(
|
|
|
101
109
|
return lines.join("\n");
|
|
102
110
|
}, []);
|
|
103
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Collect URLs from the scrollback.
|
|
114
|
+
*
|
|
115
|
+
* xterm's link addon activates a link from a mouse hover followed by mouseup,
|
|
116
|
+
* neither of which a touch device produces, so on mobile this list is the only
|
|
117
|
+
* way to reach a URL the shell printed (an `aws sso login` code, for example).
|
|
118
|
+
* Wrapped lines are joined first — a URL long enough to matter is usually the
|
|
119
|
+
* one that got split across rows.
|
|
120
|
+
*/
|
|
121
|
+
const getBufferUrls = useCallback(() => {
|
|
122
|
+
const term = termRef.current;
|
|
123
|
+
if (!term) return [];
|
|
124
|
+
const buf = term.buffer.active;
|
|
125
|
+
const endRow = buf.baseY + buf.cursorY;
|
|
126
|
+
const startRow = Math.max(0, endRow - URL_SCAN_MAX_ROWS);
|
|
127
|
+
|
|
128
|
+
const logical: string[] = [];
|
|
129
|
+
let current = "";
|
|
130
|
+
for (let i = startRow; i <= endRow; i++) {
|
|
131
|
+
const line = buf.getLine(i);
|
|
132
|
+
if (!line) continue;
|
|
133
|
+
current += line.translateToString(true);
|
|
134
|
+
// isWrapped marks a row as the continuation of the previous one, so the
|
|
135
|
+
// break is a display artefact and must not split the URL.
|
|
136
|
+
const next = buf.getLine(i + 1);
|
|
137
|
+
if (next?.isWrapped) continue;
|
|
138
|
+
logical.push(current);
|
|
139
|
+
current = "";
|
|
140
|
+
}
|
|
141
|
+
if (current) logical.push(current);
|
|
142
|
+
|
|
143
|
+
const seen = new Set<string>();
|
|
144
|
+
const urls: string[] = [];
|
|
145
|
+
for (const text of logical) {
|
|
146
|
+
for (const match of text.match(URL_PATTERN) ?? []) {
|
|
147
|
+
const url = match.replace(URL_TRAILING_PUNCT, "");
|
|
148
|
+
if (!url || seen.has(url)) continue;
|
|
149
|
+
seen.add(url);
|
|
150
|
+
urls.push(url);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return urls.reverse();
|
|
154
|
+
}, []);
|
|
155
|
+
|
|
104
156
|
const sendResize = useCallback(() => {
|
|
105
157
|
const term = termRef.current;
|
|
106
158
|
const ws = wsRef.current;
|
|
@@ -344,5 +396,5 @@ export function useTerminal(
|
|
|
344
396
|
};
|
|
345
397
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
346
398
|
|
|
347
|
-
return { connected, reconnecting, exited, sendData, getSelection, getLastCommandOutput, restart };
|
|
399
|
+
return { connected, reconnecting, exited, sendData, getSelection, getLastCommandOutput, getBufferUrls, restart };
|
|
348
400
|
}
|