@hienlh/ppm 0.17.37 → 0.17.39
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 +21 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +5 -1
- package/dist/web/assets/{ai-resource-editor-DAIaB4mX.js → ai-resource-editor-CAQ_0AXP.js} +1 -1
- package/dist/web/assets/{audio-preview-Df6_blMs.js → audio-preview-BUBpG5oQ.js} +1 -1
- package/dist/web/assets/chat-tab-BhjsCgzO.js +13 -0
- package/dist/web/assets/code-editor-CO_084SG.js +10 -0
- package/dist/web/assets/{conflict-editor-BqMdj782.js → conflict-editor-BsWmVy7f.js} +3 -3
- package/dist/web/assets/context-menu-DIiZdfbE.js +1 -0
- package/dist/web/assets/{csv-preview-BC_BuOzK.js → csv-preview-CfwxBxoL.js} +1 -1
- package/dist/web/assets/{database-viewer-DZQDQOye.js → database-viewer-iR997ZF8.js} +1 -1
- package/dist/web/assets/{diff-viewer-CaKD5pR5.js → diff-viewer-DyOLq66H.js} +1 -1
- package/dist/web/assets/{docx-preview-MBY4op8y.js → docx-preview-CwjJkYcL.js} +1 -1
- package/dist/web/assets/{extension-webview-CMtucoeg.js → extension-webview-MI4F6BtU.js} +1 -1
- package/dist/web/assets/{git-log-panel-DWZUIupS.js → git-log-panel-CYwATO2N.js} +1 -1
- package/dist/web/assets/{glide-data-grid-SLs8Xp6G.js → glide-data-grid-B3DyD4fp.js} +4 -4
- package/dist/web/assets/group-chat-tab-CgWgatpe.js +1 -0
- package/dist/web/assets/{image-preview-lRcpEoEk.js → image-preview-CcPAUgwV.js} +1 -1
- package/dist/web/assets/index-BEB87kj8.css +2 -0
- package/dist/web/assets/index-CSpawh6a.js +50 -0
- package/dist/web/assets/keybindings-store-CT2XJy_J.js +1 -0
- package/dist/web/assets/{markdown-renderer-D9r_4-qe.js → markdown-renderer-B54mHbyz.js} +2 -2
- package/dist/web/assets/{markdown-renderer-DkYo6o9f.js → markdown-renderer-UboKVdKw.js} +1 -1
- package/dist/web/assets/notification-store-Cw-106GW.js +1 -0
- package/dist/web/assets/{pdf-preview-D17y9l8U.js → pdf-preview-B9munmj0.js} +1 -1
- package/dist/web/assets/{postgres-viewer-DgpX_Kyl.js → postgres-viewer-DMBDGrnZ.js} +2 -2
- package/dist/web/assets/settings-tab-CXqpAnZU.js +1 -0
- package/dist/web/assets/{sql-query-editor-BIeAQego.js → sql-query-editor-BT11M9Xs.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-SipxlqWM.js → sqlite-viewer-BMrS0LPb.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-Cp-rP5HZ.js → system-monitor-tab-CznKv8Bb.js} +1 -1
- package/dist/web/assets/terminal-tab-DLvj76hN.js +3 -0
- package/dist/web/assets/tool-cards-BgUD_NIq.js +7 -0
- package/dist/web/assets/{use-monaco-theme-Bd1GBgB3.js → use-monaco-theme-BMgGJW1N.js} +1 -1
- package/dist/web/assets/{video-preview-DT8B2-re.js → video-preview-Bkre8g0Y.js} +1 -1
- package/dist/web/index.html +3 -3
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/server/index.ts +4 -0
- package/src/server/routes/oauth-loopback.ts +75 -0
- package/src/web/app.tsx +6 -2
- package/src/web/components/layout/dock-panel.tsx +8 -1
- package/src/web/components/layout/mobile-nav.tsx +21 -4
- package/src/web/components/terminal/terminal-finish-login.tsx +75 -0
- package/src/web/components/terminal/terminal-links-sheet.tsx +89 -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 +49 -29
- package/src/web/hooks/use-terminal-touch-selection.ts +87 -0
- package/src/web/hooks/use-terminal.ts +71 -8
- package/src/web/hooks/use-visual-viewport.ts +83 -0
- package/src/web/lib/oauth-loopback-url.ts +55 -0
- 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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Completes a CLI login that was started here and signed in on another device.
|
|
3
|
+
*
|
|
4
|
+
* The CLI is listening on a loopback port of the machine PPM runs on, but the
|
|
5
|
+
* browser that signed in was redirected to *its own* loopback address, so the
|
|
6
|
+
* result never arrived. Typing into the terminal will not help either — the CLI
|
|
7
|
+
* is holding it open, waiting. The server shares a host with the CLI, so it
|
|
8
|
+
* makes the call on the browser's behalf.
|
|
9
|
+
*/
|
|
10
|
+
import { useState } from "react";
|
|
11
|
+
import { Loader2, LogIn } from "lucide-react";
|
|
12
|
+
import { api } from "@/lib/api-client";
|
|
13
|
+
import { isDeliverableCallback } from "@/lib/oauth-loopback-url";
|
|
14
|
+
import { toast } from "sonner";
|
|
15
|
+
|
|
16
|
+
interface TerminalFinishLoginProps {
|
|
17
|
+
onDone: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function TerminalFinishLogin({ onDone }: TerminalFinishLoginProps) {
|
|
21
|
+
const [url, setUrl] = useState("");
|
|
22
|
+
const [sending, setSending] = useState(false);
|
|
23
|
+
|
|
24
|
+
const trimmed = url.trim();
|
|
25
|
+
const ready = !sending && isDeliverableCallback(trimmed);
|
|
26
|
+
|
|
27
|
+
const send = async () => {
|
|
28
|
+
setSending(true);
|
|
29
|
+
try {
|
|
30
|
+
await api.post("/api/loopback/callback", { url: trimmed });
|
|
31
|
+
toast.success("Sent to the waiting command", { duration: 2000 });
|
|
32
|
+
setUrl("");
|
|
33
|
+
onDone();
|
|
34
|
+
} catch (e) {
|
|
35
|
+
toast.error((e as Error).message, { duration: 4000 });
|
|
36
|
+
} finally {
|
|
37
|
+
setSending(false);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="shrink-0 border-t border-border px-3 py-3">
|
|
43
|
+
<p className="pb-2 text-[11px] leading-snug text-text-secondary">
|
|
44
|
+
Finished signing in and landed on a page that would not load? Paste that address here —
|
|
45
|
+
the machine will deliver it to the command still waiting.
|
|
46
|
+
</p>
|
|
47
|
+
<div className="flex items-center gap-2">
|
|
48
|
+
<input
|
|
49
|
+
value={url}
|
|
50
|
+
onChange={(e) => setUrl(e.target.value)}
|
|
51
|
+
inputMode="url"
|
|
52
|
+
autoCapitalize="off"
|
|
53
|
+
autoCorrect="off"
|
|
54
|
+
spellCheck={false}
|
|
55
|
+
placeholder="http://127.0.0.1:54132/oauth/callback?code=…"
|
|
56
|
+
aria-label="Callback address"
|
|
57
|
+
className="h-11 min-w-0 flex-1 rounded-lg border border-border bg-surface px-3 font-mono text-xs text-foreground placeholder:text-text-subtle"
|
|
58
|
+
/>
|
|
59
|
+
<button
|
|
60
|
+
onClick={() => void send()}
|
|
61
|
+
disabled={!ready}
|
|
62
|
+
aria-label="Finish login"
|
|
63
|
+
className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground disabled:opacity-40 active:opacity-80 transition-opacity"
|
|
64
|
+
>
|
|
65
|
+
{sending ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
{trimmed !== "" && !isDeliverableCallback(trimmed) && (
|
|
69
|
+
<p className="pt-1.5 text-[11px] text-error">
|
|
70
|
+
Needs to be the http://127.0.0.1:… address the sign-in redirected to.
|
|
71
|
+
</p>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tappable list of URLs found in the terminal scrollback.
|
|
3
|
+
*
|
|
4
|
+
* xterm resolves a link from a mouse hover and opens it on mouseup, so a touch
|
|
5
|
+
* device can never activate one — and without touch selection the URL cannot be
|
|
6
|
+
* copied out either. This sheet is the touch path to both.
|
|
7
|
+
*
|
|
8
|
+
* It also carries the way out of a login started here but finished on another
|
|
9
|
+
* device: see TerminalFinishLogin.
|
|
10
|
+
*/
|
|
11
|
+
import { ExternalLink, Copy, Link2Off } from "lucide-react";
|
|
12
|
+
import { BottomSheet } from "@/components/ui/mobile-bottom-sheet";
|
|
13
|
+
import { copyToClipboard } from "@/lib/clipboard";
|
|
14
|
+
import { loopbackRedirectPort } from "@/lib/oauth-loopback-url";
|
|
15
|
+
import { TerminalFinishLogin } from "./terminal-finish-login";
|
|
16
|
+
import { toast } from "sonner";
|
|
17
|
+
|
|
18
|
+
interface TerminalLinksSheetProps {
|
|
19
|
+
open: boolean;
|
|
20
|
+
onClose: () => void;
|
|
21
|
+
urls: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function TerminalLinksSheet({ open, onClose, urls }: TerminalLinksSheetProps) {
|
|
25
|
+
const handleCopy = async (url: string) => {
|
|
26
|
+
const ok = await copyToClipboard(url);
|
|
27
|
+
toast[ok ? "success" : "error"](ok ? "Link copied" : "Could not copy link", { duration: 1500 });
|
|
28
|
+
onClose();
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Ports any listed login will redirect back to. Their presence is what makes
|
|
32
|
+
// the finish-login step relevant, so it stays hidden the rest of the time.
|
|
33
|
+
const loopbackPorts = [...new Set(urls.map(loopbackRedirectPort).filter((p): p is string => !!p))];
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
// Above the mobile dock sheet (z-60) this is opened from, or it renders
|
|
37
|
+
// behind the dock and cannot be reached at all.
|
|
38
|
+
<BottomSheet open={open} onClose={onClose} zIndex={70} className="flex flex-col max-h-[70vh]">
|
|
39
|
+
<div className="shrink-0 px-4 pb-2 text-xs font-medium text-text-secondary">
|
|
40
|
+
{urls.length > 0 ? `Links in terminal (${urls.length})` : "Links in terminal"}
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div className="flex-1 min-h-0 overflow-y-auto px-2 pb-2">
|
|
44
|
+
{urls.length === 0 ? (
|
|
45
|
+
<div className="flex flex-col items-center gap-2 px-4 py-8 text-center text-sm text-text-secondary">
|
|
46
|
+
<Link2Off className="size-5 text-text-subtle" />
|
|
47
|
+
No links found in the visible scrollback.
|
|
48
|
+
</div>
|
|
49
|
+
) : (
|
|
50
|
+
urls.map((url) => {
|
|
51
|
+
const port = loopbackRedirectPort(url);
|
|
52
|
+
return (
|
|
53
|
+
<div key={url}>
|
|
54
|
+
<div className="flex items-stretch gap-1">
|
|
55
|
+
<a
|
|
56
|
+
href={url}
|
|
57
|
+
target="_blank"
|
|
58
|
+
rel="noreferrer noopener"
|
|
59
|
+
onClick={onClose}
|
|
60
|
+
className="flex min-h-11 flex-1 min-w-0 items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground active:bg-accent transition-colors"
|
|
61
|
+
>
|
|
62
|
+
<ExternalLink className="size-4 shrink-0 text-text-subtle" />
|
|
63
|
+
<span className="min-w-0 flex-1 truncate text-left font-mono text-xs">{url}</span>
|
|
64
|
+
</a>
|
|
65
|
+
<button
|
|
66
|
+
onClick={() => void handleCopy(url)}
|
|
67
|
+
aria-label={`Copy ${url}`}
|
|
68
|
+
className="flex size-11 shrink-0 items-center justify-center rounded-lg text-text-secondary active:bg-accent transition-colors"
|
|
69
|
+
>
|
|
70
|
+
<Copy className="size-4" />
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
73
|
+
{port && (
|
|
74
|
+
<p className="px-3 pb-2 text-[11px] leading-snug text-text-secondary">
|
|
75
|
+
Signing in here sends you back to port {port} <strong>on the machine</strong>,
|
|
76
|
+
not this device — so the terminal keeps waiting. Afterwards, paste the address
|
|
77
|
+
bar below to finish it.
|
|
78
|
+
</p>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
})
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{loopbackPorts.length > 0 && <TerminalFinishLogin onDone={onClose} />}
|
|
87
|
+
</BottomSheet>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Touch key row for the terminal: clipboard actions plus the keys a soft
|
|
3
|
+
* keyboard does not offer (Tab, Esc, Ctrl, arrows, ^C).
|
|
4
|
+
*
|
|
5
|
+
* The row scrolls horizontally — 44px targets for eleven controls cannot fit a
|
|
6
|
+
* phone's width — so it carries an edge fade to advertise that there is more
|
|
7
|
+
* past the right edge. Without it the last keys look absent rather than
|
|
8
|
+
* off-screen.
|
|
9
|
+
*/
|
|
10
|
+
import { Copy, ClipboardPaste, MessageSquare, Link, TextSelect } from "lucide-react";
|
|
11
|
+
import { cn } from "@/lib/utils";
|
|
12
|
+
|
|
13
|
+
export interface MobileKey {
|
|
14
|
+
label: string;
|
|
15
|
+
value: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const MOBILE_KEYS: readonly MobileKey[] = [
|
|
19
|
+
{ label: "Tab", value: "\t" },
|
|
20
|
+
{ label: "Esc", value: "\x1b" },
|
|
21
|
+
{ label: "Ctrl", value: null },
|
|
22
|
+
{ label: "↑", value: "\x1b[A" },
|
|
23
|
+
{ label: "↓", value: "\x1b[B" },
|
|
24
|
+
{ label: "←", value: "\x1b[D" },
|
|
25
|
+
{ label: "→", value: "\x1b[C" },
|
|
26
|
+
{ label: "^C", value: "\x03" },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const BUTTON_BASE =
|
|
30
|
+
"flex items-center justify-center shrink-0 min-w-11 min-h-11 rounded text-xs " +
|
|
31
|
+
"bg-surface-elevated text-text-primary active:bg-primary active:text-primary-foreground " +
|
|
32
|
+
"transition-colors select-none";
|
|
33
|
+
|
|
34
|
+
interface TerminalMobileToolbarProps {
|
|
35
|
+
ctrlMode: boolean;
|
|
36
|
+
onToggleCtrl: () => void;
|
|
37
|
+
selectMode: boolean;
|
|
38
|
+
onToggleSelect: () => void;
|
|
39
|
+
onKey: (value: string) => void;
|
|
40
|
+
onCopy: () => void;
|
|
41
|
+
onPaste: () => void;
|
|
42
|
+
onSendToChat: () => void;
|
|
43
|
+
onOpenLinks: () => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function TerminalMobileToolbar({
|
|
47
|
+
ctrlMode,
|
|
48
|
+
onToggleCtrl,
|
|
49
|
+
selectMode,
|
|
50
|
+
onToggleSelect,
|
|
51
|
+
onKey,
|
|
52
|
+
onCopy,
|
|
53
|
+
onPaste,
|
|
54
|
+
onSendToChat,
|
|
55
|
+
onOpenLinks,
|
|
56
|
+
}: TerminalMobileToolbarProps) {
|
|
57
|
+
return (
|
|
58
|
+
<div className="relative shrink-0 bg-surface border-t border-border">
|
|
59
|
+
<div className="flex items-center gap-1 px-2 py-1.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
|
60
|
+
<button
|
|
61
|
+
onClick={onToggleSelect}
|
|
62
|
+
aria-label="Select text"
|
|
63
|
+
aria-pressed={selectMode}
|
|
64
|
+
className={cn(BUTTON_BASE, selectMode && "bg-primary text-primary-foreground")}
|
|
65
|
+
>
|
|
66
|
+
<TextSelect size={16} />
|
|
67
|
+
</button>
|
|
68
|
+
<button onClick={onCopy} aria-label="Copy" className={BUTTON_BASE}>
|
|
69
|
+
<Copy size={16} />
|
|
70
|
+
</button>
|
|
71
|
+
<button onClick={onPaste} aria-label="Paste" className={BUTTON_BASE}>
|
|
72
|
+
<ClipboardPaste size={16} />
|
|
73
|
+
</button>
|
|
74
|
+
<button onClick={onOpenLinks} aria-label="Links" className={BUTTON_BASE}>
|
|
75
|
+
<Link size={16} />
|
|
76
|
+
</button>
|
|
77
|
+
<button onClick={onSendToChat} aria-label="Send to Chat" className={BUTTON_BASE}>
|
|
78
|
+
<MessageSquare size={16} />
|
|
79
|
+
</button>
|
|
80
|
+
|
|
81
|
+
<div className="w-px h-6 shrink-0 bg-border mx-0.5" />
|
|
82
|
+
|
|
83
|
+
{MOBILE_KEYS.map((key) => (
|
|
84
|
+
<button
|
|
85
|
+
key={key.label}
|
|
86
|
+
onClick={() => (key.value === null ? onToggleCtrl() : onKey(key.value))}
|
|
87
|
+
className={cn(
|
|
88
|
+
BUTTON_BASE,
|
|
89
|
+
"font-mono",
|
|
90
|
+
key.value === null && ctrlMode && "bg-primary text-primary-foreground",
|
|
91
|
+
)}
|
|
92
|
+
>
|
|
93
|
+
{key.label}
|
|
94
|
+
</button>
|
|
95
|
+
))}
|
|
96
|
+
|
|
97
|
+
{/* Trailing spacer so the last key clears the fade instead of hiding under it. */}
|
|
98
|
+
<div className="w-6 shrink-0" aria-hidden />
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div
|
|
102
|
+
className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-surface to-transparent"
|
|
103
|
+
aria-hidden
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -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
|
);
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* Also exports context-menu-specific sub-components (BottomSheetItem, etc.)
|
|
7
7
|
* used by adaptive-context-menu.tsx.
|
|
8
8
|
*/
|
|
9
|
-
import { createContext, useContext,
|
|
9
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
10
10
|
import { createPortal } from "react-dom";
|
|
11
11
|
import { cn } from "@/lib/utils";
|
|
12
12
|
import { useSwipeToDismiss } from "@/hooks/use-swipe-to-dismiss";
|
|
13
|
+
import { useVisualViewport } from "@/hooks/use-visual-viewport";
|
|
13
14
|
|
|
14
15
|
/* ------------------------------------------------------------------ */
|
|
15
16
|
/* Core BottomSheet — reusable everywhere */
|
|
@@ -33,44 +34,46 @@ export function BottomSheet({ open, onClose, children, className, zIndex = 50 }:
|
|
|
33
34
|
const { dragY, swipeHandlers, dragStyle, backdropOpacity, isDragging } =
|
|
34
35
|
useSwipeToDismiss(onClose);
|
|
35
36
|
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
const
|
|
41
|
-
useEffect(() => {
|
|
42
|
-
const viewport = window.visualViewport;
|
|
43
|
-
if (!open || !viewport) return;
|
|
44
|
-
const update = () => setVv({ top: viewport.offsetTop, height: viewport.height });
|
|
45
|
-
update();
|
|
46
|
-
viewport.addEventListener("resize", update);
|
|
47
|
-
viewport.addEventListener("scroll", update);
|
|
48
|
-
return () => {
|
|
49
|
-
viewport.removeEventListener("resize", update);
|
|
50
|
-
viewport.removeEventListener("scroll", update);
|
|
51
|
-
};
|
|
52
|
-
}, [open]);
|
|
37
|
+
// Keyboard-aware geometry. The backdrop deliberately keeps covering the whole
|
|
38
|
+
// layout viewport while only the panel tracks the keyboard: if the two ever
|
|
39
|
+
// disagree by a few pixels, the seam shows as dimmed backdrop rather than a
|
|
40
|
+
// strip of untouched page.
|
|
41
|
+
const insets = useVisualViewport(open);
|
|
53
42
|
|
|
54
43
|
if (!open) return null;
|
|
55
44
|
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
45
|
+
// The panel keeps its footing on the bottom of the screen and only its
|
|
46
|
+
// *contents* are lifted clear of the keyboard, by padding. Moving the panel
|
|
47
|
+
// itself is what left a hole: the keyboard's height can only be estimated, and
|
|
48
|
+
// an estimate that is short drags the panel's background up with it, exposing
|
|
49
|
+
// the page. Padding an anchored panel degrades instead — a bad estimate puts
|
|
50
|
+
// the toolbar slightly off, over a background that still reaches the bottom.
|
|
51
|
+
//
|
|
52
|
+
// `--sheet-vh` is what is visible and `--sheet-kb` what the keyboard covers,
|
|
53
|
+
// so a panel can ask for "the visible area" and still span to the screen edge.
|
|
54
|
+
const frameStyle = {
|
|
55
|
+
zIndex,
|
|
56
|
+
"--sheet-vh": insets ? `${insets.height}px` : "100dvh",
|
|
57
|
+
"--sheet-kb": insets ? `${insets.keyboardInset}px` : "0px",
|
|
58
|
+
} as React.CSSProperties;
|
|
61
59
|
|
|
62
60
|
return createPortal(
|
|
63
|
-
<div className="fixed inset-0" style={
|
|
61
|
+
<div className="fixed inset-0" style={frameStyle} onClick={onClose}>
|
|
64
62
|
{/* Backdrop */}
|
|
65
63
|
<div
|
|
66
64
|
className="absolute inset-0 bg-black/40 animate-in fade-in-0 duration-200"
|
|
67
65
|
style={isDragging ? { opacity: backdropOpacity } : undefined}
|
|
68
66
|
/>
|
|
69
|
-
{/* Panel
|
|
67
|
+
{/* Panel. Padding does double duty: it clears the home indicator when the
|
|
68
|
+
keyboard is down, and the keyboard itself when it is up. A panel sized
|
|
69
|
+
in `--sheet-vh` must add `--sheet-kb` back, since the padding is inside
|
|
70
|
+
that height. */}
|
|
70
71
|
<div
|
|
71
72
|
className={cn(
|
|
72
73
|
"absolute bottom-0 left-0 right-0 rounded-t-2xl bg-popover text-popover-foreground border-t border-border",
|
|
73
|
-
|
|
74
|
+
insets?.keyboardOpen
|
|
75
|
+
? "pb-[var(--sheet-kb,0px)]"
|
|
76
|
+
: "pb-[max(0.5rem,env(safe-area-inset-bottom))]",
|
|
74
77
|
!isDragging && "animate-in slide-in-from-bottom duration-200",
|
|
75
78
|
className,
|
|
76
79
|
)}
|
|
@@ -78,17 +81,34 @@ export function BottomSheet({ open, onClose, children, className, zIndex = 50 }:
|
|
|
78
81
|
onClick={(e) => e.stopPropagation()}
|
|
79
82
|
{...swipeHandlers}
|
|
80
83
|
>
|
|
81
|
-
{/* Drag handle
|
|
82
|
-
|
|
84
|
+
{/* Drag handle. shrink-0 so a flex-column sheet gives it its own height
|
|
85
|
+
instead of letting `h-full`/`flex-1` content overflow past it. */}
|
|
86
|
+
<div className="flex justify-center pt-3 pb-1 shrink-0">
|
|
83
87
|
<div className="w-10 h-1 rounded-full bg-border" />
|
|
84
88
|
</div>
|
|
85
89
|
{children}
|
|
86
90
|
</div>
|
|
87
91
|
</div>,
|
|
88
|
-
|
|
92
|
+
sheetPortalTarget(),
|
|
89
93
|
);
|
|
90
94
|
}
|
|
91
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Portal target for every sheet: the React root, never `document.body`.
|
|
98
|
+
*
|
|
99
|
+
* TabPool reparents live tab DOM into panel slots with `appendChild`, and on
|
|
100
|
+
* mobile the dock's slot lives inside a sheet. A body-level portal would put
|
|
101
|
+
* that node outside the root container React binds its delegated listeners to,
|
|
102
|
+
* so React resolves the node's root container (`#root`) against the container
|
|
103
|
+
* the event fired on (`body`), finds no match, and drops the event — every
|
|
104
|
+
* button inside a dock tab goes dead while native listeners still fire.
|
|
105
|
+
* `#root` carries no styles, so keeping the portal inside it leaves stacking
|
|
106
|
+
* order untouched.
|
|
107
|
+
*/
|
|
108
|
+
function sheetPortalTarget(): HTMLElement {
|
|
109
|
+
return document.getElementById("root") ?? document.body;
|
|
110
|
+
}
|
|
111
|
+
|
|
92
112
|
/* ------------------------------------------------------------------ */
|
|
93
113
|
/* Context-menu-specific helpers (used by adaptive-context-menu) */
|
|
94
114
|
/* ------------------------------------------------------------------ */
|